From 97a35338fac1abe55e8462c1781c71209a07aede Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 07:40:10 +0000 Subject: [PATCH] delayjoin +D: hide JOINs until a member speaks/reveals (NAMES + part/quit/nick aware) --- src/channels.rs | 96 +++++++++++++++++++++++++++++++++++- src/coremods/core_channel.rs | 13 ++++- src/coremods/core_message.rs | 2 + src/mode.rs | 14 +++++- src/server.rs | 8 ++- src/users.rs | 8 ++- 6 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src/channels.rs b/src/channels.rs index 5c39bb1..cabba28 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -20,6 +20,7 @@ pub struct Member { pub voice: bool, // +v (+) pub joined: u64, // unix ts this member joined (for +d delaymsg; 0 = unknown) pub recent_msgs: Vec, // +K repeat: this member's last few lines here + pub hidden: bool, // +D delayjoin: JOIN withheld until they reveal themselves } /// Prefix ranks, high→low — gate who may grant a prefix / kick whom. @@ -159,6 +160,7 @@ pub struct ChanModes { pub opmoderated: bool, // +U — unprivileged users' messages go to ops only pub delaymsg: Option, // +d — new joiners can't speak for N secs pub repeat: Option, // +K — block a line repeated within your last n + pub delayjoin: bool, // +D — hide JOINs until the user speaks/reveals } impl ChanModes { @@ -186,6 +188,7 @@ impl ChanModes { 'A' => self.allowinvite = on, 'P' => self.permanent = on, 'U' => self.opmoderated = on, + 'D' => self.delayjoin = on, _ => {} } } @@ -357,6 +360,66 @@ impl Server { .unwrap_or(false) } + /// +D delayjoin: announce a hidden member's withheld JOIN now (they revealed + /// themselves by speaking / being opped / changing nick). No-op if not hidden. + pub fn reveal_member(&mut self, uid: Uid, key: &str) { + let hidden = self + .channels + .get(key) + .and_then(|c| c.members.get(&uid)) + .map(|m| m.hidden) + .unwrap_or(false); + if !hidden { + return; + } + if let Some(m) = self + .channels + .get_mut(key) + .and_then(|c| c.members.get_mut(&uid)) + { + m.hidden = false; + } + let name = self + .channels + .get(key) + .map(|c| c.name.clone()) + .unwrap_or_else(|| key.to_string()); + let aud = self + .channels + .get(key) + .map(|c| c.modes.auditorium) + .unwrap_or(false); + let (prefix, acct, realname) = match self.users.get(&uid) { + Some(u) => ( + u.prefix(), + u.account.clone().unwrap_or_else(|| "*".to_string()), + u.realname.clone(), + ), + None => return, + }; + let plain = format!(":{prefix} JOIN {name}"); + let extended = format!(":{prefix} JOIN {name} {acct} :{realname}"); + let members: Vec = self + .channels + .get(key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default(); + for m in members { + if m == uid { + continue; // they already saw their own JOIN + } + if aud && self.rank(m, key) < RANK_OP { + continue; // +u auditorium still hides them from non-ops + } + let ext = self + .users + .get(&m) + .map(|u| u.caps.extended_join) + .unwrap_or(false); + self.send(m, if ext { extended.clone() } else { plain.clone() }); + } + } + /// +X exemptchanops — is `uid` exempt from `restriction` in this channel? True /// when a `+X :` entry names a rank they meet or exceed. pub fn chanop_exempt(&self, uid: Uid, key: &str, restriction: &str) -> bool { @@ -412,7 +475,19 @@ impl Server { return; } let prefix = self.users[&uid].prefix(); - self.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None); + // +D delayjoin: a still-hidden member's PART is shown only to themselves + let hidden = self + .channels + .get(&key) + .and_then(|c| c.members.get(&uid)) + .map(|m| m.hidden) + .unwrap_or(false); + let line = format!(":{prefix} PART {chan} :{reason}"); + if hidden { + self.send(uid, line); + } else { + self.to_channel(&key, &line, None); + } self.propagate_part(uid, chan, reason); if let Some(ch) = self.channels.get_mut(&key) { ch.members.remove(&uid); @@ -639,12 +714,27 @@ impl Server { let plain = format!(":{prefix} JOIN {name}"); let extended = format!(":{prefix} JOIN {name} {acct} :{realname}"); let aud = self.channels[&key].modes.auditorium; + // +D delayjoin: withhold the JOIN from everyone else until they speak/reveal + let delayjoin = self.channels[&key].modes.delayjoin; + if delayjoin { + if let Some(m) = self + .channels + .get_mut(&key) + .and_then(|c| c.members.get_mut(&uid)) + { + m.hidden = true; + } + } let members: Vec = self.channels[&key].members.keys().copied().collect(); for m in members { // +u auditorium: non-op members don't see other users join if aud && m != uid && self.rank(m, &key) < RANK_OP { continue; } + // +D: only the joining user sees their own JOIN for now + if delayjoin && m != uid { + continue; + } let ext = self .users .get(&m) @@ -747,6 +837,10 @@ impl Server { if hide && *m != uid && flags.rank() < RANK_OP { continue; } + // +D delayjoin: a still-hidden member isn't shown to anyone but themselves + if flags.hidden && *m != uid { + continue; + } let p = if multi { flags.all_prefixes() } else { diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index cb0fd88..3fba458 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -434,7 +434,18 @@ impl Command for Part { } else { format!(":{prefix} PART {target} :{reason}") }; - s.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part + // +D delayjoin: a still-hidden member's PART is shown only to themselves + let hidden = s + .channels + .get(&key) + .and_then(|c| c.members.get(&uid)) + .map(|m| m.hidden) + .unwrap_or(false); + if hidden { + s.send(uid, line.clone()); + } else { + s.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part + } s.propagate_part(uid, target, &reason); // tell linked servers if let Some(ch) = s.channels.get_mut(&key) { ch.members.remove(&uid); diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index 32f220e..89bc05d 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -400,6 +400,8 @@ pub(crate) fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) } } } + // +D delayjoin: speaking reveals a hidden member (their JOIN goes out first) + s.reveal_member(uid, &key); // deliver to every member except the sender and +D (deaf) users, tagging // per-recipient (server-time + any client-only tags on the line) let line = format!(":{prefix} {cmd} {target} :{body}"); diff --git a/src/mode.rs b/src/mode.rs index 614f013..7dfcd70 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -93,6 +93,7 @@ static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[ &DELAYMSG, &REPEAT, &EXEMPTCHANOPS, + &DELAYJOIN, ]; // --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- @@ -180,6 +181,10 @@ impl ChanMode for Prefix { _ => m.voice = adding, } } + // +D delayjoin: gaining a prefix reveals a hidden member + if adding { + s.reveal_member(tuid, key); + } Applied::Yes(Some(pn.to_string())) } } @@ -314,6 +319,9 @@ fn set_permanent(m: &mut ChanModes, v: bool) { fn set_opmoderated(m: &mut ChanModes, v: bool) { m.opmoderated = v; } +fn set_delayjoin(m: &mut ChanModes, v: bool) { + m.delayjoin = v; +} static NOKICKS: Flag = Flag { ch: 'Q', set: set_nokicks, @@ -330,6 +338,10 @@ static OPMODERATED: Flag = Flag { ch: 'U', set: set_opmoderated, }; +static DELAYJOIN: Flag = Flag { + ch: 'D', + set: set_delayjoin, +}; impl ChanMode for Flag { fn letter(&self) -> char { @@ -1247,7 +1259,7 @@ mod tests { #[test] fn registry_covers_all_channel_modes() { - for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUdKX".chars() { + for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGuBQAPJUdKXD".chars() { assert!(chan_mode(c).is_some(), "missing handler for +{c}"); } assert!(chan_mode('y').is_none()); diff --git a/src/server.rs b/src/server.rs index 284af6e..7f4bf35 100644 --- a/src/server.rs +++ b/src/server.rs @@ -451,9 +451,13 @@ impl Server { let mut seen: HashSet = HashSet::new(); for key in &user.channels { if let Some(ch) = self.channels.get_mut(key) { + // +D delayjoin: if their JOIN here was never announced, no QUIT either + let hidden = ch.members.get(&uid).map(|m| m.hidden).unwrap_or(false); ch.members.remove(&uid); - for &m in ch.members.keys() { - seen.insert(m); + if !hidden { + for &m in ch.members.keys() { + seen.insert(m); + } } } } diff --git a/src/users.rs b/src/users.rs index 2ac1088..d751c8c 100644 --- a/src/users.rs +++ b/src/users.rs @@ -373,6 +373,10 @@ impl Server { let chans: Vec = self.users[&uid].channels.iter().cloned().collect(); for key in &chans { if let Some(ch) = self.channels.get(key) { + // +D delayjoin: a still-hidden member's NICK isn't shown here + if ch.members.get(&uid).map(|m| m.hidden).unwrap_or(false) { + continue; + } for &m in ch.members.keys() { targets.insert(m); } @@ -418,7 +422,7 @@ impl Server { uid, RPL_MYINFO, &format!( - "{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdKX", + "{} echoircd-{VERSION} iowxsgBDIHrRzWc qaohvbeIklimnpstzCTcSNORMfjFLgGuBQAPJUdKXD", self.name ), ); @@ -426,7 +430,7 @@ impl Server { uid, RPL_ISUPPORT, &format!( - "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", + "CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server", self.network ), );