feat(avatars): custom image upload — W4 Phase 3

Completes W4: users can upload a custom avatar image.

- `Avatar::Custom(String)` carries a base64 PNG. `process_upload` decodes an
  arbitrary png/jpeg, downscales so the longest side is 128px (aspect kept),
  re-encodes PNG, base64s, and rejects anything over a hard cap.
- Settings "Avatar" gains an "Upload image…" button (native picker via rfd's
  xdg-portal backend, off-thread through Task::perform) and shows the current
  custom avatar as a selected tile.
- Untrusted peer avatars are validated at gossip ingest (`sanitize_incoming`):
  a custom image must be within the byte cap and decode as a PNG within bounds
  (image-crate decode limits guard against decompression bombs) or it's
  downgraded to a monogram.
- Raised the gossip max message size to 64 KB so a capped custom avatar fits
  inline on the presence plane (all peers already need a matching build).
- Deps: image (png/jpeg only), rfd (xdg-portal, no GTK); only `rfd`+`pollster`
  are actually new in the lockfile (rest were already transitive). cargo audit
  clean (0 vulns; the 2 unmaintained warnings are pre-existing S7).
- `Controller::send` now returns bool (was a Result carrying the now-larger
  CoreCommand by value, which tripped result_large_err).
- +5 avatar unit tests (upload resize/round-trip, reject non-image, ingest
  accept/reject). 227 lib tests green, clippy clean.

Manual check: Settings → Avatar → Upload; confirm the picker opens and the
image shows for you and (after redeploy) for a peer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 17:00:18 -04:00
co-authored by Claude Opus 4.8
parent 3007002b1b
commit f029a30ea7
6 changed files with 264 additions and 18 deletions
+71 -11
View File
@@ -165,6 +165,11 @@ pub enum AppMessage {
SelectTheme(AppTheme),
/// Choose our avatar (monogram or a preset); applied live + persisted (W4).
SelectAvatar(crate::avatar::Avatar),
/// Open the native file picker to choose a custom avatar image (W4 Phase 3).
PickAvatarFile,
/// Result of the avatar file picker: the chosen file's raw bytes, or `None`
/// if the user cancelled.
AvatarFilePicked(Option<Vec<u8>>),
/// Toggle the Chat drawer open/closed (drawer layout).
ToggleDrawerChat,
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
@@ -790,6 +795,39 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Re-announce to the room if we're in a call (no-op otherwise).
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
}
AppMessage::PickAvatarFile => {
// Open the native picker off the UI thread; the result comes back as
// AvatarFilePicked. Filter to the formats we can actually decode.
return Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg"])
.set_title("Choose an avatar image")
.pick_file()
.await;
match handle {
Some(h) => Some(h.read().await),
None => None,
}
},
AppMessage::AvatarFilePicked,
);
}
AppMessage::AvatarFilePicked(picked) => {
if let Some(bytes) = picked {
match crate::avatar::process_upload(&bytes) {
Ok(avatar) => {
state.config.avatar = avatar.clone();
state.config.save();
state.status_message = "Avatar updated.".to_string();
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
}
Err(e) => {
state.status_message = e;
}
}
}
}
AppMessage::ToggleDrawerChat => {
state.drawer_chat_open = !state.drawer_chat_open;
}
@@ -1254,17 +1292,37 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.align_x(iced::alignment::Horizontal::Center)
.into()
};
let mut avatar_tiles: Vec<Element<'_, AppMessage>> =
vec![avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())];
let mut avatar_tiles: Vec<Element<'_, AppMessage>> = Vec::new();
// Show the current custom avatar (if any) as the first, selected tile.
if matches!(state.config.avatar, crate::avatar::Avatar::Custom(_)) {
avatar_tiles.push(avatar_choice(state.config.avatar.clone(), "Custom".to_string()));
}
avatar_tiles.push(avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string()));
for i in 0..crate::avatar::PRESET_COUNT {
avatar_tiles.push(avatar_choice(
crate::avatar::Avatar::Preset(i),
format!("Preset {}", i + 1),
));
}
// Wrap into rows of four (no flex-wrap in iced 0.14) so the tiles don't
// run off a narrow Settings panel.
let mut tile_rows: Vec<Element<'_, AppMessage>> = Vec::new();
let mut tiles_iter = avatar_tiles.into_iter();
loop {
let chunk: Vec<Element<'_, AppMessage>> = tiles_iter.by_ref().take(4).collect();
if chunk.is_empty() {
break;
}
tile_rows.push(iced::widget::Row::with_children(chunk).spacing(12).into());
}
let upload_btn = button(text("Upload image…").size(13))
.on_press(AppMessage::PickAvatarFile)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8);
let avatar_section = column![
iced::widget::Row::with_children(avatar_tiles).spacing(12),
text("Shown next to your name in the room and chat. Applies live.")
iced::widget::Column::with_children(tile_rows).spacing(12),
upload_btn,
text("Shown next to your name in the room and chat. Custom images are PNG/JPEG, auto-resized. Applies live.")
.size(11)
.color(color_subtext),
]
@@ -2865,13 +2923,15 @@ fn avatar_view<'a>(
key: &str,
size: f32,
) -> Element<'a, AppMessage> {
match avatar.preset_png() {
Some(png) => iced::widget::image(
iced::widget::image::Handle::from_bytes(bytes::Bytes::from_static(png)),
)
.width(iced::Length::Fixed(size))
.height(iced::Length::Fixed(size))
.into(),
let png_bytes: Option<bytes::Bytes> = avatar
.preset_png()
.map(bytes::Bytes::from_static)
.or_else(|| avatar.custom_png().map(bytes::Bytes::from));
match png_bytes {
Some(b) => iced::widget::image(iced::widget::image::Handle::from_bytes(b))
.width(iced::Length::Fixed(size))
.height(iced::Length::Fixed(size))
.into(),
None => avatar_badge(name, key, size),
}
}