From 60c1951567c8e3d5afee8fde3b16ba03c39901cb Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 20 Jun 2026 23:15:06 -0400 Subject: [PATCH] Chat file attachments, stages 3-4: core wiring + chat UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the send/receive paths and the chat UI on top of the file plane. (Committed together because the UI renders the state the core wiring produces.) Core: - CoreCommand::SendChatFile {text, attachment, data}: serve the bytes on the file plane (serve_attachment) then broadcast the descriptor via send_chat. CoreCommand::FetchAttachment {from, attachment}: detached fetch -> AttachmentReady/AttachmentFailed. - On an inbound Chat with an Image attachment, auto-fetch + defensively re-validate (decodable + within pixel limits) before delivering; non-images wait for an explicit fetch (the Save/Download chip). - UiEvent::ChatMessage carries the attachment; new AttachmentReady / AttachmentFailed events keyed by attachment id. App: - πŸ“Ž attach button + native picker; reads the file, enforces the size cap, classifies image vs file, mints a random id, optimistically echoes the message + caches our own bytes (so we see our own image inline), and sends SendChatFile. - Renders inline image thumbnails (handle cached by id to avoid the per-redraw re-upload flicker), file chips with Save/Download, a loading placeholder for in-flight images, and an error line on failure. Image messages with no caption still render. - SaveAttachment: saves immediately if bytes are in hand, else fetches then saves when ready (pending_saves) via a native save dialog; filename defaulted from the sanitized descriptor. - Session-only: attachment bytes/handles cleared on leave, never persisted. Binary + clippy clean, 349 lib tests pass. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 251 ++++++++++++++++++++++++++++++++++++++++++- src/core/messages.rs | 16 ++- src/core/mod.rs | 82 +++++++++++++- 3 files changed, 345 insertions(+), 4 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 5bc2143..e7e52fe 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -114,6 +114,19 @@ struct ChatEntry { /// Sender's node id string, used to key their avatar colour (W4). `None` only /// for any future system-generated lines. from: Option, + /// Optional file attachment descriptor. The bytes (if fetched) live in + /// `AppState.attachment_data` keyed by `attachment.id`; the entry only holds + /// the descriptor so history stays cheap. + attachment: Option, +} + +/// Fetch state of a chat attachment's bytes (session-only). +#[derive(Debug, Clone)] +enum AttachmentState { + /// Bytes in hand (image decoded-valid, or a file ready to save). + Ready(Vec), + /// Fetch or decode failed; carries a short reason for the UI. + Failed(String), } /// Cap on retained chat history so a long call can't grow it without bound. @@ -264,6 +277,12 @@ pub enum AppMessage { ToggleRecording, /// Live edits to the chat input line. ChatInputChanged(String), + /// Open the native picker to attach a file to the chat. + PickAttachmentFile, + /// Result of the attach picker: (filename, bytes), or None if cancelled. + AttachmentFilePicked(Option<(String, Vec)>), + /// Save (downloading first if needed) a received attachment to disk. + SaveAttachment(crate::files::AttachmentId), /// Send the current chat input line (Enter or the Send button). ChatSubmit, /// Open a clicked chat link in the system browser (A13). @@ -362,6 +381,15 @@ pub struct AppState { /// Room text-chat history (newest last) and the pending input line. chat_messages: Vec, chat_input: String, + /// Fetched/failed state for chat attachments, keyed by attachment id. + /// Session-only (cleared on leave); never persisted. + attachment_data: HashMap, + /// Cached iced image handles for ready image attachments, keyed by id, so we + /// don't re-upload to the GPU every redraw (the e917c53 avatar flicker fix). + image_handle_cache: HashMap, + /// Attachment ids the user asked to save before the bytes arrived; when the + /// fetch completes a save dialog is opened for them. + pending_saves: std::collections::HashSet, /// Last known window size, tracked so divider clamps stay valid on resize. /// (The divider positions themselves are persisted in `config`.) window_size: Size, @@ -521,6 +549,9 @@ impl Default for AppState { recording: false, recording_started: None, chat_messages: Vec::new(), + attachment_data: HashMap::new(), + image_handle_cache: HashMap::new(), + pending_saves: std::collections::HashSet::new(), chat_input: String::new(), window_size: Size::new(ww, wh), layout_picker_open: false, @@ -996,19 +1027,45 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.recording_started = None; state.status_message = format!("Saved recording β†’ {path}"); } - UiEvent::ChatMessage { from, name, text } => { + UiEvent::ChatMessage { from, name, text, attachment } => { // Incoming peer content is untrusted β€” sanitize name + text. + // (The attachment filename was already sanitized in core.) let text = sanitize_chat(&text); - if !text.is_empty() { + // Keep the message if it has visible text OR an attachment (an + // image with no caption is still a real message). + if !text.is_empty() || attachment.is_some() { let name = sanitize_chat(&name); push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false, from: Some(from), + attachment, }); } } + UiEvent::AttachmentReady { id, data } => { + // Bytes arrived. For images we can cache the iced handle now + // (built once, not per redraw). If the user was waiting to save + // this file, the save dialog is opened from update() below by + // checking pending_saves β€” done lazily so this arm stays simple. + if crate::files::validate_image_bytes(&data).is_some() { + state.image_handle_cache.insert( + id, + iced::widget::image::Handle::from_bytes(data.clone()), + ); + } + let needs_save = state.pending_saves.remove(&id); + state.attachment_data.insert(id, AttachmentState::Ready(data)); + if needs_save { + save_attachment_to_disk(state, id); + } + } + UiEvent::AttachmentFailed { id, error } => { + state.pending_saves.remove(&id); + state.attachment_data.insert(id, AttachmentState::Failed(error.clone())); + state.status_message = format!("Attachment failed: {error}"); + } UiEvent::ScreenShareStarted => { state.self_sharing = true; state.status_message = "Sharing your screen".to_string(); @@ -1491,11 +1548,93 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { text: text.clone(), mine: true, from: Some(state.self_id.clone()), + attachment: None, }); let _ = state.controller.send(CoreCommand::SendChat(text)); state.chat_input.clear(); } } + AppMessage::PickAttachmentFile => { + // Native picker off the UI thread; returns (filename, bytes). + return Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .set_title("Attach a file to the chat") + .pick_file() + .await; + match handle { + Some(h) => Some((h.file_name(), h.read().await)), + None => None, + } + }, + AppMessage::AttachmentFilePicked, + ); + } + AppMessage::AttachmentFilePicked(picked) => { + if let Some((name, bytes)) = picked { + let size = bytes.len() as u64; + if !crate::files::size_within_cap(size) { + state.status_message = format!( + "File too large β€” max {}.", + crate::files::human_size(crate::files::MAX_ATTACHMENT_BYTES) + ); + return Task::none(); + } + let kind = crate::files::classify(&bytes); + // Random 32-byte handle for this attachment. + let id: crate::files::AttachmentId = rand::random(); + let att = crate::files::ChatAttachment { + name: crate::files::sanitize_filename(&name), + size, + kind, + id, + }; + // Keep our own bytes locally so we see our own attachment inline + // immediately (others fetch it off the file plane). + if kind == crate::files::AttachmentKind::Image + && crate::files::validate_image_bytes(&bytes).is_some() + { + state + .image_handle_cache + .insert(id, iced::widget::image::Handle::from_bytes(bytes.clone())); + } + state + .attachment_data + .insert(id, AttachmentState::Ready(bytes.clone())); + push_chat( + &mut state.chat_messages, + ChatEntry { + name: format!("{} (You)", state.name), + text: String::new(), + mine: true, + from: Some(state.self_id.clone()), + attachment: Some(att.clone()), + }, + ); + let _ = state.controller.send(CoreCommand::SendChatFile { + text: String::new(), + attachment: att, + data: bytes, + }); + } + } + AppMessage::SaveAttachment(id) => { + // If we already have the bytes, save now; otherwise fetch from the + // sender and save when AttachmentReady arrives (pending_saves). + if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) { + save_attachment_to_disk(state, id); + } else if let Some((from, att)) = find_attachment_source(state, id) { + if let Ok(eid) = from.parse::() { + state.pending_saves.insert(id); + state.status_message = format!("Downloading {}…", att.name); + let _ = state + .controller + .send(CoreCommand::FetchAttachment { from: eid, attachment: att }); + } else { + state.status_message = "Can't download: unknown sender.".to_string(); + } + } + } AppMessage::OpenUrl(url) => { // Defence in depth: only ever hand http(s) URLs to the opener. The // link span's href came from `linkify`, which only emits http/https, @@ -1729,6 +1868,54 @@ fn push_chat(messages: &mut Vec, entry: ChatEntry) { } } +/// Find the sender id + descriptor for a received attachment by its id, so a +/// fetch can be addressed. Returns `None` for our own attachments or an unknown +/// id. +fn find_attachment_source( + state: &AppState, + id: crate::files::AttachmentId, +) -> Option<(String, crate::files::ChatAttachment)> { + state.chat_messages.iter().find_map(|m| { + let att = m.attachment.as_ref()?; + if att.id == id && !m.mine { + Some((m.from.clone()?, att.clone())) + } else { + None + } + }) +} + +/// Write a ready attachment's bytes to a user-chosen location via a native save +/// dialog. The default filename comes from the (already-sanitized) descriptor. +/// No-op if the bytes aren't ready. Sync dialog: the brief block is acceptable +/// for a deliberate save action. +fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) { + let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else { + return; + }; + let data = data.clone(); + let default_name = state + .chat_messages + .iter() + .find_map(|m| { + m.attachment + .as_ref() + .filter(|a| a.id == id) + .map(|a| a.name.clone()) + }) + .unwrap_or_else(|| "download".to_string()); + if let Some(path) = rfd::FileDialog::new() + .set_file_name(default_name) + .set_title("Save attachment") + .save_file() + { + match std::fs::write(&path, &data) { + Ok(()) => state.status_message = format!("Saved {}", path.display()), + Err(e) => state.status_message = format!("Save failed: {e}"), + } + } +} + fn horizontal_space() -> iced::widget::Space { iced::widget::Space::new().width(iced::Length::Fill) } @@ -3693,6 +3880,59 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .spacing(8) .align_y(iced::alignment::Vertical::Top), ); + // Attachment row (indented under the message), if any. + if let Some(att) = &m.attachment { + let data = state.attachment_data.get(&att.id); + let elem: Element<'_, AppMessage> = + if let Some(AttachmentState::Failed(e)) = data { + text(format!("⚠ {} β€” {e}", att.name)) + .size(12) + .color(color_red) + .into() + } else if att.kind == crate::files::AttachmentKind::Image { + match state.image_handle_cache.get(&att.id) { + Some(handle) => iced::widget::image(handle.clone()) + .width(iced::Length::Fixed(260.0)) + .into(), + None => text(format!("πŸ–Ό {} β€” loading…", att.name)) + .size(12) + .color(color_subtext) + .into(), + } + } else { + let ready = + matches!(data, Some(AttachmentState::Ready(_))); + let btn_label = if ready { "Save" } else { "Download" }; + row![ + text(format!( + "πŸ“Ž {} ({})", + att.name, + crate::files::human_size(att.size) + )) + .size(12) + .color(color_text), + button(text(btn_label).size(12)) + .on_press(AppMessage::SaveAttachment(att.id)) + .style(b_style( + color_blue, + color_lavender, + color_crust, + 6.0, + )) + .padding(6), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) + .into() + }; + chat_col = chat_col.push( + row![ + iced::widget::Space::new().width(iced::Length::Fixed(30.0)), + elem + ] + .spacing(0), + ); + } } } let chat_scroll = scrollable(chat_col) @@ -3700,6 +3940,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .height(iced::Length::Fill) .anchor_bottom(); let chat_input_row = row![ + button(text("πŸ“Ž").size(15)) + .on_press(AppMessage::PickAttachmentFile) + .style(b_style(color_surface, color_overlay, color_text, 6.0)) + .padding(8), text_input("Message the room…", &state.chat_input) .on_input(AppMessage::ChatInputChanged) .on_submit(AppMessage::ChatSubmit) @@ -5286,6 +5530,7 @@ mod tests { text: "Hello".to_string(), mine: true, from: None, + attachment: None, }; push_chat(&mut messages, entry); assert_eq!(messages.len(), 1); @@ -5306,6 +5551,7 @@ mod tests { text: format!("Msg{}", i), mine: i % 2 == 0, from: None, + attachment: None, }, ); } @@ -5329,6 +5575,7 @@ mod tests { text: format!("Msg{}", i), mine: i % 2 == 0, from: None, + attachment: None, }, ); } diff --git a/src/core/messages.rs b/src/core/messages.rs index 134899d..94b68e9 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -53,6 +53,14 @@ pub enum CoreCommand { SetRecordingMode(RecordingMode), /// Broadcast a room text-chat message. No-op when not in a call. SendChat(String), + /// Send a chat message carrying a file attachment. The app has already read + + /// capped the file and built the descriptor; core makes the bytes available + /// on the file plane and broadcasts the descriptor. + SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec }, + /// Fetch a received attachment's bytes from its sender over the file plane + /// (used for on-demand file/chip downloads; images are auto-fetched on + /// receipt). Replies with `AttachmentReady`/`AttachmentFailed`. + FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment }, /// Set the pixelpass binary location (config override, empty = use `$PATH`). /// Sent at startup so screen-share can resolve the binary. SetPixelpassPath(Option), @@ -109,7 +117,13 @@ pub enum UiEvent { /// A room text-chat message arrived from a peer (never our own β€” local /// messages are echoed by the UI on send). `from` is the sender's node id /// string, used to key their avatar (W4). - ChatMessage { from: String, name: String, text: String }, + ChatMessage { from: String, name: String, text: String, attachment: Option }, + /// An attachment's bytes are now available (auto-fetched for images, or + /// fetched on demand for files). Keyed by attachment id so the UI can match + /// it to the chat entry. + AttachmentReady { id: crate::files::AttachmentId, data: Vec }, + /// An attachment fetch failed (sender gone, too large, decode error, etc.). + AttachmentFailed { id: crate::files::AttachmentId, error: String }, /// Our own screen share started; the UI flips the Share button to "Stop". ScreenShareStarted, /// Our own screen share stopped (or failed to start). diff --git a/src/core/mod.rs b/src/core/mod.rs index fc5ef44..24c8783 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -717,6 +717,44 @@ async fn build_net_stack( }) } +/// Fetch a chat attachment's bytes from `from` over the file plane in a detached +/// task, then report the result to the UI via [`UiEvent::AttachmentReady`] / +/// [`UiEvent::AttachmentFailed`] keyed by the attachment id. For images +/// (`is_image`) the bytes are defensively re-validated (decodable + within pixel +/// limits) before being handed to the renderer; an "Image" that doesn't decode is +/// reported as a failure rather than rendered. +fn spawn_attachment_fetch( + transport: Arc, + ui_tx: mpsc::Sender, + from: EndpointId, + att: crate::files::ChatAttachment, + is_image: bool, +) { + tokio::spawn(async move { + match transport.fetch_attachment(from, &att).await { + Ok(data) => { + if is_image && crate::files::validate_image_bytes(&data).is_none() { + let _ = ui_tx + .send(UiEvent::AttachmentFailed { + id: att.id, + error: "received image failed to decode".to_string(), + }) + .await; + return; + } + let _ = ui_tx + .send(UiEvent::AttachmentReady { id: att.id, data }) + .await; + } + Err(e) => { + let _ = ui_tx + .send(UiEvent::AttachmentFailed { id: att.id, error: e.to_string() }) + .await; + } + } + }); +} + /// Finalize and clear the active recording, if any, emitting `RecordingStopped`. /// No-op when not recording. Called on stop, room leave, and room switch so a /// recording is always closed cleanly (its WAV size fields patched). @@ -1734,11 +1772,27 @@ async fn run_core_loop( .insert(peer_id, state.addr.clone()); let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await; } - RoomEvent::ChatMessage { from, name, text, .. } => { + RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => { + // Auto-fetch image attachments so they render inline + // without a click; non-image files wait for an explicit + // FetchAttachment (the "Save" chip). The descriptor was + // already filename-sanitized + size-capped on ingest. + if let Some(att) = attachment.clone() + && att.kind == crate::files::AttachmentKind::Image + { + spawn_attachment_fetch( + transport_events.clone(), + ui_tx_events.clone(), + from, + att, + true, + ); + } let _ = ui_tx_events.send(UiEvent::ChatMessage { from: from.to_string(), name, text, + attachment, }).await; } RoomEvent::PeerConnectionLost(peer_id) => { @@ -2227,6 +2281,32 @@ async fn run_core_loop( } } + CoreCommand::SendChatFile { text, attachment, data } => { + if let Some(session) = &active_session { + // Make the bytes fetchable by room members, then broadcast the + // descriptor alongside the (possibly empty) caption text. + session + .transport + .serve_attachment(attachment.id, Arc::new(data)); + if let Err(e) = session.room_state.send_chat(text, Some(attachment)).await { + crate::log_msg(&format!("Failed to send chat file: {e}")); + } + } + } + + CoreCommand::FetchAttachment { from, attachment } => { + if let Some(session) = &active_session { + let is_image = attachment.kind == crate::files::AttachmentKind::Image; + spawn_attachment_fetch( + session.transport.clone(), + ui_tx.clone(), + from, + attachment, + is_image, + ); + } + } + CoreCommand::SetPixelpassPath(path) => { pixelpass_override = path.filter(|p| !p.trim().is_empty()); }