Messaging · Channels: the chat list, its rows, a channel's head and its pins

ChannelList sets chats down in sections, each a list: a press, an arrow
or Enter opens one, and the open chat is lit. A title names one section,
and a section holds a chat. ChannelItem shows a hash or a lock, its
unread count, a pin when pinned, and goes quiet when muted.
DirectMessageItem shows a person's initials with their presence. Unread
names stand out through ListItem's new strong; muted channels and
offline people go quiet through its quiet. ChannelHeader holds the name
and topic beside members, pins and a call, wrapping by the header rule.
PinnedMessages lists pins newest first; a press, an arrow or Enter opens
one, and Unpin lets one go. With none it shows a line and takes no Tab
stop.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 21:30:17 -04:00
1 parent 85df47fd5c
commit cd14b32dfe
11 files changed
+1188 -17

No files matched your search

+7 -2
View File
@@ -97,8 +97,12 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T27a Files · Items
- [x] T27b Files · Explorer
- [x] T27c Files · Find and look
- [ ] T27d Files · Transfers
- [ ] T28 Messaging
- [x] T27d Files · Transfers
- [ ] T28a Messaging · Channels
- [ ] T28b Messaging · Messages
- [ ] T28c Messaging · People
- [ ] T28d Messaging · Calls
- [ ] T28e Messaging · Stickers and GIFs
- [ ] T29 Mail
- [ ] T30 Calendar
- [ ] T31 Project — `tasks/ch31-43.md`
@@ -328,3 +332,4 @@ Per-component lines live in `tasks/`. Tags there:
| T27c | 1 | FAIL | recent and found files were keyed by their place, so the cursor left a file an open moved |
| T27c | 2 | PASS | |
| T27d | 1 | FAIL | a press on a finished download's Show button also opened the file through its row |
| T27d | 2 | PASS | |
+214
View File
@@ -0,0 +1,214 @@
use ely_gpui_component::{
data_display::Presence,
messaging::{
ChannelHeader, ChannelItem, ChannelList, DirectMessageItem, PinnedMessage, PinnedMessages,
},
};
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use jiff::{SignedDuration, Timestamp};
use super::Page;
use crate::{
probe::probe,
script::Step,
ui::{change, keep, section},
};
pub const PAGE: Page = Page {
number: 28,
slug: "messaging",
title: "Messaging",
summary: "Channels and people: the lists and heads of chats, the messages in them, the people behind them, and calls.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::DownAt("messaging-channels", 120.0, 150.0),
Step::UpAt("messaging-channels", 120.0, 150.0),
Step::Key("down"),
Step::Wait(200),
Step::Shot("channel-opened"),
];
/// The demo's chats: which is open and what waits unread in each.
#[derive(Clone)]
struct Chats {
open: SharedString,
unread: Vec<(SharedString, u32)>,
}
impl Chats {
fn unread(&self, key: &str) -> u32 {
self.unread
.iter()
.find(|(each, _)| each.as_ref() == key)
.map_or(0, |(_, count)| *count)
}
}
fn channels(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let chats = keep(
"messaging-chats",
|| Chats {
open: "design".into(),
unread: vec![
("atrium".into(), 4),
("general".into(), 12),
("random".into(), 31),
("ben".into(), 2),
],
},
window,
cx,
);
let now = chats.read(cx).clone();
let channel = |key: &'static str| ChannelItem::new(key, key).unread(now.unread(key));
let open = chats.clone();
section(
"ChannelList / ChatList",
"Channels and people in sections, each a list: a press, an arrow or Enter opens a chat and clears what waited. Unread names stand out; a muted channel keeps quiet.",
cx,
)
.child(probe(
"messaging-channels",
div().w(px(260.)).child(
ChannelList::new("messaging-channels")
.section("Starred", [("atrium", channel("atrium").pinned(true))])
.section(
"Channels",
[
("design", channel("design")),
("general", channel("general")),
("launch", channel("launch").private()),
("random", channel("random").muted(true)),
],
)
.section(
"Direct messages",
[
("ana", DirectMessageItem::new("ana", "Ana Lima", Presence::Online)),
("ben", DirectMessageItem::new("ben", "Ben Ito", Presence::Away).unread(now.unread("ben"))),
("chloe", DirectMessageItem::new("chloe", "Chloé Martin", Presence::Busy)),
("dev", DirectMessageItem::new("dev", "Dev Rao", Presence::Offline)),
],
)
.open(now.open)
.on_open(move |key, _, cx| {
change(&open, cx, |chats| {
chats.open = key.clone();
chats.unread.retain(|(each, _)| each != key);
})
}),
),
))
}
fn items(cx: &mut App) -> impl IntoElement + use<> {
section(
"ChannelItem / DirectMessageItem",
"A channel by its mark, a hash or a lock, with its unread count, quiet when muted, pinned with a pin; a person with their presence on their initials.",
cx,
)
.child(probe(
"messaging-items",
div()
.w(px(520.))
.flex()
.gap_6()
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(ChannelItem::new("item-unread", "design").unread(3))
.child(ChannelItem::new("item-private", "launch").private())
.child(ChannelItem::new("item-muted", "random").unread(31).muted(true))
.child(ChannelItem::new("item-pinned", "atrium").pinned(true)),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(DirectMessageItem::new("dm-on", "Ana Lima", Presence::Online).unread(1))
.child(DirectMessageItem::new("dm-away", "Ben Ito", Presence::Away))
.child(DirectMessageItem::new("dm-busy", "Chloé Martin", Presence::Busy))
.child(DirectMessageItem::new("dm-off", "Dev Rao", Presence::Offline)),
),
))
}
fn header(cx: &mut App) -> impl IntoElement + use<> {
section(
"ChannelHeader",
"A channel's name and topic, and beside them its members, its pins and a call; the actions drop below on a narrow window.",
cx,
)
.child(probe(
"messaging-header",
div().w(px(640.)).child(
ChannelHeader::new("messaging-header", "design")
.topic("Where the atrium takes its shape: drawings, samples and the week's reviews")
.members(12, |_, _| log::info!("gallery: members"))
.pins(3, |_, _| log::info!("gallery: pins"))
.on_call(|_, _| log::info!("gallery: call")),
),
))
}
fn pins(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let now = Timestamp::now();
let pin = |key: &str, author: &str, hours: i64, text: &str| PinnedMessage {
key: SharedString::from(key.to_string()),
author: SharedString::from(author.to_string()),
at: now - SignedDuration::from_hours(hours),
text: SharedString::from(text.to_string()),
};
let all = vec![
pin(
"review",
"Ana Lima",
3,
"Friday's review moves to 10:00, in the model room.",
),
pin(
"samples",
"Ben Ito",
30,
"Plaster samples are in the stair hall. Please leave notes on the backs.",
),
pin(
"brief",
"Chloé Martin",
200,
"The brief, final: light from above, one stair, olive trees by the windows.",
),
];
let kept = keep("messaging-pins", move || all, window, cx);
let shown = kept.read(cx).clone();
section(
"PinnedMessages",
"Messages pinned in a chat, newest first: a press, an arrow or Enter opens one where it stands; Unpin lets one go.",
cx,
)
.child(probe(
"messaging-pins",
div().w(px(420.)).child(
PinnedMessages::new("messaging-pins", shown)
.on_open(|key, _, _| log::info!("gallery: open {key}"))
.on_unpin(move |key, _, cx| change(&kept, cx, |all| all.retain(|pin| pin.key != *key))),
),
))
}
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(channels(window, cx))
.child(items(cx))
.child(header(cx))
.child(pins(window, cx))
.into_any_element()
}
+2
View File
@@ -17,6 +17,7 @@ mod layout;
mod lists;
mod media;
mod menus;
mod messaging;
mod motion;
mod navigation;
mod overlays;
@@ -69,6 +70,7 @@ pub const ALL: &[Page] = &[
generative::PAGE,
media::PAGE,
files::PAGE,
messaging::PAGE,
theme::PAGE,
];
+1
View File
@@ -19,6 +19,7 @@ pub mod layout;
pub mod lists;
pub mod media;
pub mod menus;
pub mod messaging;
pub mod motion;
pub mod navigation;
pub mod overlays;
+25 -4
View File
@@ -1,9 +1,9 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*, relative,
AnyElement, App, ClickEvent, ElementId, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*, relative,
};
use smallvec::SmallVec;
@@ -25,6 +25,8 @@ pub struct ListItem {
selected: bool,
current: bool,
disabled: bool,
strong: bool,
quiet: bool,
on_click: Option<OnClick>,
}
@@ -39,10 +41,24 @@ impl ListItem {
selected: false,
current: false,
disabled: false,
strong: false,
quiet: false,
on_click: None,
}
}
/// Sets the title in the strong weight, as a channel with unread messages has it.
pub fn strong(mut self, strong: bool) -> Self {
self.strong = strong;
self
}
/// Sets the title in the quiet color, as a muted channel has it.
pub fn quiet(mut self, quiet: bool) -> Self {
self.quiet = quiet;
self
}
/// A second line, quieter than the title.
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into());
@@ -137,7 +153,12 @@ impl RenderOnce for ListItem {
.child(
line(self.title)
.text_size(theme.text_size(TextSize::Base))
.text_color(colors.fg),
.when(self.strong, |title| title.font_weight(FontWeight::SEMIBOLD))
.text_color(if self.quiet {
colors.fg_muted
} else {
colors.fg
}),
)
.children(self.description.map(|text| {
line(text)
+309
View File
@@ -0,0 +1,309 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*,
};
use crate::{
data_display::{Avatar, CountBadge, Presence},
forms::OnValue,
lists::{ListItem, SelectableList},
primitives::{Icon, IconName},
theme::{ActiveTheme, AvatarSize, IconSize, TextSize},
};
/// What unread messages add at a row's end: a count, and a pin before it when pinned.
fn trail(id: &ElementId, unread: u32, pinned: bool, cx: &App) -> gpui::Div {
let colors = &cx.theme().colors;
div()
.flex()
.items_center()
.gap_2()
.when(pinned, |trail| {
trail.child(
Icon::new(IconName::Pin)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
})
.when(unread > 0, |trail| {
trail.child(CountBadge::new((id.clone(), "unread"), unread as usize).max(99))
})
}
/// A channel as a row: a hash, or a lock when it is private, its name, and how many messages wait unread. Unread, its name stands out; muted, it goes quiet and counts nothing; pinned, it keeps a pin. As a `ListItem` it goes into a `ChannelList`.
#[derive(IntoElement)]
pub struct ChannelItem {
id: ElementId,
name: SharedString,
private: bool,
unread: u32,
muted: bool,
pinned: bool,
}
impl ChannelItem {
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
name: name.into(),
private: false,
unread: 0,
muted: false,
pinned: false,
}
}
pub fn private(mut self) -> Self {
self.private = true;
self
}
/// Messages waiting unread.
pub fn unread(mut self, count: u32) -> Self {
self.unread = count;
self
}
pub fn muted(mut self, muted: bool) -> Self {
self.muted = muted;
self
}
pub fn pinned(mut self, pinned: bool) -> Self {
self.pinned = pinned;
self
}
fn item(self, cx: &App) -> ListItem {
let colors = &cx.theme().colors;
let unread = if self.muted { 0 } else { self.unread };
let mark = if self.private {
IconName::Lock
} else {
IconName::Hash
};
let trail = trail(&self.id, unread, self.pinned, cx);
ListItem::new(self.id, self.name)
.leading(Icon::new(mark).size(IconSize::Sm).color(colors.fg_muted))
.strong(unread > 0)
.quiet(self.muted)
.trailing(trail)
}
}
impl RenderOnce for ChannelItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
self.item(cx)
}
}
/// A chat with one person as a row: their picture or initials with their presence, their name, and how many messages wait unread; unread, the name stands out. As a `ListItem` it goes into a `ChannelList`.
#[derive(IntoElement)]
pub struct DirectMessageItem {
id: ElementId,
name: SharedString,
picture: Option<ImageSource>,
presence: Presence,
unread: u32,
}
impl DirectMessageItem {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
presence: Presence,
) -> Self {
Self {
id: id.into(),
name: name.into(),
picture: None,
presence,
unread: 0,
}
}
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
self.picture = Some(source.into());
self
}
/// Messages waiting unread.
pub fn unread(mut self, count: u32) -> Self {
self.unread = count;
self
}
fn item(self, cx: &App) -> ListItem {
let avatar = Avatar::new((self.id.clone(), "avatar"), self.name.clone())
.size(AvatarSize::Sm)
.presence(self.presence);
let avatar = match self.picture {
Some(picture) => avatar.image(picture),
None => avatar,
};
let trail = trail(&self.id, self.unread, false, cx);
ListItem::new(self.id, self.name)
.leading(avatar)
.strong(self.unread > 0)
.quiet(self.presence == Presence::Offline && self.unread == 0)
.trailing(trail)
}
}
impl RenderOnce for DirectMessageItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
self.item(cx)
}
}
/// Rows a channel list takes: a channel or a direct message.
pub enum ChatRow {
Channel(ChannelItem),
Direct(DirectMessageItem),
}
impl From<ChannelItem> for ChatRow {
fn from(item: ChannelItem) -> Self {
Self::Channel(item)
}
}
impl From<DirectMessageItem> for ChatRow {
fn from(item: DirectMessageItem) -> Self {
Self::Direct(item)
}
}
/// Channels and chats in sections down a column, such as Starred, Channels and Direct messages, each section a list: a press, an arrow or Enter opens a chat, and the one open is lit. Tab moves between sections.
#[derive(IntoElement)]
pub struct ChannelList {
id: ElementId,
sections: Vec<(SharedString, Vec<(SharedString, ChatRow)>)>,
open: Option<SharedString>,
on_open: Option<OnValue>,
}
impl ChannelList {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
sections: Vec::new(),
open: None,
on_open: None,
}
}
/// A section under `title`, its chats by key; a title names one section, and a section holds a chat.
pub fn section(
mut self,
title: impl Into<SharedString>,
rows: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<ChatRow>)>,
) -> Self {
let title = title.into();
let rows: Vec<(SharedString, ChatRow)> = rows
.into_iter()
.map(|(key, row)| (key.into(), row.into()))
.collect();
assert!(!rows.is_empty(), "section {title} holds no chats");
let named = self.sections.iter().any(|(other, _)| *other == title);
assert!(!named, "section {title} twice");
for (key, _) in &rows {
let seen = self
.sections
.iter()
.flat_map(|(_, rows)| rows)
.any(|(other, _)| other == key);
assert!(!seen, "chat {key} twice");
}
self.sections.push((title, rows));
self
}
/// The chat open now, by key.
pub fn open(mut self, key: impl Into<SharedString>) -> Self {
self.open = Some(key.into());
self
}
/// Gets the key of the chat to open.
pub fn on_open(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ChannelList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if let Some(open) = &self.open {
let listed = self
.sections
.iter()
.flat_map(|(_, rows)| rows)
.any(|(key, _)| key == open);
assert!(listed, "no chat {open}");
}
let theme = cx.theme();
let colors = &theme.colors;
let sections: Vec<_> = self
.sections
.into_iter()
.map(|(title, rows)| {
let lit = self
.open
.clone()
.filter(|open| rows.iter().any(|(key, _)| key == open));
let list = rows.into_iter().fold(
SelectableList::new((self.id.clone(), format!("section-{title}"))),
|list, (key, row)| {
let item = match row {
ChatRow::Channel(item) => item.item(cx),
ChatRow::Direct(item) => item.item(cx),
};
list.row(key, item)
},
);
let open: OnValue = {
let (id, on_open) = (self.id.clone(), self.on_open.clone());
Rc::new(move |key, window, cx| {
log::info!("channel list {id:?}: open {key}");
if let Some(on_open) = &on_open {
on_open(key, window, cx);
}
})
};
let picked = open.clone();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.px_3()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg_muted)
.child(title),
)
.child(
list.selected(lit)
.on_change(move |keys, window, cx| {
picked(keys.first().expect("a pick names a chat"), window, cx)
})
.on_activate(move |key, window, cx| open(key, window, cx)),
)
})
.collect();
div()
.debug_selector(|| "channel-list".into())
.w_full()
.flex()
.flex_col()
.gap_4()
.children(sections)
}
}
+153
View File
@@ -0,0 +1,153 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
forms::Run,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::Ellipsis,
};
/// A channel's head: its mark and name with its topic under them, and beside them its members, its pinned messages and a call, each only with its handler. The actions drop below on a narrow window.
#[derive(IntoElement)]
pub struct ChannelHeader {
id: ElementId,
name: SharedString,
private: bool,
topic: Option<SharedString>,
members: Option<(usize, Run)>,
pins: Option<(usize, Run)>,
on_call: Option<Run>,
}
impl ChannelHeader {
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
name: name.into(),
private: false,
topic: None,
members: None,
pins: None,
on_call: None,
}
}
pub fn private(mut self) -> Self {
self.private = true;
self
}
pub fn topic(mut self, topic: impl Into<SharedString>) -> Self {
self.topic = Some(topic.into());
self
}
/// How many are in it, and what a press on them does.
pub fn members(
mut self,
count: usize,
handler: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.members = Some((count, Rc::new(handler)));
self
}
/// How many messages are pinned, and what a press on them does.
pub fn pins(mut self, count: usize, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.pins = Some((count, 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 ChannelHeader {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let counted =
|key: &'static str, icon: IconName, tip: &'static str, what: Option<(usize, Run)>| {
what.map(|(count, run)| {
Button::new((self.id.clone(), key), count.to_string())
.icon(icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("channel header: {tip}");
run(window, cx)
})
})
};
let call = self.on_call.map(|run| {
IconButton::new((self.id.clone(), "call"), IconName::Phone)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Start a call")
.on_click(move |_, window, cx| {
log::info!("channel header: call");
run(window, cx)
})
});
let mark = if self.private {
IconName::Lock
} else {
IconName::Hash
};
div()
.debug_selector(|| "channel-header".into())
.w_full()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.child(
div()
.flex()
.items_center()
.gap_1p5()
.child(Icon::new(mark).size(IconSize::Md).color(colors.fg_muted))
.child(
div()
.flex_1()
.min_w_0()
.text_size(theme.text_size(TextSize::Md))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(Ellipsis::new(self.name)),
),
)
.children(self.topic.map(|topic| {
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(Ellipsis::new(topic))
})),
)
.child(
div()
.flex_none()
.flex()
.items_center()
.gap_1()
.children(counted("members", IconName::Users, "members", self.members))
.children(counted("pins", IconName::Pin, "pinned messages", self.pins))
.children(call),
)
}
}
+10
View File
@@ -0,0 +1,10 @@
mod channels;
mod header;
mod pins;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use channels::{ChannelItem, ChannelList, ChatRow, DirectMessageItem};
pub use header::ChannelHeader;
pub use pins::{PinnedMessage, PinnedMessages};
+180
View File
@@ -0,0 +1,180 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::FluentBuilder,
};
use jiff::Timestamp;
use crate::{
buttons::{ButtonVariant, IconButton},
data_display::Avatar,
forms::OnValue,
lists::{ListItem, SelectableList},
primitives::IconName,
theme::{ActiveTheme, AvatarSize, ControlSize, TextSize},
typography::format,
};
/// A pinned message: its key, who wrote it and when, and its words.
#[derive(Clone, Debug, PartialEq)]
pub struct PinnedMessage {
pub key: SharedString,
pub author: SharedString,
pub at: Timestamp,
pub text: SharedString,
}
/// Messages pinned in a chat, newest first: the words over who wrote them and when. A press, an arrow or Enter opens one where it stands; Unpin lets one go.
#[derive(IntoElement)]
pub struct PinnedMessages {
id: ElementId,
messages: Vec<PinnedMessage>,
on_open: Option<OnValue>,
on_unpin: Option<OnValue>,
}
impl PinnedMessages {
pub fn new(
id: impl Into<ElementId>,
messages: impl IntoIterator<Item = PinnedMessage>,
) -> Self {
let mut messages: Vec<PinnedMessage> = messages.into_iter().collect();
for (ix, message) in messages.iter().enumerate() {
assert!(
!messages[..ix].iter().any(|other| other.key == message.key),
"pin {} twice",
message.key
);
}
messages.sort_by_key(|message| std::cmp::Reverse(message.at));
Self {
id: id.into(),
messages,
on_open: None,
on_unpin: None,
}
}
/// Gets the key of the message to open.
pub fn on_open(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
/// Gets the key of the message to unpin.
pub fn on_unpin(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_unpin = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PinnedMessages {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let now = Timestamp::now();
let count = self.messages.len();
let rows = self.messages.iter().fold(
SelectableList::new((self.id.clone(), "pins")),
|list, message| {
let unpin = self.on_unpin.clone().map(|unpin| {
let key = message.key.clone();
let named = key.clone();
div()
.debug_selector(move || format!("unpin {named}"))
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(
IconButton::new(
(self.id.clone(), format!("unpin-{key}")),
IconName::PinOff,
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Unpin")
.on_click(move |_, window, cx| {
log::info!("pinned messages: unpin {key}");
unpin(&key, window, cx)
}),
)
});
let who = format!("{} · {}", message.author, format::relative(message.at, now));
let row = ListItem::new(
(self.id.clone(), format!("pin-{}", message.key)),
message.text.clone(),
)
.leading(
Avatar::new(
(self.id.clone(), format!("author-{}", message.key)),
message.author.clone(),
)
.size(AvatarSize::Sm),
)
.description(who);
let row = match unpin {
Some(unpin) => row.trailing(unpin),
None => row,
};
list.row(message.key.clone(), row)
},
);
let open: OnValue = {
let (id, on_open) = (self.id.clone(), self.on_open);
Rc::new(move |key, window, cx| {
log::info!("pinned messages {id:?}: open {key}");
if let Some(on_open) = &on_open {
on_open(key, window, cx);
}
})
};
let picked = open.clone();
let empty = (count == 0).then(|| {
div()
.px_3()
.py_4()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child("Nothing pinned yet.")
});
div()
.debug_selector(|| "pinned-messages".into())
.w_full()
.flex()
.flex_col()
.gap_1()
.child(
div()
.px_3()
.flex()
.items_baseline()
.gap_2()
.child(
div()
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child("Pinned"),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(count.to_string()),
),
)
.children(empty)
.when(count > 0, |pins| {
pins.child(
rows.on_change(move |keys, window, cx| {
picked(keys.first().expect("a pick names a message"), window, cx)
})
.on_activate(move |key, window, cx| open(key, window, cx)),
)
})
}
}
+264
View File
@@ -0,0 +1,264 @@
use gpui::{
Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, ParentElement,
Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div, px,
};
use jiff::{SignedDuration, Timestamp};
use super::{
ChannelHeader, ChannelItem, ChannelList, DirectMessageItem, PinnedMessage, PinnedMessages,
};
use crate::{data_display::Presence, primitives::FocusNext, theme::Theme};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
/// Moves focus to the `nth` Tab stop from none.
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
for _ in 0..nth {
window.focus_next();
}
});
settle(cx);
}
/// Channels and direct messages with design open, and the chats it opened.
struct Chatting {
opened: Vec<SharedString>,
}
impl Render for Chatting {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
let channels = [
("general", ChannelItem::new("general", "general")),
("design", ChannelItem::new("design", "design").unread(3)),
(
"random",
ChannelItem::new("random", "random").unread(9).muted(true),
),
];
let people = [
(
"ana",
DirectMessageItem::new("ana", "Ana Lima", Presence::Online),
),
(
"ben",
DirectMessageItem::new("ben", "Ben Ito", Presence::Offline).unread(2),
),
];
div().w(px(260.0)).child(
ChannelList::new("chats")
.section("Channels", channels)
.section("Direct messages", people)
.open("design")
.on_open(move |key, _, cx| {
owner.update(cx, |host, _| host.opened.push(key.clone()))
}),
)
}
}
#[gpui::test]
fn each_section_opens_by_key_and_starts_on_the_chat_open(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Chatting { opened: Vec::new() });
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
press("down", cx);
tab_to(2, cx);
press("down", cx);
assert_eq!(
host.read_with(cx, |host, _| host.opened.clone()),
["random", "ben"]
);
}
#[gpui::test]
fn enter_opens_the_chat_under_the_cursor(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Chatting { opened: Vec::new() });
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(2, cx);
press("enter", cx);
assert_eq!(host.read_with(cx, |host, _| host.opened.clone()), ["ana"]);
}
#[test]
#[should_panic(expected = "chat general twice")]
fn a_chat_is_listed_once() {
let _ = ChannelList::new("chats")
.section("Channels", [("general", ChannelItem::new("a", "general"))])
.section("Starred", [("general", ChannelItem::new("b", "general"))]);
}
/// A header with members, pins and a call, and what it asked.
struct Heading {
asked: Vec<&'static str>,
}
impl Render for Heading {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (members, pins, call) = (cx.entity(), cx.entity(), cx.entity());
div().w(px(480.0)).child(
ChannelHeader::new("header", "design")
.topic("Where the atrium takes its shape")
.members(12, move |_, cx| {
members.update(cx, |host, _| host.asked.push("members"))
})
.pins(3, move |_, cx| {
pins.update(cx, |host, _| host.asked.push("pins"))
})
.on_call(move |_, cx| call.update(cx, |host, _| host.asked.push("call"))),
)
}
}
#[gpui::test]
fn the_headers_actions_each_ask_their_own(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Heading { asked: Vec::new() });
cx.update(|window, _| window.activate_window());
settle(cx);
for nth in 1..=3 {
tab_to(nth, cx);
press("space", cx);
}
assert_eq!(
host.read_with(cx, |host, _| host.asked.clone()),
["members", "pins", "call"]
);
}
/// Two pins, the later first, and what was asked.
struct Pinning {
asked: Vec<String>,
}
impl Render for Pinning {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (open, unpin) = (cx.entity(), cx.entity());
let now = Timestamp::now();
let pin = |key: &str, hours: i64| PinnedMessage {
key: SharedString::from(key.to_string()),
author: "Ana Lima".into(),
at: now - SignedDuration::from_hours(hours),
text: SharedString::from(format!("The {key} note")),
};
div().w(px(360.0)).child(
PinnedMessages::new("pins", [pin("older", 30), pin("newer", 2)])
.on_open(move |key, _, cx| {
open.update(cx, |host, _| host.asked.push(format!("open {key}")))
})
.on_unpin(move |key, _, cx| {
unpin.update(cx, |host, _| host.asked.push(format!("unpin {key}")))
}),
)
}
}
fn pinning(cx: &mut TestAppContext) -> (Entity<Pinning>, &mut VisualTestContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Pinning { asked: Vec::new() });
cx.update(|window, _| window.activate_window());
settle(cx);
(host, cx)
}
#[gpui::test]
fn the_newest_pin_comes_first_and_enter_opens_it(cx: &mut TestAppContext) {
let (host, cx) = pinning(cx);
tab_to(1, cx);
press("enter", cx);
assert_eq!(
host.read_with(cx, |host, _| host.asked.clone()),
["open newer"]
);
}
#[gpui::test]
fn a_press_on_unpin_unpins_and_opens_nothing(cx: &mut TestAppContext) {
let (host, cx) = pinning(cx);
let unpin = cx
.debug_bounds("unpin older")
.expect("the older pin's unpin draws");
cx.simulate_click(unpin.center(), Modifiers::none());
settle(cx);
assert_eq!(
host.read_with(cx, |host, _| host.asked.clone()),
["unpin older"]
);
}
#[test]
#[should_panic(expected = "pin a twice")]
fn a_message_is_pinned_once() {
let pin = PinnedMessage {
key: "a".into(),
author: "Ana".into(),
at: Timestamp::UNIX_EPOCH,
text: "hi".into(),
};
let _ = PinnedMessages::new("pins", [pin.clone(), pin]);
}
#[test]
#[should_panic(expected = "section Channels twice")]
fn a_section_is_named_once() {
let _ = ChannelList::new("chats")
.section("Channels", [("a", ChannelItem::new("a", "a"))])
.section("Channels", [("b", ChannelItem::new("b", "b"))]);
}
#[test]
#[should_panic(expected = "section Starred holds no chats")]
fn a_section_holds_a_chat() {
let _ = ChannelList::new("chats").section("Starred", Vec::<(&str, ChannelItem)>::new());
}
/// No pins, and nothing else to focus.
struct Unpinned;
impl Render for Unpinned {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.w(px(360.0))
.child(PinnedMessages::new("pins", Vec::new()))
}
}
#[gpui::test]
fn nothing_pinned_takes_no_tab_stop(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Unpinned);
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
assert!(cx.update(|window, cx| window.focused(cx).is_none()));
}
+23 -11
View File
@@ -320,33 +320,45 @@
- [x] DownloadManager
- [x] StorageUsage → `data_display::UsageBar`
## T28 Messaging — `src/messaging`
## T28a Messaging · Channels — `src/messaging`
- [ ] ChannelList / ChatList
- [ ] ChannelItem
- [ ] DirectMessageItem
- [x] ChannelList / ChatList
- [x] ChannelItem
- [x] DirectMessageItem
- [x] ChannelHeader
- [x] PinnedMessages
## T28b Messaging · Messages
- [ ] ChatMessage
- [ ] MessageThread
- [ ] ThreadPanel
- [ ] ChatMessage
- [ ] UnreadDivider
- [ ] MessageReactions → `collab::Reactions`
- [ ] ReadReceipt
- [ ] TypingIndicator → `motion::TypingIndicator`
## T28c Messaging · People
- [ ] MemberList
- [ ] UserProfileCard
- [ ] OnlineStatus
- [ ] StatusSetter
- [ ] VoiceCallBar
- [ ] VideoCallGrid — host frames
- [ ] ScreenShareView — host frames
## T28d Messaging · Calls
- [ ] CallControls
- [ ] ParticipantTile
- [ ] VideoCallGrid — host frames
- [ ] ScreenShareView — host frames
- [ ] VoiceCallBar
- [ ] IncomingCallDialog
- [ ] HuddleIndicator
## T28e Messaging · Stickers and GIFs
- [ ] StickerPicker
- [ ] GifPicker — host images
- [ ] PinnedMessages
- [ ] ChannelHeader
- [ ] HuddleIndicator
## T29 Mail — `src/mail`