use std::{ collections::{hash_map::DefaultHasher, HashSet}, hash::{Hash, Hasher}, }; use chrono::{DateTime, Datelike, Duration, Local, Utc}; use human_format::{Formatter, Scales}; use itertools::Itertools; use log::error; use seed::{prelude::*, *}; use seed_hooks::{state_access::CloneState, topo, use_state}; use crate::{ api::urls, graphql::{front_page_query::*, show_thread_query::*}, state::{unread_query, Model, Msg, RefreshingState}, }; mod desktop; mod mobile; mod tablet; // TODO(wathiede): create a QueryString enum that wraps single and multiple message ids and thread // ids, and has a to_query_string() that knows notmuch's syntax. Then remove the smattering of // format!() calls all over with magic strings representing notmuch specific syntax. const MAX_RAW_MESSAGE_SIZE: usize = 100_000; fn set_title(title: &str) { seed::document().set_title(&format!("lb: {}", title)); } fn compute_color(data: &str) -> String { let mut hasher = DefaultHasher::new(); data.hash(&mut hasher); format!("#{:06x}", hasher.finish() % (1 << 24)) } fn tags_chiclet(tags: &[String], is_mobile: bool) -> impl Iterator> + '_ { tags.iter().map(move |tag| { let hex = compute_color(tag); let style = style! {St::BackgroundColor=>hex}; let classes = C!["tag", IF!(is_mobile => "is-small")]; let tag = tag.clone(); a![ attrs! { At::Href => urls::search(&format!("tag:{tag}"), 0) }, match tag.as_str() { "attachment" => span![classes, style, "📎"], "replied" => span![classes, style, i![C!["fa-solid", "fa-reply"]]], _ => span![classes, style, &tag], }, ev(Ev::Click, move |_| Msg::FrontPageRequest { query: format!("tag:{tag}"), after: None, before: None, first: None, last: None, }) ] }) } fn removable_tags_chiclet<'a>( thread_id: &'a str, tags: &'a [String], is_mobile: bool, ) -> Node { div![ C![ "message-tags", "field", "is-grouped", "is-grouped-multiline" ], tags.iter().map(move |tag| { let thread_id = thread_id.to_string(); let hex = compute_color(tag); let style = style! {St::BackgroundColor=>hex}; let classes = C!["tag", IF!(is_mobile => "is-small")]; let attrs = attrs! { At::Href => urls::search(&format!("tag:{tag}"), 0) }; let tag = tag.clone(); let rm_tag = tag.clone(); div![ C!["control"], div![ C!["tags", "has-addons"], a![ classes, attrs, style, match tag.as_str() { "attachment" => span!["📎"], "replied" => span![i![C!["fa-solid", "fa-reply"]]], _ => span![&tag], }, ev(Ev::Click, move |_| Msg::FrontPageRequest { query: format!("tag:{tag}"), after: None, before: None, first: None, last: None, }) ], a![ C!["tag", "is-delete"], ev(Ev::Click, move |_| Msg::RemoveTag(thread_id, rm_tag)) ] ] ] }) ] } fn pretty_authors(authors: &str) -> impl Iterator> + '_ { let one_person = authors.matches(',').count() == 0; let authors = authors.split(','); Itertools::intersperse( authors.filter_map(move |author| { if one_person { return Some(span![ attrs! { At::Title => author.trim()}, author ]); } author.split_whitespace().nth(0).map(|first| { span![ attrs! { At::Title => author.trim()}, first ] }) }), span![", "], ) } fn human_age(timestamp: i64) -> String { let now = Local::now(); let yesterday = now - Duration::days(1); let ts = DateTime::::from_timestamp(timestamp, 0) .unwrap() .with_timezone(&Local); let age = now - ts; let datetime = if age < Duration::minutes(1) { format!("{} min. ago", age.num_seconds()) } else if age < Duration::hours(1) { format!("{} min. ago", age.num_minutes()) } else if ts.date_naive() == now.date_naive() { ts.format("Today %H:%M").to_string() } else if ts.date_naive() == yesterday.date_naive() { ts.format("Yest. %H:%M").to_string() } else if age < Duration::weeks(1) { ts.format("%a %H:%M").to_string() } else if ts.year() == now.year() { ts.format("%b %d %H:%M").to_string() } else { ts.format("%b %d, %Y %H:%M").to_string() }; datetime } fn view_search_results( query: &str, results: &[FrontPageQuerySearchNodes], count: usize, pager: &FrontPageQuerySearchPageInfo, selected_threads: &HashSet, show_icon_text: bool, ) -> Node { if query.is_empty() { set_title("all mail"); } else { set_title(query); } let show_bulk_edit = !selected_threads.is_empty(); let all_checked = selected_threads.len() == results.len(); let partially_checked = !selected_threads.is_empty() && !all_checked; let rows = results.iter().map(|r| { let tid = r.thread.clone(); let check_tid = r.thread.clone(); let datetime = human_age(r.timestamp as i64); let unread_idx = r.tags.iter().position(|e| e == &"unread"); let mut tags = r.tags.clone(); if let Some(idx) = unread_idx { tags.remove(idx); }; let subject = if r.subject.is_empty() { "(no subject)" } else { &r.subject }; tr![ IF!(unread_idx.is_some() => C!["unread"]), td![label![ C!["b-checkbox", "checkbox"], input![attrs! { At::Type=>"checkbox", At::Checked=>selected_threads.contains(&tid).as_at_value(), }], span![C!["check"]], ev(Ev::Input, move |e| { if let Some(input) = e .target() .as_ref() .expect("failed to get reference to target") .dyn_ref::() { if input.checked() { Msg::SelectionAddThread(check_tid) } else { Msg::SelectionRemoveThread(check_tid) } } else { Msg::Noop } }), ]], td![ C!["from"], a![ C!["has-text-light", "text"], attrs! { At::Href => urls::thread(&tid) }, pretty_authors(&r.authors), IF!(r.total>1 => small![" ", r.total.to_string()]), ] ], td![ C!["subject"], tags_chiclet(&tags, false), " ", a![ C!["has-text-light", "text"], attrs! { At::Href => urls::thread(&tid) }, &subject, ] ], td![ C!["date"], a![ C!["has-text-light", "text"], attrs! { At::Href => urls::thread(&tid) }, datetime ] ] ] }); div![ C!["search-results"], search_toolbar(count, pager, show_bulk_edit, show_icon_text), table![ C![ "table", "index", "is-fullwidth", "is-hoverable", "is-narrow", "is-striped", ], thead![tr![ th![ C!["edit"], label![ C!["b-checkbox", "checkbox"], input![ IF!(partially_checked => C!["is-indeterminate"]), attrs! { At::Type=>"checkbox", At::Checked=>all_checked.as_at_value(), } ], span![C!["check"]], ev(Ev::Click, move |_| if all_checked { Msg::SelectionSetNone } else { Msg::SelectionSetAll }) ] ], th![C!["from"], "From"], th![C!["subject"], "Subject"], th![C!["date"], "Date"] ]], tbody![rows] ], search_toolbar(count, pager, show_bulk_edit, show_icon_text) ] } fn search_toolbar( count: usize, pager: &FrontPageQuerySearchPageInfo, show_bulk_edit: bool, show_icon_text: bool, ) -> Node { let start = pager .start_cursor .as_ref() .map(|i| i.parse().unwrap_or(0)) .unwrap_or(0) + 1; let end = pager .end_cursor .as_ref() .map(|i| i.parse().unwrap_or(count)) .unwrap_or(count) + 1; nav![ C!["level", "is-mobile"], IF!(show_bulk_edit => div![ C!["level-left"], div![ C!["level-item"], div![C!["buttons", "has-addons"], button![ C!["button", "mark-read"], attrs!{At::Title => "Mark as read"}, span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]], IF!(show_icon_text=>span!["Read"]), ev(Ev::Click, |_| Msg::SelectionMarkAsRead) ], button![ C!["button", "mark-unread"], attrs!{At::Title => "Mark as unread"}, span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]], IF!(show_icon_text=>span!["Unread"]), ev(Ev::Click, |_| Msg::SelectionMarkAsUnread) ] ] ], div![ C!["level-item"], div![C!["buttons", "has-addons"], button![ C!["button", "spam"], attrs!{At::Title => "Mark as spam"}, span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]], IF!(show_icon_text=>span!["Spam"]), ev(Ev::Click, |_| Msg::MultiMsg(vec![ Msg::SelectionAddTag("Spam".to_string()), Msg::SelectionMarkAsRead ]) ) ], ], ] ]), div![ C!["level-right"], nav![ C!["level-item", "pagination"], a![ C![ "pagination-previous", "button", //IF!(!pager.has_previous_page => "is-static"), ], IF!(!pager.has_previous_page => attrs!{ At::Disabled=>true }), "<", IF!(pager.has_previous_page => ev(Ev::Click, |_| Msg::PreviousPage)), ], a![ C![ "pagination-next", "button", //IF!(!pager.has_next_page => "is-static") ], IF!(!pager.has_next_page => attrs!{ At::Disabled=>true }), ">", IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage)) ], ul![ C!["pagination-list"], li![format!("{} - {} of {}", start, end, count)], ], ] ] ] } trait Email { fn name(&self) -> Option<&str>; fn addr(&self) -> Option<&str>; } impl Email for &'_ T { fn name(&self) -> Option<&str> { return (*self).name(); } fn addr(&self) -> Option<&str> { return (*self).addr(); } } macro_rules! implement_email { ( $($t:ty),+ ) => {$( impl Email for $t { fn name(&self) -> Option<&str> { self.name.as_deref() } fn addr(&self) -> Option<&str> { self.addr.as_deref() } } )+}; } implement_email!( ShowThreadQueryThreadMessagesTo, ShowThreadQueryThreadMessagesCc, ShowThreadQueryThreadMessagesFrom ); fn raw_text_message(contents: &str) -> Node { let (contents, truncated_msg) = if contents.len() > MAX_RAW_MESSAGE_SIZE { ( &contents[..MAX_RAW_MESSAGE_SIZE], Some(div!["... contents truncated"]), ) } else { (contents, None) }; div![C!["view-part-text-plain"], contents, truncated_msg,] } fn has_unread(tags: &[String]) -> bool { tags.contains(&String::from("unread")) } fn render_avatar(avatar: Option, from: &str) -> Node { let initials: String = from .to_lowercase() .split(" ") .map(|word| word.chars().next().unwrap()) // Limit to 2 characters because more characters don't fit in the box .take(2) .collect(); if let Some(src) = avatar { img![attrs! {At::Src=>src}] } else { let w = 64; let h = 64; let from_color = compute_color(from); svg![ attrs! {At::ViewBox=>format!("0 0 {w} {h}") }, style! { St::Display => "block", St::FontFamily => "Poppins", St::FontSize => pt(32), }, g![ rect![attrs! { At::Fill=>from_color, At::Stroke=>"black", At::StrokeWidth=>"1", // Round corners //At::Rx => px(10), At::X => 0, At::Y => 0, At::Width => h, At::Height => h, }], text![ attrs! { At::Fill => "white", At::X => percent(50), At::Y => percent(50), At::DominantBaseline => "middle", At::TextAnchor => "middle" }, initials ] ] ] } } fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node { let (from, from_detail) = match &msg.from { Some(ShowThreadQueryThreadMessagesFrom { name: Some(name), addr, }) => (name.to_string(), addr.clone()), Some(ShowThreadQueryThreadMessagesFrom { addr: Some(addr), .. }) => (addr.to_string(), None), _ => (String::from("UNKNOWN"), None), }; // TODO(wathiede): get this from server let avatar: Option = None; //let avatar: Option = Some(String::from("https://bulma.io/images/placeholders/64x64.png")); let id = msg.id.clone(); let is_unread = has_unread(&msg.tags); let img = render_avatar(avatar, &from); article![ C!["media"], figure![C!["media-left"], p![C!["image", "is-64x64"], img]], div![ C!["media-content"], div![ C!["content"], p![ strong![from], br![], small![from_detail], table![ IF!(!msg.to.is_empty() => tr![ td![ "To:" ], //td![ if i==0 { "To" }else { "" } ], td![ msg.to.iter().enumerate().map(|(i, to)| small![ if i>0 { ", " }else { "" }, match to { ShowThreadQueryThreadMessagesTo { name: Some(name), addr:Some(addr), } => format!("{name} <{addr}>"), ShowThreadQueryThreadMessagesTo { name: Some(name), addr:None } => format!("{name}"), ShowThreadQueryThreadMessagesTo { addr: Some(addr), .. } => format!("{addr}"), _ => String::from("UNKNOWN"), } ]) ] ]), IF!(!msg.cc.is_empty() => tr![ td![ "CC:" ], td![ msg.cc.iter().enumerate().map(|(i, cc)| small![ if i>0 { ", " }else { "" }, match cc { ShowThreadQueryThreadMessagesCc { name: Some(name), addr:Some(addr), } => format!("{name} <{addr}>"), ShowThreadQueryThreadMessagesCc { name: Some(name), addr:None } => format!("{name}"), ShowThreadQueryThreadMessagesCc { addr: Some(addr), .. } => format!("<{addr}>"), _ => String::from("UNKNOWN"), } ]) ] ]), tr![td![ attrs! {At::ColSpan=>2}, msg.timestamp.map(|ts| span![C!["header"], human_age(ts)]) ]] ], ], ], ], div![ C!["media-right"], span![ C!["read-status"], i![C![ "far", if is_unread { "fa-envelope" } else { "fa-envelope-open" }, ]] ], ev(Ev::Click, move |e| { e.stop_propagation(); Msg::SetUnread(id, !is_unread) }) ] ] } fn render_closed_header(msg: &ShowThreadQueryThreadMessages) -> Node { let from: String = match &msg.from { Some(ShowThreadQueryThreadMessagesFrom { name: Some(name), .. }) => name.to_string(), Some(ShowThreadQueryThreadMessagesFrom { addr: Some(addr), .. }) => addr.to_string(), _ => String::from("UNKNOWN"), }; // TODO(wathiede): get this from server let avatar: Option = None; //let avatar: Option = Some(String::from("https://bulma.io/images/placeholders/64x64.png")); let id = msg.id.clone(); let is_unread = has_unread(&msg.tags); let img = render_avatar(avatar, &from); article![ C!["media"], figure![C!["media-left"], p![C!["image", "is-64x64"], img]], div![ C!["media-content"], div![ C!["content"], p![ strong![from], br![], IF!(!msg.to.is_empty() => nodes![ small![" to "], msg.to.iter().enumerate().map(|(i, to)| small![ if i > 0 { ", " } else { "" }, to.name() .as_ref() .unwrap_or(&to.addr().unwrap_or("(UNKNOWN)")) ]).collect::>() ]), IF!(!msg.cc.is_empty() => nodes![ small![" cc "], msg.cc.iter().enumerate().map(|(i, cc)| small![ if i > 0 { ", " } else { "" }, cc.name() .as_ref() .unwrap_or(&cc.addr().unwrap_or("(UNKNOWN)")) ]).collect::>() ]), br![], msg.timestamp.map(|ts| span![C!["header"], human_age(ts)]), ], ], ], div![ C!["media-right"], span![ C!["read-status"], i![ C![ "far", if is_unread { "fa-envelope" } else { "fa-envelope-open" }, ], ev(Ev::Click, move |e| { e.stop_propagation(); Msg::SetUnread(id, !is_unread) }) ] ] ] ] } fn message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node { let expand_id = msg.id.clone(); div![ C!["message"], div![ C!["header"], if open { render_open_header(&msg) } else { render_closed_header(&msg) }, ev(Ev::Click, move |e| { e.stop_propagation(); if open { Msg::MessageCollapse(expand_id) } else { Msg::MessageExpand(expand_id) } }) ], IF!(open => div![ C!["body"], match &msg.body { ShowThreadQueryThreadMessagesBody::UnhandledContentType( ShowThreadQueryThreadMessagesBodyOnUnhandledContentType { contents ,content_tree}, ) => div![ raw_text_message(&contents), div![C!["error"], view_content_tree(&content_tree), ] ], ShowThreadQueryThreadMessagesBody::PlainText( ShowThreadQueryThreadMessagesBodyOnPlainText { contents, content_tree, }, ) => div![ raw_text_message(&contents), view_content_tree(&content_tree), ], ShowThreadQueryThreadMessagesBody::Html( ShowThreadQueryThreadMessagesBodyOnHtml { contents, content_tree, }, ) => div![ C!["view-part-text-html"], raw![contents], IF!(!msg.attachments.is_empty() => div![ C!["attachments"], hr![], h2!["Attachments"], div![C!["grid","is-col-min-6"], msg.attachments .iter() .map(|a| { let default = "UNKNOWN_FILE".to_string(); let filename = a.filename.as_ref().unwrap_or(&default); let host = seed::window().location().host().expect("couldn't get host"); let url = shared::urls::download_attachment(Some(&host), &a.id, &a.idx, filename); let mut fmtr = Formatter::new(); fmtr.with_separator(" "); fmtr.with_scales(Scales::Binary()); div![ C!["attachment", "card"], a.content_type.as_ref().map(|content_type| IF!(content_type.starts_with("image/") => div![C!["card-image","is-1by1"], div![ C!["image","is-1by1"], style!{ St::BackgroundImage=>format!(r#"url("{url}");"#), St::BackgroundSize=>"cover", St::BackgroundPosition=>"center", } ] ] )), div![C!["card-content"], div![C!["content"], &a.filename, br![], small![ fmtr.format(a.size as f64),"B"] ] ], footer![ C!["card-footer"], a![C!["card-footer-item"],span![C!["icon"], i![C!["fas", "fa-download"]]], ev(Ev::Click, move |_| { seed::window().location().set_href(&url ).expect("failed to set URL"); }) ] ] ] }) ] ]), view_content_tree(&content_tree), ], } ]) ] } #[topo::nested] fn thread( thread: &ShowThreadQueryThread, open_messages: &HashSet, show_icon_text: bool, ) -> Node { // TODO(wathiede): show per-message subject if it changes significantly from top-level subject let subject = if thread.subject.is_empty() { "(no subject)" } else { &thread.subject }; set_title(subject); let mut tags: Vec<_> = thread .messages .iter() .fold(HashSet::new(), |mut tags, msg| { tags.extend(msg.tags.clone()); tags }) .into_iter() .collect(); tags.sort(); let messages = thread.messages.iter().map(|msg| { let open = open_messages.contains(&msg.id); message_render(&msg, open) }); let read_thread_id = thread.thread_id.clone(); let unread_thread_id = thread.thread_id.clone(); let spam_add_thread_id = thread.thread_id.clone(); let spam_unread_thread_id = thread.thread_id.clone(); div![ C!["thread"], h3![C!["is-size-5"], subject], span![ C!["tags"], removable_tags_chiclet(&thread.thread_id, &tags, false) ], div![ C!["level", "is-mobile"], div![ C!["level-item"], div![ C!["buttons", "has-addons"], button![ C!["button", "mark-read"], attrs! {At::Title => "Mark as read"}, span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]], IF!(show_icon_text=>span!["Read"]), ev(Ev::Click, move |_| Msg::SetUnread(read_thread_id, false)), ], button![ C!["button", "mark-unread"], attrs! {At::Title => "Mark as unread"}, span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]], IF!(show_icon_text=>span!["Unread"]), ev(Ev::Click, move |_| Msg::SetUnread(unread_thread_id, true)), ], ], ], div![ C!["level-item"], div![ C!["buttons", "has-addons"], button![ C!["button", "spam"], attrs! {At::Title => "Spam"}, span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]], IF!(show_icon_text=>span!["Spam"]), ev(Ev::Click, move |_| Msg::MultiMsg(vec![ Msg::AddTag(spam_add_thread_id, "Spam".to_string()), Msg::SetUnread(spam_unread_thread_id, false) ])), ], ], ], ], messages, /* TODO(wathiede): plumb in orignal id a![ attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)}, "Original" ], */ ] } #[topo::nested] fn view_content_tree(content_tree: &str) -> Node { let debug_open = use_state(|| false); div![ small![ i![C![ "fa-solid", if debug_open.get() { "fa-angle-up" } else { "fa-angle-down" } ]], " Debug", ev(Ev::Click, move |_| { debug_open.set(!debug_open.get()); }) ], IF!(debug_open.get() => pre![C!["content-tree"], content_tree]), ] } fn view_header(query: &str, refresh_request: &RefreshingState) -> Node { let is_loading = refresh_request == &RefreshingState::Loading; let is_error = if let RefreshingState::Error(err) = refresh_request { error!("Failed to refresh: {err:?}"); true } else { false }; let query = Url::decode_uri_component(query).unwrap_or("".to_string()); nav![ C!["navbar"], attrs! {At::Role=>"navigation"}, div![ C!["navbar-start"], a![ C!["navbar-item", "button", IF![is_error => "is-danger"]], span![i![C![ "fa-solid", "fa-arrow-rotate-right", "refresh", IF![is_loading => "loading"], ]]], ev(Ev::Click, |_| Msg::RefreshStart), ], a![ C!["navbar-item", "button"], attrs! { At::Href => urls::search(unread_query(), 0) }, "Unread", ], a![ C!["navbar-item", "button"], attrs! { At::Href => urls::search("", 0) }, "All", ], input![ C!["navbar-item", "input"], attrs! { At::Placeholder => "Search"; At::AutoFocus => true.as_at_value(); At::Value => query, }, input_ev(Ev::Input, |q| Msg::UpdateQuery(q)), // Send search on enter. keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d { Msg::SearchQuery(query) } else { Msg::Noop }), ] ] ] } // `view` describes what to display. pub fn view(model: &Model) -> Node { let win = seed::window(); let w = win .inner_width() .expect("window width") .as_f64() .expect("window width f64"); let _h = win .inner_height() .expect("window height") .as_f64() .expect("window height f64"); div![match w { w if w < 800. => div![C!["mobile"], mobile::view(model)], w if w < 1024. => div![C!["tablet"], tablet::view(model)], _ => div![C!["desktop"], desktop::view(model)], },] }