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:
Generated
+673
-20
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -493,6 +493,7 @@ mod tests {
|
||||
is_muted: true,
|
||||
addr,
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user