Chat file attachments, stages 3-4: core wiring + chat UI
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 <noreply@anthropic.com>
This commit is contained in:
+249
-2
@@ -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<String>,
|
||||
/// 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<crate::files::ChatAttachment>,
|
||||
}
|
||||
|
||||
/// 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<u8>),
|
||||
/// 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<u8>)>),
|
||||
/// 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<ChatEntry>,
|
||||
chat_input: String,
|
||||
/// Fetched/failed state for chat attachments, keyed by attachment id.
|
||||
/// Session-only (cleared on leave); never persisted.
|
||||
attachment_data: HashMap<crate::files::AttachmentId, AttachmentState>,
|
||||
/// 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<crate::files::AttachmentId, iced::widget::image::Handle>,
|
||||
/// 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<crate::files::AttachmentId>,
|
||||
/// 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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
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::<EndpointId>() {
|
||||
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<ChatEntry>, 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+15
-1
@@ -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<u8> },
|
||||
/// 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<String>),
|
||||
@@ -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<crate::files::ChatAttachment> },
|
||||
/// 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<u8> },
|
||||
/// 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).
|
||||
|
||||
+81
-1
@@ -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<IrohTransport>,
|
||||
ui_tx: mpsc::Sender<UiEvent>,
|
||||
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user