diff --git a/web/src/api.rs b/web/src/api.rs new file mode 100644 index 0000000..5fb115e --- /dev/null +++ b/web/src/api.rs @@ -0,0 +1,86 @@ +use log::info; +use notmuch::ThreadSet; +use seed::{prelude::*, Url}; +use serde::de::Deserialize; + +const BASE_URL: &str = "/api"; +pub fn refresh() -> String { + format!("{BASE_URL}/refresh") +} +pub fn search(query: &str, page: usize, results_per_page: usize) -> String { + let query = Url::encode_uri_component(query); + format!("{BASE_URL}/search/{query}?page={page}&results_per_page={results_per_page}") +} +pub fn show(tid: &str) -> String { + format!("{BASE_URL}/show/{tid}") +} +pub fn show_pretty(tid: &str) -> String { + format!("{BASE_URL}/show/{tid}/pretty") +} +pub fn original(message_id: &str) -> String { + format!("{BASE_URL}/original/{message_id}") +} +pub mod urls { + use seed::Url; + pub fn search(query: &str, page: usize) -> Url { + let query = Url::encode_uri_component(query); + if page > 0 { + Url::new().set_hash_path(["s", &query, &format!("p{page}")]) + } else { + Url::new().set_hash_path(["s", &query]) + } + } + pub fn thread(tid: &str) -> Url { + Url::new().set_hash_path(["t", tid]) + } +} + +pub async fn search_request( + query: &str, + page: usize, + results_per_page: usize, +) -> fetch::Result { + Request::new(search(query, page, results_per_page)) + .method(Method::Get) + .fetch() + .await? + .check_status()? + .json() + .await +} + +pub async fn refresh_request() -> fetch::Result<()> { + let t = Request::new(refresh()) + .method(Method::Get) + .fetch() + .await? + .check_status()? + .text() + .await?; + info!("refresh {t}"); + Ok(()) +} + +pub async fn show_request(tid: &str) -> fetch::Result { + let b = Request::new(show(tid)) + .method(Method::Get) + .fetch() + .await? + .check_status()? + .bytes() + .await?; + let mut deserializer = serde_json::Deserializer::from_slice(&b); + deserializer.disable_recursion_limit(); + Ok(ThreadSet::deserialize(&mut deserializer) + .map_err(|_| FetchError::JsonError(fetch::JsonError::Serde(JsValue::NULL)))?) +} + +pub async fn show_pretty_request(tid: &str) -> fetch::Result { + Request::new(show_pretty(tid)) + .method(Method::Get) + .fetch() + .await? + .check_status()? + .json() + .await +} diff --git a/web/src/consts.rs b/web/src/consts.rs new file mode 100644 index 0000000..a5c60f8 --- /dev/null +++ b/web/src/consts.rs @@ -0,0 +1,2 @@ +pub const SEARCH_RESULTS_PER_PAGE: usize = 20; +pub const USE_GRAPHQL: bool = true; diff --git a/web/src/lib.rs b/web/src/lib.rs index 70f4cae..e9c92f0 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -2,1369 +2,20 @@ // - it's useful when you want to check your code with `cargo make verify` // but some rules are too "annoying" or are not applicable for your case.) #![allow(clippy::wildcard_imports)] -use std::{ - collections::hash_map::DefaultHasher, - hash::{Hash, Hasher}, -}; -use chrono::{DateTime, Datelike, Duration, Local, Utc}; -use graphql_client::GraphQLQuery; -use itertools::Itertools; -use log::{debug, error, info, Level}; -use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet}; -use seed::{prelude::*, *}; -use seed_hooks::{state_access::CloneState, topo, use_state, StateAccessEventHandlers}; -use serde::de::Deserialize; -use thiserror::Error; -use wasm_timer::Instant; - -use crate::graphql::{front_page_query::*, send_graphql, show_thread_query::*}; +use log::Level; +use seed::prelude::*; +mod api; +mod consts; mod graphql; - -const SEARCH_RESULTS_PER_PAGE: usize = 20; -const USE_GRAPHQL: bool = true; - -#[derive(Error, Debug)] -enum UIError { - #[error("No error, this should never be presented to user")] - NoError, - #[error("failed to fetch {0}: {1:?}")] - FetchError(&'static str, FetchError), - #[error("{0} error decoding: {1:?}")] - FetchDecodeError(&'static str, Vec), - #[error("no data or errors for {0}")] - NoData(&'static str), -} - -// ------ ------ -// Init -// ------ ------ - -// `init` describes what should happen when your app started. -fn init(url: Url, orders: &mut impl Orders) -> Model { - if url.hash().is_none() { - orders.request_url(urls::search("is:unread", 0)); - } else { - orders.notify(subs::UrlRequested::new(url)); - }; - orders.subscribe(on_url_changed); - - Model { - context: Context::None, - query: "".to_string(), - refreshing_state: RefreshingState::None, - ui_error: UIError::NoError, - tags: None, - } -} - -fn on_url_changed(uc: subs::UrlChanged) -> Msg { - let mut url = uc.0; - info!( - "url changed '{}', history {}", - url, - history().length().unwrap_or(0) - ); - let hpp = url.remaining_hash_path_parts(); - match hpp.as_slice() { - ["t", tid] => { - if USE_GRAPHQL { - Msg::ShowThreadRequest { - thread_id: tid.to_string(), - } - } else { - Msg::ShowPrettyRequest(tid.to_string()) - } - } - ["s", query] => { - let query = Url::decode_uri_component(query).unwrap_or("".to_string()); - if USE_GRAPHQL { - Msg::FrontPageRequest { - query, - after: None, - before: None, - first: None, - last: None, - } - } else { - Msg::SearchRequest { - query, - page: 0, - results_per_page: SEARCH_RESULTS_PER_PAGE, - } - } - } - ["s", query, page] => { - let query = Url::decode_uri_component(query).unwrap_or("".to_string()); - let page = page[1..].parse().unwrap_or(0); - if USE_GRAPHQL { - Msg::FrontPageRequest { - query, - after: Some(page.to_string()), - before: None, - first: None, - last: None, - } - } else { - Msg::SearchRequest { - query, - page, - results_per_page: SEARCH_RESULTS_PER_PAGE, - } - } - } - p => { - if !p.is_empty() { - info!("Unhandled path '{p:?}'"); - } - if USE_GRAPHQL { - Msg::FrontPageRequest { - query: "".to_string(), - after: None, - before: None, - first: None, - last: None, - } - } else { - Msg::SearchRequest { - query: "".to_string(), - page: 0, - results_per_page: SEARCH_RESULTS_PER_PAGE, - } - } - } - } -} - -mod urls { - use seed::Url; - pub fn search(query: &str, page: usize) -> Url { - let query = Url::encode_uri_component(query); - if page > 0 { - Url::new().set_hash_path(["s", &query, &format!("p{page}")]) - } else { - Url::new().set_hash_path(["s", &query]) - } - } - pub fn thread(tid: &str) -> Url { - Url::new().set_hash_path(["t", tid]) - } -} - -// ------ ------ -// Model -// ------ ------ -enum Context { - None, - Search(shared::SearchResult), - SearchResult { - query: String, - results: Vec, - count: usize, - pager: FrontPageQuerySearchPageInfo, - }, - Thread(ThreadSet), - ThreadResult(ShowThreadQueryThread), -} - -// `Model` describes our app state. -struct Model { - query: String, - context: Context, - refreshing_state: RefreshingState, - ui_error: UIError, - tags: Option>, -} - -struct Tag { - name: String, - bg_color: String, - fg_color: String, - unread: i64, -} - -#[derive(Debug, PartialEq)] -enum RefreshingState { - None, - Loading, - Error(String), -} - -// ------ ------ -// Update -// ------ ------ - -// (Remove the line below once any of your `Msg` variants doesn't implement `Copy`.) -// `Msg` describes the different events you can modify state with. -enum Msg { - Noop, - // Tell the client to refresh its state - Reload, - // Tell the server to update state - RefreshStart, - RefreshDone(Option), - SearchRequest { - query: String, - page: usize, - results_per_page: usize, - }, - SearchResult(fetch::Result), - ShowRequest(String), - ShowResult(fetch::Result), - ShowPrettyRequest(String), - ShowPrettyResult(fetch::Result), - NextPage, - PreviousPage, - UpdateQuery(String), - SearchQuery(String), - - FrontPageRequest { - query: String, - after: Option, - before: Option, - first: Option, - last: Option, - }, - FrontPageResult( - fetch::Result>, - ), - ShowThreadRequest { - thread_id: String, - }, - ShowThreadResult( - fetch::Result>, - ), -} - -// `update` describes how to handle each `Msg`. -fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { - match msg { - Msg::Noop => {} - Msg::RefreshStart => { - model.refreshing_state = RefreshingState::Loading; - orders.perform_cmd(async move { Msg::RefreshDone(refresh_request().await.err()) }); - } - Msg::RefreshDone(err) => { - model.refreshing_state = if let Some(err) = err { - RefreshingState::Error(format!("{:?}", err)) - } else { - RefreshingState::None - }; - orders.perform_cmd(async move { Msg::Reload }); - } - Msg::Reload => { - orders.perform_cmd(async move { on_url_changed(subs::UrlChanged(Url::current())) }); - } - - Msg::SearchRequest { - query, - page, - results_per_page, - } => { - info!("searching for '{query}' pg {page} # / pg {results_per_page}"); - model.query = query.clone(); - orders.skip().perform_cmd(async move { - Msg::SearchResult(search_request(&query, page, results_per_page).await) - }); - } - Msg::SearchResult(Ok(response_data)) => { - debug!("fetch ok {:#?}", response_data); - model.context = Context::Search(response_data); - } - Msg::SearchResult(Err(fetch_error)) => { - error!("fetch failed {:?}", fetch_error); - } - - Msg::ShowRequest(tid) => { - orders - .skip() - .perform_cmd(async move { Msg::ShowResult(show_request(&tid).await) }); - } - Msg::ShowResult(Ok(response_data)) => { - debug!("fetch ok {:#?}", response_data); - model.context = Context::Thread(response_data); - } - Msg::ShowResult(Err(fetch_error)) => { - error!("fetch failed {:?}", fetch_error); - } - - Msg::ShowPrettyRequest(tid) => { - orders - .skip() - .perform_cmd(async move { Msg::ShowPrettyResult(show_pretty_request(&tid).await) }); - } - Msg::ShowPrettyResult(Ok(response_data)) => { - debug!("fetch ok {:#?}", response_data); - model.context = Context::Thread(response_data); - } - Msg::ShowPrettyResult(Err(fetch_error)) => { - error!("fetch failed {:?}", fetch_error); - } - Msg::NextPage => { - match &model.context { - Context::Search(sr) => { - orders.request_url(urls::search(&sr.query, sr.page + 1)); - } - Context::SearchResult { query, pager, .. } => { - let query = query.to_string(); - let after = pager.end_cursor.clone(); - orders.perform_cmd(async move { - Msg::FrontPageRequest { - query, - after, - before: None, - first: Some(SEARCH_RESULTS_PER_PAGE as i64), - last: None, - } - }); - } - Context::Thread(_) => (), // do nothing (yet?) - Context::ThreadResult(_) => (), // do nothing (yet?) - Context::None => (), // do nothing (yet?) - }; - } - Msg::PreviousPage => { - match &model.context { - Context::Search(sr) => { - orders.request_url(urls::search(&sr.query, sr.page.saturating_sub(1))); - } - Context::SearchResult { query, pager, .. } => { - let query = query.to_string(); - let before = pager.start_cursor.clone(); - orders.perform_cmd(async move { - Msg::FrontPageRequest { - query, - after: None, - before, - first: None, - last: Some(SEARCH_RESULTS_PER_PAGE as i64), - } - }); - } - - Context::Thread(_) => (), // do nothing (yet?) - Context::ThreadResult(_) => (), // do nothing (yet?) - Context::None => (), // do nothing (yet?) - }; - } - - Msg::UpdateQuery(query) => model.query = query, - Msg::SearchQuery(query) => { - orders.request_url(urls::search(&query, 0)); - } - - Msg::FrontPageRequest { - query, - after, - before, - first, - last, - } => { - info!("making FrontPageRequest: {query} after:{after:?} before:{before:?} first:{first:?} last:{last:?}"); - model.query = query.clone(); - orders.skip().perform_cmd(async move { - Msg::FrontPageResult( - send_graphql(graphql::FrontPageQuery::build_query( - graphql::front_page_query::Variables { - query, - after, - before, - first, - last, - }, - )) - .await, - ) - }); - } - Msg::FrontPageResult(Err(e)) => error!("error FrontPageResult: {e:?}"), - Msg::FrontPageResult(Ok(graphql_client::Response { - data: None, - errors: None, - .. - })) => { - error!("FrontPageResult no data or errors, should not happen"); - } - Msg::FrontPageResult(Ok(graphql_client::Response { - data: None, - errors: Some(e), - .. - })) => { - error!("FrontPageResult error: {e:?}"); - } - Msg::FrontPageResult(Ok(graphql_client::Response { - data: Some(data), .. - })) => { - model.tags = Some( - data.tags - .into_iter() - .map(|t| Tag { - name: t.name, - bg_color: t.bg_color, - fg_color: t.fg_color, - unread: t.unread, - }) - .collect(), - ); - model.context = Context::SearchResult { - query: model.query.clone(), - results: data.search.nodes, - count: data.count as usize, - pager: data.search.page_info, - }; - } - - Msg::ShowThreadRequest { thread_id } => { - orders.skip().perform_cmd(async move { - Msg::ShowThreadResult( - send_graphql(graphql::ShowThreadQuery::build_query( - graphql::show_thread_query::Variables { thread_id }, - )) - .await, - ) - }); - } - Msg::ShowThreadResult(Ok(graphql_client::Response { - data: Some(data), .. - })) => { - model.tags = Some( - data.tags - .into_iter() - .map(|t| Tag { - name: t.name, - bg_color: t.bg_color, - fg_color: t.fg_color, - unread: t.unread, - }) - .collect(), - ); - model.context = Context::ThreadResult(data.thread); - } - Msg::ShowThreadResult(bad) => { - error!("show_thread_query error: {bad:?}"); - } - } -} - -async fn search_request( - query: &str, - page: usize, - results_per_page: usize, -) -> fetch::Result { - Request::new(api::search(query, page, results_per_page)) - .method(Method::Get) - .fetch() - .await? - .check_status()? - .json() - .await -} - -mod api { - use seed::Url; - - const BASE_URL: &str = "/api"; - pub fn refresh() -> String { - format!("{BASE_URL}/refresh") - } - pub fn search(query: &str, page: usize, results_per_page: usize) -> String { - let query = Url::encode_uri_component(query); - format!("{BASE_URL}/search/{query}?page={page}&results_per_page={results_per_page}") - } - pub fn show(tid: &str) -> String { - format!("{BASE_URL}/show/{tid}") - } - pub fn show_pretty(tid: &str) -> String { - format!("{BASE_URL}/show/{tid}/pretty") - } - pub fn original(message_id: &str) -> String { - format!("{BASE_URL}/original/{message_id}") - } -} - -async fn refresh_request() -> fetch::Result<()> { - let t = Request::new(api::refresh()) - .method(Method::Get) - .fetch() - .await? - .check_status()? - .text() - .await?; - info!("refresh {t}"); - Ok(()) -} - -async fn show_request(tid: &str) -> fetch::Result { - let b = Request::new(api::show(tid)) - .method(Method::Get) - .fetch() - .await? - .check_status()? - .bytes() - .await?; - let mut deserializer = serde_json::Deserializer::from_slice(&b); - deserializer.disable_recursion_limit(); - Ok(ThreadSet::deserialize(&mut deserializer) - .map_err(|_| FetchError::JsonError(fetch::JsonError::Serde(JsValue::NULL)))?) -} - -async fn show_pretty_request(tid: &str) -> fetch::Result { - Request::new(api::show_pretty(tid)) - .method(Method::Get) - .fetch() - .await? - .check_status()? - .json() - .await -} +mod state; +mod view; // ------ ------ // View // ------ ------ -// -// -// -// -// -// -// -// -// -// -// -// -// -fn view_message(thread: &ThreadNode) -> Node { - let message = thread.0.as_ref().expect("ThreadNode missing Message"); - let children = &thread.1; - div![ - C!["message"], - /* TODO(wathiede): collect all the tags and show them here. */ - /* TODO(wathiede): collect all the attachments from all the subparts */ - div![C!["header"], "From: ", &message.headers.from], - div![C!["header"], "Date: ", &message.headers.date], - div![C!["header"], "To: ", &message.headers.to], - div![ - C!["body"], - match &message.body { - Some(body) => view_body(body.as_slice()), - None => div![""], - }, - ], - children.iter().map(view_message) - ] -} - -fn view_body(body: &[Part]) -> Node { - div![body.iter().map(view_part)] -} - -fn view_text_plain(content: &Option) -> Node { - match &content { - Some(Content::String(content)) => p![C!["view-part-text-plain"], content], - _ => div![ - C!["error"], - format!("Unhandled content enum for text/plain"), - ], - } -} - -fn view_part(part: &Part) -> Node { - match part.content_type.as_str() { - "text/plain" => view_text_plain(&part.content), - "text/html" => { - if let Some(Content::String(html)) = &part.content { - let inliner = css_inline::CSSInliner::options() - .load_remote_stylesheets(false) - .remove_style_tags(true) - .build(); - let inlined = inliner.inline(html).expect("failed to inline CSS"); - - return div![C!["view-part-text-html"], raw![&inlined]]; - } else { - div![ - C!["error"], - format!("Unhandled content enum for multipart/mixed"), - ] - } - } - - // https://en.wikipedia.org/wiki/MIME#alternative - // RFC1341 states: In general, user agents that compose multipart/alternative entities - // should place the body parts in increasing order of preference, that is, with the - // preferred format last. - "multipart/alternative" => { - if let Some(Content::Multipart(parts)) = &part.content { - for part in parts.iter().rev() { - if part.content_type == "text/html" { - if let Some(Content::String(html)) = &part.content { - let inliner = css_inline::CSSInliner::options() - .load_remote_stylesheets(false) - .remove_style_tags(true) - .build(); - let inlined = inliner.inline(html).expect("failed to inline CSS"); - return div![Node::from_html(None, &inlined)]; - } - } - if part.content_type == "text/plain" { - return view_text_plain(&part.content); - } - } - div!["No known multipart/alternative parts"] - } else { - div![ - C!["error"], - format!("multipart/alternative with non-multipart content"), - ] - } - } - "multipart/mixed" => match &part.content { - Some(Content::Multipart(parts)) => div![parts.iter().map(view_part)], - _ => div![ - C!["error"], - format!("Unhandled content enum for multipart/mixed"), - ], - }, - _ => div![ - C!["error"], - format!("Unhandled content type: {}", part.content_type) - ], - } -} - -fn first_subject(thread: &ThreadNode) -> Option { - if let Some(msg) = &thread.0 { - return Some(msg.headers.subject.clone()); - } else { - for tn in &thread.1 { - if let Some(s) = first_subject(&tn) { - return Some(s); - } - } - } - None -} - -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_mobile_search_results( - query: &str, - results: &[FrontPageQuerySearchNodes], - count: usize, - pager: &FrontPageQuerySearchPageInfo, -) -> Node { - 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); - a![ - C!["has-text-light"], - attrs! { - At::Href => urls::thread(&tid) - }, - div![ - C!["row"], - div![C!["subject"], &r.subject], - span![C!["from", "is-size-7"], pretty_authors(&r.authors)], - div![ - span![C!["is-size-7"], tags_chiclet(&r.tags, true)], - span![C!["is-size-7", "float-right", "date"], datetime] - ] - ] - ] - }); - div![ - C!["search-results"], - view_search_pager(count, pager), - rows, - view_search_pager(count, pager), - ] -} - -fn view_mobile_search_results_legacy( - query: &str, - search_results: &shared::SearchResult, -) -> Node { - if query.is_empty() { - set_title("all mail"); - } else { - set_title(query); - } - let summaries = &search_results.summary.0; - let rows = summaries.iter().map(|r| { - /* - let tid = r.thread.clone(); - tr![ - td![ - C!["from"], - pretty_authors(&r.authors), - IF!(r.total>1 => small![" ", r.total.to_string()]), - ], - td![C!["subject"], tags_chiclet(&r.tags), " ", &r.subject], - td![C!["date"], &r.date_relative], - ev(Ev::Click, move |_| Msg::ShowPrettyRequest(tid)), - ] - */ - let tid = r.thread.clone(); - let datetime = human_age(r.timestamp as i64); - a![ - C!["has-text-light"], - attrs! { - At::Href => urls::thread(&tid) - }, - div![ - C!["row"], - div![C!["subject"], &r.subject], - span![C!["from", "is-size-7"], pretty_authors(&r.authors)], - div![ - span![C!["is-size-7"], tags_chiclet(&r.tags, true)], - span![C!["is-size-7", "float-right", "date"], datetime] - ] - ] - ] - }); - let first = search_results.page * search_results.results_per_page; - div![ - C!["search-results"], - view_search_pager_legacy(first, summaries.len(), search_results.total), - rows, - view_search_pager_legacy(first, summaries.len(), search_results.total) - ] -} - -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_results_legacy(query: &str, search_results: &shared::SearchResult) -> Node { - if query.is_empty() { - set_title("all mail"); - } else { - set_title(query); - } - let summaries = &search_results.summary.0; - let rows = summaries.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] - ] - }); - let first = search_results.page * search_results.results_per_page; - div![ - view_search_pager_legacy(first, summaries.len(), search_results.total), - 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_legacy(first, summaries.len(), search_results.total) - ] -} - -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 - )], - ], - ] -} - -fn view_search_pager_legacy(start: usize, count: usize, total: usize) -> Node { - let is_first = start <= 0; - let is_last = (start + SEARCH_RESULTS_PER_PAGE) >= total; - nav![ - C!["pagination"], - a![ - C!["pagination-previous", "button",], - IF!(is_first => attrs!{ At::Disabled=>true }), - "<", - ev(Ev::Click, |_| Msg::PreviousPage) - ], - a![ - C!["pagination-next", "button", IF!(is_last => "is-static")], - IF!(is_last => attrs!{ At::Disabled=>true }), - ">", - ev(Ev::Click, |_| Msg::NextPage) - ], - ul![ - C!["pagination-list"], - li![format!("{} - {} of {}", start, start + count, total)], - ], - ] -} - -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_thread_legacy(thread_set: &ThreadSet) -> Node { - assert_eq!(thread_set.0.len(), 1); - let thread = &thread_set.0[0]; - assert_eq!(thread.0.len(), 1); - let thread_node = &thread.0[0]; - let subject = first_subject(&thread_node).unwrap_or("".to_string()); - set_title(&subject); - div![ - C!["container"], - h1![C!["title"], subject], - view_message(&thread_node), - 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) - ] - ] -} - -#[topo::nested] -fn view_desktop(model: &Model) -> Node { - // Do two queries, one without `unread` so it loads fast, then a second with unread. - let content = match &model.context { - Context::None => div![h1!["Loading"]], - Context::Thread(thread_set) => view_thread_legacy(thread_set), - Context::ThreadResult(thread) => view_thread(thread), - Context::Search(search_results) => view_search_results_legacy(&model.query, search_results), - Context::SearchResult { - query, - results, - count, - pager, - } => view_search_results(&query, results.as_slice(), *count, pager), - }; - fn view_tag_li(display_name: &str, indent: usize, t: &Tag) -> Node { - li![a![ - attrs! { - At::Href => urls::search(&format!("tag:{}", t.name), 0) - }, - (0..indent).map(|_| span![C!["tag-indent"], ""]), - i![ - C!["tag-tag", "fa-solid", "fa-tag"], - style! { - //"--fa-primary-color" => t.fg_color, - St::Color => t.bg_color, - }, - ], - display_name, - IF!(t.unread>0 => format!(" ({})", t.unread)) - ]] - } - fn matches(a: &[&str], b: &[&str]) -> usize { - std::iter::zip(a.iter(), b.iter()) - .take_while(|(a, b)| a == b) - .count() - } - fn view_tag_list<'a>(tags: impl Iterator) -> Vec> { - let mut lis = Vec::new(); - let mut last = Vec::new(); - for t in tags { - let parts: Vec<_> = t.name.split('/').collect(); - let mut n = matches(&last, &parts); - if t.name.starts_with("ZZCrap/Free") { - info!("n: {n}, parts: {parts:?} last: {last:?}"); - } - if n <= parts.len() - 2 && parts.len() > 1 { - // Synthesize fake tags for proper indenting. - for i in n..parts.len() - 1 { - let display_name = parts[n]; - lis.push(view_tag_li( - &display_name, - n, - &Tag { - name: parts[..i + 1].join("/"), - bg_color: "#fff".to_string(), - fg_color: "#000".to_string(), - unread: 0, - }, - )); - } - last = parts[..parts.len() - 1].to_vec(); - n = parts.len() - 1; - } - let display_name = parts[n]; - lis.push(view_tag_li(&display_name, n, t)); - last = parts; - } - lis - } - let unread = model - .tags - .as_ref() - .map(|tags| tags.iter().filter(|t| t.unread > 0).collect()) - .unwrap_or(Vec::new()); - let tags_open = use_state(|| false); - let force_tags_open = unread.is_empty(); - div![ - C!["desktop-main-content"], - aside![ - C!["tags-menu", "menu"], - IF!(!unread.is_empty() => p![C!["menu-label"], "Unread"]), - IF!(!unread.is_empty() => ul![C!["menu-list"], view_tag_list(unread.into_iter())]), - p![ - C!["menu-label"], - IF!(!force_tags_open => - i![C![ - "fa-solid", - if tags_open.get() { - "fa-angle-up" - } else { - "fa-angle-down" - } - ]]), - " Tags", - ev(Ev::Click, move |_| { - tags_open.set(!tags_open.get()); - }) - ], - ul![ - C!["menu-list"], - IF!(force_tags_open||tags_open.get() => model.tags.as_ref().map(|tags| view_tag_list(tags.iter()))), - ] - ], - div![ - view_header(&model.query, &model.refreshing_state), - content, - view_header(&model.query, &model.refreshing_state), - ] - ] -} - -fn view_mobile(model: &Model) -> Node { - let content = match &model.context { - Context::None => div![h1!["Loading"]], - Context::Thread(thread_set) => view_thread_legacy(thread_set), - Context::ThreadResult(thread) => view_thread(thread), - Context::Search(search_results) => { - view_mobile_search_results_legacy(&model.query, search_results) - } - Context::SearchResult { - query, - results, - count, - pager, - } => view_mobile_search_results(&query, results.as_slice(), *count, pager), - }; - div![ - view_header(&model.query, &model.refreshing_state), - content, - view_header(&model.query, &model.refreshing_state), - ] -} - -// `view` describes what to display. -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 { - view_mobile(model) - } else { - view_desktop(model) - }, - view_footer(start.elapsed().as_millis()) - ] -} - // ------ ------ // Start // ------ ------ @@ -1380,5 +31,5 @@ pub fn start() { let lvl = Level::Info; console_log::init_with_level(lvl).expect("failed to initialize console logging"); // Mount the `app` to the element with the `id` "app". - App::start("app", init, update, view); + App::start("app", state::init, state::update, view::view); } diff --git a/web/src/model.rs b/web/src/model.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/src/model.rs @@ -0,0 +1 @@ + diff --git a/web/src/state.rs b/web/src/state.rs new file mode 100644 index 0000000..e8fb156 --- /dev/null +++ b/web/src/state.rs @@ -0,0 +1,406 @@ +use graphql_client::GraphQLQuery; +use log::{debug, info}; +use notmuch::ThreadSet; +use seed::{prelude::*, *}; +use thiserror::Error; + +use crate::{ + api, + api::urls, + consts::{SEARCH_RESULTS_PER_PAGE, USE_GRAPHQL}, + graphql, + graphql::{front_page_query::*, send_graphql, show_thread_query::*}, +}; + +// `init` describes what should happen when your app started. +pub fn init(url: Url, orders: &mut impl Orders) -> Model { + if url.hash().is_none() { + orders.request_url(urls::search("is:unread", 0)); + } else { + orders.notify(subs::UrlRequested::new(url)); + }; + orders.subscribe(on_url_changed); + + Model { + context: Context::None, + query: "".to_string(), + refreshing_state: RefreshingState::None, + ui_error: UIError::NoError, + tags: None, + } +} + +fn on_url_changed(uc: subs::UrlChanged) -> Msg { + let mut url = uc.0; + info!( + "url changed '{}', history {}", + url, + history().length().unwrap_or(0) + ); + let hpp = url.remaining_hash_path_parts(); + match hpp.as_slice() { + ["t", tid] => { + if USE_GRAPHQL { + Msg::ShowThreadRequest { + thread_id: tid.to_string(), + } + } else { + Msg::ShowPrettyRequest(tid.to_string()) + } + } + ["s", query] => { + let query = Url::decode_uri_component(query).unwrap_or("".to_string()); + if USE_GRAPHQL { + Msg::FrontPageRequest { + query, + after: None, + before: None, + first: None, + last: None, + } + } else { + Msg::SearchRequest { + query, + page: 0, + results_per_page: SEARCH_RESULTS_PER_PAGE, + } + } + } + ["s", query, page] => { + let query = Url::decode_uri_component(query).unwrap_or("".to_string()); + let page = page[1..].parse().unwrap_or(0); + if USE_GRAPHQL { + Msg::FrontPageRequest { + query, + after: Some(page.to_string()), + before: None, + first: None, + last: None, + } + } else { + Msg::SearchRequest { + query, + page, + results_per_page: SEARCH_RESULTS_PER_PAGE, + } + } + } + p => { + if !p.is_empty() { + info!("Unhandled path '{p:?}'"); + } + if USE_GRAPHQL { + Msg::FrontPageRequest { + query: "".to_string(), + after: None, + before: None, + first: None, + last: None, + } + } else { + Msg::SearchRequest { + query: "".to_string(), + page: 0, + results_per_page: SEARCH_RESULTS_PER_PAGE, + } + } + } + } +} + +// `update` describes how to handle each `Msg`. +pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { + match msg { + Msg::Noop => {} + Msg::RefreshStart => { + model.refreshing_state = RefreshingState::Loading; + orders.perform_cmd(async move { Msg::RefreshDone(api::refresh_request().await.err()) }); + } + Msg::RefreshDone(err) => { + model.refreshing_state = if let Some(err) = err { + RefreshingState::Error(format!("{:?}", err)) + } else { + RefreshingState::None + }; + orders.perform_cmd(async move { Msg::Reload }); + } + Msg::Reload => { + orders.perform_cmd(async move { on_url_changed(subs::UrlChanged(Url::current())) }); + } + + Msg::SearchRequest { + query, + page, + results_per_page, + } => { + info!("searching for '{query}' pg {page} # / pg {results_per_page}"); + model.query = query.clone(); + orders.skip().perform_cmd(async move { + Msg::SearchResult(api::search_request(&query, page, results_per_page).await) + }); + } + Msg::SearchResult(Ok(response_data)) => { + debug!("fetch ok {:#?}", response_data); + model.context = Context::Search(response_data); + } + Msg::SearchResult(Err(fetch_error)) => { + error!("fetch failed {:?}", fetch_error); + } + + Msg::ShowRequest(tid) => { + orders + .skip() + .perform_cmd(async move { Msg::ShowResult(api::show_request(&tid).await) }); + } + Msg::ShowResult(Ok(response_data)) => { + debug!("fetch ok {:#?}", response_data); + model.context = Context::Thread(response_data); + } + Msg::ShowResult(Err(fetch_error)) => { + error!("fetch failed {:?}", fetch_error); + } + + Msg::ShowPrettyRequest(tid) => { + orders.skip().perform_cmd(async move { + Msg::ShowPrettyResult(api::show_pretty_request(&tid).await) + }); + } + Msg::ShowPrettyResult(Ok(response_data)) => { + debug!("fetch ok {:#?}", response_data); + model.context = Context::Thread(response_data); + } + Msg::ShowPrettyResult(Err(fetch_error)) => { + error!("fetch failed {:?}", fetch_error); + } + Msg::NextPage => { + match &model.context { + Context::Search(sr) => { + orders.request_url(urls::search(&sr.query, sr.page + 1)); + } + Context::SearchResult { query, pager, .. } => { + let query = query.to_string(); + let after = pager.end_cursor.clone(); + orders.perform_cmd(async move { + Msg::FrontPageRequest { + query, + after, + before: None, + first: Some(SEARCH_RESULTS_PER_PAGE as i64), + last: None, + } + }); + } + Context::Thread(_) => (), // do nothing (yet?) + Context::ThreadResult(_) => (), // do nothing (yet?) + Context::None => (), // do nothing (yet?) + }; + } + Msg::PreviousPage => { + match &model.context { + Context::Search(sr) => { + orders.request_url(urls::search(&sr.query, sr.page.saturating_sub(1))); + } + Context::SearchResult { query, pager, .. } => { + let query = query.to_string(); + let before = pager.start_cursor.clone(); + orders.perform_cmd(async move { + Msg::FrontPageRequest { + query, + after: None, + before, + first: None, + last: Some(SEARCH_RESULTS_PER_PAGE as i64), + } + }); + } + + Context::Thread(_) => (), // do nothing (yet?) + Context::ThreadResult(_) => (), // do nothing (yet?) + Context::None => (), // do nothing (yet?) + }; + } + + Msg::UpdateQuery(query) => model.query = query, + Msg::SearchQuery(query) => { + orders.request_url(urls::search(&query, 0)); + } + + Msg::FrontPageRequest { + query, + after, + before, + first, + last, + } => { + info!("making FrontPageRequest: {query} after:{after:?} before:{before:?} first:{first:?} last:{last:?}"); + model.query = query.clone(); + orders.skip().perform_cmd(async move { + Msg::FrontPageResult( + send_graphql(graphql::FrontPageQuery::build_query( + graphql::front_page_query::Variables { + query, + after, + before, + first, + last, + }, + )) + .await, + ) + }); + } + Msg::FrontPageResult(Err(e)) => error!("error FrontPageResult: {e:?}"), + Msg::FrontPageResult(Ok(graphql_client::Response { + data: None, + errors: None, + .. + })) => { + error!("FrontPageResult no data or errors, should not happen"); + } + Msg::FrontPageResult(Ok(graphql_client::Response { + data: None, + errors: Some(e), + .. + })) => { + error!("FrontPageResult error: {e:?}"); + } + Msg::FrontPageResult(Ok(graphql_client::Response { + data: Some(data), .. + })) => { + model.tags = Some( + data.tags + .into_iter() + .map(|t| Tag { + name: t.name, + bg_color: t.bg_color, + fg_color: t.fg_color, + unread: t.unread, + }) + .collect(), + ); + model.context = Context::SearchResult { + query: model.query.clone(), + results: data.search.nodes, + count: data.count as usize, + pager: data.search.page_info, + }; + } + + Msg::ShowThreadRequest { thread_id } => { + orders.skip().perform_cmd(async move { + Msg::ShowThreadResult( + send_graphql(graphql::ShowThreadQuery::build_query( + graphql::show_thread_query::Variables { thread_id }, + )) + .await, + ) + }); + } + Msg::ShowThreadResult(Ok(graphql_client::Response { + data: Some(data), .. + })) => { + model.tags = Some( + data.tags + .into_iter() + .map(|t| Tag { + name: t.name, + bg_color: t.bg_color, + fg_color: t.fg_color, + unread: t.unread, + }) + .collect(), + ); + model.context = Context::ThreadResult(data.thread); + } + Msg::ShowThreadResult(bad) => { + error!("show_thread_query error: {bad:?}"); + } + } +} +// `Model` describes our app state. +pub struct Model { + pub query: String, + pub context: Context, + pub refreshing_state: RefreshingState, + pub ui_error: UIError, + pub tags: Option>, +} + +#[derive(Error, Debug)] +pub enum UIError { + #[error("No error, this should never be presented to user")] + NoError, + #[error("failed to fetch {0}: {1:?}")] + FetchError(&'static str, FetchError), + #[error("{0} error decoding: {1:?}")] + FetchDecodeError(&'static str, Vec), + #[error("no data or errors for {0}")] + NoData(&'static str), +} + +pub enum Context { + None, + Search(shared::SearchResult), + SearchResult { + query: String, + results: Vec, + count: usize, + pager: FrontPageQuerySearchPageInfo, + }, + Thread(ThreadSet), + ThreadResult(ShowThreadQueryThread), +} + +pub struct Tag { + pub name: String, + pub bg_color: String, + pub fg_color: String, + pub unread: i64, +} + +#[derive(Debug, PartialEq)] +pub enum RefreshingState { + None, + Loading, + Error(String), +} +// `Msg` describes the different events you can modify state with. +pub enum Msg { + Noop, + // Tell the client to refresh its state + Reload, + // Tell the server to update state + RefreshStart, + RefreshDone(Option), + SearchRequest { + query: String, + page: usize, + results_per_page: usize, + }, + SearchResult(fetch::Result), + ShowRequest(String), + ShowResult(fetch::Result), + ShowPrettyRequest(String), + ShowPrettyResult(fetch::Result), + NextPage, + PreviousPage, + UpdateQuery(String), + SearchQuery(String), + + FrontPageRequest { + query: String, + after: Option, + before: Option, + first: Option, + last: Option, + }, + FrontPageResult( + fetch::Result>, + ), + ShowThreadRequest { + thread_id: String, + }, + ShowThreadResult( + fetch::Result>, + ), +} diff --git a/web/src/view.rs b/web/src/view.rs new file mode 100644 index 0000000..fa21ac0 --- /dev/null +++ b/web/src/view.rs @@ -0,0 +1,848 @@ +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; + +use chrono::{DateTime, Datelike, Duration, Local, Utc}; +use itertools::Itertools; +use log::info; +use notmuch::{Content, Part, ThreadNode, ThreadSet}; +use seed::{prelude::*, *}; +use seed_hooks::{state_access::CloneState, topo, use_state}; +use wasm_timer::Instant; + +use crate::{ + api, + api::urls, + consts::{SEARCH_RESULTS_PER_PAGE, USE_GRAPHQL}, + graphql::{front_page_query::*, show_thread_query::*}, + state::{Context, Model, Msg, RefreshingState, Tag}, +}; + +fn view_message(thread: &ThreadNode) -> Node { + let message = thread.0.as_ref().expect("ThreadNode missing Message"); + let children = &thread.1; + div![ + C!["message"], + /* TODO(wathiede): collect all the tags and show them here. */ + /* TODO(wathiede): collect all the attachments from all the subparts */ + div![C!["header"], "From: ", &message.headers.from], + div![C!["header"], "Date: ", &message.headers.date], + div![C!["header"], "To: ", &message.headers.to], + div![ + C!["body"], + match &message.body { + Some(body) => view_body(body.as_slice()), + None => div![""], + }, + ], + children.iter().map(view_message) + ] +} + +fn view_body(body: &[Part]) -> Node { + div![body.iter().map(view_part)] +} + +fn view_text_plain(content: &Option) -> Node { + match &content { + Some(Content::String(content)) => p![C!["view-part-text-plain"], content], + _ => div![ + C!["error"], + format!("Unhandled content enum for text/plain"), + ], + } +} + +fn view_part(part: &Part) -> Node { + match part.content_type.as_str() { + "text/plain" => view_text_plain(&part.content), + "text/html" => { + if let Some(Content::String(html)) = &part.content { + let inliner = css_inline::CSSInliner::options() + .load_remote_stylesheets(false) + .remove_style_tags(true) + .build(); + let inlined = inliner.inline(html).expect("failed to inline CSS"); + + return div![C!["view-part-text-html"], raw![&inlined]]; + } else { + div![ + C!["error"], + format!("Unhandled content enum for multipart/mixed"), + ] + } + } + + // https://en.wikipedia.org/wiki/MIME#alternative + // RFC1341 states: In general, user agents that compose multipart/alternative entities + // should place the body parts in increasing order of preference, that is, with the + // preferred format last. + "multipart/alternative" => { + if let Some(Content::Multipart(parts)) = &part.content { + for part in parts.iter().rev() { + if part.content_type == "text/html" { + if let Some(Content::String(html)) = &part.content { + let inliner = css_inline::CSSInliner::options() + .load_remote_stylesheets(false) + .remove_style_tags(true) + .build(); + let inlined = inliner.inline(html).expect("failed to inline CSS"); + return div![Node::from_html(None, &inlined)]; + } + } + if part.content_type == "text/plain" { + return view_text_plain(&part.content); + } + } + div!["No known multipart/alternative parts"] + } else { + div![ + C!["error"], + format!("multipart/alternative with non-multipart content"), + ] + } + } + "multipart/mixed" => match &part.content { + Some(Content::Multipart(parts)) => div![parts.iter().map(view_part)], + _ => div![ + C!["error"], + format!("Unhandled content enum for multipart/mixed"), + ], + }, + _ => div![ + C!["error"], + format!("Unhandled content type: {}", part.content_type) + ], + } +} + +fn first_subject(thread: &ThreadNode) -> Option { + if let Some(msg) = &thread.0 { + return Some(msg.headers.subject.clone()); + } else { + for tn in &thread.1 { + if let Some(s) = first_subject(&tn) { + return Some(s); + } + } + } + None +} +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_mobile_search_results( + query: &str, + results: &[FrontPageQuerySearchNodes], + count: usize, + pager: &FrontPageQuerySearchPageInfo, +) -> Node { + 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); + a![ + C!["has-text-light"], + attrs! { + At::Href => urls::thread(&tid) + }, + div![ + C!["row"], + div![C!["subject"], &r.subject], + span![C!["from", "is-size-7"], pretty_authors(&r.authors)], + div![ + span![C!["is-size-7"], tags_chiclet(&r.tags, true)], + span![C!["is-size-7", "float-right", "date"], datetime] + ] + ] + ] + }); + div![ + C!["search-results"], + view_search_pager(count, pager), + rows, + view_search_pager(count, pager), + ] +} + +fn view_mobile_search_results_legacy( + query: &str, + search_results: &shared::SearchResult, +) -> Node { + if query.is_empty() { + set_title("all mail"); + } else { + set_title(query); + } + let summaries = &search_results.summary.0; + let rows = summaries.iter().map(|r| { + /* + let tid = r.thread.clone(); + tr![ + td![ + C!["from"], + pretty_authors(&r.authors), + IF!(r.total>1 => small![" ", r.total.to_string()]), + ], + td![C!["subject"], tags_chiclet(&r.tags), " ", &r.subject], + td![C!["date"], &r.date_relative], + ev(Ev::Click, move |_| Msg::ShowPrettyRequest(tid)), + ] + */ + let tid = r.thread.clone(); + let datetime = human_age(r.timestamp as i64); + a![ + C!["has-text-light"], + attrs! { + At::Href => urls::thread(&tid) + }, + div![ + C!["row"], + div![C!["subject"], &r.subject], + span![C!["from", "is-size-7"], pretty_authors(&r.authors)], + div![ + span![C!["is-size-7"], tags_chiclet(&r.tags, true)], + span![C!["is-size-7", "float-right", "date"], datetime] + ] + ] + ] + }); + let first = search_results.page * search_results.results_per_page; + div![ + C!["search-results"], + view_search_pager_legacy(first, summaries.len(), search_results.total), + rows, + view_search_pager_legacy(first, summaries.len(), search_results.total) + ] +} + +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_results_legacy(query: &str, search_results: &shared::SearchResult) -> Node { + if query.is_empty() { + set_title("all mail"); + } else { + set_title(query); + } + let summaries = &search_results.summary.0; + let rows = summaries.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] + ] + }); + let first = search_results.page * search_results.results_per_page; + div![ + view_search_pager_legacy(first, summaries.len(), search_results.total), + 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_legacy(first, summaries.len(), search_results.total) + ] +} + +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 + )], + ], + ] +} + +fn view_search_pager_legacy(start: usize, count: usize, total: usize) -> Node { + let is_first = start <= 0; + let is_last = (start + SEARCH_RESULTS_PER_PAGE) >= total; + nav![ + C!["pagination"], + a![ + C!["pagination-previous", "button",], + IF!(is_first => attrs!{ At::Disabled=>true }), + "<", + ev(Ev::Click, |_| Msg::PreviousPage) + ], + a![ + C!["pagination-next", "button", IF!(is_last => "is-static")], + IF!(is_last => attrs!{ At::Disabled=>true }), + ">", + ev(Ev::Click, |_| Msg::NextPage) + ], + ul![ + C!["pagination-list"], + li![format!("{} - {} of {}", start, start + count, total)], + ], + ] +} + +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_thread_legacy(thread_set: &ThreadSet) -> Node { + assert_eq!(thread_set.0.len(), 1); + let thread = &thread_set.0[0]; + assert_eq!(thread.0.len(), 1); + let thread_node = &thread.0[0]; + let subject = first_subject(&thread_node).unwrap_or("".to_string()); + set_title(&subject); + div![ + C!["container"], + h1![C!["title"], subject], + view_message(&thread_node), + 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) + ] + ] +} + +#[topo::nested] +fn view_desktop(model: &Model) -> Node { + // Do two queries, one without `unread` so it loads fast, then a second with unread. + let content = match &model.context { + Context::None => div![h1!["Loading"]], + Context::Thread(thread_set) => view_thread_legacy(thread_set), + Context::ThreadResult(thread) => view_thread(thread), + Context::Search(search_results) => view_search_results_legacy(&model.query, search_results), + Context::SearchResult { + query, + results, + count, + pager, + } => view_search_results(&query, results.as_slice(), *count, pager), + }; + fn view_tag_li(display_name: &str, indent: usize, t: &Tag) -> Node { + li![a![ + attrs! { + At::Href => urls::search(&format!("tag:{}", t.name), 0) + }, + (0..indent).map(|_| span![C!["tag-indent"], ""]), + i![ + C!["tag-tag", "fa-solid", "fa-tag"], + style! { + //"--fa-primary-color" => t.fg_color, + St::Color => t.bg_color, + }, + ], + display_name, + IF!(t.unread>0 => format!(" ({})", t.unread)) + ]] + } + fn matches(a: &[&str], b: &[&str]) -> usize { + std::iter::zip(a.iter(), b.iter()) + .take_while(|(a, b)| a == b) + .count() + } + fn view_tag_list<'a>(tags: impl Iterator) -> Vec> { + let mut lis = Vec::new(); + let mut last = Vec::new(); + for t in tags { + let parts: Vec<_> = t.name.split('/').collect(); + let mut n = matches(&last, &parts); + if t.name.starts_with("ZZCrap/Free") { + info!("n: {n}, parts: {parts:?} last: {last:?}"); + } + if n <= parts.len() - 2 && parts.len() > 1 { + // Synthesize fake tags for proper indenting. + for i in n..parts.len() - 1 { + let display_name = parts[n]; + lis.push(view_tag_li( + &display_name, + n, + &Tag { + name: parts[..i + 1].join("/"), + bg_color: "#fff".to_string(), + fg_color: "#000".to_string(), + unread: 0, + }, + )); + } + last = parts[..parts.len() - 1].to_vec(); + n = parts.len() - 1; + } + let display_name = parts[n]; + lis.push(view_tag_li(&display_name, n, t)); + last = parts; + } + lis + } + let unread = model + .tags + .as_ref() + .map(|tags| tags.iter().filter(|t| t.unread > 0).collect()) + .unwrap_or(Vec::new()); + let tags_open = use_state(|| false); + let force_tags_open = unread.is_empty(); + div![ + C!["desktop-main-content"], + aside![ + C!["tags-menu", "menu"], + IF!(!unread.is_empty() => p![C!["menu-label"], "Unread"]), + IF!(!unread.is_empty() => ul![C!["menu-list"], view_tag_list(unread.into_iter())]), + p![ + C!["menu-label"], + IF!(!force_tags_open => + i![C![ + "fa-solid", + if tags_open.get() { + "fa-angle-up" + } else { + "fa-angle-down" + } + ]]), + " Tags", + ev(Ev::Click, move |_| { + tags_open.set(!tags_open.get()); + }) + ], + ul![ + C!["menu-list"], + IF!(force_tags_open||tags_open.get() => model.tags.as_ref().map(|tags| view_tag_list(tags.iter()))), + ] + ], + div![ + view_header(&model.query, &model.refreshing_state), + content, + view_header(&model.query, &model.refreshing_state), + ] + ] +} + +fn view_mobile(model: &Model) -> Node { + let content = match &model.context { + Context::None => div![h1!["Loading"]], + Context::Thread(thread_set) => view_thread_legacy(thread_set), + Context::ThreadResult(thread) => view_thread(thread), + Context::Search(search_results) => { + view_mobile_search_results_legacy(&model.query, search_results) + } + Context::SearchResult { + query, + results, + count, + pager, + } => view_mobile_search_results(&query, results.as_slice(), *count, pager), + }; + div![ + view_header(&model.query, &model.refreshing_state), + content, + view_header(&model.query, &model.refreshing_state), + ] +} + +// `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 { + view_mobile(model) + } else { + view_desktop(model) + }, + view_footer(start.elapsed().as_millis()) + ] +}