Compare commits

...

3 Commits

5 changed files with 126 additions and 38 deletions

6
Cargo.lock generated
View File

@ -975,10 +975,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
"itertools",
"log 0.4.17", "log 0.4.17",
"notmuch", "notmuch",
"seed", "seed",
"serde", "serde",
"serde_json",
"wasm-bindgen-test", "wasm-bindgen-test",
] ]
@ -1781,9 +1783,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.87" version = "1.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76"
dependencies = [ dependencies = [
"itoa", "itoa",
"ryu", "ryu",

View File

@ -425,14 +425,14 @@ pub struct SearchTags(pub Vec<String>);
pub struct ThreadSummary { pub struct ThreadSummary {
pub thread: ThreadId, pub thread: ThreadId,
pub timestamp: UnixTime, pub timestamp: UnixTime,
pub date_relative: String,
/// user-friendly timestamp /// user-friendly timestamp
pub matched: isize, pub date_relative: String,
/// number of matched messages /// number of matched messages
pub total: isize, pub matched: isize,
/// total messages in thread /// total messages in thread
pub authors: String, pub total: isize,
/// comma-separated names with | between matched and unmatched /// comma-separated names with | between matched and unmatched
pub authors: String,
pub subject: String, pub subject: String,
pub tags: Vec<String>, pub tags: Vec<String>,
@ -541,6 +541,8 @@ impl Notmuch {
Ok(BufReader::new(child.stdout.take().unwrap()).lines()) Ok(BufReader::new(child.stdout.take().unwrap()).lines())
} }
// TODO(wathiede): implement tags() based on "notmuch search --output=tags '*'"
fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>, NotmuchError> fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>, NotmuchError>
where where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,

View File

@ -22,6 +22,8 @@ seed = "0.9.2"
console_log = {git = "http://git-private.h.xinu.tv/wathiede/console_log.git"} console_log = {git = "http://git-private.h.xinu.tv/wathiede/console_log.git"}
serde = { version = "1.0.147", features = ["derive"] } serde = { version = "1.0.147", features = ["derive"] }
notmuch = {path = "../notmuch"} notmuch = {path = "../notmuch"}
itertools = "0.10.5"
serde_json = { version = "1.0.93", features = ["unbounded_depth"] }
[package.metadata.wasm-pack.profile.release] [package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Os'] wasm-opt = ['-Os']

View File

@ -6,12 +6,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="modulepreload" href="/pkg/package.js" as="script" type="text/javascript"> <link rel="modulepreload" href="/pkg/package.js" as="script" type="text/javascript">
<link rel="preload" href="/pkg/package_bg.wasm" as="fetch" type="application/wasm" crossorigin="anonymous"> <link rel="preload" href="/pkg/package_bg.wasm" as="fetch" type="application/wasm" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css" integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<style> <style>
.message { .message {
padding-left: 0.5em; padding-left: 0.5em;
} }
.body { .body {
padding-bottom: 1em; padding-bottom: 1em;
border: 1px red solid;
} }
.error { .error {
background-color: red; background-color: red;
@ -23,6 +26,14 @@ iframe {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.index .from {
width: 200px;
}
.index .subject {
}
.index .date {
white-space: nowrap;
}
</style> </style>
</head> </head>

View File

@ -3,9 +3,11 @@
// but some rules are too "annoying" or are not applicable for your case.) // but some rules are too "annoying" or are not applicable for your case.)
#![allow(clippy::wildcard_imports)] #![allow(clippy::wildcard_imports)]
use itertools::Itertools;
use log::{debug, error, info, warn, Level}; use log::{debug, error, info, warn, Level};
use notmuch::{Content, Part, SearchSummary, Thread, ThreadNode, ThreadSet}; use notmuch::{Content, Part, SearchSummary, Thread, ThreadNode, ThreadSet};
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use serde::de::Deserialize;
// ------ ------ // ------ ------
// Init // Init
@ -112,7 +114,6 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
} }
async fn search_request(query: &str) -> fetch::Result<SearchSummary> { async fn search_request(query: &str) -> fetch::Result<SearchSummary> {
info!("making search request for '{}'", query);
Request::new(api::search(query)) Request::new(api::search(query))
.method(Method::Get) .method(Method::Get)
.fetch() .fetch()
@ -136,13 +137,17 @@ mod api {
} }
async fn show_request(tid: &str) -> fetch::Result<ThreadSet> { async fn show_request(tid: &str) -> fetch::Result<ThreadSet> {
Request::new(api::show(tid)) let b = Request::new(api::show(tid))
.method(Method::Get) .method(Method::Get)
.fetch() .fetch()
.await? .await?
.check_status()? .check_status()?
.json() .bytes()
.await .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)))?)
} }
// ------ ------ // ------ ------
@ -267,6 +272,42 @@ fn set_title(title: &str) {
seed::document().set_title(&format!("lb: {}", title)); seed::document().set_title(&format!("lb: {}", title));
} }
fn tags_chiclet(tags: &[String]) -> impl Iterator<Item = Node<Msg>> + '_ {
tags.iter().map(|tag| match tag.as_str() {
"attachment" => span![C!["tag"], "📎"],
"replied" => span![C!["tag"], i![C!["fa-solid", "fa-reply"]]],
_ => span![C!["tag"], tag],
})
}
fn pretty_authors(authors: &str) -> impl Iterator<Item = Node<Msg>> + '_ {
let authors = authors.split(',');
/*
if authors.len() == 1 {
return authors.iter().filter_map(|author| {
Some(span![
attrs! {
At::Title => author.trim()},
author
])
});
}
*/
authors
.filter_map(|author| {
author.split_whitespace().nth(0).map(|first| {
span![
attrs! {
At::Title => author.trim()},
first
]
})
})
.intersperse(span![", "])
}
fn view_search_results(query: &str, search_results: &SearchSummary) -> Node<Msg> { fn view_search_results(query: &str, search_results: &SearchSummary) -> Node<Msg> {
if query.is_empty() { if query.is_empty() {
set_title("all mail"); set_title("all mail");
@ -276,20 +317,31 @@ fn view_search_results(query: &str, search_results: &SearchSummary) -> Node<Msg>
let rows = search_results.0.iter().map(|r| { let rows = search_results.0.iter().map(|r| {
let tid = r.thread.clone(); let tid = r.thread.clone();
tr![ tr![
td![],
td![ td![
&r.authors, C!["from"],
pretty_authors(&r.authors),
IF!(r.total>1 => small![" ", r.total.to_string()]), IF!(r.total>1 => small![" ", r.total.to_string()]),
IF!(r.tags.contains(&"attachment".to_string()) => "📎"),
], ],
td![&r.subject], td![C!["subject"], tags_chiclet(&r.tags), " ", &r.subject],
td![&r.date_relative], td![C!["date"], &r.date_relative],
ev(Ev::Click, move |_| Msg::ShowRequest(tid)), ev(Ev::Click, move |_| Msg::ShowRequest(tid)),
] ]
}); });
div![table![ div![table![
tr![th!["tid"], th!["From"], th!["Subject"], th!["Date"]], C![
rows "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]
]] ]]
} }
@ -343,13 +395,25 @@ fn view_debug_thread_node(thread_node: &ThreadNode) -> Node<Msg> {
fn view_header(query: &str) -> Node<Msg> { fn view_header(query: &str) -> Node<Msg> {
let query = query.to_string(); let query = query.to_string();
nav![
C!["navbar"],
attrs! {At::Role=>"navigation"},
div![ div![
button![ C!["navbar-menu"],
div![
C!["navbar-start"],
a![
C!["navbar-item", "button",],
"Unread", "Unread",
ev(Ev::Click, |_| Msg::SearchRequest("is:unread".to_string())), ev(Ev::Click, |_| Msg::SearchRequest("is:unread".to_string())),
], ],
button!["All", ev(Ev::Click, |_| Msg::SearchRequest("".to_string())),], a![
C!["navbar-item", "button"],
"All",
ev(Ev::Click, |_| Msg::SearchRequest("".to_string())),
],
input![ input![
C!["navbar-item", "input"],
attrs! { attrs! {
At::Placeholder => "Search"; At::Placeholder => "Search";
At::AutoFocus => true.as_at_value(); At::AutoFocus => true.as_at_value();
@ -362,18 +426,25 @@ fn view_header(query: &str) -> Node<Msg> {
} else { } else {
Msg::Noop Msg::Noop
}), }),
], ]
]
]
] ]
} }
// `view` describes what to display. // `view` describes what to display.
fn view(model: &Model) -> Node<Msg> { fn view(model: &Model) -> Node<Msg> {
info!("view called");
let content = match &model.context { let content = match &model.context {
Context::None => div![h1!["Loading"]], Context::None => div![h1!["Loading"]],
Context::Thread(thread_set) => view_thread(thread_set), Context::Thread(thread_set) => view_thread(thread_set),
Context::Search(search_results) => view_search_results(&model.query, search_results), Context::Search(search_results) => view_search_results(&model.query, search_results),
}; };
div![view_header(&model.query), content] div![section![
C!["section"],
view_header(&model.query),
div![C!["container"], content]
]]
} }
// ------ ------ // ------ ------