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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eeeca67e55
commit
3c8a5a026c
11 files changed
+1188
-17
No files matched your search
@@ -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()));
|
||||
}
|
||||
Reference in new issue
Block a user