Mail · Writing: the composer, recipients that suggest, a signature, send later
MailComposer writes a message: To, and Cc and Bcc once asked for, as tag fields that suggest contacts and mark what is not an address; the subject and the words; the files, each with a way off; the signature under the words. Send wakes once there is someone to send to and every address is one; it hands over the draft now or at a time, and Discard lets it go. A new start from the owner begins the draft over. forms::TagInput now suggests: the rows that hold the typed words float under it through the list Combobox uses; Up, Down and Enter pick, and Escape sets them aside. RecipientInput points to it. ScheduleSend offers tomorrow morning, tomorrow afternoon, Monday morning, or a day and hour picked in a dialog. SignatureEditor writes the lines and shows how they sit under a message; it tells the owner's echo from a new signature, so the caret stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48118499ae
commit
84c3dc16de
14 files changed
+1425
-12
No files matched your search
@@ -0,0 +1,232 @@
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, Render, SharedString, TestAppContext, VisualTestContext, Window,
|
||||
};
|
||||
use jiff::{Timestamp, tz::TimeZone};
|
||||
|
||||
use super::{Mailing, heard, mailing, note, press, settle, tab_to};
|
||||
use crate::{
|
||||
forms,
|
||||
mail::{Contact, MailComposer, ScheduleSend, SignatureEditor},
|
||||
};
|
||||
|
||||
fn contacts() -> [Contact; 2] {
|
||||
[
|
||||
Contact::new("Ana Lima", "ana@atrium.studio"),
|
||||
Contact::new("Ben Ito", "ben@atrium.studio"),
|
||||
]
|
||||
}
|
||||
|
||||
fn composer(owner: Entity<Mailing>) -> gpui::AnyElement {
|
||||
let (send, discard) = (owner.clone(), owner);
|
||||
MailComposer::new("composer")
|
||||
.contacts(contacts())
|
||||
.on_send(move |draft, _, cx| {
|
||||
note(
|
||||
&send,
|
||||
format!(
|
||||
"send {} | {} | {}",
|
||||
draft.to.join(","),
|
||||
draft.subject,
|
||||
draft.body
|
||||
),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_discard(move |_, cx| note(&discard, "discard".into(), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn writing(
|
||||
part: fn(Entity<Mailing>) -> gpui::AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Mailing>, &mut VisualTestContext) {
|
||||
let (host, cx) = mailing(part, cx);
|
||||
cx.update(|_, cx| forms::bind_keys(cx));
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn next(cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| window.focus_next());
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_suggested_person_a_subject_and_words_go_out_as_one_draft(cx: &mut TestAppContext) {
|
||||
let (host, cx) = writing(composer, cx);
|
||||
tab_to(1, cx);
|
||||
cx.simulate_input("be");
|
||||
settle(cx);
|
||||
press("enter", cx);
|
||||
next(cx);
|
||||
next(cx);
|
||||
cx.simulate_input("Samples");
|
||||
next(cx);
|
||||
cx.simulate_input("They are dry.");
|
||||
next(cx);
|
||||
press("space", cx);
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
["send ben@atrium.studio | Samples | They are dry."]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn cc_and_bcc_show_once_asked(cx: &mut TestAppContext) {
|
||||
let (_, cx) = writing(composer, cx);
|
||||
assert!(cx.debug_bounds("composer-Cc").is_none());
|
||||
tab_to(2, cx);
|
||||
press("space", cx);
|
||||
assert!(cx.debug_bounds("composer-Cc").is_some());
|
||||
assert!(cx.debug_bounds("composer-Bcc").is_some());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn with_nobody_to_send_to_send_steps_aside_for_discard(cx: &mut TestAppContext) {
|
||||
let (host, cx) = writing(composer, cx);
|
||||
tab_to(5, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(
|
||||
heard(&host, cx),
|
||||
["discard"],
|
||||
"Send is off, so the stop after the words is Discard"
|
||||
);
|
||||
}
|
||||
|
||||
fn scheduling(owner: Entity<Mailing>) -> gpui::AnyElement {
|
||||
let (send, later) = (owner.clone(), owner);
|
||||
ScheduleSend::new("send")
|
||||
.zone(TimeZone::UTC)
|
||||
.on_send(move |_, cx| note(&send, "send".into(), cx))
|
||||
.on_schedule(move |at, _, cx| note(&later, format!("at {at}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_first_time_to_send_later_is_tomorrow_morning(cx: &mut TestAppContext) {
|
||||
let (host, cx) = writing(scheduling, cx);
|
||||
tab_to(2, cx);
|
||||
press("enter", cx);
|
||||
press("enter", cx);
|
||||
let morning = Timestamp::now()
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.date()
|
||||
.tomorrow()
|
||||
.expect("a day follows")
|
||||
.at(8, 0, 0, 0)
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.expect("a UTC time")
|
||||
.timestamp();
|
||||
assert_eq!(heard(&host, cx), [format!("at {morning}")]);
|
||||
}
|
||||
|
||||
/// A signature the host keeps, and every change it heard.
|
||||
struct Signing {
|
||||
words: SharedString,
|
||||
heard: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Signing {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let owner = cx.entity();
|
||||
SignatureEditor::new("signature", self.words.clone()).on_change(move |words, _, cx| {
|
||||
owner.update(cx, |host, cx| {
|
||||
host.heard.push(words.to_string());
|
||||
host.words = words.clone();
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn signing<'a>(
|
||||
words: &str,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<Signing>, &'a mut VisualTestContext) {
|
||||
super::setup(cx);
|
||||
cx.update(forms::bind_keys);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Signing {
|
||||
words: SharedString::from(words.to_string()),
|
||||
heard: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_echo_keeps_the_caret_where_it_is(cx: &mut TestAppContext) {
|
||||
let (host, cx) = signing("", cx);
|
||||
tab_to(1, cx);
|
||||
cx.simulate_input("ab");
|
||||
settle(cx);
|
||||
press("left", cx);
|
||||
cx.simulate_input("X");
|
||||
settle(cx);
|
||||
cx.simulate_input("Y");
|
||||
settle(cx);
|
||||
let heard = host.read_with(cx, |host, _| host.heard.clone());
|
||||
assert_eq!(
|
||||
heard.last().map(String::as_str),
|
||||
Some("aXYb"),
|
||||
"the caret stayed after X"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_new_signature_from_the_owner_starts_the_field_over(cx: &mut TestAppContext) {
|
||||
let (host, cx) = signing("Ana", cx);
|
||||
host.update(cx, |host, cx| {
|
||||
host.words = "Ben Ito".into();
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
tab_to(1, cx);
|
||||
cx.simulate_input("!");
|
||||
settle(cx);
|
||||
let heard = host.read_with(cx, |host, _| host.heard.clone());
|
||||
assert_eq!(heard, ["Ben Ito!"]);
|
||||
}
|
||||
|
||||
/// A composer the host starts from an address, and what it sent.
|
||||
struct Replying {
|
||||
to: SharedString,
|
||||
sent: Vec<String>,
|
||||
}
|
||||
|
||||
impl Render for Replying {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let owner = cx.entity();
|
||||
MailComposer::new("composer")
|
||||
.to([self.to.clone()])
|
||||
.subject("Re: Samples")
|
||||
.on_send(move |draft, _, cx| {
|
||||
owner.update(cx, |host, _| {
|
||||
host.sent
|
||||
.push(format!("{} | {}", draft.to.join(","), draft.subject))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_new_start_from_the_owner_begins_the_draft_over(cx: &mut TestAppContext) {
|
||||
super::setup(cx);
|
||||
cx.update(forms::bind_keys);
|
||||
let (host, cx) = cx.add_window_view(|_, _| Replying {
|
||||
to: "ana@atrium.studio".into(),
|
||||
sent: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
host.update(cx, |host, cx| {
|
||||
host.to = "ben@atrium.studio".into();
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
tab_to(5, cx);
|
||||
press("space", cx);
|
||||
assert_eq!(
|
||||
host.read_with(cx, |host, _| host.sent.clone()),
|
||||
["ben@atrium.studio | Re: Samples"]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user