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.
This commit is contained in:
1 parent
99b8a6081b
commit
dd5e36cd94
14 files changed
+1308
-7
No files matched your search
@@ -82,7 +82,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- `primitives::Severity` names info, success, warning and danger with their colors and icons, for alerts and feedback alike.
|
||||
- Rows that move are keyed. `motion::Flip` places them absolutely from last frame's heights, so no frame shows a row at its new place before it glides there, and drives the glide from elapsed time: an animation id that changes per move would rebuild the row's keyed state. `Reorder` holds a row by key, drags with an empty ghost and moves the held row itself.
|
||||
- A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`.
|
||||
- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`).
|
||||
- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`). Its trigger acts on press and keys, not on click, so a pick made on Enter's press cannot reopen it on the release; `messaging::StatusSetter`'s emoji field reuses it.
|
||||
- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. Key only the part that moves: a keyed ancestor gives every descendant a new id, and focused buttons lose focus.
|
||||
- A spotlight or tour takes its target's box from `primitives::Measure`; the lit box glides between targets.
|
||||
- Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`, `media::ImageCropper`).
|
||||
|
||||
@@ -99,7 +99,7 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T27c Files · Find and look
|
||||
- [x] T27d Files · Transfers
|
||||
- [x] T28a Messaging · Channels
|
||||
- [ ] T28b Messaging · Messages
|
||||
- [x] T28b Messaging · Messages
|
||||
- [ ] T28c Messaging · People
|
||||
- [ ] T28d Messaging · Calls
|
||||
- [ ] T28e Messaging · Stickers and GIFs
|
||||
@@ -334,3 +334,4 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T27d | 1 | FAIL | a press on a finished download's Show button also opened the file through its row |
|
||||
| T27d | 2 | PASS | |
|
||||
| T28a | 1 | PASS | |
|
||||
| T28b | 1 | PASS | |
|
||||
@@ -8,6 +8,7 @@ use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Wi
|
||||
use jiff::{SignedDuration, Timestamp};
|
||||
|
||||
mod messages;
|
||||
mod people;
|
||||
|
||||
use super::Page;
|
||||
use crate::{
|
||||
@@ -34,6 +35,22 @@ const SCRIPT: &[Step] = &[
|
||||
Step::HoverAt("messaging-conversation", 200.0, 89.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("run-time"),
|
||||
Step::DownAt("messaging-members", 200.0, 91.0),
|
||||
Step::UpAt("messaging-members", 200.0, 91.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("member-chosen"),
|
||||
Step::DownAt("messaging-status", 35.0, 48.0),
|
||||
Step::UpAt("messaging-status", 35.0, 48.0),
|
||||
Step::Wait(200),
|
||||
Step::Key("tab"),
|
||||
Step::Type("palm tree"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("status-emoji"),
|
||||
Step::Key("enter"),
|
||||
Step::Key("tab"),
|
||||
Step::Type("On leave till Monday"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("status-draft"),
|
||||
];
|
||||
|
||||
/// The demo's chats: which is open and what waits unread in each.
|
||||
@@ -218,5 +235,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(messages::conversation(window, cx))
|
||||
.child(messages::thread(window, cx))
|
||||
.child(messages::receipts(cx))
|
||||
.child(people::members(window, cx))
|
||||
.child(people::statuses(cx))
|
||||
.child(people::setter(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use ely_gpui_component::{
|
||||
data_display::Presence,
|
||||
messaging::{
|
||||
ClearAfter, Member, MemberList, OnlineStatus, Status, StatusSetter, UserProfileCard,
|
||||
},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
use gpui::{App, ElementId, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
use jiff::tz::TimeZone;
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section},
|
||||
};
|
||||
|
||||
/// A member's card: their title and the zone they work in.
|
||||
struct Person {
|
||||
member: Member,
|
||||
title: &'static str,
|
||||
zone: &'static str,
|
||||
}
|
||||
|
||||
fn people() -> Vec<Person> {
|
||||
let person = |member: Member, title, zone| Person {
|
||||
member,
|
||||
title,
|
||||
zone,
|
||||
};
|
||||
vec![
|
||||
person(
|
||||
Member::new("ana", "Ana Lima", Presence::Away).status("🌴 Back on Monday"),
|
||||
"Architect",
|
||||
"Europe/Lisbon",
|
||||
),
|
||||
person(
|
||||
Member::new("ben", "Ben Ito", Presence::Online).role("Admin"),
|
||||
"Structural engineer",
|
||||
"Asia/Tokyo",
|
||||
),
|
||||
person(
|
||||
Member::new("chloe", "Chloé Martin", Presence::Busy).status("🎧 Drawing the stair"),
|
||||
"Lead designer",
|
||||
"Europe/Paris",
|
||||
),
|
||||
person(
|
||||
Member::new("dev", "Dev Rao", Presence::Offline),
|
||||
"Model maker",
|
||||
"Asia/Kolkata",
|
||||
),
|
||||
person(
|
||||
Member::new("eli", "Eli Park", Presence::Online).role("Owner"),
|
||||
"Principal",
|
||||
"America/New_York",
|
||||
),
|
||||
person(
|
||||
Member::new("farah", "Farah Haddad", Presence::Offline),
|
||||
"Landscape architect",
|
||||
"Asia/Dubai",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn members(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let chosen = keep(
|
||||
"messaging-member",
|
||||
|| SharedString::from("chloe"),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let key = chosen.read(cx).clone();
|
||||
let all = people();
|
||||
let shown = all
|
||||
.iter()
|
||||
.find(|person| person.member.key == key)
|
||||
.expect("the chosen member is listed");
|
||||
let member = &shown.member;
|
||||
let zone = TimeZone::get(shown.zone).expect("a known zone");
|
||||
let card = UserProfileCard::new(
|
||||
(ElementId::from("messaging-profile"), member.key.to_string()),
|
||||
member.name.clone(),
|
||||
member.presence,
|
||||
)
|
||||
.title(shown.title)
|
||||
.zone(zone)
|
||||
.on_message(|_, _| log::info!("gallery: message"))
|
||||
.on_call(|_, _| log::info!("gallery: call"));
|
||||
let card = match member.status.clone() {
|
||||
Some(status) => card.status(status),
|
||||
None => card,
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let frame = div()
|
||||
.w(px(280.))
|
||||
.p_4()
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.child(card);
|
||||
section(
|
||||
"MemberList / UserProfileCard",
|
||||
"Members online and offline by name, each group under its count; offline members go quiet. A press or an arrow shows a member's card: their standing, title and status, the time where they are, and ways to reach them.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"messaging-members",
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_start()
|
||||
.gap_6()
|
||||
.child(
|
||||
div().w(px(300.)).child(
|
||||
MemberList::new(
|
||||
"messaging-members",
|
||||
all.iter().map(|person| person.member.clone()),
|
||||
)
|
||||
.selected(key)
|
||||
.on_select(move |key, _, cx| change(&chosen, cx, |chosen| *chosen = key.clone()))
|
||||
.on_activate(|key, _, _| log::info!("gallery: message {key}")),
|
||||
),
|
||||
)
|
||||
.child(frame),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn statuses(cx: &mut App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"OnlineStatus",
|
||||
"Where someone stands, as a dot in its color and its word.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"messaging-online",
|
||||
div().flex().flex_wrap().gap_6().children(
|
||||
[
|
||||
Presence::Online,
|
||||
Presence::Away,
|
||||
Presence::Busy,
|
||||
Presence::Offline,
|
||||
]
|
||||
.map(OnlineStatus::new),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn suggestion(emoji: &str, text: &str, clear: ClearAfter) -> Status {
|
||||
Status {
|
||||
emoji: Some(SharedString::from(emoji.to_string())),
|
||||
text: SharedString::from(text.to_string()),
|
||||
clear,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setter(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let kept = keep("messaging-status", || None::<Status>, window, cx);
|
||||
let status = kept.read(cx).clone();
|
||||
let (saving, clearing) = (kept.clone(), kept);
|
||||
let theme = cx.theme();
|
||||
let now = match &status {
|
||||
Some(status) => {
|
||||
let words = match &status.emoji {
|
||||
Some(emoji) => format!("{emoji} {}", status.text),
|
||||
None => status.text.to_string(),
|
||||
};
|
||||
format!("{words} · {}", status.clear.label())
|
||||
}
|
||||
None => "No status set.".to_string(),
|
||||
};
|
||||
let setter = StatusSetter::new("messaging-status")
|
||||
.suggestions([
|
||||
suggestion("📅", "In a meeting", ClearAfter::OneHour),
|
||||
suggestion("🚌", "Commuting", ClearAfter::ThirtyMinutes),
|
||||
suggestion("🤒", "Out sick", ClearAfter::Today),
|
||||
suggestion("🌴", "On leave", ClearAfter::Never),
|
||||
])
|
||||
.on_save(move |status, _, cx| {
|
||||
let status = status.clone();
|
||||
change(&saving, cx, |kept| *kept = Some(status))
|
||||
})
|
||||
.on_clear(move |_, cx| change(&clearing, cx, |kept| *kept = None));
|
||||
let setter = match status {
|
||||
Some(status) => setter.status(status),
|
||||
None => setter,
|
||||
};
|
||||
let shown = div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(now);
|
||||
section(
|
||||
"StatusSetter",
|
||||
"An emoji, a few words, and when they clear. A suggestion fills all three; Save sets the status, and Clear status removes it.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"messaging-status",
|
||||
div()
|
||||
.w(px(380.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(shown)
|
||||
.child(setter),
|
||||
))
|
||||
}
|
||||
@@ -18,7 +18,7 @@ pub enum Presence {
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
fn color(self, colors: &Palette) -> Hsla {
|
||||
pub fn color(self, colors: &Palette) -> Hsla {
|
||||
match self {
|
||||
Self::Online => colors.success,
|
||||
Self::Away => colors.warning,
|
||||
@@ -26,6 +26,15 @@ impl Presence {
|
||||
Self::Offline => colors.fg_subtle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "Online",
|
||||
Self::Away => "Away",
|
||||
Self::Busy => "Busy",
|
||||
Self::Offline => "Offline",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Up to two capitals from a name's first and last words.
|
||||
|
||||
@@ -19,6 +19,7 @@ pub use calendar::Calendar;
|
||||
pub use cron::CronEditor;
|
||||
pub use period::{MonthPicker, QuarterPicker, YearPicker};
|
||||
pub use picker::{DatePicker, DateRangePicker};
|
||||
pub(crate) use picker::{Face, dropdown, picker_field};
|
||||
pub use relative::{RelativeDatePicker, RelativeRange};
|
||||
pub use rule::CronRule;
|
||||
pub use time::{DateTimePicker, DurationPicker, TimePicker};
|
||||
|
||||
@@ -62,6 +62,7 @@ pub use date::{
|
||||
MonthPicker, QuarterPicker, RelativeDatePicker, RelativeRange, TimePicker, TimezoneSelect,
|
||||
WeekPicker, YearPicker,
|
||||
};
|
||||
pub(crate) use date::{Face, dropdown, picker_field};
|
||||
pub use expression::{ExpressionInput, evaluate, expression_highlights};
|
||||
pub use files::{DropZone, FileInput, PICTURES};
|
||||
pub(crate) use files::{browse, dropped};
|
||||
|
||||
@@ -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()])]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
mod channels;
|
||||
mod header;
|
||||
mod members;
|
||||
mod messages;
|
||||
mod people;
|
||||
mod pins;
|
||||
mod receipt;
|
||||
mod status;
|
||||
mod thread;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
@@ -10,7 +13,10 @@ mod tests;
|
||||
|
||||
pub use channels::{ChannelItem, ChannelList, ChatRow, DirectMessageItem};
|
||||
pub use header::ChannelHeader;
|
||||
pub use members::{Member, MemberList};
|
||||
pub use messages::{ChatMessage, UnreadDivider};
|
||||
pub use people::{OnlineStatus, UserProfileCard};
|
||||
pub use pins::{PinnedMessage, PinnedMessages};
|
||||
pub use receipt::{Delivery, ReadReceipt};
|
||||
pub use status::{ClearAfter, Status, StatusSetter};
|
||||
pub use thread::{MessageThread, ThreadPanel};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Subscription, Window, div, prelude::*, rems,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{
|
||||
Choice, EmojiPicker, Face, Input, InputEvent, Run, Select, TextInput, dropdown,
|
||||
float_height, picker_field, surface,
|
||||
},
|
||||
lists::{ListItem, SelectableList},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
|
||||
/// When a status clears itself.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ClearAfter {
|
||||
Never,
|
||||
ThirtyMinutes,
|
||||
OneHour,
|
||||
FourHours,
|
||||
Today,
|
||||
ThisWeek,
|
||||
}
|
||||
|
||||
impl ClearAfter {
|
||||
pub const ALL: [Self; 6] = [
|
||||
Self::Never,
|
||||
Self::ThirtyMinutes,
|
||||
Self::OneHour,
|
||||
Self::FourHours,
|
||||
Self::Today,
|
||||
Self::ThisWeek,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Never => "Don't clear",
|
||||
Self::ThirtyMinutes => "30 minutes",
|
||||
Self::OneHour => "1 hour",
|
||||
Self::FourHours => "4 hours",
|
||||
Self::Today => "Today",
|
||||
Self::ThisWeek => "This week",
|
||||
}
|
||||
}
|
||||
|
||||
fn of(label: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|clear| clear.label() == label)
|
||||
.unwrap_or_else(|| panic!("no clear after {label}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A status: an emoji when chosen, a few words, and when it clears.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Status {
|
||||
pub emoji: Option<SharedString>,
|
||||
pub text: SharedString,
|
||||
pub clear: ClearAfter,
|
||||
}
|
||||
|
||||
type OnStatus = Rc<dyn Fn(&Status, &mut Window, &mut App)>;
|
||||
|
||||
/// What the setter holds before Save: the owner's status it started from, and the edits.
|
||||
struct Draft {
|
||||
seed: Option<Status>,
|
||||
emoji: Option<SharedString>,
|
||||
clear: ClearAfter,
|
||||
input: Entity<TextInput>,
|
||||
_changes: Subscription,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// Starts over from `status`; a new one clears today.
|
||||
fn fill(&mut self, status: Option<&Status>, cx: &mut Context<Self>) {
|
||||
self.emoji = status.and_then(|status| status.emoji.clone());
|
||||
self.clear = status.map_or(ClearAfter::Today, |status| status.clear);
|
||||
let text = status.map_or(String::new(), |status| status.text.to_string());
|
||||
self.input.update(cx, |input, cx| input.set_text(text, cx));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn status(&self, cx: &App) -> Status {
|
||||
Status {
|
||||
emoji: self.emoji.clone(),
|
||||
text: self.input.read(cx).text().to_string().into(),
|
||||
clear: self.clear,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets your status: an emoji, a few words, and when it clears. Suggestions fill all three; Save hands the status over, and Clear status removes the one set. A new status from the owner starts the edits over.
|
||||
#[derive(IntoElement)]
|
||||
pub struct StatusSetter {
|
||||
id: ElementId,
|
||||
status: Option<Status>,
|
||||
suggestions: Vec<Status>,
|
||||
on_save: Option<OnStatus>,
|
||||
on_clear: Option<Run>,
|
||||
}
|
||||
|
||||
impl StatusSetter {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: None,
|
||||
suggestions: Vec::new(),
|
||||
on_save: None,
|
||||
on_clear: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The status set now.
|
||||
pub fn status(mut self, status: Status) -> Self {
|
||||
self.status = Some(status);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = Status>) -> Self {
|
||||
self.suggestions = suggestions.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_save(mut self, handler: impl Fn(&Status, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_save = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_clear(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_clear = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for StatusSetter {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let seed = self.status.clone();
|
||||
let draft = window.use_keyed_state((self.id.clone(), "draft"), cx, |window, cx| {
|
||||
let input = cx.new(|cx| TextInput::new(window, cx).placeholder("What's your status?"));
|
||||
let changes = cx.subscribe(&input, |_, _, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Changed {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
let mut draft = Draft {
|
||||
seed: seed.clone(),
|
||||
emoji: None,
|
||||
clear: ClearAfter::Today,
|
||||
input,
|
||||
_changes: changes,
|
||||
};
|
||||
draft.fill(seed.as_ref(), cx);
|
||||
draft
|
||||
});
|
||||
if draft.read(cx).seed != self.status {
|
||||
log::info!("status setter {}: started over", self.id);
|
||||
let status = self.status.clone();
|
||||
draft.update(cx, |draft, cx| {
|
||||
draft.fill(status.as_ref(), cx);
|
||||
draft.seed = status;
|
||||
});
|
||||
}
|
||||
let now = draft.read(cx).status(cx);
|
||||
let input = draft.read(cx).input.clone();
|
||||
let emoji_id: ElementId = (self.id.clone(), "emoji").into();
|
||||
let inner = dropdown(&emoji_id, window, cx).read(cx).inner.clone();
|
||||
let (picked, popup_id, shown) = (draft.clone(), emoji_id.clone(), now.emoji.clone());
|
||||
let emoji = picker_field(
|
||||
emoji_id,
|
||||
Face {
|
||||
icon: IconName::Smile,
|
||||
shown: now.emoji.clone(),
|
||||
placeholder: "".into(),
|
||||
size: ControlSize::Md,
|
||||
disabled: false,
|
||||
},
|
||||
move |close, anchor, window, cx| {
|
||||
let theme = cx.theme();
|
||||
let rem = window.rem_size();
|
||||
let height = theme.list_max_height().to_pixels(rem)
|
||||
+ theme.control_height(ControlSize::Lg).to_pixels(rem) * 2.0;
|
||||
let (escape, done) = (close.clone(), close.clone());
|
||||
let picker = EmojiPicker::new((popup_id.clone(), "picker"))
|
||||
.when_some(shown, |picker, emoji| picker.selected(emoji))
|
||||
.on_change(move |glyph, window, cx| {
|
||||
let glyph = SharedString::from(glyph.to_string());
|
||||
log::info!("status setter: emoji {glyph}");
|
||||
picked.update(cx, |draft, cx| {
|
||||
draft.emoji = Some(glyph);
|
||||
cx.notify();
|
||||
});
|
||||
done(window, cx);
|
||||
});
|
||||
float_height(
|
||||
anchor,
|
||||
height,
|
||||
surface((popup_id, "popup"), cx)
|
||||
.track_focus(&inner)
|
||||
.p_2()
|
||||
.on_mouse_down_out(move |_, window, cx| close(window, cx))
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
escape(window, cx);
|
||||
}
|
||||
})
|
||||
.child(picker),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let label = |text: &'static str| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(text)
|
||||
};
|
||||
let suggestions = (!self.suggestions.is_empty()).then(|| {
|
||||
let lit = self
|
||||
.suggestions
|
||||
.iter()
|
||||
.position(|suggestion| *suggestion == now)
|
||||
.map(|ix| format!("suggestion-{ix}"));
|
||||
let rows = self.suggestions.iter().enumerate().fold(
|
||||
SelectableList::new((self.id.clone(), "suggestions")),
|
||||
|list, (ix, suggestion)| {
|
||||
let row = ListItem::new(
|
||||
(self.id.clone(), format!("suggestion-{ix}")),
|
||||
suggestion.text.clone(),
|
||||
)
|
||||
.trailing(suggestion.clear.label());
|
||||
let row = match suggestion.emoji.clone() {
|
||||
Some(emoji) => row.leading(emoji),
|
||||
None => row,
|
||||
};
|
||||
list.row(format!("suggestion-{ix}"), row)
|
||||
},
|
||||
);
|
||||
let (all, filled) = (self.suggestions.clone(), draft.clone());
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(label("Suggestions"))
|
||||
.child(rows.selected(lit).on_change(move |keys, _, cx| {
|
||||
let key = keys.first().expect("a pick names a suggestion");
|
||||
let ix: usize = key["suggestion-".len()..]
|
||||
.parse()
|
||||
.expect("a suggestion's key holds its place");
|
||||
log::info!("status setter: suggestion {ix}");
|
||||
let suggestion = all[ix].clone();
|
||||
filled.update(cx, |draft, cx| draft.fill(Some(&suggestion), cx));
|
||||
}))
|
||||
});
|
||||
let clearing = draft.clone();
|
||||
let clear_after = Select::new(
|
||||
(self.id.clone(), "clear"),
|
||||
ClearAfter::ALL.map(|clear| Choice::new(clear.label(), clear.label())),
|
||||
)
|
||||
.selected(now.clear.label())
|
||||
.on_change(move |value, _, cx| {
|
||||
let clear = ClearAfter::of(value);
|
||||
log::info!("status setter: clear after {}", clear.label());
|
||||
clearing.update(cx, |draft, cx| {
|
||||
draft.clear = clear;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
let blank = now.emoji.is_none() && now.text.trim().is_empty();
|
||||
let clear = self.on_clear.filter(|_| self.status.is_some()).map(|run| {
|
||||
Button::new((self.id.clone(), "remove"), "Clear status")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("status setter: clear");
|
||||
run(window, cx)
|
||||
})
|
||||
});
|
||||
let save = self.on_save.map(|save| {
|
||||
let saving = draft.clone();
|
||||
Button::new((self.id.clone(), "save"), "Save")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.disabled(blank)
|
||||
.on_click(move |_, window, cx| {
|
||||
let status = saving.read(cx).status(cx);
|
||||
log::info!("status setter: save {:?} {}", status.emoji, status.text);
|
||||
save(&status, window, cx)
|
||||
})
|
||||
});
|
||||
div()
|
||||
.debug_selector(|| "status-setter".into())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(rems(theme.control_height(ControlSize::Md).0 * 2.5))
|
||||
.child(emoji),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().child(Input::new(&input))),
|
||||
)
|
||||
.children(suggestions)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(label("Clear after"))
|
||||
.child(div().flex_none().w(theme.label_width()).child(clear_after)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.children(clear)
|
||||
.children(save),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use super::{
|
||||
use crate::{data_display::Presence, primitives::FocusNext, theme::Theme};
|
||||
|
||||
mod messages;
|
||||
mod people;
|
||||
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
use gpui::{
|
||||
Context, IntoElement, ParentElement, Render, SharedString, Styled, TestAppContext, Window, div,
|
||||
px,
|
||||
};
|
||||
|
||||
use super::{press, settle, setup, tab_to};
|
||||
use crate::{
|
||||
data_display::Presence,
|
||||
forms,
|
||||
messaging::{ClearAfter, Member, MemberList, Status, StatusSetter, UserProfileCard},
|
||||
};
|
||||
|
||||
/// Four members, and what the list asked.
|
||||
struct Members {
|
||||
asked: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Members {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (select, activate) = (cx.entity(), cx.entity());
|
||||
div().w(px(320.0)).child(
|
||||
MemberList::new(
|
||||
"members",
|
||||
[
|
||||
Member::new("dev", "Dev Rao", Presence::Offline),
|
||||
Member::new("ben", "Ben Ito", Presence::Online).role("Admin"),
|
||||
Member::new("ana", "Ana Lima", Presence::Away).status("🌴 Away till Monday"),
|
||||
],
|
||||
)
|
||||
.on_select(move |key, _, cx| {
|
||||
select.update(cx, |host, _| host.asked.push(format!("select {key}")))
|
||||
})
|
||||
.on_activate(move |key, _, cx| {
|
||||
activate.update(cx, |host, _| host.asked.push(format!("activate {key}")))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_arrow_selects_a_member_and_enter_acts_on_them(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Members { asked: Vec::new() });
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
tab_to(1, cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
tab_to(2, cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
host.read_with(cx, |host, _| host.asked.clone()),
|
||||
["select ben", "activate ben", "activate dev"],
|
||||
"online comes first by name, then offline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "member ana twice")]
|
||||
fn a_member_is_listed_once() {
|
||||
let _ = MemberList::new(
|
||||
"members",
|
||||
[
|
||||
Member::new("ana", "Ana", Presence::Online),
|
||||
Member::new("ana", "Ana Lima", Presence::Away),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// A card with both ways to reach, and what it asked.
|
||||
struct Card {
|
||||
asked: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl Render for Card {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (message, call) = (cx.entity(), cx.entity());
|
||||
div().w(px(300.0)).child(
|
||||
UserProfileCard::new("card", "Ana Lima", Presence::Online)
|
||||
.title("Architect")
|
||||
.on_message(move |_, cx| message.update(cx, |host, _| host.asked.push("message")))
|
||||
.on_call(move |_, cx| call.update(cx, |host, _| host.asked.push("call"))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_cards_ways_to_reach_each_ask_their_own(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Card { asked: Vec::new() });
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
for nth in 1..=2 {
|
||||
tab_to(nth, cx);
|
||||
press("space", cx);
|
||||
}
|
||||
assert_eq!(
|
||||
host.read_with(cx, |host, _| host.asked.clone()),
|
||||
["message", "call"]
|
||||
);
|
||||
}
|
||||
|
||||
fn walk() -> Status {
|
||||
Status {
|
||||
emoji: Some("🚶".into()),
|
||||
text: "On a walk".into(),
|
||||
clear: ClearAfter::OneHour,
|
||||
}
|
||||
}
|
||||
|
||||
/// A status setter with one suggestion, the status the owner holds, and what was saved.
|
||||
struct Setting {
|
||||
status: Option<Status>,
|
||||
saved: Vec<Status>,
|
||||
cleared: usize,
|
||||
}
|
||||
|
||||
impl Render for Setting {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (save, clear) = (cx.entity(), cx.entity());
|
||||
let setter = StatusSetter::new("status")
|
||||
.suggestions([walk()])
|
||||
.on_save(move |status, _, cx| {
|
||||
save.update(cx, |host, _| host.saved.push(status.clone()))
|
||||
})
|
||||
.on_clear(move |_, cx| clear.update(cx, |host, _| host.cleared += 1));
|
||||
let setter = match self.status.clone() {
|
||||
Some(status) => setter.status(status),
|
||||
None => setter,
|
||||
};
|
||||
div().w(px(360.0)).child(setter)
|
||||
}
|
||||
}
|
||||
|
||||
fn setting(
|
||||
status: Option<Status>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (gpui::Entity<Setting>, &mut gpui::VisualTestContext) {
|
||||
setup(cx);
|
||||
cx.update(forms::bind_keys);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Setting {
|
||||
status,
|
||||
saved: Vec::new(),
|
||||
cleared: 0,
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_suggestion_fills_the_status_and_save_hands_it_over(cx: &mut TestAppContext) {
|
||||
let (host, cx) = setting(None, cx);
|
||||
tab_to(3, cx);
|
||||
press("space", cx);
|
||||
tab_to(5, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(host.read_with(cx, |host, _| host.saved.clone()), [walk()]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typed_words_save_as_they_stand(cx: &mut TestAppContext) {
|
||||
let (host, cx) = setting(None, cx);
|
||||
tab_to(2, cx);
|
||||
cx.simulate_input("Reading");
|
||||
settle(cx);
|
||||
tab_to(5, cx);
|
||||
press("space", cx);
|
||||
let saved = Status {
|
||||
emoji: None,
|
||||
text: SharedString::from("Reading"),
|
||||
clear: ClearAfter::Today,
|
||||
};
|
||||
assert_eq!(host.read_with(cx, |host, _| host.saved.clone()), [saved]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_new_status_from_the_owner_starts_the_edits_over(cx: &mut TestAppContext) {
|
||||
let (host, cx) = setting(None, cx);
|
||||
tab_to(2, cx);
|
||||
cx.simulate_input("Reading");
|
||||
settle(cx);
|
||||
host.update(cx, |host, cx| {
|
||||
host.status = Some(walk());
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
tab_to(6, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(host.read_with(cx, |host, _| host.saved.clone()), [walk()]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn clear_status_asks_only_while_a_status_is_set(cx: &mut TestAppContext) {
|
||||
let (host, cx) = setting(Some(walk()), cx);
|
||||
tab_to(5, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(host.read_with(cx, |host, _| host.cleared), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_emoji_is_searched_and_picked_and_focus_comes_back(cx: &mut TestAppContext) {
|
||||
let (host, cx) = setting(None, cx);
|
||||
tab_to(1, cx);
|
||||
press("enter", cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
settle(cx);
|
||||
cx.simulate_input("palm tree");
|
||||
settle(cx);
|
||||
press("enter", cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
settle(cx);
|
||||
cx.simulate_input("Away");
|
||||
settle(cx);
|
||||
tab_to(5, cx);
|
||||
press("space", cx);
|
||||
let saved = Status {
|
||||
emoji: Some("🌴".into()),
|
||||
text: "Away".into(),
|
||||
clear: ClearAfter::Today,
|
||||
};
|
||||
assert_eq!(
|
||||
host.read_with(cx, |host, _| host.saved.clone()),
|
||||
[saved],
|
||||
"the words follow the emoji field in Tab order"
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -340,10 +340,10 @@
|
||||
|
||||
## T28c Messaging · People
|
||||
|
||||
- [ ] MemberList
|
||||
- [ ] UserProfileCard
|
||||
- [ ] OnlineStatus
|
||||
- [ ] StatusSetter
|
||||
- [x] MemberList
|
||||
- [x] UserProfileCard
|
||||
- [x] OnlineStatus
|
||||
- [x] StatusSetter
|
||||
|
||||
## T28d Messaging · Calls
|
||||
|
||||
|
||||
Reference in new issue
Block a user