From f3fade5f235e47ab19cb6da95e8853e802796eed Mon Sep 17 00:00:00 2001 From: quaff Date: Mon, 13 Apr 2026 19:45:19 -0700 Subject: [PATCH] IRCv3 `no-implicit-names` support --- CHANGELOG.md | 1 + data/src/capabilities.rs | 17 ++++ data/src/client.rs | 174 +++++++++++++++++++++++++++------------ data/src/isupport.rs | 4 +- data/src/user.rs | 25 ++++++ 5 files changed, 168 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68979aadc..f5626cf84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Added: - Expanded `tooltips` setting to allow hiding auto-complete tooltips - emacs bindings for ctrl + u and ctrl + w - `buffer.text_input.kill_to_clipboard` to control key bindings moving killed text to clipboard +- `no-implicit-names` support Changed: diff --git a/data/src/capabilities.rs b/data/src/capabilities.rs index db48de306..2d9bccaa4 100644 --- a/data/src/capabilities.rs +++ b/data/src/capabilities.rs @@ -33,6 +33,7 @@ pub enum Capability { MessageTags, Multiline, MultiPrefix, + NoImplicitNames, ReadMarker, Sasl, ServerTime, @@ -60,6 +61,9 @@ impl FromStr for Capability { "labeled-response" => Ok(Self::LabeledResponse), "message-tags" => Ok(Self::MessageTags), "multi-prefix" => Ok(Self::MultiPrefix), + "no-implicit-names" => Ok(Self::NoImplicitNames), + // TODO(quaff): remove `draft/no-implicit-names` support when ergo & soju have both been upgraded + "draft/no-implicit-names" => Ok(Self::NoImplicitNames), "server-time" => Ok(Self::ServerTime), "setname" => Ok(Self::Setname), "soju.im/bouncer-networks" => Ok(Self::BouncerNetworks), @@ -345,6 +349,19 @@ impl Capabilities { requested.push("sasl"); } + if self.pending.contains("no-implicit-names") + && !self.acknowledged(Capability::NoImplicitNames) + { + requested.push("no-implicit-names"); + } + + // TODO(quaff): remove `draft/no-implicit-names` support when ergo & soju have both been upgraded + if self.pending.contains("draft/no-implicit-names") + && !self.acknowledged(Capability::NoImplicitNames) + { + requested.push("draft/no-implicit-names"); + } + if let Some(multiline) = self .pending .iter() diff --git a/data/src/client.rs b/data/src/client.rs index 3e666af93..27d4d961b 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -1733,10 +1733,28 @@ impl Client { .iter() .any(|who_poll| who_poll.channel == target_channel) { - self.who_polls.push_back(WhoPoll { + let mut who_poll = WhoPoll { channel: target_channel.clone(), status: WhoStatus::Joined, - }); + }; + + if self + .capabilities + .acknowledged(Capability::NoImplicitNames) + { + let message = prepare_who_polls( + &self.isupport, + &self.capabilities, + &mut who_poll, + ); + self.send( + None, + message.into(), + TokenPriority::High, + ); + } + + self.who_polls.push_back(who_poll); } if !self.mode_requests.iter().any(|mode_request| { @@ -1810,6 +1828,10 @@ impl Client { } Command::Numeric(RPL_WHOREPLY, args) => { let channel = ok!(args.get(1)); + let user = ok!(args.get(2)); + let host = ok!(args.get(3)); + let nick = ok!(args.get(5)); + let flags = ok!(args.get(6)); let casemapping = self.casemapping(); @@ -1825,8 +1847,8 @@ impl Client { self.chanmap.get_mut(&target_channel) { client_channel.update_user_away( - ok!(args.get(5)), - ok!(args.get(6)), + nick, + flags, casemapping, ); @@ -1844,6 +1866,20 @@ impl Client { self.server ); } + + if let Ok(mut user) = User::parse_from_whoreply( + nick, + flags, + user, + host, + casemapping, + isupport::get_prefix(&self.isupport), + ) { + if flags.starts_with('G') { + user.update_away(true); + } + client_channel.users.insert(user); + } } if !user_request { @@ -1865,7 +1901,13 @@ impl Client { } } Command::Numeric(RPL_WHOSPCRPL, args) => { + let token = ok!(args.get(1)); let channel = ok!(args.get(2)); + let user = ok!(args.get(3)); + let host = ok!(args.get(4)); + let nick = ok!(args.get(5)); + let flags = ok!(args.get(6)); + let account = args.get(7); let casemapping = self.casemapping(); @@ -1890,8 +1932,7 @@ impl Client { _, Some(request_token), ) if matches!(source, WhoSource::Poll) => { - if let Ok(token) = - ok!(args.get(1)).parse::() + if let Ok(token) = token.parse::() && *request_token == token { who_poll.status = WhoStatus::Receiving( @@ -1926,36 +1967,49 @@ impl Client { &who_poll.status { // Check token to ~ensure reply is to poll request - if let Ok(token) = - ok!(args.get(1)).parse::() - { + if let Ok(token) = token.parse::() { if token == WhoXPollParameters::Default.token() { client_channel.update_user_away( - ok!(args.get(3)), - ok!(args.get(4)), + nick, + flags, casemapping, ); } else if token == WhoXPollParameters::WithAccountName .token() { - let user = ok!(args.get(3)); - client_channel.update_user_away( - user, - ok!(args.get(4)), + nick, + flags, casemapping, ); client_channel.update_user_accountname( - user, - ok!(args.get(5)), + nick, + ok!(account), casemapping, ); } } } + + if let Ok(mut user) = User::parse_from_whoreply( + nick, + flags, + user, + host, + casemapping, + isupport::get_prefix(&self.isupport), + ) { + if let Some(account) = account { + user = user.with_accountname(account); + } + if flags.starts_with('G') { + user.update_away(true); + } + client_channel.users.insert(user); + } } if !user_request { @@ -2012,8 +2066,17 @@ impl Client { matches!(who_poll.status, WhoStatus::Received) })) { - self.who_polls[pos].status = - WhoStatus::Waiting(Instant::now()); + if !self + .capabilities + .acknowledged(Capability::NoImplicitNames) + || matches!( + self.who_polls[pos].status, + WhoStatus::Received + ) + { + self.who_polls[pos].status = + WhoStatus::Waiting(Instant::now()); + } if pos != 0 && let Some(who_poll) = @@ -3916,6 +3979,9 @@ impl Client { let request = match &who_poll.status { WhoStatus::Joined => { (self.capabilities.acknowledged(Capability::AwayNotify) + || self + .capabilities + .acknowledged(Capability::NoImplicitNames) || self.config.who_poll_enabled) .then_some(Request::Poll) } @@ -3961,38 +4027,11 @@ impl Client { } ); - let message = - if self.isupport.contains_key(&isupport::Kind::WHOX) { - let whox_params = if self - .capabilities - .acknowledged(Capability::AccountNotify) - { - WhoXPollParameters::WithAccountName - } else { - WhoXPollParameters::Default - }; - - who_poll.status = WhoStatus::Requested( - WhoSource::Poll, - Instant::now(), - Some(whox_params.token()), - ); - - command!( - "WHO", - who_poll.channel.to_string(), - whox_params.fields().to_string(), - whox_params.token().to_owned() - ) - } else { - who_poll.status = WhoStatus::Requested( - WhoSource::Poll, - Instant::now(), - None, - ); - - command!("WHO", who_poll.channel.to_string()) - }; + let message = prepare_who_polls( + &self.isupport, + &self.capabilities, + who_poll, + ); self.send(None, message.into(), TokenPriority::Low); } @@ -4163,6 +4202,39 @@ fn compare_channels_default(chantypes: &[char], a: &str, b: &str) -> Ordering { a.cmp(b) } +fn prepare_who_polls( + isupport: &HashMap, + capabilities: &Capabilities, + who_poll: &mut WhoPoll, +) -> irc::proto::Message { + if isupport.contains_key(&isupport::Kind::WHOX) { + let whox_params = + if capabilities.acknowledged(Capability::AccountNotify) { + WhoXPollParameters::WithAccountName + } else { + WhoXPollParameters::Default + }; + + who_poll.status = WhoStatus::Requested( + WhoSource::Poll, + Instant::now(), + Some(whox_params.token()), + ); + + command!( + "WHO", + who_poll.channel.to_string(), + whox_params.fields().to_string(), + whox_params.token().to_owned() + ) + } else { + who_poll.status = + WhoStatus::Requested(WhoSource::Poll, Instant::now(), None); + + command!("WHO", who_poll.channel.to_string()) + } +} + fn compare_channels( config: &config::server::Server, chantypes: &[char], diff --git a/data/src/isupport.rs b/data/src/isupport.rs index db5758b00..5493cedfb 100644 --- a/data/src/isupport.rs +++ b/data/src/isupport.rs @@ -1134,8 +1134,8 @@ pub enum WhoXPollParameters { impl WhoXPollParameters { pub fn fields(&self) -> &'static str { match self { - WhoXPollParameters::Default => "tcnf", - WhoXPollParameters::WithAccountName => "tcnfa", + WhoXPollParameters::Default => "tcnfuh", + WhoXPollParameters::WithAccountName => "tcnfuha", } } diff --git a/data/src/user.rs b/data/src/user.rs index 6742d6a5a..9ab7df53d 100644 --- a/data/src/user.rs +++ b/data/src/user.rs @@ -424,6 +424,31 @@ impl User { away: false, }) } + + pub fn parse_from_whoreply( + nick: &String, + flags: &str, + user: &String, + host: &String, + casemapping: isupport::CaseMap, + prefix: Option<&[isupport::PrefixMap]>, + ) -> Result { + let access_level: String = flags[1..] + .chars() + .filter(|c| { + if let Some(prefix) = prefix { + prefix.iter().any(|p| p.prefix == *c) + } else { + AccessLevel::try_from(*c).is_ok() + } + }) + .collect(); + User::parse( + format!("{access_level}{nick}!{user}@{host}").as_str(), + casemapping, + prefix, + ) + } } fn parse_user_names(