Mail · Reading: a message reader, a conversation, and quoted text

MailReader opens a message under its subject: the sender's picture,
name and address, who it went to, when it came, and Reply, Reply all
when it went to more than one, and Forward, each only with a handler;
then its words and its files, each downloadable. MailThreadView lays a
conversation oldest first: the newest stands open, the others fold to
a line of the sender, their first words and when, which a press, Enter
or Space opens. QuotedText folds words quoted from before behind a
small mark; open, they sit behind a rule. Long names give way at the
label width.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 23:57:06 -04:00
1 parent d2379bc911
commit 70e18af092
12 files changed
+920 -4

No files matched your search

+3 -1
View File
@@ -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 | |
+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-reply-all"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 17-5-5 5-5" />
<path d="M22 18v-2a4 4 0 0 0-4-4H7" />
<path d="m7 17-5-5 5-5" />
</svg>

After

Width:  |  Height:  |  Size: 388 B

+12
View File
@@ -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()
}
+97
View File
@@ -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."),
),
),
))
}
+6
View File
@@ -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;
+80
View File
@@ -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<ElementId>) -> Self {
Self {
id: id.into(),
body: SmallVec::new(),
}
}
}
impl ParentElement for QuotedText {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
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),
),
)
}
}
+386
View File
@@ -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<dyn Fn(&SharedString, &SharedString, &mut Window, &mut App)>;
/// 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<SharedString>, address: impl Into<SharedString>) -> 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<Contact>,
pub cc: Vec<Contact>,
pub at: Timestamp,
pub snippet: SharedString,
pub files: Vec<(SharedString, u64)>,
}
impl Message {
pub fn new(key: impl Into<SharedString>, 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<Item = Contact>) -> Self {
self.to = contacts.into_iter().collect();
self
}
pub fn cc(mut self, contacts: impl IntoIterator<Item = Contact>) -> Self {
self.cc = contacts.into_iter().collect();
self
}
/// Its first words, for when it folds.
pub fn snippet(mut self, text: impl Into<SharedString>) -> Self {
self.snippet = text.into();
self
}
/// A file it carries, by name and size in bytes.
pub fn file(mut self, name: impl Into<SharedString>, 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<String> {
let names = |contacts: &[Contact]| {
contacts
.iter()
.map(|contact| contact.name.as_ref())
.collect::<Vec<_>>()
.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<OnValue>,
pub reply_all: Option<OnValue>,
pub forward: Option<OnValue>,
pub download: Option<OnFile>,
}
/// 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<OnValue>| {
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<TimeZone>,
body: SmallVec<[AnyElement; 2]>,
asks: Asks,
}
impl MailReader {
pub fn new(
id: impl Into<ElementId>,
subject: impl Into<SharedString>,
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<Item = AnyElement>) {
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);
}
}
+2
View File
@@ -10,6 +10,8 @@ use crate::{
theme::Theme,
};
mod read;
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
+118
View File
@@ -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<Contact>) -> Message {
Message::new("m1", ana(), Timestamp::UNIX_EPOCH)
.to(to)
.file("plan.pdf", 2_400_000)
}
fn reader(owner: Entity<Mailing>, to: Vec<Contact>) -> 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<Mailing>) -> 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());
}
+195
View File
@@ -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<TimeZone>,
asks: Asks,
}
impl MailThreadView {
pub fn new(id: impl Into<ElementId>, subject: impl Into<SharedString>) -> 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::<SharedString>::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)
}
}
+1
View File
@@ -248,6 +248,7 @@ icons! {
Repeat => "repeat",
Repeat1 => "repeat-1",
Reply => "reply",
ReplyAll => "reply-all",
Rocket => "rocket",
RotateCcw => "rotate-ccw",
RotateCw => "rotate-cw",
+3 -3
View File
@@ -369,9 +369,9 @@
## T29b Mail · Reading
- [ ] MailReader
- [ ] MailThreadView
- [ ] QuotedText
- [x] MailReader
- [x] MailThreadView
- [x] QuotedText
## T29c Mail · Writing