The new buffer a whisper opened was a missing "#"

Every room's buffer is named "#general"; whispers were written to
"general". Not a typo without consequence - it made a second, empty buffer
per room and put the whisper in it, which is the new chat buffer that
opened when one was sent. Nothing was opening a window; a conversation was
being written where nobody was looking.

There is now one definition of a room's buffer name and one of the room
behind it, so the two cannot disagree again, and a test that pairs them -
because the mistake was invisible in every line that made it.

A whisper we send also goes to the room it was sent from rather than to
all of them. The caller says which; a caller that does not is answered
rather than refused, and it goes everywhere as before. Inbound stays in
every room, because a reply arriving from nowhere in particular has to be
visible wherever the reader happens to be.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-01 23:19:23 -04:00
co-authored by Claude Opus 5
parent 1d035c415d
commit 919a7c32b4
2 changed files with 81 additions and 13 deletions
+69 -10
View File
@@ -346,7 +346,7 @@ async fn run_room_once(state: &AppState, transport: &Transport, session: &Sessio
let _sender_guard = RemoveSenderOnDrop { state, account_id, room: room.id, sender: out_tx.clone() };
out_tx.send(format!("/join {}", room.id)).map_err(|_| anyhow!("chat socket closed before joining"))?;
let buffer_name = format!("#{}", room.name);
let buffer_name = room_buffer_name(&room.name);
// Created eagerly rather than waiting for the first live message - a
// quiet room would otherwise show no buffer/tab at all despite being
// connected and joined.
@@ -456,7 +456,7 @@ async fn handle_frame(state: &AppState, http: &http::HttpClient, host: &str, acc
}
tracing::debug!("sockchat[{account_id}]: whisper from {} (no room_id)", m.author.username);
let msg_id = (!m.message_uuid.is_empty()).then(|| m.message_uuid.clone());
for buffer in record_whisper(state, account_id, &m.author.username, &body, msg_id, avatar_url, true) {
for buffer in record_whisper(state, account_id, &m.author.username, &body, msg_id, avatar_url, true, None) {
if !m.message_uuid.is_empty() {
spawn_attachment_resolve(state.clone(), http.clone(), buffer, m.message_uuid.clone(), body.clone());
}
@@ -496,7 +496,7 @@ async fn handle_frame(state: &AppState, http: &http::HttpClient, host: &str, acc
None => None,
};
let msg_id = (!w.message_uuid.is_empty()).then(|| w.message_uuid.clone());
for buffer in record_whisper(state, account_id, &w.author.username, &body, msg_id, avatar_url, true) {
for buffer in record_whisper(state, account_id, &w.author.username, &body, msg_id, avatar_url, true, None) {
if !w.message_uuid.is_empty() {
spawn_attachment_resolve(state.clone(), http.clone(), buffer, w.message_uuid.clone(), body.clone());
}
@@ -830,12 +830,29 @@ fn spawn_attachment_resolve(state: AppState, http: http::HttpClient, buffer_id:
/// connected simultaneously.
fn room_sender_for_buffer(state: &AppState, account_id: &str, buffer_name: &str) -> Result<tokio::sync::mpsc::UnboundedSender<String>> {
let cfg = state.accounts.get_sockchat(account_id).ok_or_else(|| anyhow!("no such account"))?;
let room_name = buffer_name.strip_prefix('#').unwrap_or(buffer_name);
let room_name = room_name_of(buffer_name);
let rooms = effective_rooms(&cfg);
let room = rooms.iter().find(|r| r.name == room_name).ok_or_else(|| anyhow!("\"{buffer_name}\" isn't one of this account's configured rooms"))?;
state.runtime.sockchat_sender(account_id, room.id).ok_or_else(|| anyhow!("not currently connected to this room"))
}
/// What a room's buffer is called.
///
/// The `#` is not decoration - it is part of the name every other part of this
/// daemon and every frontend knows the room by. Writing whispers into
/// `room.name` instead of this made a second, empty buffer per room named
/// without it, which is the "new chat buffer" that opened when one was sent:
/// not a window being opened, but a conversation being written somewhere
/// nobody was looking. One definition so the two cannot disagree again.
fn room_buffer_name(room_name: &str) -> String {
format!("#{room_name}")
}
/// And the room behind a buffer's name - the same pairing read backwards.
fn room_name_of(buffer_name: &str) -> &str {
buffer_name.strip_prefix('#').unwrap_or(buffer_name)
}
/// Puts a whisper in front of whoever is reading, whichever room that is.
///
/// A whisper belongs to no room, and the site shows it wherever you happen to
@@ -862,10 +879,22 @@ fn record_whisper(
// `notify`: whether this one should raise an alert. True for a whisper
// somebody sent us; false for one we sent, which needs no telling.
notify: bool,
// `only`: the one buffer this belongs in, for a whisper we sent - it
// belongs in the conversation it was sent from, not in every room at
// once. Absent for an inbound one, which arrives from nowhere in
// particular and has to be visible wherever the reader is.
only: Option<&str>,
) -> Vec<String> {
let Some(cfg) = state.accounts.get_sockchat(account_id) else { return Vec::new() };
let mut fresh = Vec::new();
for (index, room) in effective_rooms(&cfg).into_iter().enumerate() {
let rooms: Vec<SockChatRoom> = match only {
Some(buffer_name) => {
let wanted = room_name_of(buffer_name);
effective_rooms(&cfg).into_iter().filter(|r| r.name == wanted).collect()
}
None => effective_rooms(&cfg),
};
for (index, room) in rooms.into_iter().enumerate() {
// Highlighted in the first room only, and highlighting is what raises
// an alert - so one whisper is one notification however many rooms it
// is written into. The copies are still whispers and are still drawn
@@ -873,10 +902,11 @@ fn record_whisper(
// highlight; what they are not is a second time somebody's desktop
// says the same thing.
let alert = notify && index == 0;
let name = room_buffer_name(&room.name);
let is_new = state.runtime.record_message(
state,
account_id,
&room.name,
&name,
"channel",
from,
body,
@@ -891,7 +921,7 @@ fn record_whisper(
None,
);
if is_new {
fresh.push(crate::model::buffer_id(account_id, &room.name));
fresh.push(crate::model::buffer_id(account_id, &name));
}
}
fresh
@@ -945,7 +975,7 @@ pub fn send_message(state: &AppState, account_id: &str, buffer_name: &str, body:
// as a whisper here too - passed straight through it would be a real
// whisper that this client never saw, since the site echoes none back.
if let Some((target, text)) = protocol::parse_whisper_command(body) {
return send_whisper(state, account_id, &target, &text);
return send_whisper(state, account_id, &target, &text, Some(buffer_name));
}
let body = as_reply(body, reply_to);
let Some(text) = protocol::prepare_outgoing(&body) else { return Ok(()) };
@@ -968,7 +998,16 @@ pub fn send_message(state: &AppState, account_id: &str, buffer_name: &str, body:
/// under the recipient's name reads as something they said - which is what it
/// used to do. The `@name` is what the site puts there too, so the line reads
/// the way the same whisper reads on the site.
pub fn send_whisper(state: &AppState, account_id: &str, target: &str, body: &str) -> Result<()> {
pub fn send_whisper(
state: &AppState,
account_id: &str,
target: &str,
body: &str,
// `from_buffer`: the conversation it was sent from, so it appears there
// rather than in every room. None falls back to all of them, which is
// right for a caller that has no room in mind.
from_buffer: Option<&str>,
) -> Result<()> {
let target = target.trim();
if target.is_empty() {
bail!("no one to whisper to");
@@ -986,7 +1025,7 @@ pub fn send_whisper(state: &AppState, account_id: &str, target: &str, body: &str
.unwrap_or_default();
let at = if target.starts_with('@') { "" } else { "@" };
let named = target.trim_end_matches(',');
record_whisper(state, account_id, &me, &format!("{at}{named}, {text}"), None, None, false);
record_whisper(state, account_id, &me, &format!("{at}{named}, {text}"), None, None, false, from_buffer);
Ok(())
}
@@ -1715,3 +1754,23 @@ fn update_roster(
serde_json::json!({ "bufferId": buffer_id, "members": member_list }),
);
}
#[cfg(test)]
mod buffer_name_tests {
use super::*;
#[test]
fn a_room_and_its_buffer_name_are_one_pairing() {
// The bug this exists to prevent: whispers were written to
// `room.name`, while every room's buffer is named with a "#" in front.
// That is not a typo with no consequence - it made a second, empty
// buffer per room, which is the "new chat buffer" that opened when a
// whisper was sent.
assert_eq!(room_buffer_name("general"), "#general");
assert_eq!(room_name_of(&room_buffer_name("general")), "general");
assert_eq!(room_name_of("#fishtank"), "fishtank");
// Tolerant read-back: a caller that already stripped it gets the same
// answer rather than a different one.
assert_eq!(room_name_of("fishtank"), "fishtank");
}
}
+12 -3
View File
@@ -2003,15 +2003,24 @@ pub async fn dispatch(
}
// A private message on Sneedchat, which has no direct-message
// conversations of its own - whispers arrive in one shared buffer and
// are addressed by name rather than by opening anything.
// conversations of its own: a whisper is addressed by name and shown
// in the room, rather than opening anything.
//
// `bufferId` is where it was sent from, so it appears in that
// conversation rather than in every room the account is in. Optional,
// because a caller with no room in mind is answered rather than
// refused - it simply goes everywhere instead.
"sendWhisper" => {
let (account_id, target, body) =
match (p_str_opt(params, "accountId"), p_str_opt(params, "target"), p_str_opt(params, "body")) {
(Some(a), Some(t), Some(b)) => (a, t, b),
_ => return (None, Some("sendWhisper requires \"accountId\", \"target\" and \"body\"".to_string())),
};
match backend::sockchat::send_whisper(state, account_id, target, body) {
let from_buffer = p_str_opt(params, "bufferId")
.and_then(|id| state.runtime.get_buffer(id))
.filter(|b| b.account_id == account_id)
.map(|b| b.name);
match backend::sockchat::send_whisper(state, account_id, target, body, from_buffer.as_deref()) {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(e.to_string())),
}