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.
This commit is contained in:
1 parent
1ad72bc7aa
commit
ccd48f9ba2
14 files changed
+1425
-12
No files matched your search
@@ -0,0 +1,387 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Context, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div, prelude::*,
|
||||
};
|
||||
use jiff::{Timestamp, tz::TimeZone};
|
||||
|
||||
use super::{reader::Contact, send::ScheduleSend, signature::signature_block};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
chat::{Attachment, AttachmentChip},
|
||||
forms::{Choice, Input, OnValue, Run, TagInput, TextInput, is_email},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
|
||||
/// What a composer hands over: the addresses, the subject and the words.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub to: Vec<SharedString>,
|
||||
pub cc: Vec<SharedString>,
|
||||
pub bcc: Vec<SharedString>,
|
||||
pub subject: SharedString,
|
||||
pub body: SharedString,
|
||||
}
|
||||
|
||||
type OnDraft = Rc<dyn Fn(&Draft, &mut Window, &mut App)>;
|
||||
type OnDraftAt = Rc<dyn Fn(&Draft, Timestamp, &mut Window, &mut App)>;
|
||||
|
||||
/// Whether a message can go: someone to send to, and every address an address.
|
||||
fn ready(to: &[SharedString], cc: &[SharedString], bcc: &[SharedString]) -> bool {
|
||||
let mut all = to.iter().chain(cc).chain(bcc).peekable();
|
||||
all.peek().is_some() && all.all(|address| is_email(address))
|
||||
}
|
||||
|
||||
/// Which field of addresses.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Field {
|
||||
To,
|
||||
Cc,
|
||||
Bcc,
|
||||
}
|
||||
|
||||
/// The message being written: the owner's start it came from, the addresses, whether Cc and Bcc show, and the subject and words.
|
||||
struct Composing {
|
||||
seed: (Vec<SharedString>, SharedString),
|
||||
to: Vec<SharedString>,
|
||||
cc: Vec<SharedString>,
|
||||
bcc: Vec<SharedString>,
|
||||
copies: bool,
|
||||
subject: Entity<TextInput>,
|
||||
body: Entity<TextInput>,
|
||||
}
|
||||
|
||||
impl Composing {
|
||||
/// Starts over from the owner's addresses and subject, with no words.
|
||||
fn fill(&mut self, (to, subject): (Vec<SharedString>, SharedString), cx: &mut Context<Self>) {
|
||||
self.to = to;
|
||||
self.cc.clear();
|
||||
self.bcc.clear();
|
||||
self.copies = false;
|
||||
self.subject
|
||||
.update(cx, |input, cx| input.set_text(subject.to_string(), cx));
|
||||
self.body.update(cx, |input, cx| input.set_text("", cx));
|
||||
}
|
||||
|
||||
fn draft(&self, cx: &App) -> Draft {
|
||||
Draft {
|
||||
to: self.to.clone(),
|
||||
cc: self.cc.clone(),
|
||||
bcc: self.bcc.clone(),
|
||||
subject: self.subject.read(cx).text().trim().to_string().into(),
|
||||
body: self.body.read(cx).text().to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message to write: To, and Cc and Bcc once asked for, as fields that suggest contacts and mark what is not an address; the subject and the words; the files, each with a way off; and the signature under the words. Send goes once there is someone to send to, now or at a time; Discard lets it go. A new start from the owner begins it over.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MailComposer {
|
||||
id: ElementId,
|
||||
contacts: Vec<Contact>,
|
||||
to: Vec<SharedString>,
|
||||
subject: SharedString,
|
||||
signature: Option<SharedString>,
|
||||
files: Vec<Attachment>,
|
||||
zone: Option<TimeZone>,
|
||||
on_send: Option<OnDraft>,
|
||||
on_schedule: Option<OnDraftAt>,
|
||||
on_discard: Option<Run>,
|
||||
on_attach: Option<Run>,
|
||||
on_remove: Option<OnValue>,
|
||||
}
|
||||
|
||||
impl MailComposer {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
contacts: Vec::new(),
|
||||
to: Vec::new(),
|
||||
subject: SharedString::default(),
|
||||
signature: None,
|
||||
files: Vec::new(),
|
||||
zone: None,
|
||||
on_send: None,
|
||||
on_schedule: None,
|
||||
on_discard: None,
|
||||
on_attach: None,
|
||||
on_remove: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// People to suggest as addresses are typed.
|
||||
pub fn contacts(mut self, contacts: impl IntoIterator<Item = Contact>) -> Self {
|
||||
self.contacts = contacts.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// The addresses it starts with, as a reply has them.
|
||||
pub fn to(mut self, addresses: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
|
||||
self.to = addresses.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// The subject it starts with.
|
||||
pub fn subject(mut self, subject: impl Into<SharedString>) -> Self {
|
||||
self.subject = subject.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn signature(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.signature = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Files the owner attached.
|
||||
pub fn files(mut self, files: impl IntoIterator<Item = Attachment>) -> Self {
|
||||
self.files = files.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// The zone its times to send read in; the system's otherwise.
|
||||
pub fn zone(mut self, zone: TimeZone) -> Self {
|
||||
self.zone = Some(zone);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_send(mut self, handler: impl Fn(&Draft, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_send = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the draft and the moment to send it.
|
||||
pub fn on_schedule(
|
||||
mut self,
|
||||
handler: impl Fn(&Draft, Timestamp, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_schedule = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_discard(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_discard = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Asks the owner for files to attach.
|
||||
pub fn on_attach(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_attach = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the key of the file to take off.
|
||||
pub fn on_remove(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_remove = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MailComposer {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let seed = (self.to.clone(), self.subject.clone());
|
||||
let state = window.use_keyed_state(
|
||||
(self.id.clone(), "draft"),
|
||||
cx,
|
||||
|window, cx: &mut Context<Composing>| {
|
||||
let subject = cx.new(|cx| TextInput::new(window, cx).placeholder("Subject"));
|
||||
let body = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(6, 16)
|
||||
.placeholder("Write your message")
|
||||
});
|
||||
let mut composing = Composing {
|
||||
seed: seed.clone(),
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
bcc: Vec::new(),
|
||||
copies: false,
|
||||
subject,
|
||||
body,
|
||||
};
|
||||
composing.fill(seed.clone(), cx);
|
||||
composing
|
||||
},
|
||||
);
|
||||
if state.read(cx).seed != seed {
|
||||
log::info!("mail composer {}: started over", self.id);
|
||||
state.update(cx, |composing, cx| {
|
||||
composing.seed = seed.clone();
|
||||
composing.fill(seed, cx);
|
||||
});
|
||||
}
|
||||
let now = state.read(cx);
|
||||
let (to, cc, bcc, copies) = (now.to.clone(), now.cc.clone(), now.bcc.clone(), now.copies);
|
||||
let (subject, body) = (now.subject.clone(), now.body.clone());
|
||||
let can_send = ready(&to, &cc, &bcc);
|
||||
let choices: Vec<Choice> = self
|
||||
.contacts
|
||||
.iter()
|
||||
.map(|contact| Choice::new(contact.address.clone(), contact.name.clone()))
|
||||
.collect();
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let row = |label: &'static str, field: Field, tags: Vec<SharedString>| {
|
||||
let owner = state.clone();
|
||||
div()
|
||||
.debug_selector(move || format!("composer-{label}"))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w_10()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(label),
|
||||
)
|
||||
.child(
|
||||
div().flex_1().min_w_0().child(
|
||||
TagInput::new((self.id.clone(), label), tags)
|
||||
.placeholder("Add people")
|
||||
.check(is_email)
|
||||
.suggestions(choices.clone())
|
||||
.on_change(move |next, _, cx| {
|
||||
log::info!("mail composer: {label} holds {}", next.len());
|
||||
owner.update(cx, |composing, cx| {
|
||||
match field {
|
||||
Field::To => composing.to = next,
|
||||
Field::Cc => composing.cc = next,
|
||||
Field::Bcc => composing.bcc = next,
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
};
|
||||
let showing = state.clone();
|
||||
let copies_button = (!copies).then(|| {
|
||||
Button::new((self.id.clone(), "copies"), "Cc Bcc")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
log::info!("mail composer: shows Cc and Bcc");
|
||||
showing.update(cx, |composing, cx| {
|
||||
composing.copies = true;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
});
|
||||
let files = self.files.iter().map(|file| {
|
||||
let chip = AttachmentChip::new(
|
||||
(self.id.clone(), format!("file-{}", file.key)),
|
||||
file.clone(),
|
||||
);
|
||||
match self.on_remove.clone() {
|
||||
Some(remove) => {
|
||||
let key = file.key.clone();
|
||||
chip.on_remove(move |window, cx| {
|
||||
log::info!("mail composer: takes off {key}");
|
||||
remove(&key, window, cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
None => chip.into_any_element(),
|
||||
}
|
||||
});
|
||||
let sending = state.clone();
|
||||
let send = ScheduleSend::new((self.id.clone(), "send"))
|
||||
.disabled(!can_send)
|
||||
.when_some(self.zone.clone(), |send, zone| send.zone(zone))
|
||||
.when_some(self.on_send.clone(), |send, on_send| {
|
||||
send.on_send(move |window, cx| {
|
||||
let draft = sending.read(cx).draft(cx);
|
||||
log::info!("mail composer: sends to {}", draft.to.len());
|
||||
on_send(&draft, window, cx)
|
||||
})
|
||||
});
|
||||
let scheduling = state.clone();
|
||||
let send = match self.on_schedule.clone() {
|
||||
Some(on_schedule) => send.on_schedule(move |at, window, cx| {
|
||||
let draft = scheduling.read(cx).draft(cx);
|
||||
log::info!("mail composer: sends at {at}");
|
||||
on_schedule(&draft, at, window, cx)
|
||||
}),
|
||||
None => send,
|
||||
};
|
||||
let attach = self.on_attach.map(|attach| {
|
||||
IconButton::new((self.id.clone(), "attach"), IconName::Paperclip)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip("Attach files")
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("mail composer: attach");
|
||||
attach(window, cx)
|
||||
})
|
||||
});
|
||||
let discard = self.on_discard.map(|discard| {
|
||||
IconButton::new((self.id.clone(), "discard"), IconName::Trash2)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip("Discard")
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("mail composer: discard");
|
||||
discard(window, cx)
|
||||
})
|
||||
});
|
||||
div()
|
||||
.debug_selector(|| "mail-composer".into())
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
row("To", Field::To, to)
|
||||
.children(copies_button.map(|button| div().flex_none().child(button))),
|
||||
)
|
||||
.when(copies, |form| {
|
||||
form.child(row("Cc", Field::Cc, cc))
|
||||
.child(row("Bcc", Field::Bcc, bcc))
|
||||
})
|
||||
.child(Input::new(&subject))
|
||||
.child(Input::new(&body))
|
||||
.when(!self.files.is_empty(), |form| {
|
||||
form.child(div().flex().flex_wrap().gap_2().children(files))
|
||||
})
|
||||
.children(self.signature.map(|text| signature_block(&text, cx)))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(send)
|
||||
.children(attach),
|
||||
)
|
||||
.children(discard),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_message_goes_once_someone_holds_an_address() {
|
||||
let ben: SharedString = "ben@atrium.studio".into();
|
||||
let bad: SharedString = "ben@".into();
|
||||
assert!(!ready(&[], &[], &[]), "nobody to send to");
|
||||
assert!(ready(std::slice::from_ref(&ben), &[], &[]));
|
||||
assert!(
|
||||
ready(&[], &[], std::slice::from_ref(&ben)),
|
||||
"a blind copy alone"
|
||||
);
|
||||
assert!(!ready(&[ben], &[bad], &[]), "an address that is not one");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
mod boxes;
|
||||
mod compose;
|
||||
mod list;
|
||||
mod quoted;
|
||||
mod reader;
|
||||
mod send;
|
||||
mod signature;
|
||||
mod thread;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use boxes::{Mailbox, MailboxList};
|
||||
pub use compose::{Draft, MailComposer};
|
||||
pub use list::{Mail, MailItem, MailList};
|
||||
pub use quoted::QuotedText;
|
||||
pub use reader::{Contact, MailReader, Message};
|
||||
pub use send::ScheduleSend;
|
||||
pub use signature::SignatureEditor;
|
||||
pub use thread::MailThreadView;
|
||||
@@ -0,0 +1,248 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Window, div, prelude::*};
|
||||
use jiff::{Timestamp, ToSpan, civil::DateTime, tz::TimeZone};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{DateTimePicker, Run},
|
||||
menus::{Menu, MenuItem, SplitButton},
|
||||
overlays::Dialog,
|
||||
primitives::IconName,
|
||||
typography::format,
|
||||
};
|
||||
|
||||
pub(super) type OnTime = Rc<dyn Fn(Timestamp, &mut Window, &mut App)>;
|
||||
|
||||
/// Times to send later from `now` in `zone`: tomorrow at 8:00 and at 13:00, and the next Monday at 8:00, each named with its day and hour.
|
||||
fn presets(now: Timestamp, zone: &TimeZone) -> [(String, Timestamp); 3] {
|
||||
let today = now.to_zoned(zone.clone()).date();
|
||||
let tomorrow = today.tomorrow().expect("a day follows today");
|
||||
let ahead = 7 - i64::from(today.weekday().to_monday_zero_offset());
|
||||
let monday = today
|
||||
.checked_add(ahead.days())
|
||||
.expect("a Monday lies ahead");
|
||||
let at = |day: jiff::civil::Date, hour: i8| {
|
||||
day.at(hour, 0, 0, 0)
|
||||
.to_zoned(zone.clone())
|
||||
.expect("the zone holds the hour")
|
||||
.timestamp()
|
||||
};
|
||||
let named = |name: &str, when: Timestamp| {
|
||||
let hour = format::datetime(when, zone, "%a %H:%M").expect("a fixed pattern formats");
|
||||
(format!("{name} · {hour}"), when)
|
||||
};
|
||||
[
|
||||
named("Tomorrow morning", at(tomorrow, 8)),
|
||||
named("Tomorrow afternoon", at(tomorrow, 13)),
|
||||
named("Monday morning", at(monday, 8)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether the dialog for a time of one's own shows, and the time picked in it.
|
||||
#[derive(Default)]
|
||||
struct Picking {
|
||||
open: bool,
|
||||
chosen: Option<DateTime>,
|
||||
}
|
||||
|
||||
/// Send, and beside it the times to send later: tomorrow morning, tomorrow afternoon and Monday morning in the zone given, or a time picked in a dialog. Each only with its handler; disabled, only Send shows, dimmed.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ScheduleSend {
|
||||
id: ElementId,
|
||||
zone: Option<TimeZone>,
|
||||
disabled: bool,
|
||||
on_send: Option<Run>,
|
||||
on_schedule: Option<OnTime>,
|
||||
}
|
||||
|
||||
impl ScheduleSend {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
zone: None,
|
||||
disabled: false,
|
||||
on_send: None,
|
||||
on_schedule: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone its times read in; the system's otherwise.
|
||||
pub fn zone(mut self, zone: TimeZone) -> Self {
|
||||
self.zone = Some(zone);
|
||||
self
|
||||
}
|
||||
|
||||
/// Nothing to send yet.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_send(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_send = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the moment to send at.
|
||||
pub fn on_schedule(
|
||||
mut self,
|
||||
handler: impl Fn(Timestamp, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_schedule = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ScheduleSend {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let zone = self
|
||||
.zone
|
||||
.unwrap_or_else(|| format::system_zone("schedule send"));
|
||||
let picking =
|
||||
window.use_keyed_state((self.id.clone(), "picking"), cx, |_, _| Picking::default());
|
||||
let menu = self
|
||||
.on_schedule
|
||||
.clone()
|
||||
.filter(|_| !self.disabled)
|
||||
.map(|schedule| {
|
||||
let presets = presets(Timestamp::now(), &zone).into_iter().fold(
|
||||
Menu::new(),
|
||||
|menu, (name, at)| {
|
||||
let schedule = schedule.clone();
|
||||
menu.item(MenuItem::new(name).on_click(move |window, cx| {
|
||||
log::info!("schedule send: at {at}");
|
||||
schedule(at, window, cx)
|
||||
}))
|
||||
},
|
||||
);
|
||||
let opening = picking.clone();
|
||||
presets.separator().item(
|
||||
MenuItem::new("Pick a time…")
|
||||
.icon(IconName::Calendar)
|
||||
.on_click(move |_, cx| {
|
||||
log::info!("schedule send: pick a time");
|
||||
opening.update(cx, |picking, cx| {
|
||||
picking.open = true;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
});
|
||||
let send = self.on_send.clone();
|
||||
let button = match menu {
|
||||
Some(menu) => SplitButton::new((self.id.clone(), "send"), "Send", menu)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("schedule send: send");
|
||||
if let Some(send) = &send {
|
||||
send(window, cx);
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
None => Button::new((self.id.clone(), "send"), "Send")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.disabled(self.disabled)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("schedule send: send");
|
||||
if let Some(send) = &send {
|
||||
send(window, cx);
|
||||
}
|
||||
})
|
||||
.into_any_element(),
|
||||
};
|
||||
let now = picking.read(cx);
|
||||
let dialog = (now.open).then(|| {
|
||||
let chosen = now.chosen;
|
||||
let (closing, choosing, id) = (picking.clone(), picking.clone(), self.id.clone());
|
||||
let schedule = self.on_schedule.clone();
|
||||
Dialog::new(
|
||||
(self.id.clone(), "dialog"),
|
||||
"Schedule send",
|
||||
move |_, cx| {
|
||||
closing.update(cx, |picking, cx| {
|
||||
*picking = Picking::default();
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
)
|
||||
.detail("Pick the day and the hour it goes.")
|
||||
.child(
|
||||
DateTimePicker::new((id.clone(), "when"), chosen).on_change(move |at, _, cx| {
|
||||
choosing.update(cx, |picking, cx| {
|
||||
picking.chosen = Some(at);
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.action(move |close| {
|
||||
Button::new((id.clone(), "cancel"), "Cancel")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})
|
||||
.action({
|
||||
let (id, zone) = (self.id.clone(), zone.clone());
|
||||
move |close| {
|
||||
Button::new((id, "schedule"), "Schedule")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.disabled(chosen.is_none())
|
||||
.on_click(move |_, window, cx| {
|
||||
let at = chosen
|
||||
.expect("Schedule waits for a time")
|
||||
.to_zoned(zone.clone())
|
||||
.expect("the zone holds the time")
|
||||
.timestamp();
|
||||
log::info!("schedule send: at {at}");
|
||||
close(window, cx);
|
||||
if let Some(schedule) = &schedule {
|
||||
schedule(at, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
||||
div().flex_none().child(button).children(dialog)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::civil::date;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn at(day: i8, hour: i8) -> Timestamp {
|
||||
date(2026, 9, day)
|
||||
.at(hour, 0, 0, 0)
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.expect("a UTC time")
|
||||
.timestamp()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_fall_tomorrow_and_on_the_next_monday() {
|
||||
let [(morning, a), (afternoon, b), (monday, c)] = presets(at(26, 12), &TimeZone::UTC);
|
||||
assert_eq!(
|
||||
(morning.as_str(), a),
|
||||
("Tomorrow morning · Sun 08:00", at(27, 8))
|
||||
);
|
||||
assert_eq!(
|
||||
(afternoon.as_str(), b),
|
||||
("Tomorrow afternoon · Sun 13:00", at(27, 13))
|
||||
);
|
||||
assert_eq!(
|
||||
(monday.as_str(), c),
|
||||
("Monday morning · Mon 08:00", at(28, 8))
|
||||
);
|
||||
let [_, _, (_, next)] = presets(at(28, 9), &TimeZone::UTC);
|
||||
assert_eq!(
|
||||
next,
|
||||
date(2026, 10, 5)
|
||||
.at(8, 0, 0, 0)
|
||||
.to_zoned(TimeZone::UTC)
|
||||
.expect("a UTC time")
|
||||
.timestamp(),
|
||||
"on a Monday, the next one"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Context, Div, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Subscription, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::{Input, InputEvent, OnValue, TextInput},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
/// A signature as it sits under a message: a short rule, then its lines in the muted tone.
|
||||
pub(super) fn signature_block(text: &str, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
div()
|
||||
.w_6()
|
||||
.mb_1()
|
||||
.border_t_1()
|
||||
.border_color(colors.border_strong),
|
||||
)
|
||||
.children(text.lines().map(|line| div().child(line.to_string())))
|
||||
}
|
||||
|
||||
/// The field and what it last told the owner, so an echo is known from a new signature.
|
||||
struct Signed {
|
||||
input: Entity<TextInput>,
|
||||
seed: SharedString,
|
||||
sent: Option<SharedString>,
|
||||
on_change: Option<OnValue>,
|
||||
_changes: Subscription,
|
||||
}
|
||||
|
||||
/// Writes a signature: its lines in a field, and under it how it will sit below a message. The owner gets the words as they change; a new signature from the owner starts the field over.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SignatureEditor {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
on_change: Option<OnValue>,
|
||||
}
|
||||
|
||||
impl SignatureEditor {
|
||||
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the words as they change.
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SignatureEditor {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let seed = self.text.clone();
|
||||
let signed = window.use_keyed_state(
|
||||
(self.id.clone(), "signed"),
|
||||
cx,
|
||||
|window, cx: &mut Context<Signed>| {
|
||||
let input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx)
|
||||
.multi_line(3, 6)
|
||||
.placeholder("Your name, your role, a way to reach you");
|
||||
input.set_text(seed.to_string(), cx);
|
||||
input
|
||||
});
|
||||
let changes =
|
||||
cx.subscribe_in(&input, window, |signed, input, event, window, cx| {
|
||||
if *event != InputEvent::Changed {
|
||||
return;
|
||||
}
|
||||
let words = SharedString::from(input.read(cx).text().to_string());
|
||||
if words == signed.seed {
|
||||
return;
|
||||
}
|
||||
log::info!("signature editor: {} characters", words.len());
|
||||
signed.sent = Some(words.clone());
|
||||
if let Some(on_change) = signed.on_change.clone() {
|
||||
on_change(&words, window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
Signed {
|
||||
input,
|
||||
seed: seed.clone(),
|
||||
sent: None,
|
||||
on_change: None,
|
||||
_changes: changes,
|
||||
}
|
||||
},
|
||||
);
|
||||
let text = self.text.clone();
|
||||
signed.update(cx, |signed, cx| {
|
||||
signed.on_change = self.on_change.clone();
|
||||
if signed.seed == text {
|
||||
return;
|
||||
}
|
||||
signed.seed = text.clone();
|
||||
if signed.sent.as_ref() == Some(&text) {
|
||||
return;
|
||||
}
|
||||
log::info!("signature editor: started over");
|
||||
signed
|
||||
.input
|
||||
.update(cx, |input, cx| input.set_text(text.to_string(), cx));
|
||||
});
|
||||
let input = signed.read(cx).input.clone();
|
||||
let words = input.read(cx).text().to_string();
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(Input::new(&input))
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child("Under each message"),
|
||||
)
|
||||
.child(signature_block(&words, cx))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
};
|
||||
|
||||
mod read;
|
||||
mod write;
|
||||
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
|
||||
@@ -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