Messaging · People: members, a profile card, online status and a status setter

MemberList sets members online and offline by name, each group a list
under its count. A press or an arrow chooses one; Enter or a double
press acts on them; offline members go quiet. UserProfileCard shows a
person's picture with presence, name and standing, title, status, the
time in their zone, and Message and Call, each only with a handler.
OnlineStatus draws a presence's dot and word; Presence now names itself
and gives its color, in data_display, its one home.

StatusSetter holds a draft seeded from the owner's status: an emoji
from a picker field, words in its own text field, and when they clear.
Suggestions fill all three; Save hands the status over and Clear status
removes it. The emoji field reuses the date pickers' field, whose
trigger ignores the Enter release after a pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 22:08:25 -04:00
1 parent 5bbfdcd766
commit 37e56c7ab7
14 files changed
+1308 -7

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*,
};
use crate::{
data_display::{Avatar, Badge, Presence},
forms::OnValue,
lists::{ListItem, SelectableList},
theme::{ActiveTheme, AvatarSize, TextSize},
};
/// Someone in a chat: a key, a name, a picture, where they stand, a role, their own words.
#[derive(Clone)]
pub struct Member {
pub key: SharedString,
pub name: SharedString,
pub picture: Option<ImageSource>,
pub presence: Presence,
pub role: Option<SharedString>,
pub status: Option<SharedString>,
}
impl Member {
pub fn new(
key: impl Into<SharedString>,
name: impl Into<SharedString>,
presence: Presence,
) -> Self {
Self {
key: key.into(),
name: name.into(),
picture: None,
presence,
role: None,
status: None,
}
}
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
self.picture = Some(source.into());
self
}
pub fn role(mut self, role: impl Into<SharedString>) -> Self {
self.role = Some(role.into());
self
}
pub fn status(mut self, status: impl Into<SharedString>) -> Self {
self.status = Some(status.into());
self
}
}
/// Members online and offline by name, each group a list under its count. A press or an arrow chooses one, Enter or a double press acts on them; offline members go quiet.
#[derive(IntoElement)]
pub struct MemberList {
id: ElementId,
members: Vec<Member>,
selected: Option<SharedString>,
on_select: Option<OnValue>,
on_activate: Option<OnValue>,
}
impl MemberList {
pub fn new(id: impl Into<ElementId>, members: impl IntoIterator<Item = Member>) -> Self {
let members: Vec<Member> = members.into_iter().collect();
for (ix, member) in members.iter().enumerate() {
let twice = members[..ix].iter().any(|other| other.key == member.key);
assert!(!twice, "member {} twice", member.key);
}
Self {
id: id.into(),
members,
selected: None,
on_select: None,
on_activate: None,
}
}
/// The member chosen now, by key.
pub fn selected(mut self, key: impl Into<SharedString>) -> Self {
self.selected = Some(key.into());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
pub fn on_activate(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_activate = Some(Rc::new(handler));
self
}
}
/// The groups `members` fall in, online first, each by name; empty groups drop out.
fn grouped(members: &[Member]) -> Vec<(&'static str, Vec<&Member>)> {
let group = |online: bool| {
let mut group: Vec<&Member> = members
.iter()
.filter(|member| (member.presence != Presence::Offline) == online)
.collect();
group.sort_by(|a, b| a.name.cmp(&b.name));
group
};
[("Online", group(true)), ("Offline", group(false))]
.into_iter()
.filter(|(_, group)| !group.is_empty())
.collect()
}
impl RenderOnce for MemberList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if let Some(selected) = &self.selected {
let listed = self.members.iter().any(|member| member.key == *selected);
assert!(listed, "no member {selected}");
}
let theme = cx.theme();
let colors = &theme.colors;
let groups: Vec<_> = grouped(&self.members)
.into_iter()
.map(|(title, group)| {
let count = group.len();
let lit = self
.selected
.clone()
.filter(|key| group.iter().any(|member| member.key == *key));
let list = group.into_iter().fold(
SelectableList::new((self.id.clone(), format!("group-{title}"))),
|list, member| {
let avatar = Avatar::new(
(self.id.clone(), format!("avatar-{}", member.key)),
member.name.clone(),
)
.size(AvatarSize::Sm)
.presence(member.presence);
let avatar = match member.picture.clone() {
Some(picture) => avatar.image(picture),
None => avatar,
};
let row = ListItem::new(
(self.id.clone(), format!("member-{}", member.key)),
member.name.clone(),
)
.leading(avatar)
.quiet(member.presence == Presence::Offline);
let row = match member.status.clone() {
Some(status) => row.description(status),
None => row,
};
let row = match member.role.clone() {
Some(role) => row.trailing(Badge::new(role)),
None => row,
};
list.row(member.key.clone(), row)
},
);
let (id, select, activate) = (
self.id.clone(),
self.on_select.clone(),
self.on_activate.clone(),
);
let acted = id.clone();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.px_3()
.flex()
.items_baseline()
.gap_2()
.text_size(theme.text_size(TextSize::Xs))
.child(
div()
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg_muted)
.child(title),
)
.child(div().text_color(colors.fg_subtle).child(count.to_string())),
)
.child(
list.selected(lit)
.on_change(move |keys, window, cx| {
let key = keys.first().expect("a pick names a member");
log::info!("member list {id}: select {key}");
if let Some(select) = &select {
select(key, window, cx);
}
})
.on_activate(move |key, window, cx| {
log::info!("member list {acted}: activate {key}");
if let Some(activate) = &activate {
activate(key, window, cx);
}
}),
)
})
.collect();
let empty = self.members.is_empty().then(|| {
div()
.px_3()
.py_4()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child("No members yet.")
});
div()
.debug_selector(|| "member-list".into())
.w_full()
.flex()
.flex_col()
.gap_4()
.children(empty)
.children(groups)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Each group's title with its members' names.
fn names(members: &[Member]) -> Vec<(&'static str, Vec<String>)> {
grouped(members)
.into_iter()
.map(|(title, group)| {
let names = group.iter().map(|member| member.name.to_string()).collect();
(title, names)
})
.collect()
}
#[test]
fn members_group_online_first_by_name_and_empty_groups_drop() {
let members = [
Member::new("c", "Chloé", Presence::Busy),
Member::new("d", "Dev", Presence::Offline),
Member::new("a", "Ana", Presence::Away),
Member::new("b", "Ben", Presence::Online),
];
assert_eq!(
names(&members),
[
(
"Online",
vec!["Ana".to_string(), "Ben".into(), "Chloé".into()]
),
("Offline", vec!["Dev".into()])
]
);
assert_eq!(
names(&members[1..2]),
[("Offline", vec!["Dev".to_string()])]
);
}
}