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:
+71
-11
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user