Compare commits

...
Author SHA1 Message Date
molluskandClaude Opus 4.8 7e4f2f2127 fix(chat): use async save dialog for attachments
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The Save-attachment handler called the blocking rfd::FileDialog::save_file()
directly inside iced's update() loop. That blocking dialog spins its own GTK
loop; invoked from within iced's already-running event loop (notably the Linux
xdg-desktop-portal/GTK backend, but also observed wedged on Windows) the dialog
becomes unresponsive — Save/Cancel clicks are never processed.

Convert to rfd::AsyncFileDialog returning a Task, mirroring the existing file
*picker* paths (PickAttachmentFile / PickAvatarFile / PickBackgroundFile) which
already use the async variant. The chosen path's bytes are written when the
future resolves; the status line is reported via a new AttachmentSaved message.
No blocking call remains in the update loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:43:12 -04:00
mollusk b6eac330ca Update package version for 0.3.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 02:03:07 -04:00
mollusk 7fb1c96ca9 Redesign PeerSpeak application icon
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 02:02:37 -04:00
mollusk 80a5b73e39 Merge inline chat audio player
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 01:45:43 -04:00
13 changed files with 96 additions and 50 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

+56 -31
View File
@@ -1,41 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<!-- PeerSpeak app icon: in-app mic glyph + P2P mesh nodes, Catppuccin Mocha. -->
<title>PeerSpeak</title>
<desc>Two luminous voices meet directly to form a flowing S.</desc>
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1e1e2e"/>
<stop offset="1" stop-color="#181825"/>
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
<stop stop-color="#101d42"/>
<stop offset="0.5" stop-color="#071225"/>
<stop offset="1" stop-color="#160b31"/>
</linearGradient>
<linearGradient id="voice" x1="45" y1="76" x2="214" y2="184" gradientUnits="userSpaceOnUse">
<stop stop-color="#35efff"/>
<stop offset="0.42" stop-color="#2583ff"/>
<stop offset="0.68" stop-color="#8a42ff"/>
<stop offset="1" stop-color="#ff3cdd"/>
</linearGradient>
<linearGradient id="edge" x1="30" y1="31" x2="225" y2="231" gradientUnits="userSpaceOnUse">
<stop stop-color="#2fe9ff" stop-opacity="0.7"/>
<stop offset="0.48" stop-color="#386dff" stop-opacity="0.18"/>
<stop offset="1" stop-color="#eb42ff" stop-opacity="0.65"/>
</linearGradient>
<radialGradient id="core">
<stop stop-color="#ffffff"/>
<stop offset="0.28" stop-color="#baf7ff"/>
<stop offset="0.62" stop-color="#7b67ff" stop-opacity="0.65"/>
<stop offset="1" stop-color="#7b67ff" stop-opacity="0"/>
</radialGradient>
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
<feGaussianBlur stdDeviation="6"/>
</filter>
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="#000611" flood-opacity="0.8"/>
</filter>
</defs>
<!-- Rounded-square tile -->
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
stroke="#313244" stroke-width="3"/>
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
<rect x="9.5" y="9.5" width="237" height="237" rx="53.5" fill="none" stroke="url(#edge)" stroke-width="3"/>
<!-- P2P mesh: edges (under nodes + mic) -->
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
<line x1="74" y1="74" x2="128" y2="128"/>
<line x1="182" y1="74" x2="128" y2="128"/>
<line x1="74" y1="182" x2="128" y2="128"/>
<line x1="182" y1="182" x2="128" y2="128"/>
<line x1="74" y1="74" x2="182" y2="74"/>
<line x1="74" y1="182" x2="182" y2="182"/>
</g>
<!-- Broad color glow, kept behind the silhouette. -->
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
opacity="0.5" filter="url(#shadow)"/>
<!-- P2P mesh: peer nodes -->
<g fill="#b4befe">
<circle cx="74" cy="74" r="11"/>
<circle cx="182" cy="74" r="11"/>
<circle cx="74" cy="182" r="11"/>
<circle cx="182" cy="182" r="11"/>
</g>
<!-- The two waveform halves are equal peers and meet at one bright point. -->
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128"
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
<g fill="none" stroke="#89b4fa" stroke-width="13"
stroke-linecap="round" stroke-linejoin="round">
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
</g>
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
fill="none" stroke="url(#voice)" stroke-width="25"
stroke-linecap="round" stroke-linejoin="round"/>
<path d="M157 65 C139 56 113 62 108 79" fill="none" stroke="#bdf9ff"
stroke-opacity="0.68" stroke-width="4" stroke-linecap="round"/>
<path d="M153 166 C146 184 118 192 98 181" fill="none" stroke="#f4a8ff"
stroke-opacity="0.52" stroke-width="4" stroke-linecap="round"/>
<!-- The direct connection is the brightest and simplest detail. -->
<circle cx="128" cy="128" r="30" fill="url(#core)" opacity="0.78" filter="url(#shadow)"/>
<circle cx="128" cy="128" r="6.5" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.2.0.r218.gcbba4b6
pkgver=0.3.0.r229.g7fb1c96
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

+39 -18
View File
@@ -287,6 +287,8 @@ pub enum AppMessage {
AttachmentFilePicked(Option<(String, Vec<u8>)>),
/// Save (downloading first if needed) a received attachment to disk.
SaveAttachment(crate::files::AttachmentId),
/// Result of the async save dialog: a status line to show, or None if cancelled.
AttachmentSaved(Option<String>),
/// Fetch (if needed) and start an inline audio attachment.
PlayAudio(crate::files::AttachmentId),
PauseAudio,
@@ -1094,12 +1096,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let needs_save = state.pending_saves.remove(&id);
let needs_play = state.pending_plays.remove(&id);
state.attachment_data.insert(id, AttachmentState::Ready(data));
if needs_save {
save_attachment_to_disk(state, id);
}
if needs_play {
play_ready_audio(state, id);
}
if needs_save {
return save_attachment_task(state, id);
}
}
UiEvent::AttachmentFailed { id, error } => {
state.pending_saves.remove(&id);
@@ -1663,7 +1665,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// 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);
return save_attachment_task(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);
@@ -1676,6 +1678,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
}
AppMessage::AttachmentSaved(msg) => {
if let Some(m) = msg {
state.status_message = m;
}
}
AppMessage::PlayAudio(id) => {
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
play_ready_audio(state, id);
@@ -1982,11 +1989,18 @@ fn play_ready_audio(state: &mut AppState, id: crate::files::AttachmentId) {
/// 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) {
/// Build a Task that opens the native save dialog off the UI thread and writes
/// the (already-fetched) attachment bytes to the chosen path. No-op task if the
/// bytes aren't ready.
///
/// MUST be async (`rfd::AsyncFileDialog`): the blocking `rfd::FileDialog` spins
/// its own GTK loop, and invoking it from inside iced's running event loop with
/// the Linux `xdg-desktop-portal`/GTK backend wedges the dialog — Save/Cancel
/// stop responding. The file *picker* paths already use the async variant; this
/// is the one save path that must match.
fn save_attachment_task(state: &AppState, id: crate::files::AttachmentId) -> Task<AppMessage> {
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
return;
return Task::none();
};
let data = data.clone();
let default_name = state
@@ -1999,16 +2013,23 @@ fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId)
.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}"),
}
}
Task::perform(
async move {
let handle = rfd::AsyncFileDialog::new()
.set_file_name(default_name)
.set_title("Save attachment")
.save_file()
.await;
match handle {
Some(h) => match std::fs::write(h.path(), &data) {
Ok(()) => Some(format!("Saved {}", h.path().display())),
Err(e) => Some(format!("Save failed: {e}")),
},
None => None,
}
},
AppMessage::AttachmentSaved,
)
}
fn horizontal_space() -> iced::widget::Space {