use std::{ collections::{hash_map::DefaultHasher, HashSet}, hash::{Hash, Hasher}, }; use chrono::{DateTime, Datelike, Duration, Local, Utc}; use itertools::Itertools; use log::{error, info}; use seed::{prelude::*, *}; use seed_hooks::{ state_access::{CloneState, StateAccess}, topo, use_state, }; 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; mod tablet; const MAX_RAW_MESSAGE_SIZE: usize = 100_000; 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), 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 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 { for t in tags { if t == "unread" { return true; } } false } fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: StateAccess) -> Node { let id = msg.id.clone(); let is_unread = has_unread(&msg.tags); div![ C!["message"], div![ C!["headers"], span![ C!["read-status"], i![ style! { St::Color => "gold" }, C![if is_unread { "fa-regular" } else { "fa-solid" }, "fa-star"], ev(Ev::Click, move |e| { e.stop_propagation(); Msg::SetUnread(format!("id:{id}"), !is_unread) }), ], ], " ", msg.from .as_ref() .map(|from| span![C!["header"], view_address(&from)]), " ", msg.timestamp.map(|ts| span![C!["header"], human_age(ts)]), // TODO(wathiede): add first line of message body ], ev(Ev::Click, move |e| { open.set(!open.get()); e.stop_propagation(); }), ] } fn unread_message_render( msg: &ShowThreadQueryThreadMessages, open: StateAccess, ) -> Node { let id = msg.id.clone(); let is_unread = has_unread(&msg.tags); div![ C!["message"], div![ C!["headers"], span![ C!["read-status"], i![ style! { St::Color => "gold" }, C![if is_unread { "fa-regular" } else { "fa-solid" }, "fa-star"], ev(Ev::Click, move |e| { e.stop_propagation(); Msg::SetUnread(format!("id:{id}"), !is_unread) }), ], ], 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"], "Message-ID: ", &msg.id], 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)]) ], ev(Ev::Click, move |e| { open.set(!open.get()); e.stop_propagation(); }), ], div![ C!["body"], match &msg.body { ShowThreadQueryThreadMessagesBody::UnhandledContentType( ShowThreadQueryThreadMessagesBodyOnUnhandledContentType { contents }, ) => pre![C!["error"], contents], 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"], br![], h2!["Attachments"], msg.attachments .iter() .map(|a| div!["Filename: ", &a.filename, " ", &a.content_type]) ]), view_content_tree(&content_tree), ], } ], ] } #[topo::nested] fn thread(thread: &ShowThreadQueryThread) -> Node { // TODO(wathiede): show per-message subject if it changes significantly from top-level subject set_title(&thread.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 is_unread = has_unread(&msg.tags); let open = use_state(|| is_unread); //info!("open {} {}", open.get(), msg.id); if open.get() { unread_message_render(&msg, open) } else { read_message_render(&msg, open) } }); let any_unread = thread.messages.iter().any(|msg| has_unread(&msg.tags)); let thread_id = thread.thread_id.clone(); div![ C!["thread"], h1![ C!["title"], span![ C!["read-status"], i![ style! { St::Color => "gold" }, C![ if any_unread { "fa-regular" } else { "fa-solid" }, "fa-star" ], ev(Ev::Click, move |_| Msg::SetUnread( format!("thread:{}", thread_id), !any_unread )), ], " ", ], &thread.subject, " ", tags_chiclet(&tags, 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![ hr![], 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("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!["lb-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 { let start = Instant::now(); info!("refreshing {:?}", model.refreshing_state); 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)], }, view_footer(start.elapsed().as_millis()), ] }