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::error; 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 { use seed::{prelude::*, *}; 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%)]", ]; // TODO: should this be a builder pattern? pub fn button() -> seed::Attrs { button_with_color("bg-neutral-900", "hover:bg-neutral-700") } pub fn button_with_color(bg: T, hover: T) -> seed::Attrs { C![ "rounded-md", "p-2", "border", "border-neutral-700", "text-center", "text-sm", "transition-all", "shadow-md", "hover:shadow-lg", "disabled:pointer-events-none", "disabled:opacity-50", "disabled:shadow-none", bg, hover, ] } 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 { 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, true), &catchup.items, model.read_completion_ratio, ) } else { normal_view( thread(thread_data, open_messages, &model.content_el, false), &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, true), &catchup.items, model.read_completion_ratio, ) } else { normal_view( news_post(post, &model.content_el, false), &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, versions: &Version, query: &str, refreshing_state: &RefreshingState, read_completion_ratio: f64, tags: &Option>, ) -> Node { 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, items: &[CatchupItem], read_completion_ratio: f64, ) -> Node { 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/50", ], div![ C!["absolute", "top-0", "right-4", "text-gray-500", "p-4"], span![i![C!["fas", "fa-x"]]], ev(Ev::Click, move |_| Msg::CatchupExit) ], h1![ C!["text-center"], format!("{} left ", items.iter().filter(|i| !i.seen).count(),) ] ], div![C!["mt-12", "mb-20"], content], div![ C![ "fixed", "bottom-0", "left-0", "right-0", "flex", "justify-center", "gap-4", "p-4", "border-t", "border-gray-500", "bg-black/50", ], button![ tw_classes::button(), span![i![C!["far", "fa-envelope"]]], span![C!["pl-2"], "Keep unread"], ev(Ev::Click, |_| Msg::CatchupKeepUnread) ], button![ tw_classes::button(), span![i![C!["fas", "fa-house"]]], span![C!["pl-2"], "Go home"], ev(Ev::Click, |_| Msg::MultiMsg(vec![ Msg::CatchupExit, Msg::GoToSearchResults ])) ], button![ tw_classes::button_with_color("bg-green-800", "hover:bg-green-700"), span![i![C!["far", "fa-envelope-open"]]], span![C!["pl-2"], "Mark as read"], ev(Ev::Click, |_| Msg::CatchupMarkAsRead) ] ], reading_progress(read_completion_ratio) ] } fn search_results( query: &str, results: &[FrontPageQuerySearchNodes], count: usize, pager: &FrontPageQuerySearchPageInfo, selected_threads: &HashSet, ) -> Node { 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(); let mut title_break = None; const TITLE_LENGTH_WRAP_LIMIT: usize = 40; for w in r.subject.split_whitespace() { if w.len() > TITLE_LENGTH_WRAP_LIMIT { title_break = Some(C!["break-all", "text-pretty"]); } } 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::() { 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![title_break, &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> + '_ { 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 { 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> + '_ { 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::::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 { let indeterminate = show_bulk_edit && !all_selected; let tristate_el: ElRef = use_state(|| Default::default()).get(); let tri = el_ref(&tristate_el); if let Some(tri) = tri.get() { tri.set_indeterminate(indeterminate); } let catchup = div![button![ tw_classes::button(), attrs! {At::Title => "Catch up"}, span![i![C!["far", "fa-eye"]]], span![C!["pl-2", "hidden", "md:inline"], "Catch-up"], ev(Ev::Click, |_| Msg::CatchupStart) ]]; let tristate_input = div![ C!["flex", "items-center", "mr-4"], input![ &tri, C![&tw_classes::CHECKBOX], attrs! { At::Type=>"checkbox", }, IF!(all_selected=>attrs!{At::Checked=>true}) ], ev(Ev::Input, move |_| { if all_selected { Msg::SelectionSetNone } else { Msg::SelectionSetAll } }), ]; nav![ C!["py-4", "flex", "w-full", "justify-between"], div![ C!["gap-2", "flex", IF!(show_bulk_edit => "hidden")], &tristate_input, &catchup ], div![ C!["gap-2", "flex", IF!(!show_bulk_edit => "hidden")], &tristate_input, &catchup, div![ button![ tw_classes::button(), C!["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![ tw_classes::button(), C!["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![ tw_classes::button(), C!["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![ tw_classes::button(), IF!(!pager.has_previous_page => attrs!{ At::Disabled=>true }), "<", IF!(pager.has_previous_page => ev(Ev::Click, |_| Msg::PreviousPage)), ], button![ 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 { 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, from: &str, big: bool) -> Node { 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 { 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 { 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 { 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 { let expand_id = msg.id.clone(); let from = match &msg.from { Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom { addr: Some(addr), .. }) => Some(addr.to_string()), _ => None, }; let from = from.map(|f| f.replace('.', "-").replace('@', "-")); 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", from], 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 { 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"); }) ] ] ] }) ] ] } // TODO: add cathup_mode:bool and hide elements when true #[topo::nested] fn thread( thread: &ShowThreadQueryThreadOnEmailThread, open_messages: &HashSet, content_el: &ElRef, catchup_mode: bool, ) -> Node { // 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(); let mut title_break = None; const TITLE_LENGTH_WRAP_LIMIT: usize = 40; for w in subject.split_whitespace() { if w.len() > TITLE_LENGTH_WRAP_LIMIT { title_break = Some(C!["break-all", "text-pretty"]); } } div![ C!["lg:p-4", "max-w-4xl"], div![ C!["p-4", "lg:p-0"], h3![C!["text-xl"], title_break, subject], span![removable_tags_chiclet(&thread.thread_id, &tags)], IF!(!catchup_mode => div![ C!["pt-4", "gap-2", "flex", "justify-around"], div![ button![ tw_classes::button(), C!["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![ tw_classes::button(), C!["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![ tw_classes::button(), C!["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, IF!(!catchup_mode => 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 { 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 { 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"], tw_classes::button(), span![i![C![ "fa-solid", "fa-arrow-rotate-right", IF![is_loading => "animate-spin"], ]]], ev(Ev::Click, |_| Msg::RefreshStart), ], a![ tw_classes::button(), C!["px-4", "rounded-none"], attrs! { At::Href => urls::search(unread_query(), 0) }, "Unread", ], a![ 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>) -> Node { fn inner_view_tag( display_name: &str, indent: usize, t: &Tag, search_unread: bool, ) -> Node { // 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, |_| { Msg::ScrollToTop }) ] } 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, search_unread: bool, ) -> Vec> { 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))), ] ] } // TODO: add cathup_mode:bool and hide elements when true fn news_post( post: &ShowThreadQueryThreadOnNewsPost, content_el: &ElRef, catchup_mode: bool, ) -> Node { 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 { 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, }) ] } let mut title_break = None; const TITLE_LENGTH_WRAP_LIMIT: usize = 40; for w in subject.split_whitespace() { if w.len() > TITLE_LENGTH_WRAP_LIMIT { title_break = Some(C!["break-all", "text-pretty"]); } } div![ C!["lg:p-4", "max-w-4xl"], div![ C!["p-4", "lg:p-0"], h3![C!["text-xl"], title_break, subject], span![tag(format!("News/{}", post.slug))], IF!(!catchup_mode => div![ C!["pt-4", "gap-2", "flex", "justify-around"], div![ button![ tw_classes::button(), C!["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![ tw_classes::button(), C!["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] ] ], IF!(!catchup_mode => click_to_top()), ] } fn render_news_post_header(post: &ShowThreadQueryThreadOnNewsPost) -> Node { let from = &post.site; 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![ 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![ tw_classes::button(), attrs! { At::Href => add_archive_url, At::Target => "_blank", }, span![C!["hidden", "md:inline"], "Archive "], i![C!["fas", "fa-plus"]], ], a![ 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 { 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 { 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 { button![ tw_classes::button_with_color("bg-red-500", "hover:bg-neutral-700"), C!["lg:m-0", "m-4"], span!["Top"], span![i![C!["fas", "fa-arrow-turn-up"]]], ev(Ev::Click, |_| Msg::ScrollToTop) ] }