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

+222
View File
@@ -0,0 +1,222 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*,
};
use jiff::{Timestamp, tz::TimeZone};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
data_display::{Avatar, Presence},
forms::Run,
primitives::{Icon, IconName},
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, TextSize},
typography::{Ellipsis, format},
};
/// Where someone stands: a dot in the presence's color and its word.
#[derive(IntoElement)]
pub struct OnlineStatus {
presence: Presence,
}
impl OnlineStatus {
pub fn new(presence: Presence) -> Self {
Self { presence }
}
}
impl RenderOnce for OnlineStatus {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
div()
.flex()
.items_center()
.gap_1p5()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(
div()
.flex_none()
.size(theme.status_dot())
.rounded_full()
.bg(self.presence.color(colors)),
)
.child(self.presence.label())
}
}
/// A person at a glance: their picture with presence, name and standing, their title and status, the time where they are, and ways to reach them, each only with its handler. The host gives the width.
#[derive(IntoElement)]
pub struct UserProfileCard {
id: ElementId,
name: SharedString,
presence: Presence,
picture: Option<ImageSource>,
title: Option<SharedString>,
status: Option<SharedString>,
zone: Option<TimeZone>,
on_message: Option<Run>,
on_call: Option<Run>,
}
impl UserProfileCard {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
presence: Presence,
) -> Self {
Self {
id: id.into(),
name: name.into(),
presence,
picture: None,
title: None,
status: None,
zone: None,
on_message: None,
on_call: None,
}
}
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
self.picture = Some(source.into());
self
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Their own words, an emoji first when they chose one.
pub fn status(mut self, status: impl Into<SharedString>) -> Self {
self.status = Some(status.into());
self
}
/// Their time zone, for the time where they are.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
pub fn on_message(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_message = Some(Rc::new(handler));
self
}
pub fn on_call(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_call = Some(Rc::new(handler));
self
}
}
impl RenderOnce for UserProfileCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let avatar = Avatar::new((self.id.clone(), "avatar"), self.name.clone())
.size(AvatarSize::Xl)
.presence(self.presence);
let avatar = match self.picture {
Some(picture) => avatar.image(picture),
None => avatar,
};
let line = |text: SharedString| div().min_w_0().child(Ellipsis::new(text));
let local = self.zone.map(|zone| {
let time = format::datetime(Timestamp::now(), &zone, "%H:%M")
.expect("a fixed pattern formats");
div()
.flex()
.items_center()
.gap_1p5()
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::Clock)
.size(IconSize::Sm)
.color(colors.fg_subtle),
)
.child(line(format!("{time} local time").into()))
});
let name = self.name.clone();
let message = self.on_message.map(|run| {
let name = name.clone();
Button::new((self.id.clone(), "message"), "Message")
.icon(IconName::MessageSquare)
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("profile card {name}: message");
run(window, cx)
})
});
let call = self.on_call.map(|run| {
IconButton::new((self.id.clone(), "call"), IconName::Phone)
.variant(ButtonVariant::Outline)
.size(ControlSize::Sm)
.tooltip("Call")
.on_click(move |_, window, cx| {
log::info!("profile card {name}: call");
run(window, cx)
})
});
let reach = (message.is_some() || call.is_some()).then(|| {
div()
.flex()
.items_center()
.gap_2()
.children(message.map(|message| div().flex_1().min_w_0().child(message)))
.children(call)
});
div()
.debug_selector(|| "profile-card".into())
.w_full()
.flex()
.flex_col()
.gap_3()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.items_center()
.gap_3()
.child(div().flex_none().child(avatar))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_1()
.child(
line(self.name)
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg),
)
.child(OnlineStatus::new(self.presence)),
),
)
.when(
self.title.is_some() || self.status.is_some() || local.is_some(),
|card| {
card.child(
div()
.flex()
.flex_col()
.gap_1()
.children(
self.title
.map(|title| line(title).text_color(colors.fg_muted)),
)
.children(self.status.map(|status| line(status).text_color(colors.fg)))
.children(local),
)
},
)
.children(reach)
}
}