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
+114 -4
View File
@@ -27,6 +27,18 @@ pub enum Screen {
Settings,
}
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
/// messages from peers' for colouring.
#[derive(Debug, Clone)]
struct ChatEntry {
name: String,
text: String,
mine: bool,
}
/// Cap on retained chat history so a long call can't grow it without bound.
const CHAT_HISTORY_MAX: usize = 300;
#[derive(Debug, Clone)]
pub enum AppMessage {
NicknameChanged(String),
@@ -65,6 +77,10 @@ pub enum AppMessage {
ToggleMicTest(bool),
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
ToggleRecording,
/// Live edits to the chat input line.
ChatInputChanged(String),
/// Send the current chat input line (Enter or the Send button).
ChatSubmit,
}
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
@@ -109,6 +125,9 @@ pub struct AppState {
recording: bool,
/// When the current recording started, for the header REC timer.
recording_started: Option<std::time::Instant>,
/// Room text-chat history (newest last) and the pending input line.
chat_messages: Vec<ChatEntry>,
chat_input: String,
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
mic_level: f32,
/// Whether the standalone (off-call) mic test stream is running.
@@ -182,6 +201,8 @@ impl Default for AppState {
call_started: None,
recording: false,
recording_started: None,
chat_messages: Vec::new(),
chat_input: String::new(),
mic_level: 0.0,
mic_test_active: false,
connecting: HashSet::new(),
@@ -202,7 +223,9 @@ pub fn run_gui() -> iced::Result {
.theme(theme)
.subscription(subscription)
.window(iced::window::Settings {
size: iced::Size::new(900.0, 600.0),
// Taller default so the bottom chat dock doesn't squeeze the controls
// column. Layout is responsive (Fill), so resizing still works.
size: iced::Size::new(900.0, 760.0),
position: iced::window::Position::Centered,
exit_on_close_request: true,
..Default::default()
@@ -320,6 +343,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.call_started = None;
state.recording = false;
state.recording_started = None;
state.chat_messages.clear();
state.chat_input.clear();
state.connecting.clear();
state.ever_connected.clear();
state.status_message = "Ready to connect".to_string();
@@ -382,6 +407,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.recording_started = None;
state.status_message = format!("Saved recording → {path}");
}
UiEvent::ChatMessage { name, text } => {
push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false });
}
UiEvent::Error(err) => {
state.status_message = format!("Error: {}", err);
}
@@ -479,6 +507,22 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Recording{Started,Stopped} events (so a failed start won't lie).
let _ = state.controller.send(CoreCommand::SetRecording(!state.recording));
}
AppMessage::ChatInputChanged(val) => {
state.chat_input = val;
}
AppMessage::ChatSubmit => {
let text = state.chat_input.trim().to_string();
if !text.is_empty() {
// Local echo (gossip suppresses our own author, so it won't come back).
push_chat(&mut state.chat_messages, ChatEntry {
name: format!("{} (You)", state.name),
text: text.clone(),
mine: true,
});
let _ = state.controller.send(CoreCommand::SendChat(text));
state.chat_input.clear();
}
}
AppMessage::ToggleMicTest(enabled) => {
state.mic_test_active = enabled;
if !enabled {
@@ -550,6 +594,16 @@ fn format_duration(total_secs: u64) -> String {
}
}
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
/// call can't grow the buffer without bound.
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
messages.push(entry);
if messages.len() > CHAT_HISTORY_MAX {
let overflow = messages.len() - CHAT_HISTORY_MAX;
messages.drain(..overflow);
}
}
fn horizontal_space() -> iced::widget::Space {
iced::widget::Space::new().width(iced::Length::Fill)
}
@@ -1171,14 +1225,70 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.width(iced::Length::FillPortion(1))
.height(iced::Length::Fill);
let main_layout = row![peers_panel, control_panel].spacing(15);
let main_layout = row![peers_panel, control_panel]
.spacing(15)
.height(iced::Length::Fill);
// --- CHAT DOCK (full-width strip along the bottom) ---
let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill);
if state.chat_messages.is_empty() {
chat_col = chat_col.push(
text("No messages yet — say hi to the room.")
.size(12)
.color(color_subtext),
);
} else {
for m in &state.chat_messages {
let name_color = if m.mine { color_green } else { color_lavender };
chat_col = chat_col.push(
row![
text(format!("{}:", m.name)).size(12).color(name_color),
text(&m.text).size(13).color(color_text).width(iced::Length::Fill),
]
.spacing(8),
);
}
}
let chat_scroll = scrollable(chat_col)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.anchor_bottom();
let chat_input_row = row![
text_input("Message the room…", &state.chat_input)
.on_input(AppMessage::ChatInputChanged)
.on_submit(AppMessage::ChatSubmit)
.style(t_style)
.padding(8),
button(text("Send").size(13))
.on_press(AppMessage::ChatSubmit)
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
.padding(8),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center);
let chat_dock = container(
column![
text("Chat").size(16).color(color_blue),
chat_scroll,
chat_input_row,
]
.spacing(8),
)
.style(c_style(color_mantle, color_surface, 8.0))
.padding(12)
.width(iced::Length::Fill)
.height(iced::Length::Fixed(180.0));
container(
column![
top_bar,
header_container,
vertical_space(15.0),
main_layout
vertical_space(12.0),
main_layout,
vertical_space(12.0),
chat_dock
]
)
.padding(15)