diff --git a/shared/src/lib.rs b/shared/src/lib.rs index d3a12e7..448f98b 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -9,3 +9,11 @@ pub struct SearchResult { pub results_per_page: usize, pub total: usize, } + +#[derive(Serialize, Deserialize, Debug)] +pub struct ShowResult { + messages: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum Message {} diff --git a/web/src/api.rs b/web/src/api.rs new file mode 100644 index 0000000..d328c30 --- /dev/null +++ b/web/src/api.rs @@ -0,0 +1,19 @@ +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}") +} diff --git a/web/src/lib.rs b/web/src/lib.rs index 7a82aaa..606a1bc 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,7 +1,6 @@ -// (Lines like the one below ignore selected Clippy rules -// - 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)] +mod api; +mod nm; + use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, @@ -9,9 +8,9 @@ use std::{ use itertools::Itertools; use log::{debug, error, info, Level}; +use nm::view_thread; use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet}; use seed::{prelude::*, *}; -use serde::de::Deserialize; use wasm_timer::Instant; const SEARCH_RESULTS_PER_PAGE: usize = 20; @@ -117,7 +116,7 @@ enum RefreshingState { // (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 { +pub enum Msg { Noop, RefreshStart, RefreshDone(Option), @@ -127,8 +126,6 @@ enum Msg { results_per_page: usize, }, SearchResult(fetch::Result), - ShowRequest(String), - ShowResult(fetch::Result), ShowPrettyRequest(String), ShowPrettyResult(fetch::Result), NextPage, @@ -147,6 +144,16 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { model.refreshing_state = if let Some(err) = err { RefreshingState::Error(format!("{:?}", err)) } else { + // If looking at search page, refresh the search to view update on the server side. + if let Context::Search(sr) = &model.context { + let query = sr.query.clone(); + let page = sr.page; + let results_per_page = sr.results_per_page; + orders.perform_cmd(async move { + Msg::SearchResult(search_request(&query, page, results_per_page).await) + }); + } + RefreshingState::None }; } @@ -170,19 +177,6 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { 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() @@ -230,28 +224,6 @@ async fn search_request( .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) @@ -264,20 +236,6 @@ async fn refresh_request() -> fetch::Result<()> { 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) @@ -292,131 +250,6 @@ async fn show_pretty_request(tid: &str) -> fetch::Result { // 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], - hr![], - 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"], div!["TEST"], 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)); } @@ -598,55 +431,6 @@ fn view_search_pager(start: usize, count: usize, total: usize) -> Node { ] } -fn view_thread(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![ - h1![subject], - a![ - attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)}, - "Original" - ], - view_message(&thread_node), - div![ - C!["debug"], - "Add zippy for debug dump", - view_debug_thread_set(thread_set) - ] /* pre![format!("Thread: {:#?}", thread_set).replace(" ", " ")] */ - ] -} - -fn view_debug_thread_set(thread_set: &ThreadSet) -> Node { - ul![thread_set - .0 - .iter() - .enumerate() - .map(|(i, t)| { li!["t", i, ": ", view_debug_thread(t),] })] -} -fn view_debug_thread(thread: &Thread) -> Node { - ul![thread - .0 - .iter() - .enumerate() - .map(|(i, tn)| { li!["tn", i, ": ", view_debug_thread_node(tn),] })] -} - -fn view_debug_thread_node(thread_node: &ThreadNode) -> Node { - ul![ - IF!(thread_node.0.is_some()=>li!["tn id:", &thread_node.0.as_ref().unwrap().id]), - thread_node.1.iter().enumerate().map(|(i, tn)| li![ - "tn", - i, - ": ", - view_debug_thread_node(tn) - ]) - ] -} - 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 { diff --git a/web/src/nm.rs b/web/src/nm.rs new file mode 100644 index 0000000..773c2dc --- /dev/null +++ b/web/src/nm.rs @@ -0,0 +1,193 @@ +use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet}; +use seed::{prelude::*, *}; +use serde::de::Deserialize; + +use crate::{api, set_title, Msg}; + +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)))?) +} + +pub fn view_thread(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![ + h1![subject], + a![ + attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)}, + "Original" + ], + view_message(&thread_node), + div![ + C!["debug"], + "Add zippy for debug dump", + view_debug_thread_set(thread_set) + ] /* pre![format!("Thread: {:#?}", thread_set).replace(" ", " ")] */ + ] +} + +// +// +// +// +// +// +// +// +// +// +// +// +// +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], + hr![], + 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"], div!["TEST"], 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 view_debug_thread_set(thread_set: &ThreadSet) -> Node { + ul![thread_set + .0 + .iter() + .enumerate() + .map(|(i, t)| { li!["t", i, ": ", view_debug_thread(t),] })] +} +fn view_debug_thread(thread: &Thread) -> Node { + ul![thread + .0 + .iter() + .enumerate() + .map(|(i, tn)| { li!["tn", i, ": ", view_debug_thread_node(tn),] })] +} + +fn view_debug_thread_node(thread_node: &ThreadNode) -> Node { + ul![ + IF!(thread_node.0.is_some()=>li!["tn id:", &thread_node.0.as_ref().unwrap().id]), + thread_node.1.iter().enumerate().map(|(i, tn)| li![ + "tn", + i, + ": ", + view_debug_thread_node(tn) + ]) + ] +}