feat(avatars): 6 selectable preset avatars over presence — W4 Phase 2

Adds bundled preset avatars on top of the monogram foundation.

- New `Avatar` enum (Monogram | Preset(u8)) in the avatar module, serde-encoded;
  6 preset PNGs embedded via include_bytes! (placeholder art in assets/avatars/
  — swap for real designs later). +3 unit tests.
- `PeerState` gains `avatar` (rides the gossip presence plane like `sharing`);
  `AppConfig` gains `avatar` (persisted). Avatar choice flows app → core (Join +
  new SetAvatar command) → every self-state announce, so it reaches the room incl.
  late joiners, and changing it mid-call re-announces live.
- Settings "Avatar" section: monogram + 6 preset tiles, applied live + persisted.
- Rendering: `avatar_view` draws the chosen preset image (iced `image` feature,
  now enabled) else the monogram, in the self card, peer rows, and chat (chat
  looks up the sender's avatar from presence by id).

⚠️ BREAKING gossip wire change: presence Announce is signed (S2) and the
signature is recomputed by re-serializing the parsed struct, so a new PeerState
field means old and new builds can't verify each other's presence — ALL peers
must run a build >= this one (same as the S2 change). Redeploy dopedart before
2-machine testing.

Build + clippy clean, 222 lib + integration tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 16:43:24 -04:00
co-authored by Claude Opus 4.8
parent 19194f0cee
commit 3007002b1b
16 changed files with 881 additions and 27 deletions
+98 -3
View File
@@ -163,6 +163,8 @@ pub enum AppMessage {
SelectRoomLayout(RoomLayout),
/// Choose a UI theme (applied live + persisted).
SelectTheme(AppTheme),
/// Choose our avatar (monogram or a preset); applied live + persisted (W4).
SelectAvatar(crate::avatar::Avatar),
/// Toggle the Chat drawer open/closed (drawer layout).
ToggleDrawerChat,
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
@@ -465,6 +467,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
input_device,
output_device,
echo_cancellation: state.config.echo_cancellation_enabled,
avatar: state.config.avatar.clone(),
});
}
}
@@ -483,6 +486,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
input_device,
output_device,
echo_cancellation: state.config.echo_cancellation_enabled,
avatar: state.config.avatar.clone(),
});
}
AppMessage::LeavePressed => {
@@ -780,6 +784,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.config.theme = theme;
state.config.save();
}
AppMessage::SelectAvatar(avatar) => {
state.config.avatar = avatar.clone();
state.config.save();
// Re-announce to the room if we're in a call (no-op otherwise).
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
}
AppMessage::ToggleDrawerChat => {
state.drawer_chat_open = !state.drawer_chat_open;
}
@@ -1216,6 +1226,51 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(10)
.width(iced::Length::Fill);
// Inline avatar chooser (W4): the monogram fallback plus the bundled
// presets, each a clickable tile. Same SelectAvatar message, applied live
// + persisted (and re-announced to the room).
let avatar_choice = |a: crate::avatar::Avatar, label: String| -> Element<'_, AppMessage> {
let selected = state.config.avatar == a;
let preview = avatar_view(&a, &state.name, &state.self_id, 52.0);
column![
button(
container(preview)
.center_x(iced::Length::Fixed(60.0))
.center_y(iced::Length::Fixed(60.0))
)
.on_press(AppMessage::SelectAvatar(a))
.padding(2)
.style(b_style(
if selected { color_surface } else { Color::TRANSPARENT },
color_surface,
color_text,
8.0,
)),
text(label)
.size(11)
.color(if selected { color_blue } else { color_subtext }),
]
.spacing(4)
.align_x(iced::alignment::Horizontal::Center)
.into()
};
let mut avatar_tiles: Vec<Element<'_, AppMessage>> =
vec![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),
));
}
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.")
.size(11)
.color(color_subtext),
]
.spacing(10)
.width(iced::Length::Fill);
// Reusable category header: a coloured title with a thin full-width
// divider beneath, so each class of settings reads as its own section.
let section_header = |title: &'static str| -> Element<'_, AppMessage> {
@@ -1335,6 +1390,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
theme_section,
vertical_space(section_gap),
// --- Avatar ---
section_header("Avatar"),
avatar_section,
vertical_space(section_gap),
// --- Notifications & Sounds ---
section_header("Notifications & Sounds"),
column![
@@ -1602,7 +1662,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let self_card = container(
column![
row![
avatar_badge(&state.name, &state.self_id, 34.0),
avatar_view(&state.config.avatar, &state.name, &state.self_id, 34.0),
text(format!("{} (You)", &state.name)).size(16).color(color_text),
horizontal_space(),
if state.is_muted {
@@ -1729,7 +1789,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let mut card_content = column![
row![
avatar_badge(&peer.name, &peer_id.to_string(), 38.0),
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
column![
text(&peer.name).size(16).color(color_text),
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
@@ -1940,11 +2000,27 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.width(iced::Length::Fill);
// Small avatar keyed on the sender's id (falls back to name); the
// " (You)" suffix on our own echoes is stripped for clean initials.
// Resolve the sender's chosen avatar: our own from config, a peer's
// from their current presence (looked up by id), else monogram.
let av_key = m.from.as_deref().unwrap_or(m.name.as_str());
let av_name = m.name.split(" (").next().unwrap_or(m.name.as_str());
let av = if m.mine {
state.config.avatar.clone()
} else {
m.from
.as_deref()
.and_then(|f| {
state
.peers
.iter()
.find(|(k, _)| k.to_string() == f)
.map(|(_, v)| v.avatar.clone())
})
.unwrap_or_default()
};
chat_col = chat_col.push(
row![
avatar_badge(av_name, av_key, 22.0),
avatar_view(&av, av_name, av_key, 22.0),
text(format!("{}:", m.name)).size(12).color(name_color),
body,
]
@@ -2781,6 +2857,25 @@ fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage>
.into()
}
/// Render a participant's avatar (W4): the chosen preset image if any, else the
/// monogram fallback. `name`/`key` feed the monogram; `size` is the diameter.
fn avatar_view<'a>(
avatar: &crate::avatar::Avatar,
name: &str,
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(),
None => avatar_badge(name, key, size),
}
}
/// Centered "icon + label" content for a full-width control-panel button. The
/// icon takes the button's foreground colour so it matches the label.
fn btn_content<'a>(kind: IconKind, label: &'a str, color: Color) -> Element<'a, AppMessage> {