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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user