A member list for the channel somebody is reading

The last of the ratified capabilities, and the one held back because the
version that costs nothing also gains nothing: requesting `no-implicit-names`
and then sending NAMES on join anyway is the same traffic at the same moment,
merely asked for rather than volunteered.

The gain is in not asking. An account that autojoins twenty channels was
fetching twenty member lists at connect - and, since WHO landed, twenty WHOs
beside them - every one about a room nobody had looked at yet. So the roster
is asked for when a conversation is opened instead, and the capability is what
stops the server volunteering the half it owns.

`requestMemberList` is where this hangs, because it already meant exactly
this. Discord answers it for the same reason from the other direction - it
sends a member list only for the channel being looked at - so the two services
now share one moment with one meaning: somebody opened this.

Once per channel per connection. After that the roster keeps itself current
from the joins, parts, nick changes and mode changes that arrive anyway, and a
reconnect clears the marks because a rejoin is a different set of people.

One ordering bug worth naming: the "asked" mark goes after the check for a
connection, not before. A conversation can be opened while its account is
still connecting - the buffer exists in scrollback and the socket does not -
and marking that as asked would have spent the one chance to ask on a
connection that could not carry it.

Verified against testnet.ergo.chat. Before the conversation was opened the
roster held only what the JOIN revealed, with no rank on it; after, the rank
was there - which is NAMES, arriving on open rather than on join. That the
prefix was empty beforehand is the proof the server had stopped volunteering
one: without the capability it would have sent it regardless.

Closes #156. IRC now covers 24 of the 25 client-relevant ratified extensions,
where the coverage review found 15.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 16:38:15 -04:00
co-authored by Claude Opus 5
parent d271fb7903
commit 8927d113c4
5 changed files with 79 additions and 8 deletions
+5
View File
@@ -506,6 +506,11 @@ pub(super) const WANTED_CAPS: &[&str] = &[
// - who they are identified as and whether they are away, rather than a
// bare "they are online".
"extended-monitor",
// The server stops volunteering a member list on join, because this
// client asks for one when a conversation is opened instead. An account
// that autojoins twenty channels was being sent twenty member lists at
// connect, every one of them about a room nobody had looked at yet.
"no-implicit-names",
// Four drafts, requested because WeeChat, Halloy and bIRC already ship
// them: a draft nobody has built can still change underneath an
// implementation, and a draft three clients interoperate on is a protocol
+6 -7
View File
@@ -7,7 +7,7 @@
use super::*;
pub(super) fn is_channel(target: &str) -> bool {
pub fn is_channel(target: &str) -> bool {
target.starts_with(['#', '&', '+', '!'])
}
@@ -233,12 +233,11 @@ pub(super) async fn handle_message(
let _ = sender.send(chathistory_latest(&channel));
}
}
// Who is actually in here. NAMES answers with names and, where
// `userhost-in-names` was granted, hostmasks - but never the
// services account, and never who is away. WHO answers all
// three, so it is asked once per channel on arrival rather
// than per person on demand.
who::ask(state, account_id, &channel);
// Deliberately not asking who is in here yet. That happens
// when the conversation is opened - see `who::ask_roster` and
// the `no-implicit-names` capability - because joining a
// channel and reading one are different things, and an
// autojoin list is mostly the first without the second.
}
if from != own_nick {
let line = format!("{from} entered the room");
+30
View File
@@ -46,6 +46,36 @@ pub(super) fn ask(state: &AppState, account_id: &str, channel: &str) {
}
}
/// Asks for a channel's roster: who is in it, and who they are.
///
/// Both questions, because they are two commands and one answer is half of
/// what a member list needs - NAMES gives names and ranks, WHO gives
/// hostmasks, accounts and who is away.
///
/// Asked when a conversation is opened rather than when it is joined. An
/// account that autojoins twenty channels was fetching twenty member lists at
/// connect, every one about a room nobody had looked at; `no-implicit-names`
/// is the capability that stops the *server* volunteering the first half, and
/// this is the client half of the same decision.
///
/// Once per channel per connection. After that the roster keeps itself
/// current from the joins, parts and nick changes that arrive anyway.
pub fn ask_roster(state: &AppState, account_id: &str, channel: &str) {
// Before the "asked" mark, not after. A conversation can be opened while
// its account is still connecting - the buffer exists from scrollback and
// the socket does not - and marking that as asked would mean the roster
// was never fetched at all: the one chance to ask spent on a connection
// that could not carry it.
let Some(sender) = state.runtime.irc_sender(account_id) else { return };
if !state.runtime.irc_roster_needed(&crate::model::buffer_id(account_id, channel)) {
return;
}
if let Err(e) = sender.send(Command::Raw("NAMES".to_string(), vec![channel.to_string()])) {
tracing::debug!("irc[{account_id}]: asking who is in {channel}: {e}");
}
ask(state, account_id, channel);
}
/// What one WHO reply says about one person.
pub(super) struct Seen {
pub channel: String,
+17 -1
View File
@@ -1166,7 +1166,23 @@ pub async fn dispatch(
"requestMemberList" => match p_str_opt(params, "bufferId") {
None => (None, Some("requestMemberList requires \"bufferId\"".to_string())),
Some(buffer_id) => {
let asked = backend::discord::request_member_list(state, buffer_id);
// Two services answer this, for the same reason and by
// different means. Discord sends a member list only for the
// channel being looked at; IRC could be sent one for every
// channel on join and asks `no-implicit-names` for it not to
// be. Either way this is the moment somebody opened the
// conversation, which is the only moment either wants.
let asked = match state.runtime.get_buffer(buffer_id) {
Some(buffer) if state.accounts.get_irc(&buffer.account_id).is_some() => {
if crate::backend::irc::incoming::is_channel(&buffer.name) {
backend::irc::who::ask_roster(state, &buffer.account_id, &buffer.name);
true
} else {
false
}
}
_ => backend::discord::request_member_list(state, buffer_id),
};
(Some(serde_json::json!({ "requested": asked })), None)
}
},
+21
View File
@@ -693,6 +693,8 @@ pub struct Runtime {
discord_mutes: Mutex<HashMap<(String, String), DiscordMute>>,
/// account -> the RPL_ISUPPORT tokens that server advertised.
irc_isupport: Mutex<HashMap<String, std::collections::HashSet<String>>>,
/// Channel buffers whose roster has been asked for on this connection.
irc_rostered: Mutex<std::collections::HashSet<String>>,
/// account -> the room catalogue last read off the site.
///
/// Cached because reading it costs a Tor round trip and a proof-of-work
@@ -952,6 +954,7 @@ impl Runtime {
sneedchat_motds: Mutex::new(HashMap::new()),
sneedchat_rooms: Mutex::new(HashMap::new()),
irc_isupport: Mutex::new(HashMap::new()),
irc_rostered: Mutex::new(std::collections::HashSet::new()),
discord_mutes: Mutex::new(HashMap::new()),
irc_splits: Mutex::new(HashMap::new()),
irc_whois: Mutex::new(HashMap::new()),
@@ -3106,6 +3109,24 @@ impl Runtime {
pub fn clear_irc_caps(&self, account_id: &str) {
self.irc_caps.lock().unwrap().remove(account_id);
self.irc_isupport.lock().unwrap().remove(account_id);
// Rosters are per connection: a reconnect rejoins every channel and
// the answers from the last session are about people who may not be
// there any more.
let prefix = format!("{account_id}|");
self.irc_rostered.lock().unwrap().retain(|id| !id.starts_with(&prefix));
}
/// Whether this channel's roster still needs asking for, and marks it
/// asked.
///
/// Asked when a conversation is opened rather than when it is joined,
/// which is what `no-implicit-names` is for: an account that autojoins
/// twenty channels was fetching twenty member lists at connect, every one
/// of them about a room nobody had looked at yet. True exactly once per
/// channel per connection - the roster stays current after that from the
/// joins and parts that arrive anyway.
pub fn irc_roster_needed(&self, buffer_id: &str) -> bool {
self.irc_rostered.lock().unwrap().insert(buffer_id.to_string())
}
/// Records the tokens a server advertises in RPL_ISUPPORT.