1202 lines
43 KiB
Rust
1202 lines
43 KiB
Rust
use std::{
|
|
collections::{hash_map::DefaultHasher, HashSet},
|
|
hash::{Hash, Hasher},
|
|
};
|
|
|
|
use chrono::{DateTime, Datelike, Duration, Local, Utc};
|
|
use human_format::{Formatter, Scales};
|
|
use itertools::Itertools;
|
|
use log::{error, info};
|
|
use seed::{prelude::*, *};
|
|
use seed_hooks::{state_access::CloneState, topo, use_state};
|
|
|
|
use crate::{
|
|
api::urls,
|
|
graphql::{front_page_query::*, show_thread_query::*},
|
|
state::{unread_query, Model, Msg, RefreshingState, Tag},
|
|
};
|
|
|
|
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![match tag.as_str() {
|
|
"attachment" => span![classes, style, "📎"],
|
|
"replied" => span![classes, style, i![C!["fa-solid", "fa-reply"]]],
|
|
_ => span![classes, style, &tag],
|
|
},]
|
|
})
|
|
}
|
|
|
|
fn removable_tags_chiclet<'a>(
|
|
thread_id: &'a str,
|
|
tags: &'a [String],
|
|
is_mobile: bool,
|
|
) -> Node<Msg> {
|
|
div![
|
|
C![
|
|
"message-tags",
|
|
"field",
|
|
"is-grouped",
|
|
"is-grouped-multiline"
|
|
],
|
|
tags.iter().map(move |tag| {
|
|
let thread_id = thread_id.to_string();
|
|
let hex = compute_color(tag);
|
|
let style = style! {St::BackgroundColor=>hex};
|
|
let classes = C!["tag", IF!(is_mobile => "is-small")];
|
|
let attrs = attrs! {
|
|
At::Href => urls::search(&format!("tag:{tag}"), 0)
|
|
};
|
|
let tag = tag.clone();
|
|
let rm_tag = tag.clone();
|
|
div![
|
|
C!["control"],
|
|
div![
|
|
C!["tags", "has-addons"],
|
|
a![
|
|
classes,
|
|
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!["tag", "is-delete"],
|
|
ev(Ev::Click, move |_| Msg::RemoveTag(thread_id, rm_tag))
|
|
]
|
|
]
|
|
]
|
|
})
|
|
]
|
|
}
|
|
|
|
fn pretty_authors(authors: &str) -> impl Iterator<Item = Node<Msg>> + '_ {
|
|
let one_person = authors.matches(',').count() == 0;
|
|
let authors = authors.split(',');
|
|
|
|
Itertools::intersperse(
|
|
authors.filter_map(move |author| {
|
|
if one_person {
|
|
return Some(span![
|
|
attrs! {
|
|
At::Title => author.trim()
|
|
},
|
|
author
|
|
]);
|
|
}
|
|
author.split_whitespace().nth(0).map(|first| {
|
|
span![
|
|
attrs! {
|
|
At::Title => author.trim()
|
|
},
|
|
first
|
|
]
|
|
})
|
|
}),
|
|
span![", "],
|
|
)
|
|
}
|
|
|
|
fn human_age(timestamp: i64) -> String {
|
|
let now = Local::now();
|
|
let yesterday = now - Duration::days(1);
|
|
let ts = DateTime::<Utc>::from_timestamp(timestamp, 0)
|
|
.unwrap()
|
|
.with_timezone(&Local);
|
|
let age = now - ts;
|
|
let datetime = if age < Duration::minutes(1) {
|
|
format!("{} secs. ago", age.num_seconds())
|
|
} else if age < Duration::hours(1) {
|
|
format!("{} min. ago", age.num_minutes())
|
|
} else if ts.date_naive() == now.date_naive() {
|
|
ts.format("Today %H:%M").to_string()
|
|
} else if ts.date_naive() == yesterday.date_naive() {
|
|
ts.format("Yest. %H:%M").to_string()
|
|
} else if age < Duration::weeks(1) {
|
|
ts.format("%a %H:%M").to_string()
|
|
} else if ts.year() == now.year() {
|
|
ts.format("%b %d %H:%M").to_string()
|
|
} else {
|
|
ts.format("%b %d, %Y %H:%M").to_string()
|
|
};
|
|
datetime
|
|
}
|
|
|
|
fn view_search_results(
|
|
query: &str,
|
|
results: &[FrontPageQuerySearchNodes],
|
|
count: usize,
|
|
pager: &FrontPageQuerySearchPageInfo,
|
|
selected_threads: &HashSet<String>,
|
|
show_icon_text: bool,
|
|
) -> 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"],
|
|
a![
|
|
tags_chiclet(&tags, false),
|
|
" ",
|
|
C!["has-text-light", "text", "subject-link"],
|
|
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, show_icon_text),
|
|
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, show_icon_text)
|
|
]
|
|
}
|
|
|
|
fn search_toolbar(
|
|
count: usize,
|
|
pager: &FrontPageQuerySearchPageInfo,
|
|
show_bulk_edit: bool,
|
|
show_icon_text: bool,
|
|
) -> Node<Msg> {
|
|
nav![
|
|
C!["level", "is-mobile"],
|
|
div![
|
|
C!["level-left"],
|
|
IF!(show_bulk_edit =>
|
|
div![
|
|
C!["level-item"],
|
|
div![C!["buttons", "has-addons"],
|
|
button![
|
|
C!["button", "mark-read"],
|
|
attrs!{At::Title => "Mark as read"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
|
|
IF!(show_icon_text=>span!["Read"]),
|
|
ev(Ev::Click, |_| Msg::SelectionMarkAsRead)
|
|
],
|
|
button![
|
|
C!["button", "mark-unread"],
|
|
attrs!{At::Title => "Mark as unread"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
|
|
IF!(show_icon_text=>span!["Unread"]),
|
|
ev(Ev::Click, |_| Msg::SelectionMarkAsUnread)
|
|
]
|
|
]
|
|
]),
|
|
IF!(show_bulk_edit =>
|
|
div![
|
|
C!["level-item"],
|
|
div![C!["buttons", "has-addons"],
|
|
button![
|
|
C!["button", "spam"],
|
|
attrs!{At::Title => "Mark as spam"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]],
|
|
IF!(show_icon_text=>span!["Spam"]),
|
|
ev(Ev::Click, |_|
|
|
Msg::MultiMsg(vec![
|
|
Msg::SelectionAddTag("Spam".to_string()),
|
|
Msg::SelectionMarkAsRead
|
|
])
|
|
)
|
|
],
|
|
],
|
|
])
|
|
],
|
|
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!("{count} results")],],
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|
|
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!(
|
|
ShowThreadQueryThreadOnEmailThreadMessagesTo,
|
|
ShowThreadQueryThreadOnEmailThreadMessagesCc,
|
|
ShowThreadQueryThreadOnEmailThreadMessagesFrom
|
|
);
|
|
|
|
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()
|
|
.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();
|
|
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: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<Msg> {
|
|
let (from, from_detail) = match &msg.from {
|
|
Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom {
|
|
name: Some(name),
|
|
addr,
|
|
}) => (name.to_string(), addr.clone()),
|
|
Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom {
|
|
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,
|
|
" ",
|
|
from_detail.map(|detail| span![
|
|
i![C!["far", "fa-clone"]],
|
|
ev(Ev::Click, move |e| {
|
|
e.stop_propagation();
|
|
Msg::CopyToClipboard(detail.to_string())
|
|
})
|
|
])
|
|
],
|
|
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 { "" },
|
|
{
|
|
let to = match to {
|
|
ShowThreadQueryThreadOnEmailThreadMessagesTo {
|
|
name: Some(name),
|
|
addr:Some(addr),
|
|
} => format!("{name} <{addr}>"),
|
|
ShowThreadQueryThreadOnEmailThreadMessagesTo {
|
|
name: Some(name),
|
|
addr:None
|
|
} => format!("{name}"),
|
|
ShowThreadQueryThreadOnEmailThreadMessagesTo {
|
|
addr: Some(addr), ..
|
|
} => format!("{addr}"),
|
|
_ => String::from("UNKNOWN"),
|
|
};
|
|
span![
|
|
&to, " ",
|
|
span![
|
|
i![C!["far", "fa-clone"]],
|
|
ev(Ev::Click, move |e| {
|
|
e.stop_propagation();
|
|
Msg::CopyToClipboard(to)
|
|
})
|
|
]
|
|
]
|
|
}
|
|
|
|
])
|
|
]
|
|
]),
|
|
IF!(!msg.cc.is_empty() =>
|
|
tr![
|
|
td![ "CC:" ],
|
|
td![
|
|
msg.cc.iter().enumerate().map(|(i, cc)|
|
|
small![
|
|
if i>0 { ", " }else { "" },
|
|
{
|
|
let cc = match cc {
|
|
ShowThreadQueryThreadOnEmailThreadMessagesCc {
|
|
name: Some(name),
|
|
addr:Some(addr),
|
|
} => format!("{name} <{addr}>"),
|
|
ShowThreadQueryThreadOnEmailThreadMessagesCc {
|
|
name: Some(name),
|
|
addr:None
|
|
} => format!("{name}"),
|
|
ShowThreadQueryThreadOnEmailThreadMessagesCc {
|
|
addr: Some(addr), ..
|
|
} => format!("<{addr}>"),
|
|
_ => String::from("UNKNOWN"),
|
|
};
|
|
span![
|
|
&cc, " ",
|
|
span![
|
|
i![C!["far", "fa-clone"]],
|
|
ev(Ev::Click, move |e| {
|
|
e.stop_propagation();
|
|
Msg::CopyToClipboard(cc)
|
|
})
|
|
]
|
|
]
|
|
}
|
|
])
|
|
]
|
|
]),
|
|
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(id, !is_unread)
|
|
})
|
|
]
|
|
]
|
|
}
|
|
|
|
fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<Msg> {
|
|
let from: String = match &msg.from {
|
|
Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom {
|
|
name: Some(name), ..
|
|
}) => name.to_string(),
|
|
Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom {
|
|
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(id, !is_unread)
|
|
})
|
|
]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|
|
fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, 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", "mail"],
|
|
match &msg.body {
|
|
ShowThreadQueryThreadOnEmailThreadMessagesBody::UnhandledContentType(
|
|
ShowThreadQueryThreadOnEmailThreadMessagesBodyOnUnhandledContentType { contents ,content_tree},
|
|
) => div![
|
|
raw_text_message(&contents),
|
|
div![C!["error"],
|
|
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() =>
|
|
div![
|
|
C!["attachments"],
|
|
hr![],
|
|
h2!["Attachments"],
|
|
div![C!["grid","is-col-min-6"],
|
|
msg.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 = 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!["attachment", "card"],
|
|
a.content_type.as_ref().map(|content_type|
|
|
IF!(content_type.starts_with("image/") =>
|
|
div![C!["card-image","is-1by1"],
|
|
div![
|
|
C!["image","is-1by1"],
|
|
style!{
|
|
St::BackgroundImage=>format!(r#"url("{url}");"#),
|
|
St::BackgroundSize=>"cover",
|
|
St::BackgroundPosition=>"center",
|
|
}
|
|
]
|
|
]
|
|
)),
|
|
div![C!["card-content"],
|
|
div![C!["content"],
|
|
&a.filename, br![],
|
|
small![ fmtr.format(a.size as f64),"B"]
|
|
]
|
|
],
|
|
footer![
|
|
C!["card-footer"],
|
|
a![C!["card-footer-item"],span![C!["icon"], i![C!["fas", "fa-download"]]],
|
|
ev(Ev::Click, move |_| {
|
|
seed::window().location().set_href(&url
|
|
).expect("failed to set URL");
|
|
})
|
|
]
|
|
]
|
|
]
|
|
})
|
|
]
|
|
]),
|
|
view_content_tree(&content_tree),
|
|
],
|
|
}
|
|
])
|
|
]
|
|
}
|
|
|
|
#[topo::nested]
|
|
fn thread(
|
|
thread: &ShowThreadQueryThreadOnEmailThread,
|
|
open_messages: &HashSet<String>,
|
|
show_icon_text: bool,
|
|
) -> Node<Msg> {
|
|
// TODO(wathiede): show per-message subject if it changes significantly from top-level subject
|
|
let subject = if thread.subject.is_empty() {
|
|
"(no subject)"
|
|
} else {
|
|
&thread.subject
|
|
};
|
|
set_title(subject);
|
|
let mut tags: Vec<_> = thread
|
|
.messages
|
|
.iter()
|
|
.fold(HashSet::new(), |mut tags, msg| {
|
|
tags.extend(msg.tags.clone());
|
|
tags
|
|
})
|
|
.into_iter()
|
|
.collect();
|
|
tags.sort();
|
|
let messages = thread.messages.iter().map(|msg| {
|
|
let open = open_messages.contains(&msg.id);
|
|
message_render(&msg, open)
|
|
});
|
|
let read_thread_id = thread.thread_id.clone();
|
|
let unread_thread_id = thread.thread_id.clone();
|
|
let spam_add_thread_id = thread.thread_id.clone();
|
|
let spam_unread_thread_id = thread.thread_id.clone();
|
|
div![
|
|
C!["thread"],
|
|
h3![C!["is-size-5"], subject],
|
|
span![
|
|
C!["tags"],
|
|
removable_tags_chiclet(&thread.thread_id, &tags, false)
|
|
],
|
|
div![
|
|
C!["level", "is-mobile"],
|
|
div![
|
|
C!["level-item"],
|
|
div![
|
|
C!["buttons", "has-addons"],
|
|
button![
|
|
C!["button", "mark-read"],
|
|
attrs! {At::Title => "Mark as read"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
|
|
IF!(show_icon_text=>span!["Read"]),
|
|
ev(Ev::Click, move |_| Msg::SetUnread(read_thread_id, false)),
|
|
],
|
|
button![
|
|
C!["button", "mark-unread"],
|
|
attrs! {At::Title => "Mark as unread"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
|
|
IF!(show_icon_text=>span!["Unread"]),
|
|
ev(Ev::Click, move |_| Msg::SetUnread(unread_thread_id, true)),
|
|
],
|
|
],
|
|
],
|
|
div![
|
|
C!["level-item"],
|
|
div![
|
|
C!["buttons", "has-addons"],
|
|
button![
|
|
C!["button", "spam"],
|
|
attrs! {At::Title => "Spam"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]],
|
|
IF!(show_icon_text=>span!["Spam"]),
|
|
ev(Ev::Click, move |_| Msg::MultiMsg(vec![
|
|
Msg::AddTag(spam_add_thread_id, "Spam".to_string()),
|
|
Msg::SetUnread(spam_unread_thread_id, false)
|
|
])),
|
|
],
|
|
],
|
|
],
|
|
],
|
|
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)],
|
|
},]
|
|
}
|
|
pub fn view_tags(model: &Model) -> Node<Msg> {
|
|
fn view_tag_li(display_name: &str, indent: usize, t: &Tag, search_unread: bool) -> Node<Msg> {
|
|
let href = if search_unread {
|
|
urls::search(&format!("is:unread tag:{}", t.name), 0)
|
|
} else {
|
|
urls::search(&format!("tag:{}", t.name), 0)
|
|
};
|
|
li![a![
|
|
attrs! {
|
|
At::Href => href
|
|
},
|
|
(0..indent).map(|_| span![C!["tag-indent"], ""]),
|
|
i![
|
|
C!["tag-tag", "fa-solid", "fa-tag"],
|
|
style! {
|
|
//"--fa-primary-color" => t.fg_color,
|
|
St::Color => t.bg_color,
|
|
},
|
|
],
|
|
display_name,
|
|
IF!(t.unread>0 => format!(" ({})", t.unread)),
|
|
ev(Ev::Click, |_| {
|
|
// Scroll window to the top when searching for a tag.
|
|
info!("scrolling to the top because you clicked a tag");
|
|
web_sys::window().unwrap().scroll_to_with_x_and_y(0., 0.);
|
|
})
|
|
]]
|
|
}
|
|
fn matches(a: &[&str], b: &[&str]) -> usize {
|
|
std::iter::zip(a.iter(), b.iter())
|
|
.take_while(|(a, b)| a == b)
|
|
.count()
|
|
}
|
|
fn view_tag_list<'a>(
|
|
tags: impl Iterator<Item = &'a Tag>,
|
|
search_unread: bool,
|
|
) -> Vec<Node<Msg>> {
|
|
let mut lis = 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 <= parts.len() - 2 && parts.len() > 1 {
|
|
// Synthesize fake tags for proper indenting.
|
|
for i in n..parts.len() - 1 {
|
|
let display_name = parts[n];
|
|
lis.push(view_tag_li(
|
|
&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];
|
|
lis.push(view_tag_li(&display_name, n, t, search_unread));
|
|
last = parts;
|
|
}
|
|
lis
|
|
}
|
|
let unread = model
|
|
.tags
|
|
.as_ref()
|
|
.map(|tags| tags.iter().filter(|t| t.unread > 0).collect())
|
|
.unwrap_or(Vec::new());
|
|
let tags_open = use_state(|| false);
|
|
let force_tags_open = unread.is_empty();
|
|
aside![
|
|
C!["tags-menu", "menu"],
|
|
IF!(!unread.is_empty() => p![C!["menu-label"], "Unread"]),
|
|
IF!(!unread.is_empty() => ul![C!["menu-list"], view_tag_list(unread.into_iter(),true)]),
|
|
p![
|
|
C!["menu-label"],
|
|
IF!(!force_tags_open =>
|
|
i![C![
|
|
"fa-solid",
|
|
if tags_open.get() {
|
|
"fa-angle-up"
|
|
} else {
|
|
"fa-angle-down"
|
|
}
|
|
]]),
|
|
" Tags",
|
|
ev(Ev::Click, move |_| {
|
|
tags_open.set(!tags_open.get());
|
|
})
|
|
],
|
|
ul![
|
|
C!["menu-list"],
|
|
IF!(force_tags_open||tags_open.get() => model.tags.as_ref().map(|tags| view_tag_list(tags.iter(),false))),
|
|
]
|
|
]
|
|
}
|
|
fn news_post(post: &ShowThreadQueryThreadOnNewsPost, show_icon_text: bool) -> Node<Msg> {
|
|
// TODO(wathiede): show per-message subject if it changes significantly from top-level subject
|
|
let subject = &post.title;
|
|
set_title(subject);
|
|
let read_thread_id = post.thread_id.clone();
|
|
let unread_thread_id = post.thread_id.clone();
|
|
div![
|
|
C!["thread"],
|
|
h3![C!["is-size-5"], subject],
|
|
div![
|
|
C!["level", "is-mobile"],
|
|
div![
|
|
C!["level-item"],
|
|
div![
|
|
C!["buttons", "has-addons"],
|
|
button![
|
|
C!["button", "mark-read"],
|
|
attrs! {At::Title => "Mark as read"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
|
|
IF!(show_icon_text=>span!["Read"]),
|
|
ev(Ev::Click, move |_| Msg::SetUnread(read_thread_id, false)),
|
|
],
|
|
button![
|
|
C!["button", "mark-unread"],
|
|
attrs! {At::Title => "Mark as unread"},
|
|
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
|
|
IF!(show_icon_text=>span!["Unread"]),
|
|
ev(Ev::Click, move |_| Msg::SetUnread(unread_thread_id, true)),
|
|
],
|
|
],
|
|
],
|
|
// This would be the holder for spam buttons on emails, needed to keep layout
|
|
// consistent
|
|
div![C!["level-item"], div![]]
|
|
],
|
|
div![
|
|
C!["message"],
|
|
div![C!["header"], render_news_post_header(&post)],
|
|
div![
|
|
C!["body", "news-post", format!("site-{}", post.slug)],
|
|
raw![&post.body]
|
|
]
|
|
],
|
|
/* TODO(wathiede): plumb in orignal id
|
|
a![
|
|
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
|
|
"Original"
|
|
],
|
|
*/
|
|
ev(Ev::Scroll, |e| info!("scroll event {e:?}"))
|
|
]
|
|
}
|
|
fn render_news_post_header(post: &ShowThreadQueryThreadOnNewsPost) -> Node<Msg> {
|
|
let from = &post.site;
|
|
let from_detail = post.url.clone();
|
|
let avatar: Option<String> = None;
|
|
//let avatar: Option<String> = Some(String::from("https://bulma.io/images/placeholders/64x64.png"));
|
|
let id = post.thread_id.clone();
|
|
let is_unread = !post.is_read;
|
|
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,
|
|
" ",
|
|
span![
|
|
i![C!["far", "fa-clone"]],
|
|
ev(Ev::Click, move |e| {
|
|
e.stop_propagation();
|
|
Msg::CopyToClipboard(from_detail.to_string())
|
|
})
|
|
]
|
|
],
|
|
table![tr![td![
|
|
attrs! {At::ColSpan=>2},
|
|
span![C!["header"], human_age(post.timestamp)]
|
|
]]],
|
|
],
|
|
],
|
|
],
|
|
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(id, !is_unread)
|
|
})
|
|
]
|
|
]
|
|
}
|
|
fn reading_progress(ratio: f64) -> Node<Msg> {
|
|
let percent = ratio * 100.;
|
|
progress![
|
|
C![
|
|
"read-progress",
|
|
"progress",
|
|
"is-success",
|
|
"is-small",
|
|
IF!(percent<5. => "is-invisible")
|
|
],
|
|
attrs! {
|
|
At::Value=>percent,
|
|
At::Max=>"100"
|
|
},
|
|
format!("{percent}%")
|
|
]
|
|
}
|