Implement catchup mode

Show original/delivered To if no xinu.tv addresses in To/CC fields
This commit is contained in:
2025-02-24 14:37:34 -08:00
parent 76be5b7cac
commit 2e526dace1
7 changed files with 473 additions and 104 deletions

View File

@@ -1,4 +1,4 @@
use std::{cmp::Ordering, collections::HashSet};
use std::{cell::RefCell, cmp::Ordering, collections::HashSet};
use chrono::{DateTime, Datelike, Duration, Local, Utc};
use human_format::{Formatter, Scales};
@@ -12,7 +12,7 @@ use web_sys::{HtmlElement, HtmlInputElement};
use crate::{
api::urls,
graphql::{front_page_query::*, show_thread_query::*},
state::{unread_query, Context, Model, Msg, RefreshingState, Tag},
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
@@ -68,24 +68,82 @@ mod tw_classes {
}
pub fn view(model: &Model) -> Node<Msg> {
let content = match &model.context {
Context::None => div![h1!["Loading"]],
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,
} => thread(thread_data, open_messages, &model.content_el),
} => {
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),
..
} => news_post(post, &model.content_el),
} => {
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,
} => search_results(&query, results.as_slice(), *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",
@@ -98,19 +156,77 @@ pub fn view(model: &Model) -> Node<Msg> {
],
div![
C!["w-full", "lg:w-48", "flex-none", "flex", "flex-col"],
tags(model),
versions(&model.versions)
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(&model.query, &model.refreshing_state, true),
view_header(query, refreshing_state, true),
content,
view_header(&model.query, &model.refreshing_state, false),
view_header(query, refreshing_state, false),
],
reading_progress(model.read_completion_ratio),
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)
]
}
@@ -322,16 +438,22 @@ fn search_toolbar(
let tristate_el: ElRef<HtmlInputElement> = use_state(|| Default::default()).get();
let tri = el_ref(&tristate_el);
if let Some(tri) = tri.get() {
info!(
"setting tristate to {indeterminate}, current {}",
tri.indeterminate()
);
tri.set_indeterminate(indeterminate);
}
nav![
C!["py-4", "flex", "w-full", "justify-between"],
div![
C!["gap-2", "flex", IF!(!show_bulk_edit => "invisible")],
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![
@@ -484,6 +606,53 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<
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],
@@ -505,21 +674,20 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<
"To: "
],
span![
msg.to.iter().map(|to| {
let ShowThreadQueryThreadOnEmailThreadMessagesTo { 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)),
" "
]
})
to_addrs
]
]),
IF!(msg.to.is_empty() && msg.x_original_to.is_some()=>div![
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"],
@@ -540,7 +708,7 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<
})
]
]),
IF!(msg.to.is_empty() && msg.x_original_to.is_none() && msg.delivered_to.is_some() => div![
IF!(show_delivered_to => div![
C!["text-xs"],
span![
C!["font-semibold"],
@@ -561,27 +729,6 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<
})
]
]),
IF!(!msg.cc.is_empty() =>div![
C!["text-xs"],
span![
C!["font-semibold"],
"CC: "
],
span![
msg.cc.iter().map(|cc| {
let ShowThreadQueryThreadOnEmailThreadMessagesCc { name, addr } = cc;
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"],
@@ -623,6 +770,51 @@ fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Nod
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],
@@ -643,20 +835,19 @@ fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Nod
"To: "
],
span![
msg.to.iter().enumerate().map(|(i, to)| {
let ShowThreadQueryThreadOnEmailThreadMessagesTo { name, addr } = to;
span![
addr.as_ref().map(|addr| attrs! {
At::Title => addr
}),
if i > 0 { ", " } else { "" },
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown))
]
})
to_addrs
],
" "
]),
IF!(msg.to.is_empty() && msg.x_original_to.is_some()=>div![
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"],
@@ -671,13 +862,11 @@ fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Nod
}),
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
" ",
addr.as_ref().map(|addr| copy_text_widget(&addr)),
" "
]
})
]
]),
IF!(msg.to.is_empty() && msg.x_original_to.is_none() && msg.delivered_to.is_some() => div![
IF!(show_delivered_to => div![
C!["text-xs"],
span![
C!["font-semibold"],
@@ -692,29 +881,10 @@ fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Nod
}),
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
" ",
addr.as_ref().map(|addr| copy_text_widget(&addr)),
" "
]
})
]
]),
IF!(!msg.cc.is_empty() => div![
C!["text-xs", "max-w-full", "overflow-clip", "text-ellipsis"],
span![
C!["font-semibold"],
"CC: "
],
msg.cc.iter().enumerate().map(|(i,cc)| {
let ShowThreadQueryThreadOnEmailThreadMessagesCc { name, addr } = cc;
span![
addr.as_ref().map(|addr| attrs! {
At::Title => addr
}),
if i > 0 { ", " } else { "" },
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown))
]
})
])
],
span![
C!["text-right"],
@@ -1033,8 +1203,13 @@ fn view_header(
]
}
pub fn tags(model: &Model) -> Node<Msg> {
fn view_tag(display_name: &str, indent: usize, t: &Tag, search_unread: bool) -> Node<Msg> {
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 => "",
@@ -1087,7 +1262,10 @@ pub fn tags(model: &Model) -> Node<Msg> {
.take_while(|(a, b)| a == b)
.count()
}
fn view_tags<'a>(tags: impl Iterator<Item = &'a Tag>, search_unread: bool) -> Vec<Node<Msg>> {
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 {
@@ -1097,7 +1275,7 @@ pub fn tags(model: &Model) -> Node<Msg> {
// Synthesize fake tags for proper indenting.
for i in n..parts.len() - 1 {
let display_name = parts[n];
tag_els.push(view_tag(
tag_els.push(inner_view_tag(
&display_name,
n,
&Tag {
@@ -1111,13 +1289,12 @@ pub fn tags(model: &Model) -> Node<Msg> {
n = parts.len() - 1;
}
let display_name = parts[n];
tag_els.push(view_tag(&display_name, n, t, search_unread));
tag_els.push(inner_view_tag(&display_name, n, t, search_unread));
last = parts;
}
tag_els
}
let mut unread = model
.tags
let mut unread = tags
.as_ref()
.map(|tags| tags.iter().filter(|t| t.unread > 0).collect())
.unwrap_or(Vec::new());
@@ -1138,7 +1315,7 @@ pub fn tags(model: &Model) -> Node<Msg> {
aside![
C!["p-2"],
IF!(!unread.is_empty() => p![C!["uppercase", "font-bold"], "Unread"]),
IF!(!unread.is_empty() => div![C!["flex","flex-col"], view_tags(unread.into_iter(), true)]),
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 =>
@@ -1153,7 +1330,7 @@ pub fn tags(model: &Model) -> Node<Msg> {
tags_open.on_click(|t| *t = !*t)
],
div![
IF!(force_tags_open||tags_open.get() => model.tags.as_ref().map(|tags| view_tags(tags.iter(),false))),
IF!(force_tags_open||tags_open.get() => tags.as_ref().map(|t| inner_view_tags(t.iter(),false))),
]
]
}
@@ -1328,7 +1505,6 @@ fn render_news_post_header(post: &ShowThreadQueryThreadOnNewsPost) -> Node<Msg>
}
fn reading_progress(ratio: f64) -> Node<Msg> {
let percent = ratio * 100.;
info!("percent {percent}");
div![
C![
"fixed",
@@ -1347,7 +1523,7 @@ fn reading_progress(ratio: f64) -> Node<Msg> {
]
]
}
pub fn versions(versions: &crate::state::Version) -> Node<Msg> {
pub fn view_versions(versions: &Version) -> Node<Msg> {
debug!("versions {versions:?}");
aside![
C!["p-2"],