// (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)] use log::{debug, error, info, Level}; use notmuch::{Content, Part, SearchSummary, ThreadNode, ThreadSet}; use seed::{prelude::*, *}; // ------ ------ // Init // ------ ------ // `init` describes what should happen when your app started. fn init(url: Url, orders: &mut impl Orders) -> Model { log!(url); orders.subscribe(|_: subs::UrlChanged| info!("url changed!")); orders .skip() .perform_cmd(async { Msg::SearchResult(search_request("*").await) }); Model { search_results: None, show_results: None, query: "".to_string(), } } // ------ ------ // Model // ------ ------ // `Model` describes our app state. struct Model { search_results: Option, show_results: Option, query: 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, SearchRequest(String), SearchResult(fetch::Result), ShowRequest(String), ShowResult(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::SearchRequest(query) => { model.show_results = None; model.query = query.clone(); orders .skip() .perform_cmd(async move { Msg::SearchResult(search_request(&query).await) }); } Msg::SearchResult(Ok(response_data)) => { debug!("fetch ok {:#?}", response_data); model.search_results = Some(response_data); } Msg::SearchResult(Err(fetch_error)) => { error!("fetch failed {:?}", fetch_error); } Msg::ShowRequest(query) => { model.show_results = None; orders .skip() .perform_cmd(async move { Msg::ShowResult(show_request(&query).await) }); } Msg::ShowResult(Ok(response_data)) => { debug!("fetch ok {:#?}", response_data); model.show_results = Some(response_data); } Msg::ShowResult(Err(fetch_error)) => { error!("fetch failed {:?}", fetch_error); } } } async fn search_request(query: &str) -> fetch::Result { Request::new(api::search(query)) .method(Method::Get) .fetch() .await? .check_status()? .json() .await } mod api { const BASE_URL: &str = "http://nixos-07:9345"; pub fn search(query: &str) -> String { format!("{}/search/{}", BASE_URL, query) } pub fn show(query: &str) -> String { format!("{}/show/{}", BASE_URL, query) } pub fn original(message_id: &str) -> String { format!("{}/original/{}", BASE_URL, message_id) } } async fn show_request(query: &str) -> fetch::Result { Request::new(api::show(query)) .method(Method::Get) .fetch() .await? .check_status()? .json() .await } // ------ ------ // 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 { return div![ C!["view-part-text-html"], div!["TEST"], iframe![Node::from_html(None, &html)] ]; } 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 { return div![Node::from_html(None, &html)]; } } 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 } /* let msg = thread.0.as_ref(); if let Some(msg) = msg { div![ a![attrs! {At::Href=>api::original(&msg.id)}, "Original"], table![ tr![th!["Subject"], td![&msg.headers.subject],], tr![th!["From"], td![&msg.headers.from],], tr![th!["To"], td![&msg.headers.to],], tr![th!["CC"], td![&msg.headers.cc],], tr![th!["BCC"], td![&msg.headers.bcc],], tr![th!["Reply-To"], td![&msg.headers.reply_to],], tr![th!["Date"], td![&msg.headers.date],], ], table![ tr![th!["MessageId"], td![&msg.id],], tr![ th!["Match"], td![if msg.r#match { "true" } else { "false" }], ], tr![ th!["Excluded"], td![if msg.excluded { "true" } else { "false" }], ], tr![th!["Filename"], td![&msg.filename],], tr![th!["Timestamp"], td![msg.timestamp.to_string()],], tr![th!["Date"], td![&msg.date_relative],], tr![th!["Tags"], td![format!("{:?}", msg.tags)],], ], ] } else { div![h2!["No message"]] } */ // `view` describes what to display. fn view(model: &Model) -> Node { let content = if let Some(show_results) = &model.show_results { assert_eq!(show_results.0.len(), 1); let thread = &show_results.0[0]; assert_eq!(thread.0.len(), 1); let thread_node = &thread.0[0]; div![ h1![first_subject(&thread_node)], a![ attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)}, "Original" ], view_message(&thread_node), pre!["Add zippy for debug dump"] /* pre![format!("Thread: {:#?}", show_results).replace(" ", " ")] */ ] } else if let Some(search_results) = &model.search_results { let rows = search_results.0.iter().map(|r| { let tid = r.thread.clone(); tr![ td![ &r.authors, IF!(r.total>1 => small![" ", r.total.to_string()]), IF!(r.tags.contains(&"attachment".to_string()) => "📎"), ], td![&r.subject], td![&r.date_relative], ev(Ev::Click, move |_| Msg::ShowRequest(tid)), ] }); div![table![tr![th!["From"], th!["Subject"], th!["Date"]], rows]] } else { div![h1!["Loading"]] }; let query = model.query.clone(); div![ button![ "Unread", ev(Ev::Click, |_| Msg::SearchRequest("is:unread".to_string())), ], button!["All", ev(Ev::Click, |_| Msg::SearchRequest("".to_string())),], input![ attrs! { At::Placeholder => "Search"; At::AutoFocus => true.as_at_value(); At::Value => query, }, input_ev(Ev::Input, Msg::SearchRequest), // Resend search on enter. keyboard_ev(Ev::KeyUp, |e| if e.key_code() == 0x0d { Msg::SearchRequest(query) } else { Msg::Noop }), ], content ] } // ------ ------ // Start // ------ ------ // (This function is invoked by `init` function in `index.html`.) #[wasm_bindgen(start)] pub fn start() { // This provides better error messages in debug mode. // It's disabled in release mode so it doesn't bloat up the file size. #[cfg(debug_assertions)] console_error_panic_hook::set_once(); 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); }