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

+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()));
}