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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a281d91ab
commit
f946af6216
12 files changed
+920
-4
No files matched your search
@@ -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 | |
|
||||
@@ -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 |
@@ -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()
|
||||
}
|
||||
@@ -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."),
|
||||
),
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use crate::{
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
mod read;
|
||||
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -369,9 +369,9 @@
|
||||
|
||||
## T29b Mail · Reading
|
||||
|
||||
- [ ] MailReader
|
||||
- [ ] MailThreadView
|
||||
- [ ] QuotedText
|
||||
- [x] MailReader
|
||||
- [x] MailThreadView
|
||||
- [x] QuotedText
|
||||
|
||||
## T29c Mail · Writing
|
||||
|
||||
|
||||
Reference in new issue
Block a user