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
Generated
+673 -20
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ async-trait = "0.1.89"
base64 = "0.22.1"
bytes = "1.11.1"
dirs = "6.0.0"
iced = { version = "0.14.0", features = ["canvas"] }
iced = { version = "0.14.0", features = ["canvas", "image"] }
iroh = "1.0.0-rc.0"
iroh-gossip = "0.99.0"
opus = "0.3.1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+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> {
+68 -1
View File
@@ -1,4 +1,5 @@
//! Avatar helpers: deterministic monogram + colour for a participant.
//! Avatar helpers: a participant's chosen [`Avatar`] plus the deterministic
//! monogram + colour used as the fallback.
//!
//! Every participant gets a visual avatar (W4). When they haven't chosen a preset
//! or uploaded an image, we fall back to a *monogram*: their initial(s) on a
@@ -6,6 +7,46 @@
//! name where the id isn't available, e.g. a chat line). Pure + dependency-free
//! so it's unit-testable and needs no image decoding for the common case.
use serde::{Deserialize, Serialize};
/// Number of bundled preset avatars (`preset1.png`..`preset6.png`).
pub const PRESET_COUNT: u8 = 6;
/// The raw PNG bytes of each bundled preset, embedded so they're available
/// offline and identical on every peer. Index 0 = preset 1, etc.
pub const PRESET_PNGS: [&[u8]; PRESET_COUNT as usize] = [
include_bytes!("../assets/avatars/preset1.png"),
include_bytes!("../assets/avatars/preset2.png"),
include_bytes!("../assets/avatars/preset3.png"),
include_bytes!("../assets/avatars/preset4.png"),
include_bytes!("../assets/avatars/preset5.png"),
include_bytes!("../assets/avatars/preset6.png"),
];
/// A participant's chosen avatar. Rides the gossip presence plane (`PeerState`)
/// and is persisted in `AppConfig`. `Monogram` is the default fallback (rendered
/// from name + a colour key, no image). `Preset(i)` selects a bundled preset by
/// zero-based index. (Custom uploaded images are W4 Phase 3 — a future variant.)
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Avatar {
/// Initials-on-colour fallback.
#[default]
Monogram,
/// A bundled preset by zero-based index; out-of-range falls back to monogram.
Preset(u8),
}
impl Avatar {
/// The embedded PNG bytes for this avatar, or `None` when it's a monogram
/// (or an out-of-range preset index, which renders as a monogram).
pub fn preset_png(&self) -> Option<&'static [u8]> {
match self {
Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(),
Avatar::Monogram => None,
}
}
}
/// Curated palette the monogram background is chosen from. Picked to be distinct
/// and legible under the app's (mostly dark) themes; the matching text colour is
/// decided per-background by [`use_dark_text_on`]. Order is part of the stable
@@ -92,6 +133,32 @@ mod tests {
let _ = color_for_key("");
}
#[test]
fn avatar_default_is_monogram() {
assert_eq!(Avatar::default(), Avatar::Monogram);
assert!(Avatar::default().preset_png().is_none());
}
#[test]
fn preset_png_in_range_and_out_of_range() {
// Every declared preset index resolves to embedded bytes.
for i in 0..PRESET_COUNT {
assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing");
}
// Out-of-range index gracefully yields None (→ monogram fallback).
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
assert!(Avatar::Preset(250).preset_png().is_none());
}
#[test]
fn avatar_serde_round_trips_and_defaults() {
// Round-trips through JSON (it rides presence + persists in config).
for a in [Avatar::Monogram, Avatar::Preset(0), Avatar::Preset(5)] {
let s = serde_json::to_string(&a).unwrap();
assert_eq!(serde_json::from_str::<Avatar>(&s).unwrap(), a);
}
}
#[test]
fn dark_text_chosen_on_light_backgrounds() {
assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text
+2
View File
@@ -62,6 +62,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
is_muted: false,
addr: endpoint_a.addr(),
sharing: None,
avatar: Default::default(),
};
room_a.join(&ticket_str, state_a, vec![]).await?;
println!("Node A joined topic.");
@@ -80,6 +81,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
is_muted: false,
addr: endpoint_b.addr(),
sharing: None,
avatar: Default::default(),
};
room_b.join(&ticket_str, state_b, vec![]).await?;
println!("Node B joined topic.");
+4
View File
@@ -176,6 +176,9 @@ pub struct AppConfig {
/// Chosen UI colour theme.
#[serde(default)]
pub theme: AppTheme,
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
#[serde(default)]
pub avatar: crate::avatar::Avatar,
/// What a call recording captures (mixed / per-peer stems / both).
#[serde(default)]
pub recording_mode: RecordingMode,
@@ -233,6 +236,7 @@ impl Default for AppConfig {
chat_drawer_width: default_chat_drawer_width(),
room_layout: RoomLayout::default(),
theme: AppTheme::default(),
avatar: crate::avatar::Avatar::default(),
recording_mode: RecordingMode::default(),
custom_sound_self_join: None,
custom_sound_peer_join: None,
+3 -1
View File
@@ -4,9 +4,11 @@ use iroh::EndpointId;
#[derive(Debug, Clone)]
pub enum CoreCommand {
Join { name: String, ticket: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool },
Join { name: String, ticket: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
Leave,
ToggleMute,
/// Change our avatar (W4) and re-announce it to the room over presence.
SetAvatar(crate::avatar::Avatar),
ToggleDeafen,
SetPttMode(bool),
SetPttActive(bool),
+25 -1
View File
@@ -454,6 +454,9 @@ async fn run_core_loop(
// Peers locally muted by us: decoded for level metering but not mixed.
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
let mut current_name = "Anonymous".to_string();
// Our chosen avatar (W4), set on Join and changeable via SetAvatar; included
// in every self-state we announce over presence.
let mut current_avatar = crate::avatar::Avatar::default();
let mut network_mode = NetworkMode::default();
// Pixelpass binary override (config), and the ticket of our own active screen
// share (rides our presence so the room — incl. late joiners — can watch).
@@ -466,8 +469,9 @@ async fn run_core_loop(
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
CoreCommand::Join { name, ticket, input_device, output_device, echo_cancellation } => {
CoreCommand::Join { name, ticket, input_device, output_device, echo_cancellation, avatar } => {
current_name = name.clone();
current_avatar = avatar;
// Finalize any recording before tearing down the old session — its
// capture/mixer feeders are about to stop.
@@ -561,6 +565,7 @@ async fn run_core_loop(
is_muted: is_muted.load(Ordering::Relaxed),
addr: endpoint.addr(),
sharing: None,
avatar: current_avatar.clone(),
};
// Retain peers across leave so a rejoin can dial them (A8). A
@@ -1066,6 +1071,23 @@ async fn run_core_loop(
is_muted: new_state,
addr: session.endpoint.addr(),
sharing: current_sharing.clone(),
avatar: current_avatar.clone(),
};
let _ = session.room_state.update_self_state(self_state).await;
}
}
CoreCommand::SetAvatar(avatar) => {
current_avatar = avatar;
// Re-announce presence so the room (incl. late joiners, via the
// retained presence) picks up the new avatar (W4).
if let Some(session) = &active_session {
let self_state = PeerState {
name: current_name.clone(),
is_muted: is_muted.load(Ordering::Relaxed),
addr: session.endpoint.addr(),
sharing: current_sharing.clone(),
avatar: current_avatar.clone(),
};
let _ = session.room_state.update_self_state(self_state).await;
}
@@ -1274,6 +1296,7 @@ async fn run_core_loop(
is_muted: is_muted.load(Ordering::Relaxed),
addr: session.endpoint.addr(),
sharing: Some(ticket),
avatar: current_avatar.clone(),
};
let _ = session.room_state.update_self_state(self_state).await;
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
@@ -1298,6 +1321,7 @@ async fn run_core_loop(
is_muted: is_muted.load(Ordering::Relaxed),
addr: session.endpoint.addr(),
sharing: None,
avatar: current_avatar.clone(),
};
let _ = session.room_state.update_self_state(self_state).await;
}
+1
View File
@@ -493,6 +493,7 @@ mod tests {
is_muted: true,
addr,
sharing: None,
avatar: crate::avatar::Avatar::default(),
}
}
+6
View File
@@ -34,6 +34,11 @@ pub struct PeerState {
/// older configs / peers that predate the field still deserialize.
#[serde(default)]
pub sharing: Option<String>,
/// This peer's chosen avatar (W4). Rides presence so it reaches everyone
/// (incl. late joiners) with no server, like `sharing`. Defaulted so older
/// peers/configs that predate the field still deserialize (→ monogram).
#[serde(default)]
pub avatar: crate::avatar::Avatar,
}
#[derive(Debug, Clone)]
@@ -180,6 +185,7 @@ mod tests {
is_muted: false,
addr,
sharing: None,
avatar: crate::avatar::Avatar::default(),
}
}