feat: in-room text chat over the gossip plane

Add room text chat riding the existing iroh-gossip topic (same layer as the
presence roster). New GossipMessage::Chat { name, text, ts }; the gossip loop
forwards it as RoomEvent::ChatMessage, core relays it to the UI as
UiEvent::ChatMessage, and RoomState::send_chat broadcasts an authored line
(display name from self-state, ms timestamp). CoreCommand::SendChat sends; our
own author is suppressed by the existing self-echo guard, so the UI echoes our
sent line locally instead.

UI: a full-width chat dock along the bottom of the room (the chosen layout) —
bottom-anchored scrollback with per-sender name colouring (green = you), an
input with Enter-to-send + a Send button, history capped at 300 lines. The room
window default grows to 900x760 so the dock doesn't squeeze the controls column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:55:49 -04:00
co-authored by Claude Opus 4.8
parent c3cf00f46f
commit a6aca73c67
5 changed files with 179 additions and 6 deletions
+41 -2
View File
@@ -20,6 +20,9 @@ pub struct GossipPayload {
pub enum GossipMessage {
Announce(PeerState),
Leave,
/// A room text-chat message: the author's display name, the text, and a
/// sender-stamped millisecond timestamp.
Chat { name: String, text: String, ts: u64 },
}
pub struct IrohGossipState {
@@ -168,6 +171,15 @@ impl RoomState for IrohGossipState {
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
}
}
GossipMessage::Chat { name, text, ts } => {
crate::log_msg(&format!("Gossip chat from author={:?}", payload.author));
let _ = event_tx.send(RoomEvent::ChatMessage {
from: payload.author,
name,
text,
ts,
}).await;
}
}
}
Err(e) => {
@@ -240,6 +252,33 @@ impl RoomState for IrohGossipState {
Ok(())
}
async fn send_chat(&self, text: String) -> Result<(), NetError> {
let (name, author) = {
let guard = self.self_state.lock().unwrap();
match guard.as_ref() {
Some(s) => (s.name.clone(), s.addr.id),
None => return Err(NetError::Other("Not in a room".to_string())),
}
};
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let sender_opt = self.active_sender.lock().unwrap().clone();
if let Some(sender) = sender_opt {
let payload = GossipPayload {
author,
msg: GossipMessage::Chat { name, text, ts },
};
if let Ok(bytes) = serde_json::to_vec(&payload) {
sender.broadcast(bytes.into()).await
.map_err(|e| NetError::Gossip(e.to_string()))?;
}
}
Ok(())
}
async fn leave(&self) -> Result<(), NetError> {
crate::log_msg("RoomState::leave called");
{
@@ -328,8 +367,8 @@ mod tests {
GossipMessage::Announce(state) => {
assert_eq!(state, peer_state);
}
GossipMessage::Leave => {
panic!("Expected GossipMessage::Announce, got Leave");
GossipMessage::Leave | GossipMessage::Chat { .. } => {
panic!("Expected GossipMessage::Announce");
}
}
}