feat(avatars): monogram avatars in roster, self card, and chat — W4 Phase 1

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 16:28:05 -04:00
co-authored by Claude Opus 4.8
parent 2621576ed9
commit 19194f0cee
5 changed files with 157 additions and 7 deletions
+47 -3
View File
@@ -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<String>,
}
/// 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<AppMessage> {
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<AppMessage> {
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 23) 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,
},
);
}
+100
View File
@@ -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 12 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
}
}
+3 -2
View File
@@ -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).
+6 -2
View File
@@ -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
+1
View File
@@ -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;