822 lines
27 KiB
Rust
822 lines
27 KiB
Rust
use std::{
|
|
collections::{hash_map::DefaultHasher, HashSet},
|
|
hash::{Hash, Hasher},
|
|
};
|
|
|
|
use chrono::{DateTime, Datelike, Duration, Local, Utc};
|
|
use itertools::Itertools;
|
|
use log::error;
|
|
use seed::{prelude::*, *};
|
|
use seed_hooks::{state_access::CloneState, topo, use_state};
|
|
|
|
use crate::{
|
|
api::urls,
|
|
consts::SEARCH_RESULTS_PER_PAGE,
|
|
graphql::{front_page_query::*, show_thread_query::*},
|
|
state::{unread_query, Model, Msg, RefreshingState},
|
|
};
|
|
|
|
mod desktop;
|
|
mod mobile;
|
|
mod tablet;
|
|
|
|
// 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;
|
|
fn set_title(title: &str) {
|
|
seed::document().set_title(&format!("lb: {}", title));
|
|
}
|
|
|
|
fn compute_color(data: &str) -> String {
|
|
let mut hasher = DefaultHasher::new();
|
|
data.hash(&mut hasher);
|
|
format!("#{:06x}", hasher.finish() % (1 << 24))
|
|
}
|
|
|
|
fn tags_chiclet(tags: &[String], is_mobile: bool) -> impl Iterator<Item = Node<Msg>> + '_ {
|
|
tags.iter().map(move |tag| {
|
|
let hex = compute_color(tag);
|
|
let style = style! {St::BackgroundColor=>hex};
|
|
let classes = C!["tag", IF!(is_mobile => "is-small")];
|
|
let tag = tag.clone();
|
|
a![
|
|
attrs! {
|
|
At::Href => urls::search(&format!("tag:{tag}"), 0)
|
|
},
|
|
match tag.as_str() {
|
|
"attachment" => span![classes, style, "📎"],
|
|
"replied" => span![classes, style, i![C!["fa-solid", "fa-reply"]]],
|
|
_ => span![classes, style, &tag],
|
|
},
|
|
ev(Ev::Click, move |_| Msg::FrontPageRequest {
|
|
query: format!("tag:{tag}"),
|
|
after: None,
|
|
before: None,
|
|
first: None,
|
|
last: None,
|
|
})
|
|
]
|
|
})
|
|
}
|
|
|
|
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!("{} min. 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
|
|
}
|
|
|
|
fn view_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 show_bulk_edit = !selected_threads.is_empty();
|
|
let all_checked = selected_threads.len() == results.len();
|
|
let partially_checked = !selected_threads.is_empty() && !all_checked;
|
|
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 subject = if r.subject.is_empty() {
|
|
"(no subject)"
|
|
} else {
|
|
&r.subject
|
|
};
|
|
tr![
|
|
IF!(unread_idx.is_some() => C!["unread"]),
|
|
td![label![
|
|
C!["b-checkbox", "checkbox"],
|
|
input![attrs! {
|
|
At::Type=>"checkbox",
|
|
At::Checked=>selected_threads.contains(&tid).as_at_value(),
|
|
}],
|
|
span![C!["check"]],
|
|
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
|
|
}
|
|
}),
|
|
]],
|
|
td![
|
|
C!["from"],
|
|
a![
|
|
C!["has-text-light", "text"],
|
|
attrs! {
|
|
At::Href => urls::thread(&tid)
|
|
},
|
|
pretty_authors(&r.authors),
|
|
IF!(r.total>1 => small![" ", r.total.to_string()]),
|
|
]
|
|
],
|
|
td![
|
|
C!["subject"],
|
|
tags_chiclet(&tags, false),
|
|
" ",
|
|
a![
|
|
C!["has-text-light", "text"],
|
|
attrs! {
|
|
At::Href => urls::thread(&tid)
|
|
},
|
|
&subject,
|
|
]
|
|
],
|
|
td![
|
|
C!["date"],
|
|
a![
|
|
C!["has-text-light", "text"],
|
|
attrs! {
|
|
At::Href => urls::thread(&tid)
|
|
},
|
|
datetime
|
|
]
|
|
]
|
|
]
|
|
});
|
|
|
|
div![
|
|
C!["search-results"],
|
|
search_toolbar(count, pager, show_bulk_edit),
|
|
table![
|
|
C![
|
|
"table",
|
|
"index",
|
|
"is-fullwidth",
|
|
"is-hoverable",
|
|
"is-narrow",
|
|
"is-striped",
|
|
],
|
|
thead![tr![
|
|
th![
|
|
C!["edit"],
|
|
label![
|
|
C!["b-checkbox", "checkbox"],
|
|
input![
|
|
IF!(partially_checked => C!["is-indeterminate"]),
|
|
attrs! {
|
|
At::Type=>"checkbox",
|
|
At::Checked=>all_checked.as_at_value(),
|
|
}
|
|
],
|
|
span![C!["check"]],
|
|
ev(Ev::Click, move |_| if all_checked {
|
|
Msg::SelectionSetNone
|
|
} else {
|
|
Msg::SelectionSetAll
|
|
})
|
|
]
|
|
],
|
|
th![C!["from"], "From"],
|
|
th![C!["subject"], "Subject"],
|
|
th![C!["date"], "Date"]
|
|
]],
|
|
tbody![rows]
|
|
],
|
|
search_toolbar(count, pager, show_bulk_edit)
|
|
]
|
|
}
|
|
|
|
fn search_toolbar(
|
|
count: usize,
|
|
pager: &FrontPageQuerySearchPageInfo,
|
|
show_bulk_edit: bool,
|
|
) -> Node<Msg> {
|
|
let start = pager
|
|
.start_cursor
|
|
.as_ref()
|
|
.map(|i| i.parse().unwrap_or(0))
|
|
.unwrap_or(0);
|
|
nav![
|
|
C!["level"],
|
|
div![
|
|
C!["level-left"],
|
|
IF!(show_bulk_edit =>
|
|
span![
|
|
// TODO(wathiede): add "Mark as spam"
|
|
C!["level-item", "buttons", "has-addons"],
|
|
button![
|
|
C!["button"],
|
|
attrs!{At::Title => "Mark as read"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
|
|
span!["Read"],
|
|
ev(Ev::Click, |_| Msg::SelectionMarkAsRead)
|
|
],
|
|
button![
|
|
C!["button"],
|
|
attrs!{At::Title => "Mark as unread"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
|
|
span!["Unread"],
|
|
ev(Ev::Click, |_| Msg::SelectionMarkAsUnread)
|
|
]
|
|
]),
|
|
],
|
|
div![
|
|
C!["level-right"],
|
|
nav![
|
|
C!["level-item", "pagination"],
|
|
a![
|
|
C![
|
|
"pagination-previous",
|
|
"button",
|
|
//IF!(!pager.has_previous_page => "is-static"),
|
|
],
|
|
IF!(!pager.has_previous_page => attrs!{ At::Disabled=>true }),
|
|
"<",
|
|
IF!(pager.has_previous_page => ev(Ev::Click, |_| Msg::PreviousPage)),
|
|
],
|
|
a![
|
|
C![
|
|
"pagination-next",
|
|
"button",
|
|
//IF!(!pager.has_next_page => "is-static")
|
|
],
|
|
IF!(!pager.has_next_page => attrs!{ At::Disabled=>true }),
|
|
">",
|
|
IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage))
|
|
],
|
|
ul![
|
|
C!["pagination-list"],
|
|
li![format!(
|
|
"{} - {} of {}",
|
|
start,
|
|
count.min(start + SEARCH_RESULTS_PER_PAGE),
|
|
count
|
|
)],
|
|
],
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|
|
trait Email {
|
|
fn name(&self) -> Option<&str>;
|
|
fn addr(&self) -> Option<&str>;
|
|
}
|
|
|
|
impl<T: Email> Email for &'_ T {
|
|
fn name(&self) -> Option<&str> {
|
|
return (*self).name();
|
|
}
|
|
fn addr(&self) -> Option<&str> {
|
|
return (*self).addr();
|
|
}
|
|
}
|
|
|
|
macro_rules! implement_email {
|
|
( $($t:ty),+ ) => {$(
|
|
impl Email for $t {
|
|
fn name(&self) -> Option<&str> {
|
|
self.name.as_deref()
|
|
}
|
|
fn addr(&self) -> Option<&str> {
|
|
self.addr.as_deref()
|
|
}
|
|
}
|
|
)+};
|
|
}
|
|
|
|
implement_email!(
|
|
ShowThreadQueryThreadMessagesTo,
|
|
ShowThreadQueryThreadMessagesCc,
|
|
ShowThreadQueryThreadMessagesFrom
|
|
);
|
|
|
|
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"], contents, truncated_msg,]
|
|
}
|
|
|
|
fn has_unread(tags: &[String]) -> bool {
|
|
tags.contains(&String::from("unread"))
|
|
}
|
|
|
|
fn render_avatar(avatar: Option<String>, from: &str) -> Node<Msg> {
|
|
let initials: String = from
|
|
.to_lowercase()
|
|
.split(" ")
|
|
.map(|word| word.chars().next().unwrap())
|
|
// Limit to 2 characters because more characters don't fit in the box
|
|
.take(2)
|
|
.collect();
|
|
if let Some(src) = avatar {
|
|
img![attrs! {At::Src=>src}]
|
|
} else {
|
|
let w = 64;
|
|
let h = 64;
|
|
let from_color = compute_color(from);
|
|
svg![
|
|
attrs! {At::ViewBox=>format!("0 0 {w} {h}") },
|
|
style! {
|
|
St::Display => "block",
|
|
St::FontFamily => "Poppins",
|
|
St::FontSize => pt(32),
|
|
},
|
|
g![
|
|
rect![attrs! {
|
|
At::Fill=>from_color,
|
|
At::Stroke=>"black",
|
|
At::StrokeWidth=>"1",
|
|
|
|
// Round corners
|
|
//At::Rx => px(10),
|
|
At::X => 0,
|
|
At::Y => 0,
|
|
At::Width => h,
|
|
At::Height => h,
|
|
}],
|
|
text![
|
|
attrs! {
|
|
At::Fill => "white",
|
|
|
|
At::X => percent(50),
|
|
At::Y => percent(50),
|
|
At::DominantBaseline => "middle",
|
|
At::TextAnchor => "middle"
|
|
},
|
|
initials
|
|
]
|
|
]
|
|
]
|
|
}
|
|
}
|
|
|
|
fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
|
|
let (from, from_detail) = match &msg.from {
|
|
Some(ShowThreadQueryThreadMessagesFrom {
|
|
name: Some(name),
|
|
addr,
|
|
}) => (name.to_string(), addr.clone()),
|
|
Some(ShowThreadQueryThreadMessagesFrom {
|
|
addr: Some(addr), ..
|
|
}) => (addr.to_string(), None),
|
|
_ => (String::from("UNKNOWN"), None),
|
|
};
|
|
// TODO(wathiede): get this from server
|
|
let avatar: Option<String> = None;
|
|
//let avatar: Option<String> = Some(String::from("https://bulma.io/images/placeholders/64x64.png"));
|
|
let id = msg.id.clone();
|
|
let is_unread = has_unread(&msg.tags);
|
|
let img = render_avatar(avatar, &from);
|
|
article![
|
|
C!["media"],
|
|
figure![C!["media-left"], p![C!["image", "is-64x64"], img]],
|
|
div![
|
|
C!["media-content"],
|
|
div![
|
|
C!["content"],
|
|
p![
|
|
strong![from],
|
|
br![],
|
|
small![from_detail],
|
|
table![
|
|
IF!(!msg.to.is_empty() =>
|
|
tr![
|
|
td![ "To:" ],
|
|
//td![ if i==0 { "To" }else { "" } ],
|
|
td![
|
|
msg.to.iter().enumerate().map(|(i, to)|
|
|
small![
|
|
if i>0 { ", " }else { "" },
|
|
match to {
|
|
ShowThreadQueryThreadMessagesTo {
|
|
name: Some(name),
|
|
addr:Some(addr),
|
|
} => format!("{name} <{addr}>"),
|
|
ShowThreadQueryThreadMessagesTo {
|
|
name: Some(name),
|
|
addr:None
|
|
} => format!("{name}"),
|
|
ShowThreadQueryThreadMessagesTo {
|
|
addr: Some(addr), ..
|
|
} => format!("{addr}"),
|
|
_ => String::from("UNKNOWN"),
|
|
}
|
|
|
|
])
|
|
]
|
|
]),
|
|
IF!(!msg.cc.is_empty() =>
|
|
tr![
|
|
td![ "CC:" ],
|
|
td![
|
|
msg.cc.iter().enumerate().map(|(i, cc)|
|
|
small![
|
|
if i>0 { ", " }else { "" },
|
|
match cc {
|
|
ShowThreadQueryThreadMessagesCc {
|
|
name: Some(name),
|
|
addr:Some(addr),
|
|
} => format!("{name} <{addr}>"),
|
|
ShowThreadQueryThreadMessagesCc {
|
|
name: Some(name),
|
|
addr:None
|
|
} => format!("{name}"),
|
|
ShowThreadQueryThreadMessagesCc {
|
|
addr: Some(addr), ..
|
|
} => format!("<{addr}>"),
|
|
_ => String::from("UNKNOWN"),
|
|
}
|
|
|
|
])
|
|
]
|
|
]),
|
|
tr![td![
|
|
attrs! {At::ColSpan=>2},
|
|
msg.timestamp.map(|ts| span![C!["header"], human_age(ts)])
|
|
]]
|
|
],
|
|
],
|
|
],
|
|
],
|
|
div![
|
|
C!["media-right"],
|
|
span![
|
|
C!["read-status"],
|
|
i![C![
|
|
"far",
|
|
if is_unread {
|
|
"fa-envelope"
|
|
} else {
|
|
"fa-envelope-open"
|
|
},
|
|
]]
|
|
],
|
|
ev(Ev::Click, move |e| {
|
|
e.stop_propagation();
|
|
Msg::SetUnread(format!("id:{id}"), !is_unread)
|
|
})
|
|
]
|
|
]
|
|
}
|
|
|
|
fn render_closed_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
|
|
let from: String = match &msg.from {
|
|
Some(ShowThreadQueryThreadMessagesFrom {
|
|
name: Some(name), ..
|
|
}) => name.to_string(),
|
|
Some(ShowThreadQueryThreadMessagesFrom {
|
|
addr: Some(addr), ..
|
|
}) => addr.to_string(),
|
|
_ => String::from("UNKNOWN"),
|
|
};
|
|
// TODO(wathiede): get this from server
|
|
let avatar: Option<String> = None;
|
|
//let avatar: Option<String> = Some(String::from("https://bulma.io/images/placeholders/64x64.png"));
|
|
let id = msg.id.clone();
|
|
let is_unread = has_unread(&msg.tags);
|
|
let img = render_avatar(avatar, &from);
|
|
article![
|
|
C!["media"],
|
|
figure![C!["media-left"], p![C!["image", "is-64x64"], img]],
|
|
div![
|
|
C!["media-content"],
|
|
div![
|
|
C!["content"],
|
|
p![
|
|
strong![from],
|
|
br![],
|
|
IF!(!msg.to.is_empty() =>
|
|
nodes![
|
|
small![" to "],
|
|
msg.to.iter().enumerate().map(|(i, to)| small![
|
|
if i > 0 { ", " } else { "" },
|
|
to.name()
|
|
.as_ref()
|
|
.unwrap_or(&to.addr().unwrap_or("(UNKNOWN)"))
|
|
]).collect::<Vec<_>>()
|
|
]),
|
|
IF!(!msg.cc.is_empty() =>
|
|
nodes![
|
|
small![" cc "],
|
|
msg.cc.iter().enumerate().map(|(i, cc)| small![
|
|
if i > 0 { ", " } else { "" },
|
|
cc.name()
|
|
.as_ref()
|
|
.unwrap_or(&cc.addr().unwrap_or("(UNKNOWN)"))
|
|
]).collect::<Vec<_>>()
|
|
]),
|
|
br![],
|
|
msg.timestamp.map(|ts| span![C!["header"], human_age(ts)]),
|
|
],
|
|
],
|
|
],
|
|
div![
|
|
C!["media-right"],
|
|
span![
|
|
C!["read-status"],
|
|
i![
|
|
C![
|
|
"far",
|
|
if is_unread {
|
|
"fa-envelope"
|
|
} else {
|
|
"fa-envelope-open"
|
|
},
|
|
],
|
|
ev(Ev::Click, move |e| {
|
|
e.stop_propagation();
|
|
Msg::SetUnread(format!("id:{id}"), !is_unread)
|
|
})
|
|
]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|
|
fn message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<Msg> {
|
|
let expand_id = msg.id.clone();
|
|
div![
|
|
C!["message"],
|
|
div![
|
|
C!["header"],
|
|
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!["body"],
|
|
match &msg.body {
|
|
ShowThreadQueryThreadMessagesBody::UnhandledContentType(
|
|
ShowThreadQueryThreadMessagesBodyOnUnhandledContentType { contents },
|
|
) => pre![C!["error"], contents],
|
|
ShowThreadQueryThreadMessagesBody::PlainText(
|
|
ShowThreadQueryThreadMessagesBodyOnPlainText {
|
|
contents,
|
|
content_tree,
|
|
},
|
|
) => div![
|
|
raw_text_message(&contents),
|
|
view_content_tree(&content_tree),
|
|
],
|
|
ShowThreadQueryThreadMessagesBody::Html(
|
|
ShowThreadQueryThreadMessagesBodyOnHtml {
|
|
contents,
|
|
content_tree,
|
|
},
|
|
) => div![
|
|
C!["view-part-text-html"],
|
|
raw![contents],
|
|
IF!(!msg.attachments.is_empty() =>
|
|
div![
|
|
C!["attachments"],
|
|
hr![],
|
|
h2!["Attachments"],
|
|
msg.attachments
|
|
.iter()
|
|
.map(|a| div!["Filename: ", &a.filename, " ", &a.content_type])
|
|
]),
|
|
view_content_tree(&content_tree),
|
|
],
|
|
}
|
|
])
|
|
]
|
|
}
|
|
|
|
#[topo::nested]
|
|
fn thread(thread: &ShowThreadQueryThread, open_messages: &HashSet<String>) -> 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();
|
|
div![
|
|
C!["thread"],
|
|
h3![C!["is-size-5"], subject],
|
|
span![C!["tags"], tags_chiclet(&tags, false)],
|
|
span![
|
|
// TODO(wathiede): add "Mark as spam"
|
|
C!["level-item", "buttons", "has-addons"],
|
|
button![
|
|
C!["button"],
|
|
attrs! {At::Title => "Mark as read"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
|
|
span!["Read"],
|
|
ev(Ev::Click, move |_| Msg::SetUnread(
|
|
format!("thread:{read_thread_id}"),
|
|
false
|
|
)),
|
|
],
|
|
button![
|
|
C!["button"],
|
|
attrs! {At::Title => "Mark as unread"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
|
|
span!["Unread"],
|
|
ev(Ev::Click, move |_| Msg::SetUnread(
|
|
format!("thread:{unread_thread_id}"),
|
|
true
|
|
)),
|
|
],
|
|
],
|
|
messages,
|
|
/* 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",
|
|
ev(Ev::Click, move |_| {
|
|
debug_open.set(!debug_open.get());
|
|
})
|
|
],
|
|
IF!(debug_open.get() =>
|
|
pre![C!["content-tree"], content_tree]),
|
|
]
|
|
}
|
|
|
|
fn view_header(query: &str, refresh_request: &RefreshingState) -> 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!["navbar"],
|
|
attrs! {At::Role=>"navigation"},
|
|
div![
|
|
C!["navbar-start"],
|
|
a![
|
|
C!["navbar-item", "button", IF![is_error => "is-danger"]],
|
|
span![i![C![
|
|
"fa-solid",
|
|
"fa-arrow-rotate-right",
|
|
"refresh",
|
|
IF![is_loading => "loading"],
|
|
]]],
|
|
ev(Ev::Click, |_| Msg::RefreshStart),
|
|
],
|
|
a![
|
|
C!["navbar-item", "button"],
|
|
attrs! {
|
|
At::Href => urls::search(unread_query(), 0)
|
|
},
|
|
"Unread",
|
|
],
|
|
a![
|
|
C!["navbar-item", "button"],
|
|
attrs! {
|
|
At::Href => urls::search("", 0)
|
|
},
|
|
"All",
|
|
],
|
|
input![
|
|
C!["navbar-item", "input"],
|
|
attrs! {
|
|
At::Placeholder => "Search";
|
|
At::AutoFocus => true.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
|
|
}),
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|
|
// `view` describes what to display.
|
|
pub fn view(model: &Model) -> Node<Msg> {
|
|
let win = seed::window();
|
|
let w = win
|
|
.inner_width()
|
|
.expect("window width")
|
|
.as_f64()
|
|
.expect("window width f64");
|
|
let _h = win
|
|
.inner_height()
|
|
.expect("window height")
|
|
.as_f64()
|
|
.expect("window height f64");
|
|
div![match w {
|
|
w if w < 800. => div![C!["mobile"], mobile::view(model)],
|
|
w if w < 1024. => div![C!["tablet"], tablet::view(model)],
|
|
_ => div![C!["desktop"], desktop::view(model)],
|
|
},]
|
|
}
|