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
|
/// Sender's node id string, used to key their avatar colour (W4). `None` only
|
||||||
/// for any future system-generated lines.
|
/// for any future system-generated lines.
|
||||||
from: Option<String>,
|
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.
|
/// Cap on retained chat history so a long call can't grow it without bound.
|
||||||
@@ -264,6 +277,12 @@ pub enum AppMessage {
|
|||||||
ToggleRecording,
|
ToggleRecording,
|
||||||
/// Live edits to the chat input line.
|
/// Live edits to the chat input line.
|
||||||
ChatInputChanged(String),
|
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).
|
/// Send the current chat input line (Enter or the Send button).
|
||||||
ChatSubmit,
|
ChatSubmit,
|
||||||
/// Open a clicked chat link in the system browser (A13).
|
/// 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.
|
/// Room text-chat history (newest last) and the pending input line.
|
||||||
chat_messages: Vec<ChatEntry>,
|
chat_messages: Vec<ChatEntry>,
|
||||||
chat_input: String,
|
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.
|
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||||
/// (The divider positions themselves are persisted in `config`.)
|
/// (The divider positions themselves are persisted in `config`.)
|
||||||
window_size: Size,
|
window_size: Size,
|
||||||
@@ -521,6 +549,9 @@ impl Default for AppState {
|
|||||||
recording: false,
|
recording: false,
|
||||||
recording_started: None,
|
recording_started: None,
|
||||||
chat_messages: Vec::new(),
|
chat_messages: Vec::new(),
|
||||||
|
attachment_data: HashMap::new(),
|
||||||
|
image_handle_cache: HashMap::new(),
|
||||||
|
pending_saves: std::collections::HashSet::new(),
|
||||||
chat_input: String::new(),
|
chat_input: String::new(),
|
||||||
window_size: Size::new(ww, wh),
|
window_size: Size::new(ww, wh),
|
||||||
layout_picker_open: false,
|
layout_picker_open: false,
|
||||||
@@ -996,19 +1027,45 @@ 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 { from, name, text } => {
|
UiEvent::ChatMessage { from, name, text, attachment } => {
|
||||||
// Incoming peer content is untrusted — sanitize name + text.
|
// Incoming peer content is untrusted — sanitize name + text.
|
||||||
|
// (The attachment filename was already sanitized in core.)
|
||||||
let text = sanitize_chat(&text);
|
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);
|
let name = sanitize_chat(&name);
|
||||||
push_chat(&mut state.chat_messages, ChatEntry {
|
push_chat(&mut state.chat_messages, ChatEntry {
|
||||||
name,
|
name,
|
||||||
text,
|
text,
|
||||||
mine: false,
|
mine: false,
|
||||||
from: Some(from),
|
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 => {
|
UiEvent::ScreenShareStarted => {
|
||||||
state.self_sharing = true;
|
state.self_sharing = true;
|
||||||
state.status_message = "Sharing your screen".to_string();
|
state.status_message = "Sharing your screen".to_string();
|
||||||
@@ -1491,11 +1548,93 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
mine: true,
|
mine: true,
|
||||||
from: Some(state.self_id.clone()),
|
from: Some(state.self_id.clone()),
|
||||||
|
attachment: None,
|
||||||
});
|
});
|
||||||
let _ = state.controller.send(CoreCommand::SendChat(text));
|
let _ = state.controller.send(CoreCommand::SendChat(text));
|
||||||
state.chat_input.clear();
|
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) => {
|
AppMessage::OpenUrl(url) => {
|
||||||
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
// 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,
|
// 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 {
|
fn horizontal_space() -> iced::widget::Space {
|
||||||
iced::widget::Space::new().width(iced::Length::Fill)
|
iced::widget::Space::new().width(iced::Length::Fill)
|
||||||
}
|
}
|
||||||
@@ -3693,6 +3880,59 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::alignment::Vertical::Top),
|
.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)
|
let chat_scroll = scrollable(chat_col)
|
||||||
@@ -3700,6 +3940,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.height(iced::Length::Fill)
|
.height(iced::Length::Fill)
|
||||||
.anchor_bottom();
|
.anchor_bottom();
|
||||||
let chat_input_row = row![
|
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)
|
text_input("Message the room…", &state.chat_input)
|
||||||
.on_input(AppMessage::ChatInputChanged)
|
.on_input(AppMessage::ChatInputChanged)
|
||||||
.on_submit(AppMessage::ChatSubmit)
|
.on_submit(AppMessage::ChatSubmit)
|
||||||
@@ -5286,6 +5530,7 @@ mod tests {
|
|||||||
text: "Hello".to_string(),
|
text: "Hello".to_string(),
|
||||||
mine: true,
|
mine: true,
|
||||||
from: None,
|
from: None,
|
||||||
|
attachment: None,
|
||||||
};
|
};
|
||||||
push_chat(&mut messages, entry);
|
push_chat(&mut messages, entry);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
@@ -5306,6 +5551,7 @@ mod tests {
|
|||||||
text: format!("Msg{}", i),
|
text: format!("Msg{}", i),
|
||||||
mine: i % 2 == 0,
|
mine: i % 2 == 0,
|
||||||
from: None,
|
from: None,
|
||||||
|
attachment: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5329,6 +5575,7 @@ mod tests {
|
|||||||
text: format!("Msg{}", i),
|
text: format!("Msg{}", i),
|
||||||
mine: i % 2 == 0,
|
mine: i % 2 == 0,
|
||||||
from: None,
|
from: None,
|
||||||
|
attachment: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -53,6 +53,14 @@ pub enum CoreCommand {
|
|||||||
SetRecordingMode(RecordingMode),
|
SetRecordingMode(RecordingMode),
|
||||||
/// Broadcast a room text-chat message. No-op when not in a call.
|
/// Broadcast a room text-chat message. No-op when not in a call.
|
||||||
SendChat(String),
|
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`).
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||||
/// Sent at startup so screen-share can resolve the binary.
|
/// Sent at startup so screen-share can resolve the binary.
|
||||||
SetPixelpassPath(Option<String>),
|
SetPixelpassPath(Option<String>),
|
||||||
@@ -109,7 +117,13 @@ pub enum UiEvent {
|
|||||||
/// A room text-chat message arrived from a peer (never our own — local
|
/// 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
|
/// messages are echoed by the UI on send). `from` is the sender's node id
|
||||||
/// string, used to key their avatar (W4).
|
/// 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".
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||||
ScreenShareStarted,
|
ScreenShareStarted,
|
||||||
/// Our own screen share stopped (or failed to start).
|
/// 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`.
|
/// 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
|
/// 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).
|
/// 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());
|
.insert(peer_id, state.addr.clone());
|
||||||
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 { 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 {
|
let _ = ui_tx_events.send(UiEvent::ChatMessage {
|
||||||
from: from.to_string(),
|
from: from.to_string(),
|
||||||
name,
|
name,
|
||||||
text,
|
text,
|
||||||
|
attachment,
|
||||||
}).await;
|
}).await;
|
||||||
}
|
}
|
||||||
RoomEvent::PeerConnectionLost(peer_id) => {
|
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) => {
|
CoreCommand::SetPixelpassPath(path) => {
|
||||||
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user