From 19194f0cee668c60763eeb8e8bff2b2764f07a55 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 14 Jun 2026 16:28:05 -0400 Subject: [PATCH] =?UTF-8?q?feat(avatars):=20monogram=20avatars=20in=20rost?= =?UTF-8?q?er,=20self=20card,=20and=20chat=20=E2=80=94=20W4=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for user avatars (W4). Every participant now shows a circular monogram avatar: their initial(s) on a colour deterministically derived from a stable key (node id, or name where no id is available). This is also the universal fallback for the later preset/upload phases. - New pure `avatar` module: `initials`, `color_for_key` (FNV-1a → 12-colour palette), `use_dark_text_on` (contrast). +5 unit tests. Dependency-free. - `avatar_badge` view helper: a radius-capped coloured container + centred initials (plain iced widgets, no canvas/image needed). - Wired into the self card, each peer row (left of the name — pairs with the A10 fixed-width row), and each chat line. - Threaded the chat sender's node id end to end (RoomEvent → UiEvent::ChatMessage gains `from`, ChatEntry gains `from`) so chat avatars are id-keyed and ready for the Phase 2/3 per-peer avatar lookup. Build + clippy clean, 219 lib tests green. Visual confirm: join/create a room. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 50 ++++++++++++++++++++-- src/avatar.rs | 100 +++++++++++++++++++++++++++++++++++++++++++ src/core/messages.rs | 5 ++- src/core/mod.rs | 8 +++- src/lib.rs | 1 + 5 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 src/avatar.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 1a6b2d0..c35d41e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -36,6 +36,9 @@ struct ChatEntry { name: String, text: String, mine: bool, + /// Sender's node id string, used to key their avatar colour (W4). `None` only + /// for any future system-generated lines. + from: Option, } /// Cap on retained chat history so a long call can't grow it without bound. @@ -593,12 +596,17 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.recording_started = None; state.status_message = format!("Saved recording → {path}"); } - UiEvent::ChatMessage { name, text } => { + UiEvent::ChatMessage { from, name, text } => { // Incoming peer content is untrusted — sanitize name + text. let text = sanitize_chat(&text); if !text.is_empty() { let name = sanitize_chat(&name); - push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false }); + push_chat(&mut state.chat_messages, ChatEntry { + name, + text, + mine: false, + from: Some(from), + }); } } UiEvent::ScreenShareStarted => { @@ -783,6 +791,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { name: format!("{} (You)", state.name), text: text.clone(), mine: true, + from: Some(state.self_id.clone()), }); let _ = state.controller.send(CoreCommand::SendChat(text)); state.chat_input.clear(); @@ -1593,6 +1602,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let self_card = container( column![ row![ + avatar_badge(&state.name, &state.self_id, 34.0), text(format!("{} (You)", &state.name)).size(16).color(color_text), horizontal_space(), if state.is_muted { @@ -1601,6 +1611,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text("[Active]").size(14).color(color_green) } ] + .spacing(10) .align_y(iced::alignment::Vertical::Center), // Live "you're sharing" badge — only present while sharing. { @@ -1718,6 +1729,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let mut card_content = column![ row![ + avatar_badge(&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) @@ -1926,12 +1938,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let body = rich_text(spans) .on_link_click(AppMessage::OpenUrl) .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. + 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()); chat_col = chat_col.push( row![ + avatar_badge(av_name, av_key, 22.0), text(format!("{}:", m.name)).size(12).color(name_color), body, ] - .spacing(8), + .spacing(8) + .align_y(iced::alignment::Vertical::Top), ); } } @@ -2740,6 +2758,29 @@ fn icon<'a>(kind: IconKind, size: f32, color: Color) -> Element<'a, AppMessage> .into() } +/// A circular monogram avatar (W4): the participant's initial(s) on a colour +/// deterministically derived from `key` (their node id, or display name where no +/// id is available). This is the fallback shown until presets / custom uploads +/// (W4 Phases 2–3) override it. `size` is the diameter in px. +fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage> { + let (r, g, b) = crate::avatar::color_for_key(key); + let bg = Color::from_rgb8(r, g, b); + let fg = if crate::avatar::use_dark_text_on((r, g, b)) { + Color::from_rgb8(0x1e, 0x1e, 0x2e) + } else { + Color::WHITE + }; + container(text(crate::avatar::initials(name)).size(size * 0.42).color(fg)) + .center_x(iced::Length::Fixed(size)) + .center_y(iced::Length::Fixed(size)) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(bg)), + border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (size / 2.0).into() }, + ..Default::default() + }) + .into() +} + /// 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> { @@ -3209,6 +3250,7 @@ mod tests { name: "Alice".to_string(), text: "Hello".to_string(), mine: true, + from: None, }; push_chat(&mut messages, entry); assert_eq!(messages.len(), 1); @@ -3228,6 +3270,7 @@ mod tests { name: format!("User{}", i), text: format!("Msg{}", i), mine: i % 2 == 0, + from: None, }, ); } @@ -3250,6 +3293,7 @@ mod tests { name: format!("User{}", i), text: format!("Msg{}", i), mine: i % 2 == 0, + from: None, }, ); } diff --git a/src/avatar.rs b/src/avatar.rs new file mode 100644 index 0000000..7b644d3 --- /dev/null +++ b/src/avatar.rs @@ -0,0 +1,100 @@ +//! Avatar helpers: deterministic monogram + colour for a participant. +//! +//! 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 +//! colour deterministically derived from a stable key (their node id, or their +//! 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. + +/// 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 +/// mapping — don't reorder without accepting that everyone's colour shifts. +pub const PALETTE: [(u8, u8, u8); 12] = [ + (0xE5, 0x73, 0x73), // red + (0xE5, 0x9E, 0x57), // orange + (0xE5, 0xC0, 0x7B), // amber + (0x8C, 0xC2, 0x65), // green + (0x5E, 0xC8, 0xA0), // teal + (0x5C, 0xB3, 0xE5), // blue + (0x6E, 0x8C, 0xE5), // azure + (0x9A, 0x8C, 0xE5), // indigo + (0xC0, 0x7B, 0xE5), // violet + (0xE5, 0x7B, 0xC0), // pink + (0xB0, 0x8B, 0x6E), // brown + (0x8A, 0x9B, 0xA8), // slate +]; + +/// Pick a stable palette colour for `key` (a node id string, or a name). Uses an +/// FNV-1a hash so the same key always maps to the same colour across machines. +pub fn color_for_key(key: &str) -> (u8, u8, u8) { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in key.as_bytes() { + hash ^= u64::from(*b); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + PALETTE[(hash % PALETTE.len() as u64) as usize] +} + +/// Whether dark text is more legible than white on the given background, by +/// relative luminance (sRGB-weighted). Light backgrounds → dark text. +pub fn use_dark_text_on((r, g, b): (u8, u8, u8)) -> bool { + let lum = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b); + lum > 150.0 +} + +/// The 1–2 character monogram for a display name: the first letters of its first +/// two alphanumeric words, uppercased. Falls back to "?" when nothing usable +/// (empty name, or only emoji/punctuation) remains. +pub fn initials(name: &str) -> String { + let mut firsts = name + .split_whitespace() + .filter_map(|w| w.chars().find(|c| c.is_alphanumeric())); + match (firsts.next(), firsts.next()) { + (Some(a), Some(b)) => format!("{}{}", a.to_uppercase(), b.to_uppercase()), + (Some(a), None) => a.to_uppercase().to_string(), + _ => "?".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initials_takes_first_two_words() { + assert_eq!(initials("Alice"), "A"); + assert_eq!(initials("alice bob"), "AB"); + assert_eq!(initials(" spaced out name "), "SO"); + } + + #[test] + fn initials_handles_unicode_and_skips_non_alnum() { + assert_eq!(initials("héllo"), "H"); + // Leading emoji word is skipped; the next alphanumeric word is used. + assert_eq!(initials("🎙 Mike"), "M"); + } + + #[test] + fn initials_falls_back_to_question_mark() { + assert_eq!(initials(""), "?"); + assert_eq!(initials(" "), "?"); + assert_eq!(initials("🎉🎊"), "?"); + } + + #[test] + fn color_is_deterministic_and_in_palette() { + let c1 = color_for_key("node-abc"); + let c2 = color_for_key("node-abc"); + assert_eq!(c1, c2, "same key must map to same colour"); + assert!(PALETTE.contains(&c1)); + // Different keys generally differ; at minimum the function is total. + let _ = color_for_key(""); + } + + #[test] + fn dark_text_chosen_on_light_backgrounds() { + assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text + assert!(!use_dark_text_on((0x10, 0x10, 0x10))); // near-black bg → light text + } +} diff --git a/src/core/messages.rs b/src/core/messages.rs index 8037895..5922d3e 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -69,8 +69,9 @@ pub enum UiEvent { /// Call recording stopped; carries the finished WAV path. RecordingStopped { path: String }, /// A room text-chat message arrived from a peer (never our own — local - /// messages are echoed by the UI on send). - ChatMessage { name: String, text: String }, + /// messages are echoed by the UI on send). `from` is the sender's node id + /// string, used to key their avatar (W4). + ChatMessage { from: String, name: String, text: String }, /// Our own screen share started; the UI flips the Share button to "Stop". ScreenShareStarted, /// Our own screen share stopped (or failed to start). diff --git a/src/core/mod.rs b/src/core/mod.rs index 4237f3e..ea13640 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -972,8 +972,12 @@ async fn run_core_loop( known_peers_events.lock().unwrap().insert(peer_id, state.addr.clone()); let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await; } - RoomEvent::ChatMessage { name, text, .. } => { - let _ = ui_tx_events.send(UiEvent::ChatMessage { name, text }).await; + RoomEvent::ChatMessage { from, name, text, .. } => { + let _ = ui_tx_events.send(UiEvent::ChatMessage { + from: from.to_string(), + name, + text, + }).await; } RoomEvent::PeerConnectionLost(peer_id) => { // Transient drop: do NOT tear down the peer. Its audio diff --git a/src/lib.rs b/src/lib.rs index 77c8fa3..a1030ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod theme; pub mod notify; pub mod screenshare; pub mod sanitize; +pub mod avatar; use std::path::PathBuf; use std::sync::OnceLock;