diff --git a/src/app/mod.rs b/src/app/mod.rs index 4c9e10b..17743d5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { @@ -109,6 +125,9 @@ pub struct AppState { recording: bool, /// When the current recording started, for the header REC timer. recording_started: Option, + /// Room text-chat history (newest last) and the pending input line. + chat_messages: Vec, + 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 { 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 { 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 { // 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, 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) diff --git a/src/core/messages.rs b/src/core/messages.rs index 8510871..fa09f25 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -29,6 +29,8 @@ pub enum CoreCommand { /// Start/stop recording the call to a local WAV (your mic + the incoming /// mix). No-op start if already recording / not in a call. SetRecording(bool), + /// Broadcast a room text-chat message. No-op when not in a call. + SendChat(String), } #[derive(Debug, Clone)] @@ -51,5 +53,8 @@ pub enum UiEvent { RecordingStarted { path: String }, /// Call recording stopped; carries the finished WAV path. RecordingStopped { path: String }, + /// A room text-chat message arrived from a peer (never our own — local + /// messages are echoed by the UI on send). + ChatMessage { name: String, text: String }, Error(String), } diff --git a/src/core/mod.rs b/src/core/mod.rs index 1fc8328..b525973 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -850,6 +850,9 @@ async fn run_core_loop( transport_events.connect_peer(state.addr.clone()).await; let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await; } + RoomEvent::ChatMessage { name, text, .. } => { + let _ = ui_tx_events.send(UiEvent::ChatMessage { name, text }).await; + } RoomEvent::PeerConnectionLost(peer_id) => { // Transient drop: do NOT tear down the peer. Its audio // supervisor stays alive and keeps redialing the @@ -1058,6 +1061,14 @@ async fn run_core_loop( stop_recording(&recorder, &is_recording, &ui_tx).await; } } + + CoreCommand::SendChat(text) => { + if let Some(session) = &active_session + && let Err(e) = session.room_state.send_chat(text).await + { + crate::log_msg(&format!("Failed to send chat: {e}")); + } + } } } diff --git a/src/network/gossip.rs b/src/network/gossip.rs index fd27e0a..093e4f1 100644 --- a/src/network/gossip.rs +++ b/src/network/gossip.rs @@ -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"); } } } diff --git a/src/network/mod.rs b/src/network/mod.rs index d73f337..6750c69 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -42,6 +42,10 @@ pub enum RoomEvent { /// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the /// reconnect path the way it used to. PeerConnectionLost(EndpointId), + /// A peer sent a room text-chat message. Carries the sender's id, their + /// display name (embedded so it shows even without a presence entry), the + /// text, and a sender-stamped millisecond timestamp. + ChatMessage { from: EndpointId, name: String, text: String, ts: u64 }, } /// Transport-level link state for a peer, surfaced so the UI can show when a @@ -127,6 +131,10 @@ pub trait RoomState: Send + Sync { /// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it. async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>; + /// Broadcasts a room text-chat message authored by us (our display name is + /// taken from the current self-state). + async fn send_chat(&self, text: String) -> Result<(), NetError>; + /// Leaves the room and announces departure. async fn leave(&self) -> Result<(), NetError>;