421 lines
13 KiB
Rust

use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
};
use chrono::{DateTime, Datelike, Duration, Local, Utc};
use itertools::Itertools;
use log::info;
use seed::{prelude::*, *};
use wasm_timer::Instant;
use crate::{
api::urls,
consts::{SEARCH_RESULTS_PER_PAGE, USE_GRAPHQL},
graphql::{front_page_query::*, show_thread_query::*},
state::{Model, Msg, RefreshingState},
};
mod desktop;
mod legacy;
mod mobile;
fn set_title(title: &str) {
seed::document().set_title(&format!("lb: {}", title));
}
fn tags_chiclet(tags: &[String], is_mobile: bool) -> impl Iterator<Item = Node<Msg>> + '_ {
tags.iter().map(move |tag| {
let mut hasher = DefaultHasher::new();
tag.hash(&mut hasher);
let hex = format!("#{:06x}", hasher.finish() % (1 << 24));
let style = style! {St::BackgroundColor=>hex};
let classes = C!["tag", IF!(is_mobile => "is-small")];
let tag = tag.clone();
a![
attrs! {
At::Href => urls::search(&format!("tag:{tag}"), 0)
},
match tag.as_str() {
"attachment" => span![classes, style, "📎"],
"replied" => span![classes, style, i![C!["fa-solid", "fa-reply"]]],
_ => span![classes, style, &tag],
},
ev(Ev::Click, move |_| Msg::SearchRequest {
query: format!("tag:{tag}"),
page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE,
})
]
})
}
fn pretty_authors(authors: &str) -> impl Iterator<Item = Node<Msg>> + '_ {
let one_person = authors.matches(',').count() == 0;
let authors = authors.split(',');
Itertools::intersperse(
authors.filter_map(move |author| {
if one_person {
return Some(span![
attrs! {
At::Title => author.trim()},
author
]);
}
author.split_whitespace().nth(0).map(|first| {
span![
attrs! {
At::Title => author.trim()},
first
]
})
}),
span![", "],
)
}
fn human_age(timestamp: i64) -> String {
let now = Local::now();
let yesterday = now - Duration::days(1);
let ts = DateTime::<Utc>::from_timestamp(timestamp, 0)
.unwrap()
.with_timezone(&Local);
let age = now - ts;
let datetime = if age < Duration::minutes(1) {
format!("{} min. ago", age.num_seconds())
} else if age < Duration::hours(1) {
format!("{} min. ago", age.num_minutes())
} else if ts.date_naive() == now.date_naive() {
ts.format("Today %H:%M").to_string()
} else if ts.date_naive() == yesterday.date_naive() {
ts.format("Yest. %H:%M").to_string()
} else if age < Duration::weeks(1) {
ts.format("%a %H:%M").to_string()
} else if ts.year() == now.year() {
ts.format("%b %d %H:%M").to_string()
} else {
ts.format("%b %d, %Y %H:%M").to_string()
};
datetime
}
fn view_search_results(
query: &str,
results: &[FrontPageQuerySearchNodes],
count: usize,
pager: &FrontPageQuerySearchPageInfo,
) -> Node<Msg> {
info!("pager {pager:?}");
if query.is_empty() {
set_title("all mail");
} else {
set_title(query);
}
let rows = results.iter().map(|r| {
let tid = r.thread.clone();
let datetime = human_age(r.timestamp as i64);
tr![
td![
C!["from"],
pretty_authors(&r.authors),
// TODO(wathiede): visualize message count if more than one message is in the
// thread
//IF!(r.total>1 => small![" ", r.total.to_string()]),
],
td![
C!["subject"],
tags_chiclet(&r.tags, false),
" ",
a![
C!["has-text-light"],
attrs! {
At::Href => urls::thread(&tid)
},
&r.subject,
]
],
td![C!["date"], datetime]
]
});
div![
view_search_pager(count, pager),
table![
C![
"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]
],
view_search_pager(count, pager)
]
}
fn view_search_pager(count: usize, pager: &FrontPageQuerySearchPageInfo) -> Node<Msg> {
let start = pager
.start_cursor
.as_ref()
.map(|i| i.parse().unwrap_or(0))
.unwrap_or(0);
nav![
C!["pagination"],
a![
C![
"pagination-previous",
"button",
//IF!(!pager.has_previous_page => "is-static"),
],
IF!(!pager.has_previous_page => attrs!{ At::Disabled=>true }),
"<",
IF!(pager.has_previous_page => ev(Ev::Click, |_| Msg::PreviousPage)),
],
a![
C![
"pagination-next",
"button",
//IF!(!pager.has_next_page => "is-static")
],
IF!(!pager.has_next_page => attrs!{ At::Disabled=>true }),
">",
IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage))
],
ul![
C!["pagination-list"],
li![format!(
"{} - {} of {}",
start,
count.min(start + SEARCH_RESULTS_PER_PAGE),
count
)],
],
]
}
trait Email {
fn name(&self) -> Option<&str>;
fn addr(&self) -> Option<&str>;
}
impl<T: Email> Email for &'_ T {
fn name(&self) -> Option<&str> {
return (*self).name();
}
fn addr(&self) -> Option<&str> {
return (*self).addr();
}
}
macro_rules! implement_email {
( $($t:ty),+ ) => {$(
impl Email for $t {
fn name(&self) -> Option<&str> {
self.name.as_deref()
}
fn addr(&self) -> Option<&str> {
self.addr.as_deref()
}
}
)+};
}
implement_email!(
ShowThreadQueryThreadMessagesTo,
ShowThreadQueryThreadMessagesCc,
ShowThreadQueryThreadMessagesFrom
);
fn view_address(email: impl Email) -> Node<Msg> {
span![
C!["tag", "is-black"],
email.addr().as_ref().map(|a| attrs! {At::Title=>a}),
email
.name()
.as_ref()
.unwrap_or(&email.addr().unwrap_or("(UNKNOWN)"))
]
}
fn view_addresses(addrs: &[impl Email]) -> Vec<Node<Msg>> {
addrs.into_iter().map(view_address).collect::<Vec<_>>()
}
fn view_thread(thread: &ShowThreadQueryThread) -> Node<Msg> {
// TODO(wathiede): show per-message subject if it changes significantly from top-level subject
set_title(&thread.subject);
let messages = thread.messages.iter().map(|msg| {
div![
C!["message"],
/* TODO(wathiede): collect all the tags and show them here. */
/* TODO(wathiede): collect all the attachments from all the subparts */
msg.from
.as_ref()
.map(|from| div![C!["header"], "From: ", view_address(&from)]),
msg.timestamp
.map(|ts| div![C!["header"], "Date: ", human_age(ts)]),
div![
C!["header"],
IF!(!msg.to.is_empty() => span!["To: ", view_addresses(&msg.to)]),
IF!(!msg.cc.is_empty() => span!["CC: ", view_addresses(&msg.cc)])
],
div![
C!["body"],
match &msg.body {
ShowThreadQueryThreadMessagesBody::UnhandledContentType(
ShowThreadQueryThreadMessagesBodyOnUnhandledContentType { contents },
) => pre![C!["error"], contents],
ShowThreadQueryThreadMessagesBody::PlainText(
ShowThreadQueryThreadMessagesBodyOnPlainText {
contents,
content_tree,
},
) => div![C!["view-part-text-plain"], contents, pre![content_tree]],
ShowThreadQueryThreadMessagesBody::Html(
ShowThreadQueryThreadMessagesBodyOnHtml {
contents,
content_tree,
},
) => div![
C!["view-part-text-html"],
raw![contents],
pre![content_tree]
],
}
],
]
});
div![
C!["thread"],
p![C!["is-size-4"], &thread.subject],
messages,
/* TODO(wathiede): plumb in orignal id
a![
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
"Original"
],
*/
]
}
fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
let is_loading = refresh_request == &RefreshingState::Loading;
let is_error = if let RefreshingState::Error(err) = refresh_request {
error!("Failed to refresh: {err:?}");
true
} else {
false
};
let query = Url::decode_uri_component(query).unwrap_or("".to_string());
nav![
C!["navbar"],
attrs! {At::Role=>"navigation"},
div![
C!["navbar-start"],
a![
C!["navbar-item", "button", IF![is_error => "is-danger"]],
span![i![C![
"fa-solid",
"fa-arrow-rotate-right",
"refresh",
IF![is_loading => "loading"],
]]],
ev(Ev::Click, |_| Msg::RefreshStart),
],
a![
C!["navbar-item", "button"],
attrs! {
At::Href => urls::search("is:unread", 0)
},
"Unread",
],
a![
C!["navbar-item", "button"],
attrs! {
At::Href => urls::search("", 0)
},
"All",
],
input![
C!["navbar-item", "input"],
attrs! {
At::Placeholder => "Search";
At::AutoFocus => true.as_at_value();
At::Value => query,
},
input_ev(Ev::Input, |q| if USE_GRAPHQL {
Msg::UpdateQuery(q)
} else {
Msg::SearchRequest {
query: Url::encode_uri_component(if q.is_empty() {
"*".to_string()
} else {
q
}),
page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE,
}
}),
// Send search on enter.
keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d {
if USE_GRAPHQL {
Msg::SearchQuery(query)
} else {
Msg::SearchRequest {
query: Url::encode_uri_component(query),
page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE,
}
}
} else {
Msg::Noop
}),
]
]
]
}
fn view_footer(render_time_ms: u128) -> Node<Msg> {
footer![
C!["footer"],
div![
C!["content", "has-text-right", "is-size-7"],
format!("Render time {} ms", render_time_ms)
]
]
}
// `view` describes what to display.
pub fn view(model: &Model) -> Node<Msg> {
info!("refreshing {:?}", model.refreshing_state);
let is_mobile = seed::window()
.match_media("(max-width: 768px)")
.expect("failed media query")
.map(|mql| mql.matches())
.unwrap_or(false);
let start = Instant::now();
info!("view called");
div![
if is_mobile {
C!["mobile"]
} else {
C!["desktop"]
},
if is_mobile {
mobile::view(model)
} else {
desktop::view(model)
},
view_footer(start.elapsed().as_millis())
]
}