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:
+114
-4
@@ -27,6 +27,18 @@ pub enum Screen {
|
|||||||
Settings,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AppMessage {
|
pub enum AppMessage {
|
||||||
NicknameChanged(String),
|
NicknameChanged(String),
|
||||||
@@ -65,6 +77,10 @@ pub enum AppMessage {
|
|||||||
ToggleMicTest(bool),
|
ToggleMicTest(bool),
|
||||||
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
|
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
|
||||||
ToggleRecording,
|
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> {
|
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||||||
@@ -109,6 +125,9 @@ pub struct AppState {
|
|||||||
recording: bool,
|
recording: bool,
|
||||||
/// When the current recording started, for the header REC timer.
|
/// When the current recording started, for the header REC timer.
|
||||||
recording_started: Option<std::time::Instant>,
|
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.
|
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||||||
mic_level: f32,
|
mic_level: f32,
|
||||||
/// Whether the standalone (off-call) mic test stream is running.
|
/// Whether the standalone (off-call) mic test stream is running.
|
||||||
@@ -182,6 +201,8 @@ impl Default for AppState {
|
|||||||
call_started: None,
|
call_started: None,
|
||||||
recording: false,
|
recording: false,
|
||||||
recording_started: None,
|
recording_started: None,
|
||||||
|
chat_messages: Vec::new(),
|
||||||
|
chat_input: String::new(),
|
||||||
mic_level: 0.0,
|
mic_level: 0.0,
|
||||||
mic_test_active: false,
|
mic_test_active: false,
|
||||||
connecting: HashSet::new(),
|
connecting: HashSet::new(),
|
||||||
@@ -202,7 +223,9 @@ pub fn run_gui() -> iced::Result {
|
|||||||
.theme(theme)
|
.theme(theme)
|
||||||
.subscription(subscription)
|
.subscription(subscription)
|
||||||
.window(iced::window::Settings {
|
.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,
|
position: iced::window::Position::Centered,
|
||||||
exit_on_close_request: true,
|
exit_on_close_request: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -320,6 +343,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.call_started = None;
|
state.call_started = None;
|
||||||
state.recording = false;
|
state.recording = false;
|
||||||
state.recording_started = None;
|
state.recording_started = None;
|
||||||
|
state.chat_messages.clear();
|
||||||
|
state.chat_input.clear();
|
||||||
state.connecting.clear();
|
state.connecting.clear();
|
||||||
state.ever_connected.clear();
|
state.ever_connected.clear();
|
||||||
state.status_message = "Ready to connect".to_string();
|
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.recording_started = None;
|
||||||
state.status_message = format!("Saved recording → {path}");
|
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) => {
|
UiEvent::Error(err) => {
|
||||||
state.status_message = format!("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).
|
// Recording{Started,Stopped} events (so a failed start won't lie).
|
||||||
let _ = state.controller.send(CoreCommand::SetRecording(!state.recording));
|
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) => {
|
AppMessage::ToggleMicTest(enabled) => {
|
||||||
state.mic_test_active = enabled;
|
state.mic_test_active = enabled;
|
||||||
if !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 {
|
fn horizontal_space() -> iced::widget::Space {
|
||||||
iced::widget::Space::new().width(iced::Length::Fill)
|
iced::widget::Space::new().width(iced::Length::Fill)
|
||||||
}
|
}
|
||||||
@@ -1171,14 +1225,70 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.width(iced::Length::FillPortion(1))
|
.width(iced::Length::FillPortion(1))
|
||||||
.height(iced::Length::Fill);
|
.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(
|
container(
|
||||||
column![
|
column![
|
||||||
top_bar,
|
top_bar,
|
||||||
header_container,
|
header_container,
|
||||||
vertical_space(15.0),
|
vertical_space(12.0),
|
||||||
main_layout
|
main_layout,
|
||||||
|
vertical_space(12.0),
|
||||||
|
chat_dock
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
.padding(15)
|
.padding(15)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ pub enum CoreCommand {
|
|||||||
/// Start/stop recording the call to a local WAV (your mic + the incoming
|
/// Start/stop recording the call to a local WAV (your mic + the incoming
|
||||||
/// mix). No-op start if already recording / not in a call.
|
/// mix). No-op start if already recording / not in a call.
|
||||||
SetRecording(bool),
|
SetRecording(bool),
|
||||||
|
/// Broadcast a room text-chat message. No-op when not in a call.
|
||||||
|
SendChat(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -51,5 +53,8 @@ pub enum UiEvent {
|
|||||||
RecordingStarted { path: String },
|
RecordingStarted { path: String },
|
||||||
/// Call recording stopped; carries the finished WAV path.
|
/// Call recording stopped; carries the finished WAV path.
|
||||||
RecordingStopped { path: String },
|
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),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -850,6 +850,9 @@ async fn run_core_loop(
|
|||||||
transport_events.connect_peer(state.addr.clone()).await;
|
transport_events.connect_peer(state.addr.clone()).await;
|
||||||
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).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) => {
|
RoomEvent::PeerConnectionLost(peer_id) => {
|
||||||
// Transient drop: do NOT tear down the peer. Its audio
|
// Transient drop: do NOT tear down the peer. Its audio
|
||||||
// supervisor stays alive and keeps redialing the
|
// supervisor stays alive and keeps redialing the
|
||||||
@@ -1058,6 +1061,14 @@ async fn run_core_loop(
|
|||||||
stop_recording(&recorder, &is_recording, &ui_tx).await;
|
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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-2
@@ -20,6 +20,9 @@ pub struct GossipPayload {
|
|||||||
pub enum GossipMessage {
|
pub enum GossipMessage {
|
||||||
Announce(PeerState),
|
Announce(PeerState),
|
||||||
Leave,
|
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 {
|
pub struct IrohGossipState {
|
||||||
@@ -168,6 +171,15 @@ impl RoomState for IrohGossipState {
|
|||||||
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
|
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) => {
|
Err(e) => {
|
||||||
@@ -240,6 +252,33 @@ impl RoomState for IrohGossipState {
|
|||||||
Ok(())
|
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> {
|
async fn leave(&self) -> Result<(), NetError> {
|
||||||
crate::log_msg("RoomState::leave called");
|
crate::log_msg("RoomState::leave called");
|
||||||
{
|
{
|
||||||
@@ -328,8 +367,8 @@ mod tests {
|
|||||||
GossipMessage::Announce(state) => {
|
GossipMessage::Announce(state) => {
|
||||||
assert_eq!(state, peer_state);
|
assert_eq!(state, peer_state);
|
||||||
}
|
}
|
||||||
GossipMessage::Leave => {
|
GossipMessage::Leave | GossipMessage::Chat { .. } => {
|
||||||
panic!("Expected GossipMessage::Announce, got Leave");
|
panic!("Expected GossipMessage::Announce");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ pub enum RoomEvent {
|
|||||||
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
||||||
/// reconnect path the way it used to.
|
/// reconnect path the way it used to.
|
||||||
PeerConnectionLost(EndpointId),
|
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
|
/// 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.
|
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
|
||||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
|
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.
|
/// Leaves the room and announces departure.
|
||||||
async fn leave(&self) -> Result<(), NetError>;
|
async fn leave(&self) -> Result<(), NetError>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user