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:
1 parent
85df47fd5c
commit
cd14b32dfe
11 files changed
+1188
-17
No files matched your search
@@ -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 | |
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user