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:
+47
-3
@@ -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 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+100
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user