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,
},
);
}