Files
ElyOffice/apps/ely-mail/src/view.rs
T

287 lines
10 KiB
Rust

//! What the window draws.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
feedback::{Banner, EmptyState, ErrorView},
layout::ScrollArea,
overlays::ConfirmDialog,
primitives::{IconName, Severity},
typography::format,
};
use ely_mail_engine::{Account, AccountId, Auth, LocalFolder, Protocol, Security, Server, sync};
use ely_platform::AppId;
use ely_ui::Shell;
use gpui::{
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Window, div,
};
use crate::{
mail::{Mail, text},
menu,
};
/// "IMAP · imap.example.com"
pub fn protocol(account: &Account, cx: &App) -> SharedString {
let name = protocol_name(account.protocol);
text(
cx,
"mail.account.line",
&[("protocol", name), ("host", &account.incoming.host)],
)
}
fn server(server: &Server, cx: &App) -> SharedString {
let security = match server.security {
Security::Tls => "TLS",
Security::StartTls => "STARTTLS",
};
text(
cx,
"mail.account.server",
&[
("host", &server.host),
("port", &server.port.to_string()),
("security", security),
],
)
}
pub fn auth(auth: Auth, cx: &App) -> SharedString {
let key = match auth {
Auth::Password => "mail.auth.password",
Auth::Google => "mail.auth.google",
Auth::Microsoft => "mail.auth.microsoft",
};
text(cx, key, &[])
}
impl Mail {
fn add_button(&self, cx: &App) -> Button {
Button::new("mail-add", text(cx, "mail.add.open", &[]))
.variant(ButtonVariant::Primary)
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::AddAccount), cx))
}
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
let error = |key: &str, detail: &str, cx: &App| {
ErrorView::new("mail-error", IconName::TriangleAlert, text(cx, key, &[]))
.detail(detail.to_owned())
.into_any_element()
};
if let Err(detail) = &self.store {
return error("mail.store.unopened", detail, cx);
}
let accounts = match &self.accounts {
None => return div().into_any_element(),
Some(Err(detail)) => return error("mail.store.unread", detail, cx),
Some(Ok(accounts)) => accounts,
};
let Some((id, account)) = accounts.iter().find(|(id, _)| Some(*id) == self.picked) else {
return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[]))
.action(self.add_button(cx))
.into_any_element();
};
let label = |key: &str| text(cx, key, &[]);
let incoming = format!(
"{} · {}",
protocol_name(account.protocol),
server(&account.incoming, cx)
);
let details = DescriptionList::new()
.text(label("mail.account.address"), account.address.clone())
.text(label("mail.account.user"), account.user.clone())
.text(label("mail.account.incoming"), incoming)
.text(
label("mail.account.outgoing"),
server(&account.outgoing, cx),
)
.text(label("mail.account.auth"), auth(account.auth, cx));
let (id, protocol) = (*id, account.protocol);
let (details, failed) = self.sync_rows(id, protocol, details, cx);
let remove = Button::new("mail-remove", label("mail.remove.open"))
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx));
let page = div()
.p_6()
.flex()
.flex_col()
.gap_4()
.max_w_128()
.children(self.paused_banner(cx))
.children(failed)
.child(details)
.child(self.sync_buttons(id, cx))
.children(self.offline_switches(id, protocol, cx))
.child(div().child(remove));
ScrollArea::new("mail-account-scroll")
.vertical()
.size_full()
.child(page)
.into_any_element()
}
/// When it last synced, what is here, why it failed.
fn sync_rows(
&self,
id: AccountId,
protocol: Protocol,
details: DescriptionList,
cx: &App,
) -> (DescriptionList, Option<Banner>) {
let Some(syncing) = self.syncs.get(&id) else {
return (details, None);
};
let label = |key: &str| text(cx, key, &[]);
let last = match (&syncing.task, syncing.synced.and_then(|synced| synced.at)) {
(Some(_), _) => label("mail.sync.running"),
(None, Some(at)) => local_time(at).into(),
(None, None) => label("mail.sync.never"),
};
let mut details = details.text(label("mail.sync.last"), last);
if let Protocol::Pop { keep } = protocol {
let server = label(if keep {
"mail.sync.kept"
} else {
"mail.sync.deleted"
});
details = details
.text(label("mail.sync.range"), label("mail.sync.range_pop"))
.text(label("mail.sync.server"), server);
} else {
for (key, value) in imap_range(&syncing.folders, cx) {
details = details.text(key, value);
}
}
if let (Some(synced), Protocol::Imap) = (syncing.synced, protocol) {
for (key, n) in [
("mail.sync.uncached", synced.uncached),
("mail.sync.partial", synced.partial),
] {
let value = format!("{key}_value");
let words = ely_ui::i18n::counted(cx, &value, n, &[("n", &n.to_string())]);
details = details.text(label(key), words);
}
}
let failed = syncing.failed.as_ref().map(|error| {
Banner::new(
"mail-sync-failed",
Severity::Danger,
text(cx, "mail.sync.failed", &[("error", error)]),
)
});
(details, failed)
}
fn remove_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let removing = self.removing.as_ref()?;
let this = cx.weak_entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |mail, cx| {
mail.removing = None;
cx.notify();
})
.ok();
}
};
let size = format::file_size(removing.cached.bytes, false);
let count = removing.cached.messages;
let args = [
("address", removing.address.as_str()),
("n", &count.to_string()),
("size", &size),
];
let message = ely_ui::i18n::counted(cx, "mail.remove.message", count, &args);
let dialog = ConfirmDialog::new(
"mail-remove",
text(cx, "mail.remove.title", &args),
message,
dismiss,
)
.confirm(text(cx, "mail.remove.go", &[]))
.destructive()
.on_confirm({
let removing = removing.clone();
move |_, cx| {
let removing = removing.clone();
this.update(cx, |mail, cx| mail.remove(removing, cx)).ok();
}
});
Some(dialog.into_any_element())
}
}
/// What the rest download, then folders kept whole.
pub fn imap_range(folders: &[LocalFolder], cx: &App) -> Vec<(SharedString, SharedString)> {
let (whole, rest): (Vec<_>, Vec<_>) = folders.iter().partition(|folder| folder.offline);
let label = |key: &str| text(cx, key, &[]);
let days = sync::BODY_DAYS.to_string();
let mut rows = Vec::new();
if whole.is_empty() || !rest.is_empty() {
let key = match whole.is_empty() {
true => "mail.sync.range_value",
false => "mail.sync.range_rest",
};
rows.push((label("mail.sync.range"), text(cx, key, &[("days", &days)])));
}
if !whole.is_empty() {
let sep = label("mail.sync.whole_sep");
let names: Vec<&str> = whole.iter().map(|folder| folder.name.as_str()).collect();
let value = text(cx, "mail.sync.whole_value", &[("names", &names.join(&sep))]);
rows.push((label("mail.sync.whole"), value));
}
rows
}
pub fn protocol_name(protocol: Protocol) -> &'static str {
match protocol {
Protocol::Imap => "IMAP",
Protocol::Pop { .. } => "POP3",
}
}
impl Render for Mail {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let left = (!self.accounts().is_empty()).then(|| self.side.clone().into());
let read_only = self.read_only.as_ref().map(|reason| {
Banner::new(
"mail-read-only",
Severity::Warning,
text(cx, "mail.store.read_only", &[("error", reason)]),
)
});
let shell = Shell::new(AppId::Mail.name(), &self.focus)
.left(left)
.children(read_only)
.child(self.body(cx));
div()
.size_full()
.key_context(menu::CONTEXT)
.track_focus(&self.focus)
.on_action(
cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)),
)
.on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx)))
.on_action(cx.listener(|mail, _: &menu::SyncAll, _, cx| mail.sync_all(cx)))
.child(shell)
.children(self.add_dialog(cx))
.children(self.remove_dialog(cx))
}
}
/// Local time to the minute.
fn local_time(at: i64) -> String {
match jiff::Timestamp::from_second(at) {
Ok(at) => at
.to_zoned(jiff::tz::TimeZone::system())
.strftime("%Y-%m-%d %H:%M")
.to_string(),
Err(error) => {
log::error!("mail: a sync time out of range: {error}");
at.to_string()
}
}
}