356 lines
10 KiB
Rust
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);
|
|
}
|