use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, }; use chrono::{DateTime, Datelike, Duration, Local, Utc}; use itertools::Itertools; use log::info; use seed::{prelude::*, *}; use wasm_timer::Instant; use crate::{ api::urls, consts::{SEARCH_RESULTS_PER_PAGE, USE_GRAPHQL}, graphql::{front_page_query::*, show_thread_query::*}, state::{Model, Msg, RefreshingState}, }; mod desktop; mod legacy; mod mobile; fn set_title(title: &str) { seed::document().set_title(&format!("lb: {}", title)); } fn tags_chiclet(tags: &[String], is_mobile: bool) -> impl Iterator> + '_ { tags.iter().map(move |tag| { let mut hasher = DefaultHasher::new(); tag.hash(&mut hasher); let hex = format!("#{:06x}", hasher.finish() % (1 << 24)); 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::SearchRequest { query: format!("tag:{tag}"), page: 0, results_per_page: SEARCH_RESULTS_PER_PAGE, }) ] }) } 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, ) -> Node { info!("pager {pager:?}"); if query.is_empty() { set_title("all mail"); } else { set_title(query); } let rows = results.iter().map(|r| { let tid = r.thread.clone(); let datetime = human_age(r.timestamp as i64); tr![ td![ C!["from"], pretty_authors(&r.authors), // TODO(wathiede): visualize message count if more than one message is in the // thread //IF!(r.total>1 => small![" ", r.total.to_string()]), ], td![ C!["subject"], tags_chiclet(&r.tags, false), " ", a![ C!["has-text-light"], attrs! { At::Href => urls::thread(&tid) }, &r.subject, ] ], td![C!["date"], datetime] ] }); div![ view_search_pager(count, pager), table![ C![ "table", "index", "is-fullwidth", "is-hoverable", "is-narrow", "is-striped", ], thead![tr![ th![C!["from"], "From"], th![C!["subject"], "Subject"], th![C!["date"], "Date"] ]], tbody![rows] ], view_search_pager(count, pager) ] } fn view_search_pager(count: usize, pager: &FrontPageQuerySearchPageInfo) -> Node { let start = pager .start_cursor .as_ref() .map(|i| i.parse().unwrap_or(0)) .unwrap_or(0); nav![ C!["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, count.min(start + SEARCH_RESULTS_PER_PAGE), 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 view_address(email: impl Email) -> Node { span![ C!["tag", "is-black"], email.addr().as_ref().map(|a| attrs! {At::Title=>a}), email .name() .as_ref() .unwrap_or(&email.addr().unwrap_or("(UNKNOWN)")) ] } fn view_addresses(addrs: &[impl Email]) -> Vec> { addrs.into_iter().map(view_address).collect::>() } fn view_thread(thread: &ShowThreadQueryThread) -> Node { // TODO(wathiede): show per-message subject if it changes significantly from top-level subject set_title(&thread.subject); let messages = thread.messages.iter().map(|msg| { div![ C!["message"], /* TODO(wathiede): collect all the tags and show them here. */ /* TODO(wathiede): collect all the attachments from all the subparts */ msg.from .as_ref() .map(|from| div![C!["header"], "From: ", view_address(&from)]), msg.timestamp .map(|ts| div![C!["header"], "Date: ", human_age(ts)]), div![ C!["header"], IF!(!msg.to.is_empty() => span!["To: ", view_addresses(&msg.to)]), IF!(!msg.cc.is_empty() => span!["CC: ", view_addresses(&msg.cc)]) ], div![ C!["body"], match &msg.body { ShowThreadQueryThreadMessagesBody::UnhandledContentType( ShowThreadQueryThreadMessagesBodyOnUnhandledContentType { contents }, ) => pre![C!["error"], contents], ShowThreadQueryThreadMessagesBody::PlainText( ShowThreadQueryThreadMessagesBodyOnPlainText { contents, content_tree, }, ) => div![C!["view-part-text-plain"], contents, pre![content_tree]], ShowThreadQueryThreadMessagesBody::Html( ShowThreadQueryThreadMessagesBodyOnHtml { contents, content_tree, }, ) => div![ C!["view-part-text-html"], raw![contents], pre![content_tree] ], } ], ] }); div![ C!["thread"], p![C!["is-size-4"], &thread.subject], messages, /* TODO(wathiede): plumb in orignal id a![ attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)}, "Original" ], */ ] } 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("is:unread", 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| if USE_GRAPHQL { Msg::UpdateQuery(q) } else { Msg::SearchRequest { query: Url::encode_uri_component(if q.is_empty() { "*".to_string() } else { q }), page: 0, results_per_page: SEARCH_RESULTS_PER_PAGE, } }), // Send search on enter. keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d { if USE_GRAPHQL { Msg::SearchQuery(query) } else { Msg::SearchRequest { query: Url::encode_uri_component(query), page: 0, results_per_page: SEARCH_RESULTS_PER_PAGE, } } } else { Msg::Noop }), ] ] ] } fn view_footer(render_time_ms: u128) -> Node { footer![ C!["footer"], div![ C!["content", "has-text-right", "is-size-7"], format!("Render time {} ms", render_time_ms) ] ] } // `view` describes what to display. pub fn view(model: &Model) -> Node { info!("refreshing {:?}", model.refreshing_state); let is_mobile = seed::window() .match_media("(max-width: 768px)") .expect("failed media query") .map(|mql| mql.matches()) .unwrap_or(false); let start = Instant::now(); info!("view called"); div![ if is_mobile { C!["mobile"] } else { C!["desktop"] }, if is_mobile { mobile::view(model) } else { desktop::view(model) }, view_footer(start.elapsed().as_millis()) ] }