letterbox/web/src/lib.rs

356 lines
10 KiB
Rust

// (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<Msg>) -> 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<SearchSummary>,
show_results: Option<ThreadSet>,
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<SearchSummary>),
ShowRequest(String),
ShowResult(fetch::Result<ThreadSet>),
}
// `update` describes how to handle each `Msg`.
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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<SearchSummary> {
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<ThreadSet> {
Request::new(api::show(query))
.method(Method::Get)
.fetch()
.await?
.check_status()?
.json()
.await
}
// ------ ------
// View
// ------ ------
// <subject>
// <tags>
//
// <from1> <date>
// <to1>
// <content1>
// <zippy>
// <children1>
// </zippy>
//
// <from2> <date>
// <to2>
// <body2>
fn view_message(thread: &ThreadNode) -> Node<Msg> {
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!["<no body>"],
},
],
children.iter().map(view_message)
]
}
fn view_body(body: &[Part]) -> Node<Msg> {
div![body.iter().map(view_part)]
}
fn view_text_plain(content: &Option<Content>) -> Node<Msg> {
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<Msg> {
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<String> {
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<Msg> {
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);
}