diff --git a/TASKS.md b/TASKS.md index 5a12a33..eff2ccd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -103,7 +103,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T28c Messaging · People - [x] T28d Messaging · Calls - [x] T28e Messaging · Stickers and GIFs -- [ ] T29a Mail · Boxes and lists +- [x] T29a Mail · Boxes and lists - [ ] T29b Mail · Reading - [ ] T29c Mail · Writing - [ ] T29d Mail · Sorting @@ -342,3 +342,5 @@ Per-component lines live in `tasks/`. Tags there: | T28d | 1 | PASS | | | T28e | 1 | FAIL | a GIF focused past the fold stayed out of view | | T28e | 2 | PASS | | +| T29a | 1 | FAIL | Space on a focused star also toggled the list's selection | +| T29a | 2 | PASS | | diff --git a/assets/icons/reply-all.svg b/assets/icons/reply-all.svg new file mode 100644 index 0000000..99ca689 --- /dev/null +++ b/assets/icons/reply-all.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/examples/gallery/pages/mail/mod.rs b/examples/gallery/pages/mail/mod.rs index f70d92a..873fa14 100644 --- a/examples/gallery/pages/mail/mod.rs +++ b/examples/gallery/pages/mail/mod.rs @@ -8,6 +8,8 @@ use ely_gpui_component::{ use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; use jiff::{SignedDuration, Timestamp}; +mod read; + use super::Page; use crate::{ probe::probe, @@ -33,6 +35,14 @@ const SCRIPT: &[Step] = &[ Step::UpAt("mail-list", 417.0, 112.0), Step::Wait(200), Step::Shot("starred"), + Step::DownAt("mail-reader", 12.0, 227.0), + Step::UpAt("mail-reader", 12.0, 227.0), + Step::Wait(300), + Step::Shot("quoted-open"), + Step::DownAt("mail-thread", 200.0, 120.0), + Step::UpAt("mail-thread", 200.0, 120.0), + Step::Wait(300), + Step::Shot("thread-opened"), ]; fn boxes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { @@ -200,5 +210,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(boxes(window, cx)) .child(list(window, cx)) .child(zero(cx)) + .child(read::reader(cx)) + .child(read::thread(cx)) .into_any_element() } diff --git a/examples/gallery/pages/mail/read.rs b/examples/gallery/pages/mail/read.rs new file mode 100644 index 0000000..23b1e65 --- /dev/null +++ b/examples/gallery/pages/mail/read.rs @@ -0,0 +1,97 @@ +use ely_gpui_component::mail::{Contact, MailReader, MailThreadView, Message, QuotedText}; +use gpui::{App, IntoElement, ParentElement, Styled, div, px}; +use jiff::{SignedDuration, Timestamp}; + +use crate::{probe::probe, ui::section}; + +fn ana() -> Contact { + Contact::new("Ana Lima", "ana@atrium.studio") +} + +fn ben() -> Contact { + Contact::new("Ben Ito", "ben@atrium.studio") +} + +fn you() -> Contact { + Contact::new("You", "you@atrium.studio") +} + +fn ago(minutes: i64) -> Timestamp { + Timestamp::now() - SignedDuration::from_mins(minutes) +} + +fn paragraph(text: &'static str) -> impl IntoElement { + div().child(text) +} + +pub fn reader(cx: &mut App) -> impl IntoElement + use<> { + let message = Message::new("review", ana(), ago(12)) + .to([you(), ben()]) + .cc([Contact::new("Dev Rao", "dev@atrium.studio")]) + .file("atrium-plan.pdf", 2_400_000) + .file("stair-hall.jpg", 3_100_000); + section( + "MailReader / QuotedText", + "A message open for reading: the subject, who wrote to whom and when, replies, the words and the files. Words quoted from before fold behind a small mark until pressed.", + cx, + ) + .child(probe( + "mail-reader", + div().w(px(640.)).child( + MailReader::new("mail-reader", "Friday's review moves to 10:00", message) + .on_reply(|key, _, _| log::info!("gallery: reply {key}")) + .on_reply_all(|key, _, _| log::info!("gallery: reply all {key}")) + .on_forward(|key, _, _| log::info!("gallery: forward {key}")) + .on_download(|key, file, _, _| log::info!("gallery: download {key} {file}")) + .child(paragraph("Morning both,")) + .child(paragraph( + "Friday's review moves to 10:00, in the model room. The client wants to see the stair at the scale we'll build it, so please bring the plaster samples if they've dried.", + )) + .child(paragraph("The plan and a photo of the stair hall are attached. — Ana")) + .child( + QuotedText::new("mail-quoted") + .child(paragraph("On Thursday, Ben Ito wrote:")) + .child(paragraph( + "The samples are in the stair hall: lime, tadelakt and a sanded gypsum. The tadelakt needs another day.", + )), + ), + ), + )) +} + +pub fn thread(cx: &mut App) -> impl IntoElement + use<> { + let message = |key: &'static str, from: Contact, minutes: i64, snippet: &'static str| { + Message::new(key, from, ago(minutes)) + .to([you()]) + .snippet(snippet) + }; + section( + "MailThreadView", + "A conversation, oldest first: the newest stands open, and the others fold to a line of who, their first words and when, which a press, Enter or Space opens.", + cx, + ) + .child(probe( + "mail-thread", + div().w(px(640.)).child( + MailThreadView::new("mail-thread", "Plaster samples") + .on_reply(|key, _, _| log::info!("gallery: reply {key}")) + .on_forward(|key, _, _| log::info!("gallery: forward {key}")) + .message( + message("s1", ben(), 60 * 30, "Three finishes are in the stair hall: lime, tadelakt and a sanded gypsum."), + paragraph("Three finishes are in the stair hall: lime, tadelakt and a sanded gypsum. Leave notes on the backs."), + ) + .message( + message("s2", ana(), 60 * 26, "The lime reads warmest by the windows. Can we see it at dusk?"), + paragraph("The lime reads warmest by the windows. Can we see it at dusk?"), + ) + .message( + message("s3", Contact::new("Chloé Martin", "chloe@atrium.studio"), 60 * 5, "Dusk works. I'll bring the lamp we use for the model."), + paragraph("Dusk works. I'll bring the lamp we use for the model."), + ) + .message( + message("s4", ben(), 40, "The tadelakt is sealed. All three are ready for Friday."), + paragraph("The tadelakt is sealed. All three are ready for Friday."), + ), + ), + )) +} diff --git a/src/mail/mod.rs b/src/mail/mod.rs index 81f5296..58a8f6d 100644 --- a/src/mail/mod.rs +++ b/src/mail/mod.rs @@ -1,8 +1,14 @@ mod boxes; mod list; +mod quoted; +mod reader; +mod thread; #[cfg(all(test, feature = "test-support"))] mod tests; pub use boxes::{Mailbox, MailboxList}; pub use list::{Mail, MailItem, MailList}; +pub use quoted::QuotedText; +pub use reader::{Contact, MailReader, Message}; +pub use thread::MailThreadView; diff --git a/src/mail/quoted.rs b/src/mail/quoted.rs new file mode 100644 index 0000000..8a88836 --- /dev/null +++ b/src/mail/quoted.rs @@ -0,0 +1,80 @@ +use gpui::{ + AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, + Window, div, +}; +use smallvec::SmallVec; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + layout::Collapsible, + primitives::IconName, + theme::{ActiveTheme, ControlSize}, +}; + +/// Words quoted from an earlier message, folded behind "···" until it is pressed; open, they sit behind a rule. The fold is the text's own, logged. +#[derive(IntoElement)] +pub struct QuotedText { + id: ElementId, + body: SmallVec<[AnyElement; 2]>, +} + +impl QuotedText { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + body: SmallVec::new(), + } + } +} + +impl ParentElement for QuotedText { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for QuotedText { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let open = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false); + let shown = *open.read(cx); + let theme = cx.theme(); + let (id, named) = (self.id.clone(), self.id.clone()); + div() + .flex() + .flex_col() + .gap_2() + .child( + div().flex().child( + IconButton::new((self.id.clone(), "toggle"), IconName::Ellipsis) + .variant(ButtonVariant::Subtle) + .size(ControlSize::Sm) + .tooltip(if shown { + "Hide quoted text" + } else { + "Show quoted text" + }) + .on_click(move |_, _, cx| { + log::info!("quoted text {id}: {}", if shown { "hide" } else { "show" }); + open.update(cx, |open, cx| { + *open = !*open; + cx.notify(); + }) + }), + ), + ) + .child( + Collapsible::new((self.id.clone(), "fold"), shown).child( + div() + .debug_selector(move || format!("quoted {named}")) + .pl_3() + .border_l_2() + .border_color(theme.colors.border_strong) + .text_color(theme.colors.fg_muted) + .flex() + .flex_col() + .gap_2() + .children(self.body), + ), + ) + } +} diff --git a/src/mail/reader.rs b/src/mail/reader.rs new file mode 100644 index 0000000..2d3b8b8 --- /dev/null +++ b/src/mail/reader.rs @@ -0,0 +1,386 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Div, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, Styled, Window, div, prelude::*, relative, +}; +use jiff::{Timestamp, tz::TimeZone}; +use smallvec::SmallVec; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + chat::FileMessage, + data_display::Avatar, + forms::OnValue, + primitives::IconName, + theme::{ActiveTheme, AvatarSize, ControlSize, TextSize}, + typography::{Ellipsis, format}, +}; + +pub(super) type OnFile = Rc; + +/// Someone a message is from or to: a name and an address. +#[derive(Clone, Debug, PartialEq)] +pub struct Contact { + pub name: SharedString, + pub address: SharedString, +} + +impl Contact { + pub fn new(name: impl Into, address: impl Into) -> Self { + Self { + name: name.into(), + address: address.into(), + } + } +} + +/// A message as a reader shows it: who sent it and to whom, when, its first words for when it folds, and its files by name and size. +#[derive(Clone, Debug, PartialEq)] +pub struct Message { + pub key: SharedString, + pub from: Contact, + pub to: Vec, + pub cc: Vec, + pub at: Timestamp, + pub snippet: SharedString, + pub files: Vec<(SharedString, u64)>, +} + +impl Message { + pub fn new(key: impl Into, from: Contact, at: Timestamp) -> Self { + Self { + key: key.into(), + from, + to: Vec::new(), + cc: Vec::new(), + at, + snippet: SharedString::default(), + files: Vec::new(), + } + } + + pub fn to(mut self, contacts: impl IntoIterator) -> Self { + self.to = contacts.into_iter().collect(); + self + } + + pub fn cc(mut self, contacts: impl IntoIterator) -> Self { + self.cc = contacts.into_iter().collect(); + self + } + + /// Its first words, for when it folds. + pub fn snippet(mut self, text: impl Into) -> Self { + self.snippet = text.into(); + self + } + + /// A file it carries, by name and size in bytes. + pub fn file(mut self, name: impl Into, bytes: u64) -> Self { + self.files.push((name.into(), bytes)); + self + } +} + +/// Who a message went to, as a line: "to Ana Lima, Ben Ito · cc Dev Rao"; none when it names nobody. +pub(super) fn recipients(to: &[Contact], cc: &[Contact]) -> Option { + let names = |contacts: &[Contact]| { + contacts + .iter() + .map(|contact| contact.name.as_ref()) + .collect::>() + .join(", ") + }; + match (to.is_empty(), cc.is_empty()) { + (true, true) => None, + (false, true) => Some(format!("to {}", names(to))), + (true, false) => Some(format!("cc {}", names(cc))), + (false, false) => Some(format!("to {} · cc {}", names(to), names(cc))), + } +} + +/// What an open message can ask: a reply, a reply to all, a forward, and a file. +#[derive(Clone, Default)] +pub(super) struct Asks { + pub reply: Option, + pub reply_all: Option, + pub forward: Option, + pub download: Option, +} + +/// One message open: the sender's picture, name and address, who it went to, when, and replies, each only with its handler; then its words and its files. +pub(super) fn open_letter( + id: &ElementId, + message: &Message, + body: SmallVec<[AnyElement; 2]>, + zone: &TimeZone, + asks: &Asks, + cx: &App, +) -> Div { + let theme = cx.theme(); + let colors = &theme.colors; + let key = message.key.clone(); + let act = |name: &'static str, icon, tip: &'static str, ask: Option| { + ask.map(|ask| { + let key = key.clone(); + IconButton::new((id.clone(), format!("{name}-{key}")), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(tip) + .on_click(move |_, window, cx| { + log::info!("mail {key}: {name}"); + ask(&key, window, cx) + }) + }) + }; + let many = message.to.len() + message.cc.len() > 1; + let date = + format::datetime(message.at, zone, "%b %-d, %Y · %H:%M").expect("a fixed pattern formats"); + let files = message.files.iter().enumerate().map(|(ix, (name, bytes))| { + let file = FileMessage::new( + (id.clone(), format!("file-{key}-{ix}")), + name.clone(), + *bytes, + ); + match asks.download.clone() { + Some(download) => { + let (key, name) = (key.clone(), name.clone()); + file.on_download(move |window, cx| { + log::info!("mail {key}: download {name}"); + download(&key, &name, window, cx) + }) + .into_any_element() + } + None => file.into_any_element(), + } + }); + let avatar = Avatar::new( + (id.clone(), format!("from-{key}")), + message.from.name.clone(), + ) + .size(AvatarSize::Md); + let named = key.clone(); + div() + .debug_selector(move || format!("letter {named}")) + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .flex_wrap() + .items_start() + .justify_between() + .gap_2() + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .flex() + .gap_3() + .child(div().flex_none().child(avatar)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_baseline() + .gap_2() + .child( + div() + .flex_none() + .max_w(theme.label_width()) + .text_size(theme.text_size(TextSize::Base)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(Ellipsis::new(message.from.name.clone())), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_muted) + .child(Ellipsis::new(message.from.address.clone())), + ), + ) + .children(recipients(&message.to, &message.cc).map(|line| { + div() + .min_w_0() + .text_color(colors.fg_muted) + .child(Ellipsis::new(line)) + })), + ), + ) + .child( + div() + .flex_none() + .flex() + .items_center() + .gap_1() + .child( + div() + .pr_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(date), + ) + .children(act("reply", IconName::Reply, "Reply", asks.reply.clone())) + .children(act( + "reply-all", + IconName::ReplyAll, + "Reply all", + asks.reply_all.clone().filter(|_| many), + )) + .children(act( + "forward", + IconName::Forward, + "Forward", + asks.forward.clone(), + )), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Base)) + .line_height(relative(1.6)) + .text_color(colors.fg) + .children(body), + ) + .when(!message.files.is_empty(), |letter| { + letter.child(div().flex().flex_wrap().gap_2().children(files)) + }) +} + +/// A message open for reading: its subject over the sender's picture, name and address, who it went to, when it came, and replies, each only with its handler; then its words and its files. +#[derive(IntoElement)] +pub struct MailReader { + id: ElementId, + subject: SharedString, + message: Message, + zone: Option, + body: SmallVec<[AnyElement; 2]>, + asks: Asks, +} + +impl MailReader { + pub fn new( + id: impl Into, + subject: impl Into, + message: Message, + ) -> Self { + Self { + id: id.into(), + subject: subject.into(), + message, + zone: None, + body: SmallVec::new(), + asks: Asks::default(), + } + } + + /// The zone its date reads in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// Gets the message's key. + pub fn on_reply( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.asks.reply = Some(Rc::new(handler)); + self + } + + /// Offered when the message went to more than one; gets its key. + pub fn on_reply_all( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.asks.reply_all = Some(Rc::new(handler)); + self + } + + /// Gets the message's key. + pub fn on_forward( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.asks.forward = Some(Rc::new(handler)); + self + } + + /// Gets the message's key and the file's name. + pub fn on_download( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.asks.download = Some(Rc::new(handler)); + self + } +} + +impl ParentElement for MailReader { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for MailReader { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("mail reader")); + let theme = cx.theme(); + div() + .debug_selector(|| "mail-reader".into()) + .w_full() + .flex() + .flex_col() + .gap_4() + .child( + div() + .text_size(theme.text_size(TextSize::Xl)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.colors.fg) + .child(self.subject), + ) + .child(open_letter( + &self.id, + &self.message, + self.body, + &zone, + &self.asks, + cx, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recipients_read_as_one_line() { + let ana = Contact::new("Ana Lima", "ana@atrium.studio"); + let ben = Contact::new("Ben Ito", "ben@atrium.studio"); + let dev = Contact::new("Dev Rao", "dev@atrium.studio"); + assert_eq!( + recipients(&[ana.clone(), ben], std::slice::from_ref(&dev)).as_deref(), + Some("to Ana Lima, Ben Ito · cc Dev Rao") + ); + assert_eq!(recipients(&[ana], &[]).as_deref(), Some("to Ana Lima")); + assert_eq!(recipients(&[], &[dev]).as_deref(), Some("cc Dev Rao")); + assert_eq!(recipients(&[], &[]), None); + } +} diff --git a/src/mail/tests/mod.rs b/src/mail/tests/mod.rs index 8a29980..0401bbc 100644 --- a/src/mail/tests/mod.rs +++ b/src/mail/tests/mod.rs @@ -10,6 +10,8 @@ use crate::{ theme::Theme, }; +mod read; + fn setup(cx: &mut TestAppContext) { cx.update(|cx| { Theme::init(cx); diff --git a/src/mail/tests/read.rs b/src/mail/tests/read.rs new file mode 100644 index 0000000..f0f487d --- /dev/null +++ b/src/mail/tests/read.rs @@ -0,0 +1,118 @@ +use gpui::{Entity, IntoElement, ParentElement, TestAppContext, div}; +use jiff::{Timestamp, tz::TimeZone}; + +use super::{Mailing, heard, mailing, note, press, tab_to}; +use crate::mail::{Contact, MailReader, MailThreadView, Message, QuotedText}; + +fn ana() -> Contact { + Contact::new("Ana Lima", "ana@atrium.studio") +} + +fn ben() -> Contact { + Contact::new("Ben Ito", "ben@atrium.studio") +} + +fn review(to: Vec) -> Message { + Message::new("m1", ana(), Timestamp::UNIX_EPOCH) + .to(to) + .file("plan.pdf", 2_400_000) +} + +fn reader(owner: Entity, to: Vec) -> gpui::AnyElement { + let (reply, all, forward, download) = (owner.clone(), owner.clone(), owner.clone(), owner); + MailReader::new("reader", "Friday's review", review(to)) + .zone(TimeZone::UTC) + .on_reply(move |key, _, cx| note(&reply, format!("reply {key}"), cx)) + .on_reply_all(move |key, _, cx| note(&all, format!("reply all {key}"), cx)) + .on_forward(move |key, _, cx| note(&forward, format!("forward {key}"), cx)) + .on_download(move |key, file, _, cx| note(&download, format!("download {key} {file}"), cx)) + .child("Moved to 10:00.") + .into_any_element() +} + +#[gpui::test] +fn a_readers_asks_each_reach_their_own(cx: &mut TestAppContext) { + let (host, cx) = mailing( + |owner| reader(owner, vec![Contact::new("Me", "me@atrium.studio"), ben()]), + cx, + ); + for nth in 1..=4 { + tab_to(nth, cx); + press("space", cx); + } + assert_eq!( + heard(&host, cx), + [ + "reply m1", + "reply all m1", + "forward m1", + "download m1 plan.pdf" + ] + ); +} + +#[gpui::test] +fn reply_all_waits_for_more_than_one(cx: &mut TestAppContext) { + let (host, cx) = mailing(|owner| reader(owner, vec![ben()]), cx); + tab_to(2, cx); + press("space", cx); + assert_eq!(heard(&host, cx), ["forward m1"]); +} + +fn thread(_: Entity) -> gpui::AnyElement { + let message = |key: &'static str| { + Message::new(key, ana(), Timestamp::UNIX_EPOCH) + .to([ben()]) + .snippet("Earlier words") + }; + MailThreadView::new("thread", "Friday's review") + .zone(TimeZone::UTC) + .message(message("m1"), div().child("First")) + .message(message("m2"), div().child("Second")) + .message(message("m3"), div().child("Newest")) + .into_any_element() +} + +#[gpui::test] +fn the_newest_stands_open_and_space_opens_a_fold(cx: &mut TestAppContext) { + let (_, cx) = mailing(thread, cx); + assert!( + cx.debug_bounds("letter m3").is_some(), + "the newest stands open" + ); + assert!( + cx.debug_bounds("letter m1").is_none(), + "the first is folded" + ); + tab_to(1, cx); + press("space", cx); + assert!(cx.debug_bounds("letter m1").is_some(), "Space opened it"); + assert!( + cx.debug_bounds("letter m2").is_none(), + "the middle stays folded" + ); +} + +#[test] +#[should_panic(expected = "message m1 twice")] +fn a_message_sits_once_in_a_thread() { + let _ = MailThreadView::new("thread", "Twice") + .message(review(Vec::new()), div()) + .message(review(Vec::new()), div()); +} + +#[gpui::test] +fn quoted_words_stay_folded_until_asked(cx: &mut TestAppContext) { + let (_, cx) = mailing( + |_| { + QuotedText::new("quote") + .child("On Friday, Ana wrote: the samples are dry.") + .into_any_element() + }, + cx, + ); + assert!(cx.debug_bounds("quoted quote").is_none()); + tab_to(1, cx); + press("space", cx); + assert!(cx.debug_bounds("quoted quote").is_some()); +} diff --git a/src/mail/thread.rs b/src/mail/thread.rs new file mode 100644 index 0000000..05fd08a --- /dev/null +++ b/src/mail/thread.rs @@ -0,0 +1,195 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; +use jiff::{Timestamp, tz::TimeZone}; +use smallvec::SmallVec; + +use super::reader::{Asks, Message, open_letter}; +use crate::{ + data_display::Avatar, + primitives::FocusRing, + theme::{ActiveTheme, AvatarSize, Radius, TextSize}, + typography::{Ellipsis, format}, +}; + +/// A conversation: its subject over its messages, oldest first. The newest stands open; the rest fold to a line of the sender, the first words and the date, which a press, Enter or Space opens. +#[derive(IntoElement)] +pub struct MailThreadView { + id: ElementId, + subject: SharedString, + messages: Vec<(Message, SmallVec<[AnyElement; 2]>)>, + zone: Option, + asks: Asks, +} + +impl MailThreadView { + pub fn new(id: impl Into, subject: impl Into) -> Self { + Self { + id: id.into(), + subject: subject.into(), + messages: Vec::new(), + zone: None, + asks: Asks::default(), + } + } + + /// The next message, and its words. + pub fn message(mut self, message: Message, body: impl IntoElement) -> Self { + let twice = self + .messages + .iter() + .any(|(other, _)| other.key == message.key); + assert!(!twice, "message {} twice", message.key); + self.messages + .push((message, SmallVec::from_iter([body.into_any_element()]))); + self + } + + /// The zone dates read in; the system's otherwise. + pub fn zone(mut self, zone: TimeZone) -> Self { + self.zone = Some(zone); + self + } + + /// Gets the key of the message to answer. + pub fn on_reply( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.asks.reply = Some(Rc::new(handler)); + self + } + + /// Offered on a message that went to more than one; gets its key. + pub fn on_reply_all( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.asks.reply_all = Some(Rc::new(handler)); + self + } + + /// Gets the key of the message to forward. + pub fn on_forward( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.asks.forward = Some(Rc::new(handler)); + self + } + + /// Gets a message's key and the file's name. + pub fn on_download( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.asks.download = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for MailThreadView { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + assert!(!self.messages.is_empty(), "a thread holds a message"); + let opened = window.use_keyed_state((self.id.clone(), "opened"), cx, |_, _| { + Vec::::new() + }); + let zone = self + .zone + .unwrap_or_else(|| format::system_zone("mail thread")); + let now = Timestamp::now(); + let newest = self.messages.len() - 1; + let open_now = opened.read(cx).clone(); + let theme = cx.theme(); + let colors = &theme.colors; + let messages = self + .messages + .into_iter() + .enumerate() + .map(|(ix, (message, body))| { + let open = ix == newest || open_now.contains(&message.key); + let part = if open { + open_letter(&self.id, &message, body, &zone, &self.asks, cx).into_any_element() + } else { + let (key, opening, id) = (message.key.clone(), opened.clone(), self.id.clone()); + let date = format::relative(message.at, now); + div() + .id((self.id.clone(), format!("fold-{}", message.key))) + .flex() + .items_center() + .gap_3() + .px_2() + .py_1p5() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(gpui::transparent_black()) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .hover(|style| style.bg(colors.hover)) + .text_size(theme.text_size(TextSize::Sm)) + .on_click(move |_, _, cx| { + log::info!("mail thread {id}: open {key}"); + opening.update(cx, |opened, cx| { + opened.push(key.clone()); + cx.notify(); + }) + }) + .child( + div().flex_none().child( + Avatar::new( + (self.id.clone(), format!("folded-{}", message.key)), + message.from.name.clone(), + ) + .size(AvatarSize::Sm), + ), + ) + .child( + div() + .flex_none() + .max_w(theme.label_width()) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(Ellipsis::new(message.from.name.clone())), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_muted) + .child(Ellipsis::new(message.snippet.clone())), + ) + .child( + div() + .flex_none() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(date), + ) + .into_any_element() + }; + div() + .when(ix > 0, |row| { + row.pt_3().border_t_1().border_color(colors.border) + }) + .child(part) + }); + div() + .debug_selector(|| "mail-thread".into()) + .w_full() + .flex() + .flex_col() + .gap_3() + .child( + div() + .text_size(theme.text_size(TextSize::Xl)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(self.subject), + ) + .children(messages) + } +} diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index e4121e9..a1958a3 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -248,6 +248,7 @@ icons! { Repeat => "repeat", Repeat1 => "repeat-1", Reply => "reply", + ReplyAll => "reply-all", Rocket => "rocket", RotateCcw => "rotate-ccw", RotateCw => "rotate-cw", diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index d21c8ae..7674fbb 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -369,9 +369,9 @@ ## T29b Mail · Reading -- [ ] MailReader -- [ ] MailThreadView -- [ ] QuotedText +- [x] MailReader +- [x] MailThreadView +- [x] QuotedText ## T29c Mail · Writing