Bill Thiede 2e526dace1 Implement catchup mode
Show original/delivered To if no xinu.tv addresses in To/CC fields
2025-02-24 14:38:18 -08:00

1550 lines
51 KiB
Rust

use std::{cell::RefCell, cmp::Ordering, collections::HashSet};
use chrono::{DateTime, Datelike, Duration, Local, Utc};
use human_format::{Formatter, Scales};
use itertools::Itertools;
use letterbox_shared::compute_color;
use log::{debug, error, info};
use seed::{prelude::*, *};
use seed_hooks::{state_access::CloneState, topo, use_state, StateAccessEventHandlers};
use web_sys::{HtmlElement, HtmlInputElement};
use crate::{
api::urls,
graphql::{front_page_query::*, show_thread_query::*},
state::{unread_query, CatchupItem, Context, Model, Msg, RefreshingState, Tag, Version},
};
// TODO(wathiede): create a QueryString enum that wraps single and multiple message ids and thread
// ids, and has a to_query_string() that knows notmuch's syntax. Then remove the smattering of
// format!() calls all over with magic strings representing notmuch specific syntax.
const MAX_RAW_MESSAGE_SIZE: usize = 100_000;
mod tw_classes {
pub const TAG: &[&str] = &[
"rounded-md",
"px-2",
"py-1",
"text-xs",
"[text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]",
];
pub const TAG_X: &[&str] = &[
"rounded-r",
"bg-neutral-800",
"px-2",
"py-1",
"mr-1",
"text-xs",
"[text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]",
];
pub const BUTTON: &[&str] = &[
"bg-neutral-900",
"rounded-md",
"p-2",
"border",
"border-neutral-700",
"text-center",
"text-sm",
"transition-all",
"shadow-md",
"hover:shadow-lg",
"hover:bg-neutral-700",
"disabled:pointer-events-none",
"disabled:opacity-50",
"disabled:shadow-none",
];
pub const CHECKBOX: &[&str] = &[
"w-8",
"h-8",
"accent-green-600",
"appearance-none",
"checked:appearance-auto",
"indeterminate:appearance-auto",
"rounded",
"border",
"border-neutral-500",
];
}
pub fn view(model: &Model) -> Node<Msg> {
match &model.context {
Context::None => normal_view(
div![h1!["Loading"]],
&model.versions,
&model.query,
&model.refreshing_state,
model.read_completion_ratio,
&model.tags,
),
Context::ThreadResult {
thread: ShowThreadQueryThread::EmailThread(thread_data),
open_messages,
} => {
if let Some(catchup) = &model.catchup {
catchup_view(
thread(thread_data, open_messages, &model.content_el),
&catchup.items,
model.read_completion_ratio,
)
} else {
normal_view(
thread(thread_data, open_messages, &model.content_el),
&model.versions,
&model.query,
&model.refreshing_state,
model.read_completion_ratio,
&model.tags,
)
}
}
Context::ThreadResult {
thread: ShowThreadQueryThread::NewsPost(post),
..
} => {
if let Some(catchup) = &model.catchup {
catchup_view(
news_post(post, &model.content_el),
&catchup.items,
model.read_completion_ratio,
)
} else {
normal_view(
news_post(post, &model.content_el),
&model.versions,
&model.query,
&model.refreshing_state,
model.read_completion_ratio,
&model.tags,
)
}
}
Context::SearchResult {
query,
results,
count,
pager,
selected_threads,
} => normal_view(
search_results(&query, results.as_slice(), *count, pager, selected_threads),
&model.versions,
&model.query,
&model.refreshing_state,
model.read_completion_ratio,
&model.tags,
),
}
}
fn normal_view(
content: Node<Msg>,
versions: &Version,
query: &str,
refreshing_state: &RefreshingState,
read_completion_ratio: f64,
tags: &Option<Vec<Tag>>,
) -> Node<Msg> {
div![
C![
"relative",
"flex",
"flex-wrap-reverse",
"bg-black",
"text-white",
"lg:flex-nowrap",
"w-full"
],
div![
C!["w-full", "lg:w-48", "flex-none", "flex", "flex-col"],
view_tags(tags),
view_versions(&versions)
],
div![
// TODO: This "overflow-hidden" is a hack because I can't figure out
// how to prevent the search input box on mobile for growing it's
// parent wider
C!["flex-auto", "flex", "flex-col", "overflow-hidden"],
view_header(query, refreshing_state, true),
content,
view_header(query, refreshing_state, false),
],
reading_progress(read_completion_ratio),
]
}
fn catchup_view(
content: Node<Msg>,
items: &[CatchupItem],
read_completion_ratio: f64,
) -> Node<Msg> {
div![
C!["w-full", "relative", "text-white"],
div![
C![
"fixed",
"top-0",
"right-0",
"left-0",
"p-4",
"border-b",
"border-gray-500",
"bg-black",
],
div![
C!["absolute", "right-4", "text-gray-500"],
span![i![C!["fas", "fa-x"]]],
ev(Ev::Click, move |_| Msg::GoToSearchResults)
],
h1![
C!["text-center"],
format!("{} left ", items.iter().filter(|i| !i.seen).count(),)
]
],
div![C!["mt-12", "mb-4"], content],
div![
C![
"fixed",
"bottom-0",
"left-0",
"right-0",
"flex",
"justify-center",
"gap-4",
"p-4",
"border-t",
"border-gray-500",
"bg-black",
],
button![
C![&tw_classes::BUTTON],
"Keep unread",
ev(Ev::Click, move |_| Msg::CatchupKeepUnread)
],
button![
C![&tw_classes::BUTTON, "bg-green-500"],
"Mark as read",
ev(Ev::Click, move |_| Msg::CatchupMarkAsRead)
]
],
reading_progress(read_completion_ratio)
]
}
fn search_results(
query: &str,
results: &[FrontPageQuerySearchNodes],
count: usize,
pager: &FrontPageQuerySearchPageInfo,
selected_threads: &HashSet<String>,
) -> Node<Msg> {
if query.is_empty() {
set_title("all mail");
} else {
set_title(query);
}
let rows = results.iter().map(|r| {
let tid = r.thread.clone();
let check_tid = r.thread.clone();
let datetime = human_age(r.timestamp as i64);
let unread_idx = r.tags.iter().position(|e| e == &"unread");
let mut tags = r.tags.clone();
if let Some(idx) = unread_idx {
tags.remove(idx);
};
let is_unread = unread_idx.is_some();
div![
C![
"flex",
"flex-nowrap",
"w-auto",
"flex-auto",
"py-4",
"border-b",
"border-neutral-800"
],
div![
C!["flex", "items-center", "mr-4"],
input![
C![&tw_classes::CHECKBOX],
attrs! {
At::Type=>"checkbox",
At::Checked=>selected_threads.contains(&tid).as_at_value(),
}
],
ev(Ev::Input, move |e| {
if let Some(input) = e
.target()
.as_ref()
.expect("failed to get reference to target")
.dyn_ref::<web_sys::HtmlInputElement>()
{
if input.checked() {
Msg::SelectionAddThread(check_tid)
} else {
Msg::SelectionRemoveThread(check_tid)
}
} else {
Msg::Noop
}
}),
],
a![
C!["flex-grow"],
IF!(is_unread => C!["font-bold"]),
attrs! {
At::Href => urls::thread(&tid)
},
div![&r.subject],
span![C!["text-xs"], pretty_authors(&r.authors)],
div![
C!["flex", "flex-wrap", "justify-between"],
span![tags_chiclet(&tags)],
span![C!["text-sm"], datetime]
]
]
]
});
let show_bulk_edit = !selected_threads.is_empty();
let all_selected = selected_threads.len() == results.len();
div![
C!["flex", "flex-col", "flex-auto", "p-4"],
search_toolbar(count, pager, show_bulk_edit, all_selected),
div![rows],
search_toolbar(count, pager, show_bulk_edit, all_selected),
]
}
fn set_title(title: &str) {
seed::document().set_title(&format!("lb: {}", title));
}
// TODO: unifiy tags_chiclet, removable_tags_chiclet, and tags inside news_post()
fn tags_chiclet(tags: &[String]) -> impl Iterator<Item = Node<Msg>> + '_ {
tags.iter().map(move |tag| {
let hex = compute_color(tag);
let style = style! {St::BackgroundColor=>hex};
let tag = tag.clone();
a![
C!["mr-1"],
match tag.as_str() {
"attachment" => span![C![&tw_classes::TAG], style, "📎"],
"replied" => span![C![&tw_classes::TAG], style, i![C!["fa-solid", "fa-reply"]]],
_ => span![C![&tw_classes::TAG], style, &tag],
}
]
})
}
fn removable_tags_chiclet<'a>(thread_id: &'a str, tags: &'a [String]) -> Node<Msg> {
div![
C!["flex"],
tags.iter().map(move |tag| {
let thread_id = thread_id.to_string();
let hex = compute_color(tag);
let style = style! {St::BackgroundColor=>hex};
let attrs = attrs! {
At::Href => urls::search(&format!("tag:{tag}"), 0)
};
let tag = tag.clone();
let rm_tag = tag.clone();
div![
a![
C![&tw_classes::TAG, "rounded-r-none"],
attrs,
style,
match tag.as_str() {
"attachment" => span!["📎"],
"replied" => span![i![C!["fa-solid", "fa-reply"]]],
_ => span![&tag],
},
ev(Ev::Click, move |_| Msg::FrontPageRequest {
query: format!("tag:{tag}"),
after: None,
before: None,
first: None,
last: None,
})
],
a![
C![&tw_classes::TAG_X],
span![i![C!["fa-solid", "fa-xmark"]]],
ev(Ev::Click, move |_| Msg::RemoveTag(thread_id, rm_tag))
]
]
})
]
}
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!("{} secs. 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
}
#[topo::nested]
fn search_toolbar(
count: usize,
pager: &FrontPageQuerySearchPageInfo,
show_bulk_edit: bool,
all_selected: bool,
) -> Node<Msg> {
let indeterminate = show_bulk_edit && !all_selected;
let tristate_el: ElRef<HtmlInputElement> = use_state(|| Default::default()).get();
let tri = el_ref(&tristate_el);
if let Some(tri) = tri.get() {
tri.set_indeterminate(indeterminate);
}
nav![
C!["py-4", "flex", "w-full", "justify-between"],
div![
C!["gap-2", "flex", IF!(show_bulk_edit => "hidden")],
div![button![
C![&tw_classes::BUTTON],
attrs! {At::Title => "Mark as read"},
span![i![C!["far", "fa-eye"]]],
span![C!["pl-2", "hidden", "md:inline"], "Catch-up"],
ev(Ev::Click, |_| Msg::StartCatchup)
]],
],
div![
C!["gap-2", "flex", IF!(!show_bulk_edit => "hidden")],
div![
C!["flex", "items-center", "mr-4"],
input![
tri,
C![&tw_classes::CHECKBOX],
attrs! {
At::Type=>"checkbox",
At::Checked=>all_selected,
}
],
ev(Ev::Input, move |_| {
if all_selected {
Msg::SelectionSetNone
} else {
Msg::SelectionSetAll
}
}),
],
div![
button![
C![&tw_classes::BUTTON, "rounded-r-none"],
attrs! {At::Title => "Mark as read"},
span![i![C!["far", "fa-envelope-open"]]],
span![C!["pl-2", "hidden", "md:inline"], "Read"],
ev(Ev::Click, |_| Msg::SelectionMarkAsRead)
],
button![
C![&tw_classes::BUTTON, "rounded-l-none"],
attrs! {At::Title => "Mark as unread"},
span![i![C!["far", "fa-envelope"]]],
span![C!["pl-2", "hidden", "md:inline"], "Unread"],
ev(Ev::Click, |_| Msg::SelectionMarkAsUnread)
]
],
div![button![
C![&tw_classes::BUTTON, "text-red-500"],
attrs! {At::Title => "Mark as spam"},
span![i![C!["far", "fa-hand"]]],
span![C!["pl-2", "hidden", "md:inline"], "Spam"],
ev(Ev::Click, |_| Msg::MultiMsg(vec![
Msg::SelectionAddTag("Spam".to_string()),
Msg::SelectionMarkAsRead
]))
]],
],
div![
C!["flex", "gap-2", "items-center"],
p![format!("{count} results")],
button![
C![&tw_classes::BUTTON],
IF!(!pager.has_previous_page => attrs!{ At::Disabled=>true }),
"<",
IF!(pager.has_previous_page => ev(Ev::Click, |_| Msg::PreviousPage)),
],
button![
C![&tw_classes::BUTTON],
IF!(!pager.has_next_page => attrs!{ At::Disabled=>true }),
">",
IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage))
]
]
]
}
fn raw_text_message(contents: &str) -> Node<Msg> {
let (contents, truncated_msg) = if contents.len() > MAX_RAW_MESSAGE_SIZE {
(
&contents[..MAX_RAW_MESSAGE_SIZE],
Some(div!["... contents truncated"]),
)
} else {
(contents, None)
};
div![
C!["view-part-text-plain", "font-mono", "whitespace-pre-line"],
contents,
truncated_msg,
]
}
fn has_unread(tags: &[String]) -> bool {
tags.contains(&String::from("unread"))
}
fn render_avatar(photo_url: Option<String>, from: &str, big: bool) -> Node<Msg> {
let size = if big {
C!["w-16", "h-16", "text-4xl"]
} else {
C!["w-8", "h-8", "text-l"]
};
if let Some(photo_url) = photo_url {
return div![
size,
img![attrs! {
At::Src => photo_url,
}]
];
}
let initials: String = from
.to_lowercase()
.trim()
.split(" ")
.map(|word| word.chars().next().unwrap())
.filter(|c| c.is_alphanumeric())
// Limit to 2 characters because more characters don't fit in the box
.take(2)
.collect();
let from_color = compute_color(from);
div![
C![
"[text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]",
"font-extrabold",
"grid",
"place-content-center",
"uppercase"
],
size,
style! {St::BackgroundColor=>from_color},
span![initials]
]
}
fn copy_text_widget(text: &str) -> Node<Msg> {
let text = text.to_string();
span![
i![C!["NOTPORTED", "far", "fa-clone"]],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::CopyToClipboard(text)
})
]
}
fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<Msg> {
let (from, from_detail, photo_url) = match &msg.from {
Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom {
name: Some(name),
addr,
photo_url,
}) => (name.to_string(), addr.clone(), photo_url.clone()),
Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom {
addr: Some(addr),
photo_url,
..
}) => (addr.to_string(), None, photo_url.clone()),
_ => (String::from("UNKNOWN"), None, None),
};
let id = msg.id.clone();
let is_unread = has_unread(&msg.tags);
let avatar = render_avatar(photo_url, &from, true);
let unknown = "UNKNOWN".to_string();
let to_xinu = RefCell::new(false);
let to_addrs: Vec<_> = msg
.to
.iter()
.map(|to| {
let ShowThreadQueryThreadOnEmailThreadMessagesTo { name, addr } = to;
span![
addr.as_ref().map(|addr| {
if addr.ends_with("xinu.tv") {
*to_xinu.borrow_mut() = true;
}
attrs! {
At::Title => addr
}
}),
name.as_ref()
.unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
" ",
addr.as_ref().map(|addr| copy_text_widget(&addr)),
" "
]
})
.collect();
let cc_addrs: Vec<_> = msg
.cc
.iter()
.map(|cc| {
let ShowThreadQueryThreadOnEmailThreadMessagesCc { name, addr } = cc;
span![
addr.as_ref().map(|addr| {
if addr.ends_with("xinu.tv") {
*to_xinu.borrow_mut() = true;
}
attrs! {
At::Title => addr
}
}),
name.as_ref()
.unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
" ",
addr.as_ref().map(|addr| copy_text_widget(&addr)),
" "
]
})
.collect();
let show_x_original_to = !*to_xinu.borrow() && msg.x_original_to.is_some();
let show_delivered_to = !*to_xinu.borrow() && !show_x_original_to && msg.delivered_to.is_some();
div![
C!["flex", "p-4", "bg-neutral-800"],
div![avatar],
div![
C!["px-4", "mr-auto"],
span![
C!["font-semibold", "text-sm"],
from_detail.as_ref().map(|addr| attrs! {
At::Title => addr
}),
&from,
" ",
from_detail.as_ref().map(|text| copy_text_widget(&text))
],
IF!(!msg.to.is_empty() =>div![
C!["text-xs"],
span![
C!["font-semibold"],
"To: "
],
span![
to_addrs
]
]),
IF!(!msg.cc.is_empty() =>div![
C!["text-xs"],
span![
C!["font-semibold"],
"CC: "
],
span![
cc_addrs
]
]),
IF!(show_x_original_to => div![
C!["text-xs"],
span![
C!["font-semibold"],
"Original To: "
],
span![
msg.x_original_to.as_ref().map(|to| {
let ShowThreadQueryThreadOnEmailThreadMessagesXOriginalTo { name, addr } = to;
span![
addr.as_ref().map(|addr| attrs! {
At::Title => addr
}),
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
" ",
addr.as_ref().map(|addr| copy_text_widget(&addr)),
" "
]
})
]
]),
IF!(show_delivered_to => div![
C!["text-xs"],
span![
C!["font-semibold"],
"Delivered To: "
],
span![
msg.delivered_to.as_ref().map(|to| {
let ShowThreadQueryThreadOnEmailThreadMessagesDeliveredTo { name, addr } = to;
span![
addr.as_ref().map(|addr| attrs! {
At::Title => addr
}),
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
" ",
addr.as_ref().map(|addr| copy_text_widget(&addr)),
" "
]
})
]
]),
],
span![
C!["text-right"],
msg.timestamp
.map(|ts| div![C!["text-xs", "text-nowrap"], human_age(ts)]),
i![C![
"mx-4",
"read-status",
"far",
if is_unread {
"fa-envelope"
} else {
"fa-envelope-open"
},
]],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::SetUnread(id, !is_unread)
}),
]
]
}
fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<Msg> {
let (from, from_detail, photo_url) = match &msg.from {
Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom {
name: Some(name),
addr,
photo_url,
}) => (name.to_string(), addr.clone(), photo_url.clone()),
Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom {
addr: Some(addr),
photo_url,
..
}) => (addr.to_string(), None, photo_url.clone()),
_ => (String::from("UNKNOWN"), None, None),
};
let id = msg.id.clone();
let is_unread = has_unread(&msg.tags);
let avatar = render_avatar(photo_url, &from, false);
let unknown = "UNKNOWN".to_string();
let to_xinu = RefCell::new(false);
let to_addrs: Vec<_> = msg
.to
.iter()
.enumerate()
.map(|(i, to)| {
let ShowThreadQueryThreadOnEmailThreadMessagesTo { name, addr } = to;
span![
addr.as_ref().map(|addr| {
if addr.ends_with("xinu.tv") {
*to_xinu.borrow_mut() = true;
}
attrs! {
At::Title => addr
}
}),
if i > 0 { ", " } else { "" },
name.as_ref()
.unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown))
]
})
.collect();
let cc_addrs: Vec<_> = msg
.cc
.iter()
.enumerate()
.map(|(i, cc)| {
let ShowThreadQueryThreadOnEmailThreadMessagesCc { name, addr } = cc;
span![
addr.as_ref().map(|addr| {
if addr.ends_with("xinu.tv") {
*to_xinu.borrow_mut() = true;
}
attrs! {
At::Title => addr
}
}),
if i > 0 { ", " } else { "" },
name.as_ref()
.unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown))
]
})
.collect();
let show_x_original_to = !*to_xinu.borrow() && msg.x_original_to.is_some();
let show_delivered_to = !*to_xinu.borrow() && !show_x_original_to && msg.delivered_to.is_some();
div![
C!["flex", "p-4", "bg-neutral-800"],
div![avatar],
div![
C!["px-4", "mr-auto"],
span![
C!["font-semibold", "text-sm"],
from_detail.as_ref().map(|addr| attrs! {
At::Title => addr
}),
&from
],
" ",
IF!(!msg.to.is_empty() => div![
C!["text-xs"],
span![
C!["font-semibold"],
"To: "
],
span![
to_addrs
],
" "
]),
IF!(!msg.cc.is_empty() => div![
C!["text-xs", "max-w-full", "overflow-clip", "text-ellipsis"],
span![
C!["font-semibold"],
"CC: "
],
cc_addrs
]),
IF!(show_x_original_to => div![
C!["text-xs"],
span![
C!["font-semibold"],
"Original To: "
],
span![
msg.x_original_to.as_ref().map(|to| {
let ShowThreadQueryThreadOnEmailThreadMessagesXOriginalTo { name, addr } = to;
span![
addr.as_ref().map(|addr| attrs! {
At::Title => addr
}),
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
" ",
]
})
]
]),
IF!(show_delivered_to => div![
C!["text-xs"],
span![
C!["font-semibold"],
"Delivered To: "
],
span![
msg.delivered_to.as_ref().map(|to| {
let ShowThreadQueryThreadOnEmailThreadMessagesDeliveredTo { name, addr } = to;
span![
addr.as_ref().map(|addr| attrs! {
At::Title => addr
}),
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
" ",
]
})
]
]),
],
span![
C!["text-right"],
msg.timestamp
.map(|ts| div![C!["text-xs", "text-nowrap"], human_age(ts)]),
i![C![
"mx-4",
"read-status",
"far",
if is_unread {
"fa-envelope"
} else {
"fa-envelope-open"
},
]],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::SetUnread(id, !is_unread)
}),
]
]
}
fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool) -> Node<Msg> {
let expand_id = msg.id.clone();
div![
C!["lg:mb-4"],
div![
if open {
render_open_header(&msg)
} else {
render_closed_header(&msg)
},
ev(Ev::Click, move |e| {
e.stop_propagation();
if open {
Msg::MessageCollapse(expand_id)
} else {
Msg::MessageExpand(expand_id)
}
})
],
IF!(open =>
div![
C!["bg-white", "text-black", "p-4", "min-w-full", "w-0","overflow-x-auto"],
match &msg.body {
ShowThreadQueryThreadOnEmailThreadMessagesBody::UnhandledContentType(
ShowThreadQueryThreadOnEmailThreadMessagesBodyOnUnhandledContentType { contents ,content_tree},
) => div![
raw_text_message(&contents),
div![C!["bg-red-500","p-4", "min-w-full", "w-0", "overflow-x-auto"],
view_content_tree(&content_tree),
]
],
ShowThreadQueryThreadOnEmailThreadMessagesBody::PlainText(
ShowThreadQueryThreadOnEmailThreadMessagesBodyOnPlainText {
contents,
content_tree,
},
) => div![
raw_text_message(&contents),
view_content_tree(&content_tree),
],
ShowThreadQueryThreadOnEmailThreadMessagesBody::Html(
ShowThreadQueryThreadOnEmailThreadMessagesBodyOnHtml {
contents,
content_tree,
},
) => div![
C!["view-part-text-html"],
raw![contents],
IF!(!msg.attachments.is_empty() => render_attachements(&msg.attachments)),
view_content_tree(&content_tree),
],
}
])
]
}
fn render_attachements(
attachments: &[ShowThreadQueryThreadOnEmailThreadMessagesAttachments],
) -> Node<Msg> {
div![
C!["border-t", "border-neutral-200", "mt-2", "pt-2"],
h2![C!["text-lg"], "Attachments"],
div![
C!["flex", "flex-wrap", "gap-2"],
attachments.iter().map(|a| {
let default = "UNKNOWN_FILE".to_string();
let filename = a.filename.as_ref().unwrap_or(&default);
let host = seed::window().location().host().expect("couldn't get host");
let url = letterbox_shared::urls::download_attachment(
Some(&host),
&a.id,
&a.idx,
filename,
);
let mut fmtr = Formatter::new();
fmtr.with_separator(" ");
fmtr.with_scales(Scales::Binary());
div![
C![
"flex",
"flex-col",
"flex-none",
"p-2",
"bg-neutral-200",
"border",
"border-neutral-400",
],
a.content_type.as_ref().map(
|content_type| IF!(content_type.starts_with("image/") => img![
C!["w-32", "h-32", "md:w-64", "md:h-64", "object-cover"],
attrs!{At::Src=>url},
])
),
div![
C!["py-2", "flex", "flex-nowrap", "items-center"],
div![
C!["flex", "flex-col", "grow", "w-16", "md:w-32"],
span![C!["shrink", "truncate"], &a.filename],
span![C!["text-xs"], fmtr.format(a.size as f64), "B"]
],
a![
C![
"aspect-square",
"px-2",
"pt-1",
"bg-neutral-300",
"border",
"border-neutral-400"
],
span![i![C!["fas", "fa-download"]]],
ev(Ev::Click, move |_| {
seed::window()
.location()
.set_href(&url)
.expect("failed to set URL");
})
]
]
]
})
]
]
}
#[topo::nested]
fn thread(
thread: &ShowThreadQueryThreadOnEmailThread,
open_messages: &HashSet<String>,
content_el: &ElRef<HtmlElement>,
) -> Node<Msg> {
// TODO(wathiede): show per-message subject if it changes significantly from top-level subject
let subject = if thread.subject.is_empty() {
"(no subject)"
} else {
&thread.subject
};
set_title(subject);
let mut tags: Vec<_> = thread
.messages
.iter()
.fold(HashSet::new(), |mut tags, msg| {
tags.extend(msg.tags.clone());
tags
})
.into_iter()
.collect();
tags.sort();
let messages = thread.messages.iter().map(|msg| {
let open = open_messages.contains(&msg.id);
message_render(&msg, open)
});
let read_thread_id = thread.thread_id.clone();
let unread_thread_id = thread.thread_id.clone();
let spam_add_thread_id = thread.thread_id.clone();
let spam_unread_thread_id = thread.thread_id.clone();
div![
C!["lg:p-4"],
div![
C!["p-4", "lg:p-0"],
h3![C!["text-xl"], subject],
span![removable_tags_chiclet(&thread.thread_id, &tags)],
div![
C!["pt-4", "gap-2", "flex", "justify-around"],
div![
button![
C![&tw_classes::BUTTON, "rounded-r-none"],
attrs! {At::Title => "Mark as read"},
span![i![C!["far", "fa-envelope-open"]]],
span![C!["pl-2"], "Read"],
ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::SetUnread(read_thread_id, false),
Msg::GoToSearchResults
])),
],
button![
C![&tw_classes::BUTTON, "rounded-l-none"],
attrs! {At::Title => "Mark as unread"},
span![i![C!["far", "fa-envelope"]]],
span![C!["pl-2"], "Unread"],
ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::SetUnread(unread_thread_id, true),
Msg::GoToSearchResults
])),
],
],
div![button![
C![&tw_classes::BUTTON, "text-red-500"],
attrs! {At::Title => "Spam"},
span![i![C!["far", "fa-hand"]]],
span![C!["pl-2"], "Spam"],
ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::AddTag(spam_add_thread_id, "Spam".to_string()),
Msg::SetUnread(spam_unread_thread_id, false),
Msg::GoToSearchResults
])),
]]
],
],
div![
C!["lg:mt-4", "mail-thread"],
el_ref(content_el),
messages,
click_to_top()
],
/* TODO(wathiede): plumb in orignal id
a![
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
"Original"
],
*/
]
}
#[topo::nested]
fn view_content_tree(content_tree: &str) -> Node<Msg> {
let debug_open = use_state(|| false);
div![
small![
i![C![
"fa-solid",
if debug_open.get() {
"fa-angle-up"
} else {
"fa-angle-down"
}
]],
" Debug",
debug_open.on_click(|d| *d = !*d)
],
IF!(debug_open.get() =>
pre![C!["NOTPORTED","content-tree"], content_tree]),
]
}
fn view_header(
query: &str,
refresh_request: &RefreshingState,
auto_focus_search: bool,
) -> 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!["flex", "px-4", "pt-4", "overflow-hidden"],
a![
C![IF![is_error => "bg-red-500"], "rounded-r-none"],
C![&tw_classes::BUTTON],
span![i![C![
"fa-solid",
"fa-arrow-rotate-right",
IF![is_loading => "animate-spin"],
]]],
ev(Ev::Click, |_| Msg::RefreshStart),
],
a![
C![&tw_classes::BUTTON],
C!["px-4", "rounded-none"],
attrs! {
At::Href => urls::search(unread_query(), 0)
},
"Unread",
],
a![
C![&tw_classes::BUTTON],
C!["px-4", "rounded-none"],
attrs! {
At::Href => urls::search("", 0)
},
"All",
],
input![
C!["grow", "pl-2", "text-black", "rounded-r"],
attrs! {
At::Placeholder => "Search";
At::AutoFocus => auto_focus_search.as_at_value();
At::Value => query,
},
input_ev(Ev::Input, |q| Msg::UpdateQuery(q)),
// Send search on enter.
keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d {
Msg::SearchQuery(query)
} else {
Msg::Noop
}),
]
]
}
pub fn view_tags(tags: &Option<Vec<Tag>>) -> Node<Msg> {
fn inner_view_tag(
display_name: &str,
indent: usize,
t: &Tag,
search_unread: bool,
) -> Node<Msg> {
// Hacky, but necessary for tailwind to see all the classes we're using
let indent_cls = match indent {
0 => "",
1 => "pl-2",
2 => "pl-4",
3 => "pl-6",
4 => "pl-8",
5 => "pl-10",
_ => "pl-12",
};
let href = if search_unread {
urls::search(&format!("is:unread tag:{}", t.name), 0)
} else {
urls::search(&format!("tag:{}", t.name), 0)
};
div![
C!["flex", "flex-row", "flex-nowrap"],
i![
C![indent_cls, "pr-1", "fa-solid", "fa-tag"],
style! {
//"--fa-primary-color" => t.fg_color,
St::Color => t.bg_color,
},
],
a![
C!["grow", "truncate"],
attrs! {
At::Href => href
},
display_name,
],
span![
C![
"self-center",
"justify-self-end",
"text-sm",
"text-neutral-400"
],
IF!(t.unread>0 => format!("{}", t.unread))
],
ev(Ev::Click, |_| {
// Scroll window to the top when searching for a tag.
info!("scrolling to the top because you clicked a tag");
web_sys::window().unwrap().scroll_to_with_x_and_y(0., 0.);
})
]
}
fn matches(a: &[&str], b: &[&str]) -> usize {
std::iter::zip(a.iter(), b.iter())
.take_while(|(a, b)| a == b)
.count()
}
fn inner_view_tags<'a>(
tags: impl Iterator<Item = &'a Tag>,
search_unread: bool,
) -> Vec<Node<Msg>> {
let mut tag_els = Vec::new();
let mut last = Vec::new();
for t in tags {
let parts: Vec<_> = t.name.split('/').collect();
let mut n = matches(&last, &parts);
if n + 2 <= parts.len() && parts.len() > 1 {
// Synthesize fake tags for proper indenting.
for i in n..parts.len() - 1 {
let display_name = parts[n];
tag_els.push(inner_view_tag(
&display_name,
n,
&Tag {
name: parts[..i + 1].join("/"),
bg_color: "#fff".to_string(),
unread: 0,
},
search_unread,
));
}
n = parts.len() - 1;
}
let display_name = parts[n];
tag_els.push(inner_view_tag(&display_name, n, t, search_unread));
last = parts;
}
tag_els
}
let mut unread = tags
.as_ref()
.map(|tags| tags.iter().filter(|t| t.unread > 0).collect())
.unwrap_or(Vec::new());
unread.sort_by(|a, b| {
let r = if a.name.starts_with('@') && b.name.starts_with('@') {
a.name.cmp(&b.name)
} else if a.name.starts_with('@') {
Ordering::Less
} else if b.name.starts_with('@') {
Ordering::Greater
} else {
a.name.cmp(&b.name)
};
return r;
});
let tags_open = use_state(|| false);
let force_tags_open = unread.is_empty();
aside![
C!["p-2"],
IF!(!unread.is_empty() => p![C!["uppercase", "font-bold"], "Unread"]),
IF!(!unread.is_empty() => div![C!["flex","flex-col"], inner_view_tags(unread.into_iter(), true)]),
p![
span![C!["uppercase", "font-bold", "pr-2"], "Tags"],
IF!(!force_tags_open =>
i![C![
"fa-solid",
if tags_open.get() {
"fa-angle-up"
} else {
"fa-angle-down"
}
]]),
tags_open.on_click(|t| *t = !*t)
],
div![
IF!(force_tags_open||tags_open.get() => tags.as_ref().map(|t| inner_view_tags(t.iter(),false))),
]
]
}
fn news_post(post: &ShowThreadQueryThreadOnNewsPost, content_el: &ElRef<HtmlElement>) -> Node<Msg> {
let subject = &post.title;
set_title(subject);
let read_thread_id = post.thread_id.clone();
let unread_thread_id = post.thread_id.clone();
fn tag(tag: String) -> Node<Msg> {
let hex = compute_color(&tag);
let style = style! {St::BackgroundColor=>hex};
let attrs = attrs! {
At::Href => urls::search(&format!("tag:{tag}"), 0)
};
let tag = tag.clone();
a![
attrs,
span![C![&tw_classes::TAG], style, &tag],
ev(Ev::Click, move |_| Msg::FrontPageRequest {
query: format!("tag:{tag}"),
after: None,
before: None,
first: None,
last: None,
})
]
}
div![
C!["lg:p-4", "max-w-4xl"],
div![
C!["p-4", "lg:p-0"],
h3![C!["text-xl"], subject],
span![tag(format!("News/{}", post.slug))],
div![
C!["pt-4", "gap-2", "flex", "justify-around"],
div![
button![
C![&tw_classes::BUTTON, "rounded-r-none"],
attrs! {At::Title => "Mark as read"},
span![i![C!["far", "fa-envelope-open"]]],
span![C!["pl-2"], "Read"],
ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::SetUnread(read_thread_id, false),
Msg::GoToSearchResults
])),
],
button![
C![&tw_classes::BUTTON, "rounded-l-none"],
attrs! {At::Title => "Mark as unread"},
span![i![C!["far", "fa-envelope"]]],
span![C!["pl-2"], "Unread"],
ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::SetUnread(unread_thread_id, true),
Msg::GoToSearchResults
])),
],
],
// Placeholder for symmetry with email view that has Spam button
div![],
],
],
div![
C!["lg:mt-4"],
div![render_news_post_header(&post)],
div![
C![
"bg-white",
"text-black",
"p-4",
"lg:mb-4",
"min-w-full",
"overflow-x-auto",
"news-post",
format!("site-{}", post.slug)
],
el_ref(content_el),
raw![&post.body]
]
],
click_to_top(),
]
}
fn render_news_post_header(post: &ShowThreadQueryThreadOnNewsPost) -> Node<Msg> {
let from = &post.site;
// TODO: move avatar/favicon stuff to the server side and and come up with a solution for emails
let id = post.thread_id.clone();
let is_unread = !post.is_read;
let url = &post.url;
let idx = url
.match_indices('/')
.nth(2)
.map(|(idx, _)| idx)
.unwrap_or(url.len());
let domain = &url[..idx];
let add_archive_url = format!("https://archive.is/?url={url}");
let view_archive_url = format!("https://archive.is/newest/{url}");
let favicon = div![
C![
"w-16",
"h-16",
"text-4xl",
"[text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]",
"font-extrabold",
"grid",
"place-content-center",
"uppercase"
],
img![
C!["object-cover", "w-16", "h-16"],
attrs! {At::Src=>format!("https://www.google.com/s2/favicons?sz=64&domain={domain}")}
],
];
div![
C!["flex", "p-4", "bg-neutral-800"],
div![favicon],
div![
C!["px-4", "mr-auto"],
div![
div![C!["font-semibold", "text-sm"], from],
div![
C!["flex", "gap-2", "pt-2", "text-sm"],
a![
C![&tw_classes::BUTTON],
attrs! {
At::Href => post.url,
At::Target => "_blank",
},
span![C!["hidden", "md:inline"], "Source "],
i![C!["fas", "fa-up-right-from-square"]],
],
a![
C![&tw_classes::BUTTON],
attrs! {
At::Href => add_archive_url,
At::Target => "_blank",
},
span![C!["hidden", "md:inline"], "Archive "],
i![C!["fas", "fa-plus"]],
],
a![
C![&tw_classes::BUTTON],
attrs! {
At::Href => view_archive_url,
At::Target => "_blank",
},
span![C!["hidden", "md:inline"], "Archive "],
i![C!["fas", "fa-magnifying-glass"]],
]
]
]
],
span![
C!["text-right"],
div![C!["text-xs", "text-nowrap"], human_age(post.timestamp)],
i![C![
"mx-4"
"read-status",
"far",
if is_unread {
"fa-envelope"
} else {
"fa-envelope-open"
},
]]
],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::SetUnread(id, !is_unread)
})
]
}
fn reading_progress(ratio: f64) -> Node<Msg> {
let percent = ratio * 100.;
div![
C![
"fixed",
"top-0",
"left-0",
"w-full",
"h-1",
"bg-gray-200",
IF!(percent<1. => "hidden")
],
div![
C!["h-1", "bg-green-500"],
style! {
St::Width => format!("{}%", percent)
}
]
]
}
pub fn view_versions(versions: &Version) -> Node<Msg> {
debug!("versions {versions:?}");
aside![
C!["p-2"],
p![C!["uppercase", "font-bold"], "Versions"],
p![C!["pl-2"], "Client"],
p![C!["pl-4"], &versions.client],
versions
.server
.as_ref()
.map(|v| vec![p![C!["pl-2"], "Server"], p![C!["pl-4"], v]])
]
}
fn click_to_top() -> Node<Msg> {
button![
C![&tw_classes::BUTTON, "bg-red-500", "lg:m-0", "m-4"],
span!["Top"],
span![i![C!["fas", "fa-arrow-turn-up"]]],
ev(Ev::Click, |_| web_sys::window()
.unwrap()
.scroll_to_with_x_and_y(0., 0.))
]
}