Compare commits
No commits in common. "5923547159545bae48a0c00ea75a80acbddf63b2" and "4957b485a02664ca64cb5ddfc6588a1f0bfd0585" have entirely different histories.
5923547159
...
4957b485a0
@ -9,8 +9,6 @@
|
||||
integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" />
|
||||
<!-- Pretty checkboxes -->
|
||||
<link data-trunk rel="css" href="static/main.css" />
|
||||
<style>
|
||||
.message {
|
||||
display: inline-block;
|
||||
@ -65,14 +63,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.index .edit {
|
||||
width: 1.5em;
|
||||
}
|
||||
|
||||
.index .unread {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.index .from {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@ -86,6 +76,16 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.index .subject:hover .mark-read-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.index .subject .mark-read-button {
|
||||
display: none;
|
||||
font-size: .6rem;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.index .date {
|
||||
width: 10em;
|
||||
white-space: nowrap;
|
||||
@ -152,20 +152,9 @@
|
||||
}
|
||||
|
||||
.search-results .row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
border-bottom: 1px #444 solid;
|
||||
padding-bottom: .5em;
|
||||
padding-top: .5em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-results .row .checkbox {}
|
||||
|
||||
.search-results .row .summary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-results .row .subject {
|
||||
@ -192,13 +181,13 @@
|
||||
|
||||
/* Hide quoted emails */
|
||||
/*
|
||||
div[name="quote"],
|
||||
blockquote[type="cite"],
|
||||
.gmail_quote {
|
||||
div[name="quote"],
|
||||
blockquote[type="cite"],
|
||||
.gmail_quote {
|
||||
background-color: red;
|
||||
display: none;
|
||||
}
|
||||
*/
|
||||
}
|
||||
*/
|
||||
|
||||
.desktop .main-content {
|
||||
display: grid;
|
||||
|
||||
@ -1,10 +1,22 @@
|
||||
use gloo_net::{http::Request, Error};
|
||||
use log::info;
|
||||
use notmuch::ThreadSet;
|
||||
use seed::Url;
|
||||
|
||||
const BASE_URL: &str = "/api";
|
||||
pub fn refresh() -> String {
|
||||
format!("{BASE_URL}/refresh")
|
||||
}
|
||||
pub fn search(query: &str, page: usize, results_per_page: usize) -> String {
|
||||
let query = Url::encode_uri_component(query);
|
||||
format!("{BASE_URL}/search/{query}?page={page}&results_per_page={results_per_page}")
|
||||
}
|
||||
pub fn show_pretty(tid: &str) -> String {
|
||||
format!("{BASE_URL}/show/{tid}/pretty")
|
||||
}
|
||||
pub fn original(message_id: &str) -> String {
|
||||
format!("{BASE_URL}/original/{message_id}")
|
||||
}
|
||||
pub mod urls {
|
||||
use seed::Url;
|
||||
pub fn search(query: &str, page: usize) -> Url {
|
||||
@ -20,8 +32,24 @@ pub mod urls {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_request(
|
||||
query: &str,
|
||||
page: usize,
|
||||
results_per_page: usize,
|
||||
) -> Result<shared::SearchResult, Error> {
|
||||
Request::get(&search(query, page, results_per_page))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn refresh_request() -> Result<(), Error> {
|
||||
let t = Request::get(&refresh()).send().await?.text().await?;
|
||||
info!("refresh {t}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show_pretty_request(tid: &str) -> Result<ThreadSet, Error> {
|
||||
Request::get(&show_pretty(tid)).send().await?.json().await
|
||||
}
|
||||
|
||||
@ -1 +1,2 @@
|
||||
pub const SEARCH_RESULTS_PER_PAGE: usize = 20;
|
||||
pub const USE_GRAPHQL: bool = true;
|
||||
|
||||
189
web/src/state.rs
189
web/src/state.rs
@ -1,14 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use graphql_client::GraphQLQuery;
|
||||
use log::{error, info};
|
||||
use log::{debug, error, info};
|
||||
use notmuch::ThreadSet;
|
||||
use seed::{app::subs, prelude::*, *};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
api,
|
||||
api::urls,
|
||||
consts::SEARCH_RESULTS_PER_PAGE,
|
||||
consts::{SEARCH_RESULTS_PER_PAGE, USE_GRAPHQL},
|
||||
graphql,
|
||||
graphql::{front_page_query::*, send_graphql, show_thread_query::*},
|
||||
};
|
||||
@ -44,11 +43,18 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
|
||||
);
|
||||
let hpp = url.remaining_hash_path_parts();
|
||||
match hpp.as_slice() {
|
||||
["t", tid] => Msg::ShowThreadRequest {
|
||||
["t", tid] => {
|
||||
if USE_GRAPHQL {
|
||||
Msg::ShowThreadRequest {
|
||||
thread_id: tid.to_string(),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
Msg::ShowPrettyRequest(tid.to_string())
|
||||
}
|
||||
}
|
||||
["s", query] => {
|
||||
let query = Url::decode_uri_component(query).unwrap_or("".to_string());
|
||||
if USE_GRAPHQL {
|
||||
Msg::FrontPageRequest {
|
||||
query,
|
||||
after: None,
|
||||
@ -56,10 +62,18 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
|
||||
first: None,
|
||||
last: None,
|
||||
}
|
||||
} else {
|
||||
Msg::SearchRequest {
|
||||
query,
|
||||
page: 0,
|
||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||
}
|
||||
}
|
||||
}
|
||||
["s", query, page] => {
|
||||
let query = Url::decode_uri_component(query).unwrap_or("".to_string());
|
||||
let page = page[1..].parse().unwrap_or(0);
|
||||
if USE_GRAPHQL {
|
||||
Msg::FrontPageRequest {
|
||||
query,
|
||||
after: Some(page.to_string()),
|
||||
@ -67,11 +81,19 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
|
||||
first: None,
|
||||
last: None,
|
||||
}
|
||||
} else {
|
||||
Msg::SearchRequest {
|
||||
query,
|
||||
page,
|
||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||
}
|
||||
}
|
||||
}
|
||||
p => {
|
||||
if !p.is_empty() {
|
||||
info!("Unhandled path '{p:?}'");
|
||||
}
|
||||
if USE_GRAPHQL {
|
||||
Msg::FrontPageRequest {
|
||||
query: "".to_string(),
|
||||
after: None,
|
||||
@ -79,6 +101,13 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
|
||||
first: None,
|
||||
last: None,
|
||||
}
|
||||
} else {
|
||||
Msg::SearchRequest {
|
||||
query: "".to_string(),
|
||||
page: 0,
|
||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -104,8 +133,42 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
}
|
||||
Msg::OnResize => (),
|
||||
|
||||
Msg::SearchRequest {
|
||||
query,
|
||||
page,
|
||||
results_per_page,
|
||||
} => {
|
||||
info!("searching for '{query}' pg {page} # / pg {results_per_page}");
|
||||
model.query = query.clone();
|
||||
orders.skip().perform_cmd(async move {
|
||||
Msg::SearchResult(api::search_request(&query, page, results_per_page).await)
|
||||
});
|
||||
}
|
||||
Msg::SearchResult(Ok(response_data)) => {
|
||||
debug!("fetch ok {:#?}", response_data);
|
||||
model.context = Context::Search(response_data);
|
||||
}
|
||||
Msg::SearchResult(Err(fetch_error)) => {
|
||||
error!("fetch failed {:?}", fetch_error);
|
||||
}
|
||||
|
||||
Msg::ShowPrettyRequest(tid) => {
|
||||
orders.skip().perform_cmd(async move {
|
||||
Msg::ShowPrettyResult(api::show_pretty_request(&tid).await)
|
||||
});
|
||||
}
|
||||
Msg::ShowPrettyResult(Ok(response_data)) => {
|
||||
debug!("fetch ok {:#?}", response_data);
|
||||
model.context = Context::Thread(response_data);
|
||||
}
|
||||
Msg::ShowPrettyResult(Err(fetch_error)) => {
|
||||
error!("fetch failed {:?}", fetch_error);
|
||||
}
|
||||
Msg::NextPage => {
|
||||
match &model.context {
|
||||
Context::Search(sr) => {
|
||||
orders.request_url(urls::search(&sr.query, sr.page + 1));
|
||||
}
|
||||
Context::SearchResult { query, pager, .. } => {
|
||||
let query = query.to_string();
|
||||
let after = pager.end_cursor.clone();
|
||||
@ -119,12 +182,16 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
}
|
||||
});
|
||||
}
|
||||
Context::ThreadResult { .. } => (), // do nothing (yet?)
|
||||
Context::Thread(_) => (), // do nothing (yet?)
|
||||
Context::ThreadResult(_) => (), // do nothing (yet?)
|
||||
Context::None => (), // do nothing (yet?)
|
||||
};
|
||||
}
|
||||
Msg::PreviousPage => {
|
||||
match &model.context {
|
||||
Context::Search(sr) => {
|
||||
orders.request_url(urls::search(&sr.query, sr.page.saturating_sub(1)));
|
||||
}
|
||||
Context::SearchResult { query, pager, .. } => {
|
||||
let query = query.to_string();
|
||||
let before = pager.start_cursor.clone();
|
||||
@ -139,7 +206,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
});
|
||||
}
|
||||
|
||||
Context::ThreadResult { .. } => (), // do nothing (yet?)
|
||||
Context::Thread(_) => (), // do nothing (yet?)
|
||||
Context::ThreadResult(_) => (), // do nothing (yet?)
|
||||
Context::None => (), // do nothing (yet?)
|
||||
};
|
||||
}
|
||||
@ -226,7 +294,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
results: data.search.nodes,
|
||||
count: data.count as usize,
|
||||
pager: data.search.page_info,
|
||||
selected_threads: HashSet::new(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -254,89 +321,11 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let mut open_messages: HashSet<_> = data
|
||||
.thread
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|msg| msg.tags.iter().any(|t| t == "unread"))
|
||||
.map(|msg| msg.id.clone())
|
||||
.collect();
|
||||
if open_messages.is_empty() {
|
||||
open_messages = data
|
||||
.thread
|
||||
.messages
|
||||
.iter()
|
||||
.map(|msg| msg.id.clone())
|
||||
.collect();
|
||||
}
|
||||
model.context = Context::ThreadResult {
|
||||
thread: data.thread,
|
||||
open_messages,
|
||||
};
|
||||
model.context = Context::ThreadResult(data.thread);
|
||||
}
|
||||
Msg::ShowThreadResult(bad) => {
|
||||
error!("show_thread_query error: {bad:#?}");
|
||||
}
|
||||
Msg::SelectionMarkAsRead => {
|
||||
if let Context::SearchResult {
|
||||
selected_threads, ..
|
||||
} = &mut model.context
|
||||
{
|
||||
let threads = selected_threads
|
||||
.iter()
|
||||
.map(|tid| format!("thread:{tid}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
orders
|
||||
.skip()
|
||||
.perform_cmd(async move { Msg::SetUnread(threads, false) });
|
||||
}
|
||||
}
|
||||
Msg::SelectionMarkAsUnread => {
|
||||
if let Context::SearchResult {
|
||||
selected_threads, ..
|
||||
} = &mut model.context
|
||||
{
|
||||
let threads = selected_threads
|
||||
.iter()
|
||||
.map(|tid| format!("thread:{tid}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
orders
|
||||
.skip()
|
||||
.perform_cmd(async move { Msg::SetUnread(threads, true) });
|
||||
}
|
||||
}
|
||||
Msg::SelectionAddThread(tid) => {
|
||||
if let Context::SearchResult {
|
||||
selected_threads, ..
|
||||
} = &mut model.context
|
||||
{
|
||||
selected_threads.insert(tid);
|
||||
info!("selected threads {selected_threads:?}");
|
||||
}
|
||||
}
|
||||
Msg::SelectionRemoveThread(tid) => {
|
||||
if let Context::SearchResult {
|
||||
selected_threads, ..
|
||||
} = &mut model.context
|
||||
{
|
||||
selected_threads.remove(&tid);
|
||||
info!("selected threads {selected_threads:?}");
|
||||
}
|
||||
}
|
||||
Msg::MessageCollapse(id) => {
|
||||
if let Context::ThreadResult { open_messages, .. } = &mut model.context {
|
||||
open_messages.remove(&id);
|
||||
info!("open_messages threads {open_messages:?}");
|
||||
}
|
||||
}
|
||||
Msg::MessageExpand(id) => {
|
||||
if let Context::ThreadResult { open_messages, .. } = &mut model.context {
|
||||
open_messages.insert(id);
|
||||
info!("open_messages threads {open_messages:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Model` describes our app state.
|
||||
@ -363,17 +352,15 @@ pub enum UIError {
|
||||
|
||||
pub enum Context {
|
||||
None,
|
||||
Search(shared::SearchResult),
|
||||
SearchResult {
|
||||
query: String,
|
||||
results: Vec<FrontPageQuerySearchNodes>,
|
||||
count: usize,
|
||||
pager: FrontPageQuerySearchPageInfo,
|
||||
selected_threads: HashSet<String>,
|
||||
},
|
||||
ThreadResult {
|
||||
thread: ShowThreadQueryThread,
|
||||
open_messages: HashSet<String>,
|
||||
},
|
||||
Thread(ThreadSet),
|
||||
ThreadResult(ShowThreadQueryThread),
|
||||
}
|
||||
|
||||
pub struct Tag {
|
||||
@ -399,6 +386,14 @@ pub enum Msg {
|
||||
// Tell the server to update state
|
||||
RefreshStart,
|
||||
RefreshDone(Option<gloo_net::Error>),
|
||||
SearchRequest {
|
||||
query: String,
|
||||
page: usize,
|
||||
results_per_page: usize,
|
||||
},
|
||||
SearchResult(Result<shared::SearchResult, gloo_net::Error>),
|
||||
ShowPrettyRequest(String),
|
||||
ShowPrettyResult(Result<ThreadSet, gloo_net::Error>),
|
||||
NextPage,
|
||||
PreviousPage,
|
||||
UpdateQuery(String),
|
||||
@ -422,12 +417,4 @@ pub enum Msg {
|
||||
ShowThreadResult(
|
||||
Result<graphql_client::Response<graphql::show_thread_query::ResponseData>, gloo_net::Error>,
|
||||
),
|
||||
|
||||
SelectionMarkAsRead,
|
||||
SelectionMarkAsUnread,
|
||||
SelectionAddThread(String),
|
||||
SelectionRemoveThread(String),
|
||||
|
||||
MessageCollapse(String),
|
||||
MessageExpand(String),
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ use seed_hooks::{state_access::CloneState, topo, use_state};
|
||||
use crate::{
|
||||
api::urls,
|
||||
state::{Context, Model, Msg, Tag},
|
||||
view::{self, view_header, view_search_results},
|
||||
view::{self, legacy, view_header, view_search_results},
|
||||
};
|
||||
|
||||
#[topo::nested]
|
||||
@ -12,17 +12,15 @@ pub(super) fn view(model: &Model) -> Node<Msg> {
|
||||
// Do two queries, one without `unread` so it loads fast, then a second with unread.
|
||||
let content = match &model.context {
|
||||
Context::None => div![h1!["Loading"]],
|
||||
Context::ThreadResult {
|
||||
thread,
|
||||
open_messages,
|
||||
} => view::thread(thread, open_messages),
|
||||
Context::Thread(thread_set) => legacy::thread(thread_set),
|
||||
Context::ThreadResult(thread) => view::thread(thread),
|
||||
Context::Search(search_results) => legacy::search_results(&model.query, search_results),
|
||||
Context::SearchResult {
|
||||
query,
|
||||
results,
|
||||
count,
|
||||
pager,
|
||||
selected_threads,
|
||||
} => view_search_results(&query, results.as_slice(), *count, pager, selected_threads),
|
||||
} => view_search_results(&query, results.as_slice(), *count, pager),
|
||||
};
|
||||
fn view_tag_li(display_name: &str, indent: usize, t: &Tag, search_unread: bool) -> Node<Msg> {
|
||||
let href = if search_unread {
|
||||
|
||||
270
web/src/view/legacy.rs
Normal file
270
web/src/view/legacy.rs
Normal file
@ -0,0 +1,270 @@
|
||||
use notmuch::{Content, Part, ThreadNode, ThreadSet};
|
||||
use seed::{prelude::*, *};
|
||||
|
||||
use crate::{
|
||||
api,
|
||||
api::urls,
|
||||
consts::SEARCH_RESULTS_PER_PAGE,
|
||||
state::Msg,
|
||||
view::{human_age, pretty_authors, set_title, tags_chiclet},
|
||||
};
|
||||
|
||||
pub(super) fn search_results(query: &str, search_results: &shared::SearchResult) -> Node<Msg> {
|
||||
if query.is_empty() {
|
||||
set_title("all mail");
|
||||
} else {
|
||||
set_title(query);
|
||||
}
|
||||
let summaries = &search_results.summary.0;
|
||||
let rows = summaries.iter().map(|r| {
|
||||
let tid = r.thread.clone();
|
||||
let datetime = human_age(r.timestamp as i64);
|
||||
tr![
|
||||
td![
|
||||
C!["from"],
|
||||
pretty_authors(&r.authors),
|
||||
IF!(r.total>1 => small![" ", r.total.to_string()]),
|
||||
],
|
||||
td![
|
||||
C!["subject"],
|
||||
tags_chiclet(&r.tags, false),
|
||||
" ",
|
||||
a![
|
||||
C!["has-text-light"],
|
||||
attrs! {
|
||||
At::Href => urls::thread(&tid)
|
||||
},
|
||||
&r.subject,
|
||||
]
|
||||
],
|
||||
td![C!["date"], datetime]
|
||||
]
|
||||
});
|
||||
let first = search_results.page * search_results.results_per_page;
|
||||
div![
|
||||
search_pager(first, summaries.len(), search_results.total),
|
||||
table![
|
||||
C![
|
||||
"table",
|
||||
"index",
|
||||
"is-fullwidth",
|
||||
"is-hoverable",
|
||||
"is-narrow",
|
||||
"is-striped",
|
||||
],
|
||||
thead![tr![
|
||||
th![C!["from"], "From"],
|
||||
th![C!["subject"], "Subject"],
|
||||
th![C!["date"], "Date"]
|
||||
]],
|
||||
tbody![rows]
|
||||
],
|
||||
search_pager(first, summaries.len(), search_results.total)
|
||||
]
|
||||
}
|
||||
|
||||
fn search_pager(start: usize, count: usize, total: usize) -> Node<Msg> {
|
||||
let is_first = start <= 0;
|
||||
let is_last = (start + SEARCH_RESULTS_PER_PAGE) >= total;
|
||||
nav![
|
||||
C!["pagination"],
|
||||
a![
|
||||
C!["pagination-previous", "button",],
|
||||
IF!(is_first => attrs!{ At::Disabled=>true }),
|
||||
"<",
|
||||
ev(Ev::Click, |_| Msg::PreviousPage)
|
||||
],
|
||||
a![
|
||||
C!["pagination-next", "button", IF!(is_last => "is-static")],
|
||||
IF!(is_last => attrs!{ At::Disabled=>true }),
|
||||
">",
|
||||
ev(Ev::Click, |_| Msg::NextPage)
|
||||
],
|
||||
ul![
|
||||
C!["pagination-list"],
|
||||
li![format!("{} - {} of {}", start, start + count, total)],
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn thread(thread_set: &ThreadSet) -> Node<Msg> {
|
||||
assert_eq!(thread_set.0.len(), 1);
|
||||
let thread = &thread_set.0[0];
|
||||
assert_eq!(thread.0.len(), 1);
|
||||
let thread_node = &thread.0[0];
|
||||
let subject = first_subject(&thread_node).unwrap_or("<No subject>".to_string());
|
||||
set_title(&subject);
|
||||
div![
|
||||
C!["container"],
|
||||
h1![C!["title"], subject],
|
||||
view_message(&thread_node),
|
||||
a![
|
||||
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
|
||||
"Original"
|
||||
],
|
||||
]
|
||||
}
|
||||
pub(super) fn mobile_search_results(
|
||||
query: &str,
|
||||
search_results: &shared::SearchResult,
|
||||
) -> Node<Msg> {
|
||||
if query.is_empty() {
|
||||
set_title("all mail");
|
||||
} else {
|
||||
set_title(query);
|
||||
}
|
||||
let summaries = &search_results.summary.0;
|
||||
let rows = summaries.iter().map(|r| {
|
||||
/*
|
||||
let tid = r.thread.clone();
|
||||
tr![
|
||||
td![
|
||||
C!["from"],
|
||||
pretty_authors(&r.authors),
|
||||
IF!(r.total>1 => small![" ", r.total.to_string()]),
|
||||
],
|
||||
td![C!["subject"], tags_chiclet(&r.tags), " ", &r.subject],
|
||||
td![C!["date"], &r.date_relative],
|
||||
ev(Ev::Click, move |_| Msg::ShowPrettyRequest(tid)),
|
||||
]
|
||||
*/
|
||||
let tid = r.thread.clone();
|
||||
let datetime = human_age(r.timestamp as i64);
|
||||
a![
|
||||
C!["has-text-light"],
|
||||
attrs! {
|
||||
At::Href => urls::thread(&tid)
|
||||
},
|
||||
div![
|
||||
C!["row"],
|
||||
div![C!["subject"], &r.subject],
|
||||
span![C!["from", "is-size-7"], pretty_authors(&r.authors)],
|
||||
div![
|
||||
span![C!["is-size-7"], tags_chiclet(&r.tags, true)],
|
||||
span![C!["is-size-7", "float-right", "date"], datetime]
|
||||
]
|
||||
]
|
||||
]
|
||||
});
|
||||
let first = search_results.page * search_results.results_per_page;
|
||||
div![
|
||||
C!["search-results"],
|
||||
search_pager(first, summaries.len(), search_results.total),
|
||||
rows,
|
||||
search_pager(first, summaries.len(), search_results.total)
|
||||
]
|
||||
}
|
||||
fn view_message(thread: &ThreadNode) -> Node<Msg> {
|
||||
let message = thread.0.as_ref().expect("ThreadNode missing Message");
|
||||
let children = &thread.1;
|
||||
div![
|
||||
C!["message"],
|
||||
/* TODO(wathiede): collect all the tags and show them here. */
|
||||
/* TODO(wathiede): collect all the attachments from all the subparts */
|
||||
div![C!["header"], "From: ", &message.headers.from],
|
||||
div![C!["header"], "Date: ", &message.headers.date],
|
||||
div![C!["header"], "To: ", &message.headers.to],
|
||||
div![
|
||||
C!["body"],
|
||||
match &message.body {
|
||||
Some(body) => view_body(body.as_slice()),
|
||||
None => div!["<no body>"],
|
||||
},
|
||||
],
|
||||
children.iter().map(view_message)
|
||||
]
|
||||
}
|
||||
|
||||
fn view_body(body: &[Part]) -> Node<Msg> {
|
||||
div![body.iter().map(view_part)]
|
||||
}
|
||||
|
||||
fn view_text_plain(content: &Option<Content>) -> Node<Msg> {
|
||||
match &content {
|
||||
Some(Content::String(content)) => p![C!["view-part-text-plain"], content],
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for text/plain"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn view_part(part: &Part) -> Node<Msg> {
|
||||
match part.content_type.as_str() {
|
||||
"text/plain" => view_text_plain(&part.content),
|
||||
"text/html" => {
|
||||
if let Some(Content::String(html)) = &part.content {
|
||||
/* Build problems w/ css_inline. TODO(wathiede): move to server
|
||||
let inliner = css_inline::CSSInliner::options()
|
||||
.load_remote_stylesheets(false)
|
||||
.remove_style_tags(true)
|
||||
.build();
|
||||
let inlined = inliner.inline(html).expect("failed to inline CSS");
|
||||
*/
|
||||
|
||||
return div![C!["view-part-text-html"], raw![&html]];
|
||||
} else {
|
||||
div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for multipart/mixed"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/MIME#alternative
|
||||
// RFC1341 states: In general, user agents that compose multipart/alternative entities
|
||||
// should place the body parts in increasing order of preference, that is, with the
|
||||
// preferred format last.
|
||||
"multipart/alternative" => {
|
||||
if let Some(Content::Multipart(parts)) = &part.content {
|
||||
for part in parts.iter().rev() {
|
||||
if part.content_type == "text/html" {
|
||||
if let Some(Content::String(html)) = &part.content {
|
||||
/*
|
||||
let inliner = css_inline::CSSInliner::options()
|
||||
.load_remote_stylesheets(false)
|
||||
.remove_style_tags(true)
|
||||
.build();
|
||||
let inlined = inliner.inline(html).expect("failed to inline CSS");
|
||||
*/
|
||||
return div![Node::from_html(None, &html)];
|
||||
}
|
||||
}
|
||||
if part.content_type == "text/plain" {
|
||||
return view_text_plain(&part.content);
|
||||
}
|
||||
}
|
||||
div!["No known multipart/alternative parts"]
|
||||
} else {
|
||||
div![
|
||||
C!["error"],
|
||||
format!("multipart/alternative with non-multipart content"),
|
||||
]
|
||||
}
|
||||
}
|
||||
"multipart/mixed" => match &part.content {
|
||||
Some(Content::Multipart(parts)) => div![parts.iter().map(view_part)],
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for multipart/mixed"),
|
||||
],
|
||||
},
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content type: {}", part.content_type)
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn first_subject(thread: &ThreadNode) -> Option<String> {
|
||||
if let Some(msg) = &thread.0 {
|
||||
return Some(msg.headers.subject.clone());
|
||||
} else {
|
||||
for tn in &thread.1 {
|
||||
if let Some(s) = first_subject(&tn) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@ -1,28 +1,29 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use seed::{prelude::*, *};
|
||||
|
||||
use crate::{
|
||||
api::urls,
|
||||
graphql::front_page_query::*,
|
||||
state::{Context, Model, Msg},
|
||||
view::{self, human_age, pretty_authors, search_toolbar, set_title, tags_chiclet, view_header},
|
||||
view::{
|
||||
self, human_age, legacy, pretty_authors, set_title, tags_chiclet, view_header,
|
||||
view_search_pager,
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) fn view(model: &Model) -> Node<Msg> {
|
||||
let content = match &model.context {
|
||||
Context::None => div![h1!["Loading"]],
|
||||
Context::ThreadResult {
|
||||
thread,
|
||||
open_messages,
|
||||
} => view::thread(thread, open_messages),
|
||||
Context::Thread(thread_set) => legacy::thread(thread_set),
|
||||
Context::ThreadResult(thread) => view::thread(thread),
|
||||
Context::Search(search_results) => {
|
||||
legacy::mobile_search_results(&model.query, search_results)
|
||||
}
|
||||
Context::SearchResult {
|
||||
query,
|
||||
results,
|
||||
count,
|
||||
pager,
|
||||
selected_threads,
|
||||
} => search_results(&query, results.as_slice(), *count, pager, selected_threads),
|
||||
} => search_results(&query, results.as_slice(), *count, pager),
|
||||
};
|
||||
div![
|
||||
view_header(&model.query, &model.refreshing_state),
|
||||
@ -36,7 +37,6 @@ fn search_results(
|
||||
results: &[FrontPageQuerySearchNodes],
|
||||
count: usize,
|
||||
pager: &FrontPageQuerySearchPageInfo,
|
||||
selected_threads: &HashSet<String>,
|
||||
) -> Node<Msg> {
|
||||
if query.is_empty() {
|
||||
set_title("all mail");
|
||||
@ -45,59 +45,27 @@ fn search_results(
|
||||
}
|
||||
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);
|
||||
};
|
||||
div![
|
||||
C!["row"],
|
||||
label![
|
||||
C!["b-checkbox", "checkbox", "is-large"],
|
||||
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
|
||||
}
|
||||
}),
|
||||
],
|
||||
a![
|
||||
C!["has-text-light", "summary"],
|
||||
IF!(unread_idx.is_some() => C!["unread"]),
|
||||
C!["has-text-light"],
|
||||
attrs! {
|
||||
At::Href => urls::thread(&tid)
|
||||
},
|
||||
div![
|
||||
C!["row"],
|
||||
div![C!["subject"], &r.subject],
|
||||
span![C!["from", "is-size-7"], pretty_authors(&r.authors)],
|
||||
div![
|
||||
span![C!["is-size-7"], tags_chiclet(&tags, true)],
|
||||
span![C!["is-size-7"], tags_chiclet(&r.tags, true)],
|
||||
span![C!["is-size-7", "float-right", "date"], datetime]
|
||||
]
|
||||
]
|
||||
]
|
||||
});
|
||||
let show_bulk_edit = !selected_threads.is_empty();
|
||||
div![
|
||||
C!["search-results"],
|
||||
search_toolbar(count, pager, show_bulk_edit),
|
||||
div![C!["index"], rows],
|
||||
search_toolbar(count, pager, show_bulk_edit),
|
||||
view_search_pager(count, pager),
|
||||
rows,
|
||||
view_search_pager(count, pager),
|
||||
]
|
||||
}
|
||||
|
||||
@ -5,19 +5,23 @@ use std::{
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, Local, Utc};
|
||||
use itertools::Itertools;
|
||||
use log::error;
|
||||
use log::{error, info};
|
||||
use seed::{prelude::*, *};
|
||||
use seed_hooks::{state_access::CloneState, topo, use_state};
|
||||
use seed_hooks::{
|
||||
state_access::{CloneState, StateAccess},
|
||||
topo, use_state,
|
||||
};
|
||||
use wasm_timer::Instant;
|
||||
|
||||
use crate::{
|
||||
api::urls,
|
||||
consts::SEARCH_RESULTS_PER_PAGE,
|
||||
consts::{SEARCH_RESULTS_PER_PAGE, USE_GRAPHQL},
|
||||
graphql::{front_page_query::*, show_thread_query::*},
|
||||
state::{Model, Msg, RefreshingState},
|
||||
};
|
||||
|
||||
mod desktop;
|
||||
mod legacy;
|
||||
mod mobile;
|
||||
mod tablet;
|
||||
|
||||
@ -43,12 +47,10 @@ fn tags_chiclet(tags: &[String], is_mobile: bool) -> impl Iterator<Item = Node<M
|
||||
"replied" => span![classes, style, i![C!["fa-solid", "fa-reply"]]],
|
||||
_ => span![classes, style, &tag],
|
||||
},
|
||||
ev(Ev::Click, move |_| Msg::FrontPageRequest {
|
||||
ev(Ev::Click, move |_| Msg::SearchRequest {
|
||||
query: format!("tag:{tag}"),
|
||||
after: None,
|
||||
before: None,
|
||||
first: None,
|
||||
last: None,
|
||||
page: 0,
|
||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||
})
|
||||
]
|
||||
})
|
||||
@ -109,50 +111,17 @@ fn view_search_results(
|
||||
results: &[FrontPageQuerySearchNodes],
|
||||
count: usize,
|
||||
pager: &FrontPageQuerySearchPageInfo,
|
||||
selected_threads: &HashSet<String>,
|
||||
) -> Node<Msg> {
|
||||
info!("pager {pager:?}");
|
||||
if query.is_empty() {
|
||||
set_title("all mail");
|
||||
} else {
|
||||
set_title(query);
|
||||
}
|
||||
let show_bulk_edit = !selected_threads.is_empty();
|
||||
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);
|
||||
};
|
||||
tr![
|
||||
IF!(unread_idx.is_some() => C!["unread"]),
|
||||
td![label![
|
||||
C!["checkbox"],
|
||||
input![
|
||||
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::<web_sys::HtmlInputElement>()
|
||||
{
|
||||
if input.checked() {
|
||||
Msg::SelectionAddThread(check_tid)
|
||||
} else {
|
||||
Msg::SelectionRemoveThread(check_tid)
|
||||
}
|
||||
} else {
|
||||
Msg::Noop
|
||||
}
|
||||
}),
|
||||
]
|
||||
]],
|
||||
td![
|
||||
C!["from"],
|
||||
pretty_authors(&r.authors),
|
||||
@ -160,7 +129,7 @@ fn view_search_results(
|
||||
],
|
||||
td![
|
||||
C!["subject"],
|
||||
tags_chiclet(&tags, false),
|
||||
tags_chiclet(&r.tags, false),
|
||||
" ",
|
||||
a![
|
||||
C!["has-text-light"],
|
||||
@ -168,6 +137,16 @@ fn view_search_results(
|
||||
At::Href => urls::thread(&tid)
|
||||
},
|
||||
&r.subject,
|
||||
button![
|
||||
C!["mark-read-button", "button", "is-dark", "is-small"],
|
||||
"Read",
|
||||
ev(Ev::Click, move |e| {
|
||||
info!("click happens");
|
||||
e.stop_propagation();
|
||||
e.prevent_default();
|
||||
Msg::SetUnread(format!("thread:{tid}"), false)
|
||||
}),
|
||||
]
|
||||
]
|
||||
],
|
||||
td![C!["date"], datetime]
|
||||
@ -175,7 +154,7 @@ fn view_search_results(
|
||||
});
|
||||
|
||||
div![
|
||||
search_toolbar(count, pager, show_bulk_edit),
|
||||
view_search_pager(count, pager),
|
||||
table![
|
||||
C![
|
||||
"table",
|
||||
@ -186,52 +165,24 @@ fn view_search_results(
|
||||
"is-striped",
|
||||
],
|
||||
thead![tr![
|
||||
th![C!["edit"], ""],
|
||||
th![C!["from"], "From"],
|
||||
th![C!["subject"], "Subject"],
|
||||
th![C!["date"], "Date"]
|
||||
]],
|
||||
tbody![rows]
|
||||
],
|
||||
search_toolbar(count, pager, show_bulk_edit)
|
||||
view_search_pager(count, pager)
|
||||
]
|
||||
}
|
||||
|
||||
fn search_toolbar(
|
||||
count: usize,
|
||||
pager: &FrontPageQuerySearchPageInfo,
|
||||
show_bulk_edit: bool,
|
||||
) -> Node<Msg> {
|
||||
fn view_search_pager(count: usize, pager: &FrontPageQuerySearchPageInfo) -> 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![
|
||||
C!["level-item", "buttons"],
|
||||
button![
|
||||
C!["button"],
|
||||
attrs!{At::Title => "Mark as read"},
|
||||
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
|
||||
ev(Ev::Click, |_| Msg::SelectionMarkAsRead),
|
||||
],
|
||||
button![
|
||||
C!["button"],
|
||||
attrs!{At::Title => "Mark as unread"},
|
||||
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
|
||||
ev(Ev::Click, |_| Msg::SelectionMarkAsUnread),
|
||||
],
|
||||
]),
|
||||
],
|
||||
div![
|
||||
C!["level-right"],
|
||||
nav![
|
||||
C!["level-item", "pagination"],
|
||||
C!["pagination"],
|
||||
a![
|
||||
C![
|
||||
"pagination-previous",
|
||||
@ -262,8 +213,6 @@ fn search_toolbar(
|
||||
)],
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
trait Email {
|
||||
@ -327,12 +276,16 @@ fn raw_text_message(contents: &str) -> Node<Msg> {
|
||||
}
|
||||
|
||||
fn has_unread(tags: &[String]) -> bool {
|
||||
tags.contains(&String::from("unread"))
|
||||
for t in tags {
|
||||
if t == "unread" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<Msg> {
|
||||
fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: StateAccess<bool>) -> Node<Msg> {
|
||||
let id = msg.id.clone();
|
||||
let expand_id = msg.id.clone();
|
||||
let is_unread = has_unread(&msg.tags);
|
||||
div![
|
||||
C!["message"],
|
||||
@ -344,10 +297,7 @@ fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<
|
||||
style! {
|
||||
St::Color => "gold"
|
||||
},
|
||||
C![
|
||||
if is_unread { "fa-regular" } else { "fa-solid" },
|
||||
"fa-envelope"
|
||||
],
|
||||
C![if is_unread { "fa-regular" } else { "fa-solid" }, "fa-star"],
|
||||
ev(Ev::Click, move |e| {
|
||||
e.stop_propagation();
|
||||
Msg::SetUnread(format!("id:{id}"), !is_unread)
|
||||
@ -363,18 +313,16 @@ fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<
|
||||
// TODO(wathiede): add first line of message body
|
||||
],
|
||||
ev(Ev::Click, move |e| {
|
||||
open.set(!open.get());
|
||||
e.stop_propagation();
|
||||
if open {
|
||||
Msg::MessageCollapse(expand_id)
|
||||
} else {
|
||||
Msg::MessageExpand(expand_id)
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
fn unread_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<Msg> {
|
||||
fn unread_message_render(
|
||||
msg: &ShowThreadQueryThreadMessages,
|
||||
open: StateAccess<bool>,
|
||||
) -> Node<Msg> {
|
||||
let id = msg.id.clone();
|
||||
let expand_id = msg.id.clone();
|
||||
let is_unread = has_unread(&msg.tags);
|
||||
div![
|
||||
C!["message"],
|
||||
@ -386,10 +334,7 @@ fn unread_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Nod
|
||||
style! {
|
||||
St::Color => "gold"
|
||||
},
|
||||
C![
|
||||
if is_unread { "fa-regular" } else { "fa-solid" },
|
||||
"fa-envelope"
|
||||
],
|
||||
C![if is_unread { "fa-regular" } else { "fa-solid" }, "fa-star"],
|
||||
ev(Ev::Click, move |e| {
|
||||
e.stop_propagation();
|
||||
Msg::SetUnread(format!("id:{id}"), !is_unread)
|
||||
@ -408,12 +353,8 @@ fn unread_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Nod
|
||||
IF!(!msg.cc.is_empty() => span!["CC: ", view_addresses(&msg.cc)])
|
||||
],
|
||||
ev(Ev::Click, move |e| {
|
||||
open.set(!open.get());
|
||||
e.stop_propagation();
|
||||
if open {
|
||||
Msg::MessageCollapse(expand_id)
|
||||
} else {
|
||||
Msg::MessageExpand(expand_id)
|
||||
}
|
||||
}),
|
||||
],
|
||||
div![
|
||||
@ -456,7 +397,7 @@ fn unread_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Nod
|
||||
}
|
||||
|
||||
#[topo::nested]
|
||||
fn thread(thread: &ShowThreadQueryThread, open_messages: &HashSet<String>) -> Node<Msg> {
|
||||
fn thread(thread: &ShowThreadQueryThread) -> Node<Msg> {
|
||||
// TODO(wathiede): show per-message subject if it changes significantly from top-level subject
|
||||
set_title(&thread.subject);
|
||||
let mut tags: Vec<_> = thread
|
||||
@ -471,39 +412,40 @@ fn thread(thread: &ShowThreadQueryThread, open_messages: &HashSet<String>) -> No
|
||||
tags.sort();
|
||||
let messages = thread.messages.iter().map(|msg| {
|
||||
let is_unread = has_unread(&msg.tags);
|
||||
let open = open_messages.contains(&msg.id);
|
||||
if open {
|
||||
let open = use_state(|| is_unread);
|
||||
//info!("open {} {}", open.get(), msg.id);
|
||||
if open.get() {
|
||||
unread_message_render(&msg, open)
|
||||
} else {
|
||||
read_message_render(&msg, open)
|
||||
}
|
||||
});
|
||||
let read_thread_id = thread.thread_id.clone();
|
||||
let unread_thread_id = thread.thread_id.clone();
|
||||
let any_unread = thread.messages.iter().any(|msg| has_unread(&msg.tags));
|
||||
let thread_id = thread.thread_id.clone();
|
||||
div![
|
||||
C!["thread"],
|
||||
h3![C!["is-size-5"], &thread.subject,],
|
||||
tags_chiclet(&tags, false),
|
||||
h1![
|
||||
C!["title"],
|
||||
span![
|
||||
C!["level-item", "buttons"],
|
||||
button![
|
||||
C!["button"],
|
||||
attrs! {At::Title => "Mark as read"},
|
||||
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
|
||||
C!["read-status"],
|
||||
i![
|
||||
style! {
|
||||
St::Color => "gold"
|
||||
},
|
||||
C![
|
||||
if any_unread { "fa-regular" } else { "fa-solid" },
|
||||
"fa-star"
|
||||
],
|
||||
ev(Ev::Click, move |_| Msg::SetUnread(
|
||||
format!("thread:{read_thread_id}"),
|
||||
false
|
||||
format!("thread:{}", thread_id),
|
||||
!any_unread
|
||||
)),
|
||||
],
|
||||
button![
|
||||
C!["button"],
|
||||
attrs! {At::Title => "Mark as unread"},
|
||||
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
|
||||
ev(Ev::Click, move |_| Msg::SetUnread(
|
||||
format!("thread:{unread_thread_id}"),
|
||||
true
|
||||
)),
|
||||
" ",
|
||||
],
|
||||
&thread.subject,
|
||||
" ",
|
||||
tags_chiclet(&tags, false)
|
||||
],
|
||||
messages,
|
||||
/* TODO(wathiede): plumb in orignal id
|
||||
@ -584,10 +526,30 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
|
||||
At::AutoFocus => true.as_at_value();
|
||||
At::Value => query,
|
||||
},
|
||||
input_ev(Ev::Input, |q| Msg::UpdateQuery(q)),
|
||||
input_ev(Ev::Input, |q| if USE_GRAPHQL {
|
||||
Msg::UpdateQuery(q)
|
||||
} else {
|
||||
Msg::SearchRequest {
|
||||
query: Url::encode_uri_component(if q.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
q
|
||||
}),
|
||||
page: 0,
|
||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||
}
|
||||
}),
|
||||
// Send search on enter.
|
||||
keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d {
|
||||
if USE_GRAPHQL {
|
||||
Msg::SearchQuery(query)
|
||||
} else {
|
||||
Msg::SearchRequest {
|
||||
query: Url::encode_uri_component(query),
|
||||
page: 0,
|
||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Msg::Noop
|
||||
}),
|
||||
@ -609,13 +571,14 @@ fn view_footer(render_time_ms: u128) -> Node<Msg> {
|
||||
// `view` describes what to display.
|
||||
pub fn view(model: &Model) -> Node<Msg> {
|
||||
let start = Instant::now();
|
||||
info!("refreshing {:?}", model.refreshing_state);
|
||||
let win = seed::window();
|
||||
let w = win
|
||||
.inner_width()
|
||||
.expect("window width")
|
||||
.as_f64()
|
||||
.expect("window width f64");
|
||||
let _h = win
|
||||
let h = win
|
||||
.inner_height()
|
||||
.expect("window height")
|
||||
.as_f64()
|
||||
|
||||
@ -9,17 +9,15 @@ pub(super) fn view(model: &Model) -> Node<Msg> {
|
||||
// Do two queries, one without `unread` so it loads fast, then a second with unread.
|
||||
let content = match &model.context {
|
||||
Context::None => div![h1!["Loading"]],
|
||||
Context::ThreadResult {
|
||||
thread,
|
||||
open_messages,
|
||||
} => view::thread(thread, open_messages),
|
||||
Context::Thread(_) => unimplemented!("tablet legacy thread view"),
|
||||
Context::ThreadResult(thread) => view::thread(thread),
|
||||
Context::Search(_) => unimplemented!("tablet legacy search results view"),
|
||||
Context::SearchResult {
|
||||
query,
|
||||
results,
|
||||
count,
|
||||
pager,
|
||||
selected_threads,
|
||||
} => view_search_results(&query, results.as_slice(), *count, pager, selected_threads),
|
||||
} => view_search_results(&query, results.as_slice(), *count, pager),
|
||||
};
|
||||
div![
|
||||
C!["main-content"],
|
||||
|
||||
@ -1,268 +0,0 @@
|
||||
/* Bulma Utilities */
|
||||
.b-checkbox.checkbox {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Box-shadow on hover */
|
||||
.b-checkbox.checkbox {
|
||||
outline: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:not(.button) {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:not(.button) + .checkbox:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
outline: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox] + .check {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #7a7a7a;
|
||||
transition: background 150ms ease-out;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check {
|
||||
background: #00d1b2 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #00d1b2;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-white {
|
||||
background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%230a0a0a' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-black {
|
||||
background: #0a0a0a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:white' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #0a0a0a;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-light {
|
||||
background: whitesmoke url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:rgba(0, 0, 0, 0.7)' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: whitesmoke;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-dark {
|
||||
background: #363636 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #363636;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-primary {
|
||||
background: #00d1b2 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #00d1b2;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-link {
|
||||
background: #485fc7 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #485fc7;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-info {
|
||||
background: #3e8ed0 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #3e8ed0;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-success {
|
||||
background: #48c78e url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #48c78e;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-warning {
|
||||
background: #ffe08a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:rgba(0, 0, 0, 0.7)' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #ffe08a;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-danger {
|
||||
background: #f14668 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #f14668;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check {
|
||||
background: #00d1b2 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #00d1b2;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-white, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-white {
|
||||
background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%230a0a0a' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-black, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-black {
|
||||
background: #0a0a0a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:white' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #0a0a0a;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-light, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-light {
|
||||
background: whitesmoke url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:rgba(0, 0, 0, 0.7)' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: whitesmoke;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-dark, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-dark {
|
||||
background: #363636 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #363636;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-primary, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-primary {
|
||||
background: #00d1b2 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #00d1b2;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-link, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-link {
|
||||
background: #485fc7 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #485fc7;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-info, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-info {
|
||||
background: #3e8ed0 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #3e8ed0;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-success, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-success {
|
||||
background: #48c78e url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #48c78e;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-warning, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-warning {
|
||||
background: #ffe08a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:rgba(0, 0, 0, 0.7)' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #ffe08a;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-danger, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-danger {
|
||||
background: #f14668 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
|
||||
border-color: #f14668;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus + .check {
|
||||
box-shadow: 0 0 0.5em rgba(122, 122, 122, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check {
|
||||
box-shadow: 0 0 0.5em rgba(0, 209, 178, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-white {
|
||||
box-shadow: 0 0 0.5em rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-black {
|
||||
box-shadow: 0 0 0.5em rgba(10, 10, 10, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-light {
|
||||
box-shadow: 0 0 0.5em rgba(245, 245, 245, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-dark {
|
||||
box-shadow: 0 0 0.5em rgba(54, 54, 54, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-primary {
|
||||
box-shadow: 0 0 0.5em rgba(0, 209, 178, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-link {
|
||||
box-shadow: 0 0 0.5em rgba(72, 95, 199, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-info {
|
||||
box-shadow: 0 0 0.5em rgba(62, 142, 208, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-success {
|
||||
box-shadow: 0 0 0.5em rgba(72, 199, 142, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-warning {
|
||||
box-shadow: 0 0 0.5em rgba(255, 224, 138, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-danger {
|
||||
box-shadow: 0 0 0.5em rgba(241, 70, 104, 0.8);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox .control-label {
|
||||
padding-left: calc(0.75em - 1px);
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox.button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check {
|
||||
border-color: #00d1b2;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-white {
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-black {
|
||||
border-color: #0a0a0a;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-light {
|
||||
border-color: whitesmoke;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-dark {
|
||||
border-color: #363636;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-primary {
|
||||
border-color: #00d1b2;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-link {
|
||||
border-color: #485fc7;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-info {
|
||||
border-color: #3e8ed0;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-success {
|
||||
border-color: #48c78e;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-warning {
|
||||
border-color: #ffe08a;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-danger {
|
||||
border-color: #f14668;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox.is-small {
|
||||
border-radius: 2px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox.is-medium {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.b-checkbox.checkbox.is-large {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
1
web/static/main.min.css
vendored
1
web/static/main.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user