1550 lines
51 KiB
Rust
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.))
|
|
]
|
|
}
|