diff --git a/TASKS.md b/TASKS.md index 9dca411..232edf1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/messaging/mod.rs b/examples/gallery/pages/messaging/mod.rs new file mode 100644 index 0000000..1dd6e5a --- /dev/null +++ b/examples/gallery/pages/messaging/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 6e423d4..8b98345 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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, ]; diff --git a/src/lib.rs b/src/lib.rs index 8ec1296..e6d8aca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/lists/item.rs b/src/lists/item.rs index 4b1c719..cdb3b6e 100644 --- a/src/lists/item.rs +++ b/src/lists/item.rs @@ -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, } @@ -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) -> 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) diff --git a/src/messaging/channels.rs b/src/messaging/channels.rs new file mode 100644 index 0000000..3252149 --- /dev/null +++ b/src/messaging/channels.rs @@ -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, name: impl Into) -> 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, + presence: Presence, + unread: u32, +} + +impl DirectMessageItem { + pub fn new( + id: impl Into, + name: impl Into, + presence: Presence, + ) -> Self { + Self { + id: id.into(), + name: name.into(), + picture: None, + presence, + unread: 0, + } + } + + pub fn picture(mut self, source: impl Into) -> 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 for ChatRow { + fn from(item: ChannelItem) -> Self { + Self::Channel(item) + } +} + +impl From 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, + on_open: Option, +} + +impl ChannelList { + pub fn new(id: impl Into) -> 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, + rows: impl IntoIterator, impl Into)>, + ) -> 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) -> 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) + } +} diff --git a/src/messaging/header.rs b/src/messaging/header.rs new file mode 100644 index 0000000..f635e1c --- /dev/null +++ b/src/messaging/header.rs @@ -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, + members: Option<(usize, Run)>, + pins: Option<(usize, Run)>, + on_call: Option, +} + +impl ChannelHeader { + pub fn new(id: impl Into, name: impl Into) -> 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) -> 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), + ) + } +} diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs new file mode 100644 index 0000000..3c720b6 --- /dev/null +++ b/src/messaging/mod.rs @@ -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}; diff --git a/src/messaging/pins.rs b/src/messaging/pins.rs new file mode 100644 index 0000000..db94a4d --- /dev/null +++ b/src/messaging/pins.rs @@ -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, + on_open: Option, + on_unpin: Option, +} + +impl PinnedMessages { + pub fn new( + id: impl Into, + messages: impl IntoIterator, + ) -> Self { + let mut messages: Vec = 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)), + ) + }) + } +} diff --git a/src/messaging/tests/mod.rs b/src/messaging/tests/mod.rs new file mode 100644 index 0000000..23ec5a5 --- /dev/null +++ b/src/messaging/tests/mod.rs @@ -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, +} + +impl Render for Chatting { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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) -> 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, +} + +impl Render for Pinning { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &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) -> 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())); +} diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index a9014e1..177894f 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -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`