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