Implement catchup mode

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

View File

@ -310,6 +310,40 @@ impl QueryRoot {
info!("count {newsreader_query:?} newsreader count {newsreader_count} notmuch count {notmuch_count} tantivy count {tantivy_count} total {total}"); info!("count {newsreader_query:?} newsreader count {newsreader_count} notmuch count {notmuch_count} tantivy count {tantivy_count} total {total}");
Ok(total) Ok(total)
} }
async fn catchup<'ctx>(
&self,
ctx: &Context<'ctx>,
query: String,
) -> Result<Vec<String>, Error> {
let nm = ctx.data_unchecked::<Notmuch>();
let pool = ctx.data_unchecked::<PgPool>();
let query: Query = query.parse()?;
// TODO: implement optimized versions of fetching just IDs
let newsreader_fut = newsreader_search(pool, None, None, None, None, &query);
let notmuch_fut = notmuch_search(nm, None, None, None, None, &query);
let (newsreader_results, notmuch_results) = join!(newsreader_fut, notmuch_fut);
let newsreader_results = newsreader_results?;
let notmuch_results = notmuch_results?;
info!(
"newsreader_results ({}) notmuch_results ({})",
newsreader_results.len(),
notmuch_results.len(),
);
let results: Vec<_> = newsreader_results
.into_iter()
.chain(notmuch_results)
.collect();
let ids = results
.into_iter()
.map(|r| match r {
ThreadSummaryCursor::Newsreader(_, ts) => ts.thread,
ThreadSummaryCursor::Notmuch(_, ts) => ts.thread,
})
.collect();
Ok(ids)
}
// TODO: this function doesn't get parallelism, possibly because notmuch is sync and blocks, // TODO: this function doesn't get parallelism, possibly because notmuch is sync and blocks,
// rewrite that with tokio::process:Command // rewrite that with tokio::process:Command

View File

@ -682,7 +682,7 @@ fn compute_offset_limit(
first: Option<i32>, first: Option<i32>,
last: Option<i32>, last: Option<i32>,
) -> (i32, i32) { ) -> (i32, i32) {
let default_page_size = 100; let default_page_size = 10000;
match (after, before, first, last) { match (after, before, first, last) {
// Reasonable defaults // Reasonable defaults
(None, None, None, None) => (0, default_page_size), (None, None, None, None) => (0, default_page_size),

View File

@ -0,0 +1,3 @@
query CatchupQuery($query: String!) {
catchup(query: $query)
}

View File

@ -1292,6 +1292,45 @@
} }
} }
}, },
{
"args": [
{
"defaultValue": null,
"description": null,
"name": "query",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "catchup",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
}
},
{ {
"args": [ "args": [
{ {

View File

@ -12,6 +12,14 @@ use serde::{de::DeserializeOwned, Serialize};
)] )]
pub struct FrontPageQuery; pub struct FrontPageQuery;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/catchup.graphql",
response_derives = "Debug"
)]
pub struct CatchupQuery;
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
schema_path = "graphql/schema.json", schema_path = "graphql/schema.json",

View File

@ -53,6 +53,7 @@ pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
client: version, client: version,
server: None, server: None,
}, },
catchup: None,
} }
} }
@ -182,9 +183,9 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
} }
Msg::GoToSearchResults => { Msg::GoToSearchResults => {
let url = urls::search(&model.query, 0); let url = urls::search(&model.query, 0);
info!("GoToSearchRestuls Start"); info!("GoToSearchResults Start");
orders.request_url(url); orders.request_url(url);
info!("GoToSearchRestuls End"); info!("GoToSearchResults End");
} }
Msg::UpdateQuery(query) => model.query = query, Msg::UpdateQuery(query) => model.query = query,
@ -390,6 +391,38 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::ShowThreadResult(bad) => { Msg::ShowThreadResult(bad) => {
error!("show_thread_query error: {bad:#?}"); error!("show_thread_query error: {bad:#?}");
} }
Msg::CatchupRequest { query } => {
orders.perform_cmd(async move {
Msg::CatchupResult(
send_graphql::<_, graphql::catchup_query::ResponseData>(
graphql::CatchupQuery::build_query(graphql::catchup_query::Variables {
query,
}),
)
.await,
)
});
}
Msg::CatchupResult(Ok(graphql_client::Response {
data: Some(data), ..
})) => {
let items = data.catchup;
if items.is_empty() {
orders.send_msg(Msg::GoToSearchResults);
model.catchup = None;
} else {
orders.request_url(urls::thread(&items[0]));
model.catchup = Some(Catchup {
items: items
.into_iter()
.map(|id| CatchupItem { id, seen: false })
.collect(),
});
}
}
Msg::CatchupResult(bad) => {
error!("catchup_query error: {bad:#?}");
}
Msg::SelectionSetNone => { Msg::SelectionSetNone => {
if let Context::SearchResult { if let Context::SearchResult {
selected_threads, .. selected_threads, ..
@ -504,7 +537,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}); });
} }
Msg::WindowScrolled => { Msg::WindowScrolled => {
info!("WindowScrolled");
if let Some(el) = model.content_el.get() { if let Some(el) = model.content_el.get() {
let ih = window() let ih = window()
.inner_height() .inner_height()
@ -513,7 +545,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
.value_of(); .value_of();
let r = el.get_bounding_client_rect(); let r = el.get_bounding_client_rect();
info!("r {r:?} ih {ih}");
if r.height() < ih { if r.height() < ih {
// The whole content fits in the window, no scrollbar // The whole content fits in the window, no scrollbar
orders.send_msg(Msg::SetProgress(0.)); orders.send_msg(Msg::SetProgress(0.));
@ -554,8 +585,67 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
} }
model.versions.server = Some(version); model.versions.server = Some(version);
} }
Msg::StartCatchup => {
let query = if model.query.contains("is:unread") {
model.query.to_string()
} else {
format!("{} is:unread", model.query)
};
info!("starting catchup mode w/ {}", query);
orders.send_msg(Msg::CatchupRequest { query });
}
Msg::CatchupKeepUnread => {
if let Some(thread_id) = current_thread_id(&model.context) {
orders.send_msg(Msg::SetUnread(thread_id, true));
};
orders.send_msg(Msg::CatchupNext);
}
Msg::CatchupMarkAsRead => {
if let Some(thread_id) = current_thread_id(&model.context) {
orders.send_msg(Msg::SetUnread(thread_id, false));
};
orders.send_msg(Msg::CatchupNext);
}
Msg::CatchupNext => {
let Some(catchup) = &mut model.catchup else {
orders.send_msg(Msg::GoToSearchResults);
return;
};
let Some(idx) = catchup.items.iter().position(|i| !i.seen) else {
orders.send_msg(Msg::GoToSearchResults);
return;
};
catchup.items[idx].seen = true;
if idx < catchup.items.len() - 1 {
orders.request_url(urls::thread(&catchup.items[idx + 1].id));
return;
} else {
orders.send_msg(Msg::GoToSearchResults);
return;
};
}
} }
} }
fn current_thread_id(context: &Context) -> Option<String> {
match context {
Context::ThreadResult {
thread:
ShowThreadQueryThread::EmailThread(ShowThreadQueryThreadOnEmailThread {
thread_id, ..
}),
..
} => Some(thread_id.clone()),
Context::ThreadResult {
thread:
ShowThreadQueryThread::NewsPost(ShowThreadQueryThreadOnNewsPost { thread_id, .. }),
..
} => Some(thread_id.clone()),
_ => None,
}
}
// `Model` describes our app state. // `Model` describes our app state.
pub struct Model { pub struct Model {
pub query: String, pub query: String,
@ -565,6 +655,7 @@ pub struct Model {
pub read_completion_ratio: f64, pub read_completion_ratio: f64,
pub content_el: ElRef<HtmlElement>, pub content_el: ElRef<HtmlElement>,
pub versions: Version, pub versions: Version,
pub catchup: Option<Catchup>,
} }
#[derive(Debug)] #[derive(Debug)]
@ -601,6 +692,15 @@ pub enum Context {
}, },
} }
pub struct Catchup {
pub items: Vec<CatchupItem>,
}
pub struct CatchupItem {
pub id: String,
pub seen: bool,
}
pub struct Tag { pub struct Tag {
pub name: String, pub name: String,
pub bg_color: String, pub bg_color: String,
@ -651,10 +751,14 @@ pub enum Msg {
ShowThreadResult( ShowThreadResult(
Result<graphql_client::Response<graphql::show_thread_query::ResponseData>, gloo_net::Error>, Result<graphql_client::Response<graphql::show_thread_query::ResponseData>, gloo_net::Error>,
), ),
CatchupRequest {
query: String,
},
CatchupResult(
Result<graphql_client::Response<graphql::catchup_query::ResponseData>, gloo_net::Error>,
),
#[allow(dead_code)]
SelectionSetNone, SelectionSetNone,
#[allow(dead_code)]
SelectionSetAll, SelectionSetAll,
SelectionAddTag(String), SelectionAddTag(String),
#[allow(dead_code)] #[allow(dead_code)]
@ -673,4 +777,9 @@ pub enum Msg {
WindowScrolled, WindowScrolled,
SetProgress(f64), SetProgress(f64),
UpdateServerVersion(String), UpdateServerVersion(String),
StartCatchup,
CatchupKeepUnread,
CatchupMarkAsRead,
CatchupNext,
} }

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 chrono::{DateTime, Datelike, Duration, Local, Utc};
use human_format::{Formatter, Scales}; use human_format::{Formatter, Scales};
@ -12,7 +12,7 @@ use web_sys::{HtmlElement, HtmlInputElement};
use crate::{ use crate::{
api::urls, api::urls,
graphql::{front_page_query::*, show_thread_query::*}, 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 // 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> { pub fn view(model: &Model) -> Node<Msg> {
let content = match &model.context { match &model.context {
Context::None => div![h1!["Loading"]], Context::None => normal_view(
div![h1!["Loading"]],
&model.versions,
&model.query,
&model.refreshing_state,
model.read_completion_ratio,
&model.tags,
),
Context::ThreadResult { Context::ThreadResult {
thread: ShowThreadQueryThread::EmailThread(thread_data), thread: ShowThreadQueryThread::EmailThread(thread_data),
open_messages, 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 { Context::ThreadResult {
thread: ShowThreadQueryThread::NewsPost(post), 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 { Context::SearchResult {
query, query,
results, results,
count, count,
pager, pager,
selected_threads, 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![ div![
C![ C![
"relative", "relative",
@ -98,19 +156,77 @@ pub fn view(model: &Model) -> Node<Msg> {
], ],
div![ div![
C!["w-full", "lg:w-48", "flex-none", "flex", "flex-col"], C!["w-full", "lg:w-48", "flex-none", "flex", "flex-col"],
tags(model), view_tags(tags),
versions(&model.versions) view_versions(&versions)
], ],
div![ div![
// TODO: This "overflow-hidden" is a hack because I can't figure out // 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 // how to prevent the search input box on mobile for growing it's
// parent wider // parent wider
C!["flex-auto", "flex", "flex-col", "overflow-hidden"], C!["flex-auto", "flex", "flex-col", "overflow-hidden"],
view_header(&model.query, &model.refreshing_state, true), view_header(query, refreshing_state, true),
content, 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 tristate_el: ElRef<HtmlInputElement> = use_state(|| Default::default()).get();
let tri = el_ref(&tristate_el); let tri = el_ref(&tristate_el);
if let Some(tri) = tri.get() { if let Some(tri) = tri.get() {
info!(
"setting tristate to {indeterminate}, current {}",
tri.indeterminate()
);
tri.set_indeterminate(indeterminate); tri.set_indeterminate(indeterminate);
} }
nav![ nav![
C!["py-4", "flex", "w-full", "justify-between"], C!["py-4", "flex", "w-full", "justify-between"],
div![ 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![ div![
C!["flex", "items-center", "mr-4"], C!["flex", "items-center", "mr-4"],
input![ input![
@ -484,6 +606,53 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<
let is_unread = has_unread(&msg.tags); let is_unread = has_unread(&msg.tags);
let avatar = render_avatar(photo_url, &from, true); let avatar = render_avatar(photo_url, &from, true);
let unknown = "UNKNOWN".to_string(); 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![ div![
C!["flex", "p-4", "bg-neutral-800"], C!["flex", "p-4", "bg-neutral-800"],
div![avatar], div![avatar],
@ -505,21 +674,20 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<
"To: " "To: "
], ],
span![ span![
msg.to.iter().map(|to| { to_addrs
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)),
" "
]
})
] ]
]), ]),
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"], C!["text-xs"],
span![ span![
C!["font-semibold"], 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"], C!["text-xs"],
span![ span![
C!["font-semibold"], 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![ span![
C!["text-right"], C!["text-right"],
@ -623,6 +770,51 @@ fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Nod
let is_unread = has_unread(&msg.tags); let is_unread = has_unread(&msg.tags);
let avatar = render_avatar(photo_url, &from, false); let avatar = render_avatar(photo_url, &from, false);
let unknown = "UNKNOWN".to_string(); 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![ div![
C!["flex", "p-4", "bg-neutral-800"], C!["flex", "p-4", "bg-neutral-800"],
div![avatar], div![avatar],
@ -643,20 +835,19 @@ fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Nod
"To: " "To: "
], ],
span![ span![
msg.to.iter().enumerate().map(|(i, to)| { to_addrs
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))
]
})
], ],
" " " "
]), ]),
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"], C!["text-xs"],
span![ span![
C!["font-semibold"], 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)), 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"], C!["text-xs"],
span![ span![
C!["font-semibold"], 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)), 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![ span![
C!["text-right"], C!["text-right"],
@ -1033,8 +1203,13 @@ fn view_header(
] ]
} }
pub fn tags(model: &Model) -> Node<Msg> { pub fn view_tags(tags: &Option<Vec<Tag>>) -> Node<Msg> {
fn view_tag(display_name: &str, indent: usize, t: &Tag, search_unread: bool) -> 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 // Hacky, but necessary for tailwind to see all the classes we're using
let indent_cls = match indent { let indent_cls = match indent {
0 => "", 0 => "",
@ -1087,7 +1262,10 @@ pub fn tags(model: &Model) -> Node<Msg> {
.take_while(|(a, b)| a == b) .take_while(|(a, b)| a == b)
.count() .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 tag_els = Vec::new();
let mut last = Vec::new(); let mut last = Vec::new();
for t in tags { for t in tags {
@ -1097,7 +1275,7 @@ pub fn tags(model: &Model) -> Node<Msg> {
// Synthesize fake tags for proper indenting. // Synthesize fake tags for proper indenting.
for i in n..parts.len() - 1 { for i in n..parts.len() - 1 {
let display_name = parts[n]; let display_name = parts[n];
tag_els.push(view_tag( tag_els.push(inner_view_tag(
&display_name, &display_name,
n, n,
&Tag { &Tag {
@ -1111,13 +1289,12 @@ pub fn tags(model: &Model) -> Node<Msg> {
n = parts.len() - 1; n = parts.len() - 1;
} }
let display_name = parts[n]; 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; last = parts;
} }
tag_els tag_els
} }
let mut unread = model let mut unread = tags
.tags
.as_ref() .as_ref()
.map(|tags| tags.iter().filter(|t| t.unread > 0).collect()) .map(|tags| tags.iter().filter(|t| t.unread > 0).collect())
.unwrap_or(Vec::new()); .unwrap_or(Vec::new());
@ -1138,7 +1315,7 @@ pub fn tags(model: &Model) -> Node<Msg> {
aside![ aside![
C!["p-2"], C!["p-2"],
IF!(!unread.is_empty() => p![C!["uppercase", "font-bold"], "Unread"]), 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![ p![
span![C!["uppercase", "font-bold", "pr-2"], "Tags"], span![C!["uppercase", "font-bold", "pr-2"], "Tags"],
IF!(!force_tags_open => IF!(!force_tags_open =>
@ -1153,7 +1330,7 @@ pub fn tags(model: &Model) -> Node<Msg> {
tags_open.on_click(|t| *t = !*t) tags_open.on_click(|t| *t = !*t)
], ],
div![ 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> { fn reading_progress(ratio: f64) -> Node<Msg> {
let percent = ratio * 100.; let percent = ratio * 100.;
info!("percent {percent}");
div![ div![
C![ C![
"fixed", "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:?}"); debug!("versions {versions:?}");
aside![ aside![
C!["p-2"], C!["p-2"],