Compare commits

...

4 Commits

4 changed files with 144 additions and 102 deletions

2
dev.sh
View File

@ -1,6 +1,6 @@
tmux new-session -d -s letterbox-dev tmux new-session -d -s letterbox-dev
tmux rename-window web tmux rename-window web
tmux send-keys "cd web; trunk serve --address 0.0.0.0 --port 6758" C-m tmux send-keys "cd web; trunk serve --address 0.0.0.0 --port 6758 --proxy-backend http://localhost:9345/ --proxy-rewrite=/api/" C-m
tmux new-window -n server tmux new-window -n server
tmux send-keys "cd server; cargo watch -x run" C-m tmux send-keys "cd server; cargo watch -x run" C-m
tmux attach -d -t letterbox-dev tmux attach -d -t letterbox-dev

6
web/Makefile Normal file
View File

@ -0,0 +1,6 @@
.PHONY: all
# Build in release mode and push to minio for serving.
all:
trunk build --release
mc mirror --overwrite --remove dist/ m/letterbox/

View File

@ -16,9 +16,13 @@
.error { .error {
background-color: red; background-color: red;
} }
.text_plain { .view-part-text-plain {
white-space: pre-line; white-space: pre-line;
} }
iframe {
height: 100%;
width: 100%;
}
</style> </style>
</head> </head>

View File

@ -3,8 +3,8 @@
// 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 log::{debug, error, info, Level}; use log::{debug, error, info, warn, Level};
use notmuch::{Content, Part, SearchSummary, ThreadNode, ThreadSet}; use notmuch::{Content, Part, SearchSummary, Thread, ThreadNode, ThreadSet};
use seed::{prelude::*, *}; use seed::{prelude::*, *};
// ------ ------ // ------ ------
@ -13,27 +13,50 @@ use seed::{prelude::*, *};
// `init` describes what should happen when your app started. // `init` describes what should happen when your app started.
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model { fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
warn!("init called");
log!(url); log!(url);
orders.subscribe(|_: subs::UrlChanged| info!("url changed!")); let mut url = url.clone();
orders let mut query = "".to_string();
.skip() let hpp = url.next_hash_path_part();
.perform_cmd(async { Msg::SearchResult(search_request("*").await) }); log!(hpp);
match hpp {
Some("t") => {
let tid = url.next_hash_path_part().unwrap_or("").to_string();
orders.send_msg(Msg::ShowRequest(tid));
}
Some("s") => {
query = url.next_hash_path_part().unwrap_or("").to_string();
orders.send_msg(Msg::SearchRequest(query.clone()));
}
p => {
log!(p);
orders.send_msg(Msg::SearchRequest("".to_string()));
}
};
orders.subscribe(|uc: subs::UrlChanged| {
info!("uc {:#?}", uc);
});
info!("init query '{}'", query);
Model { Model {
search_results: None, context: Context::None,
show_results: None, query,
query: "".to_string(),
} }
} }
// ------ ------ // ------ ------
// Model // Model
// ------ ------ // ------ ------
enum Context {
None,
Search(SearchSummary),
Thread(ThreadSet),
}
// `Model` describes our app state. // `Model` describes our app state.
struct Model { struct Model {
search_results: Option<SearchSummary>,
show_results: Option<ThreadSet>,
query: String, query: String,
context: Context,
} }
// ------ ------ // ------ ------
@ -54,34 +77,34 @@ enum Msg {
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) { fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg { match msg {
Msg::Noop => {} Msg::Noop => {}
Msg::SearchRequest(query) => { Msg::SearchRequest(query) => {
model.show_results = None;
model.query = query.clone(); model.query = query.clone();
let url = Url::new().set_hash_path(["s", &query]);
orders.request_url(url);
orders orders
.skip() .skip()
.perform_cmd(async move { Msg::SearchResult(search_request(&query).await) }); .perform_cmd(async move { Msg::SearchResult(search_request(&query).await) });
} }
Msg::SearchResult(Ok(response_data)) => { Msg::SearchResult(Ok(response_data)) => {
debug!("fetch ok {:#?}", response_data); debug!("fetch ok {:#?}", response_data);
model.search_results = Some(response_data); model.context = Context::Search(response_data);
} }
Msg::SearchResult(Err(fetch_error)) => { Msg::SearchResult(Err(fetch_error)) => {
error!("fetch failed {:?}", fetch_error); error!("fetch failed {:?}", fetch_error);
} }
Msg::ShowRequest(query) => {
model.show_results = None; Msg::ShowRequest(tid) => {
let url = Url::new().set_hash_path(["t", &tid]);
orders.request_url(url);
orders orders
.skip() .skip()
.perform_cmd(async move { Msg::ShowResult(show_request(&query).await) }); .perform_cmd(async move { Msg::ShowResult(show_request(&tid).await) });
} }
Msg::ShowResult(Ok(response_data)) => { Msg::ShowResult(Ok(response_data)) => {
debug!("fetch ok {:#?}", response_data); debug!("fetch ok {:#?}", response_data);
model.show_results = Some(response_data); model.context = Context::Thread(response_data);
} }
Msg::ShowResult(Err(fetch_error)) => { Msg::ShowResult(Err(fetch_error)) => {
error!("fetch failed {:?}", fetch_error); error!("fetch failed {:?}", fetch_error);
} }
@ -89,6 +112,7 @@ 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()
@ -99,20 +123,20 @@ async fn search_request(query: &str) -> fetch::Result<SearchSummary> {
} }
mod api { mod api {
const BASE_URL: &str = "http://nixos-07:9345"; const BASE_URL: &str = "/api";
pub fn search(query: &str) -> String { pub fn search(query: &str) -> String {
format!("{}/search/{}", BASE_URL, query) format!("{}/search/{}", BASE_URL, query)
} }
pub fn show(query: &str) -> String { pub fn show(tid: &str) -> String {
format!("{}/show/{}", BASE_URL, query) format!("{}/show/{}", BASE_URL, tid)
} }
pub fn original(message_id: &str) -> String { pub fn original(message_id: &str) -> String {
format!("{}/original/{}", BASE_URL, message_id) format!("{}/original/{}", BASE_URL, message_id)
} }
} }
async fn show_request(query: &str) -> fetch::Result<ThreadSet> { async fn show_request(tid: &str) -> fetch::Result<ThreadSet> {
Request::new(api::show(query)) Request::new(api::show(tid))
.method(Method::Get) .method(Method::Get)
.fetch() .fetch()
.await? .await?
@ -179,11 +203,7 @@ fn view_part(part: &Part) -> Node<Msg> {
"text/plain" => view_text_plain(&part.content), "text/plain" => view_text_plain(&part.content),
"text/html" => { "text/html" => {
if let Some(Content::String(html)) = &part.content { if let Some(Content::String(html)) = &part.content {
return div![ return div![C!["view-part-text-html"], div!["TEST"], raw![&html]];
C!["view-part-text-html"],
div!["TEST"],
iframe![Node::from_html(None, &html)]
];
} else { } else {
div![ div![
C!["error"], C!["error"],
@ -242,61 +262,13 @@ fn first_subject(thread: &ThreadNode) -> Option<String> {
} }
None 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_search_results(query: &str, search_results: &SearchSummary) -> Node<Msg> {
fn view(model: &Model) -> Node<Msg> { seed::document().set_title(&format!("lb: {}", query));
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 rows = search_results.0.iter().map(|r| {
let tid = r.thread.clone(); let tid = r.thread.clone();
tr![ tr![
td![],
td![ td![
&r.authors, &r.authors,
IF!(r.total>1 => small![" ", r.total.to_string()]), IF!(r.total>1 => small![" ", r.total.to_string()]),
@ -307,11 +279,62 @@ fn view(model: &Model) -> Node<Msg> {
ev(Ev::Click, move |_| Msg::ShowRequest(tid)), ev(Ev::Click, move |_| Msg::ShowRequest(tid)),
] ]
}); });
div![table![tr![th!["From"], th!["Subject"], th!["Date"]], rows]] div![table![
} else { tr![th!["tid"], th!["From"], th!["Subject"], th!["Date"]],
div![h1!["Loading"]] rows
}; ]]
let query = model.query.clone(); }
fn view_thread(thread_set: &ThreadSet) -> Node<Msg> {
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("<No subject>".to_string());
seed::document().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),
pre![
"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<Msg> {
ul![thread_set
.0
.iter()
.enumerate()
.map(|(i, t)| { li!["t", i, ": ", view_debug_thread(t),] })]
}
fn view_debug_thread(thread: &Thread) -> Node<Msg> {
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<Msg> {
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) -> Node<Msg> {
let query = query.to_string();
div![ div![
button![ button![
"Unread", "Unread",
@ -326,16 +349,25 @@ fn view(model: &Model) -> Node<Msg> {
}, },
input_ev(Ev::Input, Msg::SearchRequest), input_ev(Ev::Input, Msg::SearchRequest),
// Resend search on enter. // Resend search on enter.
keyboard_ev(Ev::KeyUp, |e| if e.key_code() == 0x0d { keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d {
Msg::SearchRequest(query) Msg::SearchRequest(query)
} else { } else {
Msg::Noop Msg::Noop
}), }),
], ],
content
] ]
} }
// `view` describes what to display.
fn view(model: &Model) -> Node<Msg> {
let content = match &model.context {
Context::None => div![h1!["Loading"]],
Context::Thread(thread_set) => view_thread(thread_set),
Context::Search(search_results) => view_search_results(&model.query, search_results),
};
div![view_header(&model.query), content]
}
// ------ ------ // ------ ------
// Start // Start
// ------ ------ // ------ ------