Compare commits

...

8 Commits

15 changed files with 373 additions and 659 deletions

View File

@ -3,7 +3,6 @@
rustflags = [ "--cfg=web_sys_unstable_apis" ] rustflags = [ "--cfg=web_sys_unstable_apis" ]
[registry] [registry]
default = "xinu"
global-credential-providers = ["cargo:token"] global-credential-providers = ["cargo:token"]
[registries.xinu] [registries.xinu]

View File

@ -1,15 +1,10 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
default-members = [ default-members = ["server"]
"server" members = ["web", "server", "notmuch", "procmail2notmuch", "shared"]
]
members = [ [profile.dev]
"web", opt-level = 1
"server",
"notmuch",
"procmail2notmuch",
"shared"
]
[profile.release] [profile.release]
lto = true lto = true

View File

@ -1,4 +1,3 @@
use chrono::NaiveDateTime;
use clap::Parser; use clap::Parser;
use letterbox_server::mail::read_mail_to_db; use letterbox_server::mail::read_mail_to_db;
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;

View File

@ -110,6 +110,48 @@ impl Transformer for StripHtml {
} }
} }
struct InlineRemoteStyle<'a> {
base_url: &'a Option<Url>,
}
#[async_trait]
impl<'a> Transformer for InlineRemoteStyle<'a> {
async fn transform(&self, _: &Option<Url>, html: &str) -> Result<String, TransformError> {
let css = concat!(
"/* chrome-default.css */\n",
include_str!("chrome-default.css"),
"\n/* mvp.css */\n",
include_str!("mvp.css"),
"\n/* Xinu Specific overrides */\n",
include_str!("custom.css"),
);
let inline_opts = InlineOptions {
//inline_style_tags: true,
//keep_style_tags: false,
//keep_link_tags: true,
base_url: self.base_url.clone(),
//load_remote_stylesheets: true,
//preallocate_node_capacity: 32,
..InlineOptions::default()
};
//info!("HTML:\n{html}");
info!("base_url: {:#?}", self.base_url);
Ok(
match CSSInliner::options()
.base_url(self.base_url.clone())
.build()
.inline(&html)
{
Ok(inlined_html) => inlined_html,
Err(err) => {
error!("failed to inline remote CSS: {err}");
html.to_string()
}
},
)
}
}
struct InlineStyle; struct InlineStyle;
#[async_trait] #[async_trait]
@ -229,6 +271,7 @@ impl Transformer for AddOutlink {
struct SlurpContents { struct SlurpContents {
cacher: Arc<Mutex<FilesystemCacher>>, cacher: Arc<Mutex<FilesystemCacher>>,
inline_css: bool,
site_selectors: HashMap<String, Vec<Selector>>, site_selectors: HashMap<String, Vec<Selector>>,
} }
@ -267,6 +310,36 @@ impl Transformer for SlurpContents {
cacher.set(link.as_str(), body.as_bytes()); cacher.set(link.as_str(), body.as_bytes());
body body
}; };
let body = Arc::new(body);
let base_url = Some(link.clone());
let body = if self.inline_css {
let inner_body = Arc::clone(&body);
let res = tokio::task::spawn_blocking(move || {
let res = CSSInliner::options()
.base_url(base_url)
.build()
.inline(&inner_body);
match res {
Ok(inlined_html) => inlined_html,
Err(err) => {
error!("failed to inline remote CSS: {err}");
Arc::into_inner(inner_body).expect("failed to take body out of Arc")
}
}
})
.await;
match res {
Ok(inlined_html) => inlined_html,
Err(err) => {
error!("failed to spawn inline remote CSS: {err}");
Arc::into_inner(body).expect("failed to take body out of Arc")
}
}
} else {
Arc::into_inner(body).expect("failed to take body out of Arc")
};
let doc = Html::parse_document(&body); let doc = Html::parse_document(&body);
let mut results = Vec::new(); let mut results = Vec::new();
@ -277,7 +350,7 @@ impl Transformer for SlurpContents {
//warn!("couldn't find '{:?}' in {}", selector, link); //warn!("couldn't find '{:?}' in {}", selector, link);
} }
} }
Ok(results.join("<hr>")) Ok(results.join("<br>"))
} }
} }

View File

@ -1,4 +1,4 @@
use std::{fs::File, io, io::Read}; use std::{fs::File, io::Read};
use mailparse::{ use mailparse::{
addrparse_header, dateparse, parse_mail, MailHeaderMap, MailParseError, ParsedMail, addrparse_header, dateparse, parse_mail, MailHeaderMap, MailParseError, ParsedMail,
@ -63,8 +63,7 @@ pub async fn read_mail_to_db(pool: &PgPool, path: &str) -> Result<(), MailError>
println!("Feed: {feed_id} Subject: {}", subject); println!("Feed: {feed_id} Subject: {}", subject);
if let Some(m) = first_html(&m) { if let Some(_m) = first_html(&m) {
let body = m.get_body()?;
info!("add email {slug} {subject} {message_id} {date} {uid} {url}"); info!("add email {slug} {subject} {message_id} {date} {uid} {url}");
} else { } else {
return Err(MailError::MissingHtmlPart.into()); return Err(MailError::MissingHtmlPart.into());

View File

@ -15,8 +15,8 @@ use crate::{
config::Config, config::Config,
error::ServerError, error::ServerError,
graphql::{Corpus, NewsPost, Tag, Thread, ThreadSummary}, graphql::{Corpus, NewsPost, Tag, Thread, ThreadSummary},
thread_summary_from_row, AddOutlink, EscapeHtml, FrameImages, InlineStyle, Query, SanitizeHtml, thread_summary_from_row, AddOutlink, EscapeHtml, FrameImages, InlineRemoteStyle, Query,
SlurpContents, ThreadSummaryRecord, Transformer, NEWSREADER_TAG_PREFIX, SanitizeHtml, SlurpContents, ThreadSummaryRecord, Transformer, NEWSREADER_TAG_PREFIX,
NEWSREADER_THREAD_PREFIX, NEWSREADER_THREAD_PREFIX,
}; };
@ -189,7 +189,6 @@ pub async fn thread(
let slug = r.site.unwrap_or("no-slug".to_string()); let slug = r.site.unwrap_or("no-slug".to_string());
let site = r.name.unwrap_or("NO SITE".to_string()); let site = r.name.unwrap_or("NO SITE".to_string());
let default_homepage = "http://no-homepage";
// TODO: remove the various places that have this as an Option // TODO: remove the various places that have this as an Option
let link = Some(Url::parse(&r.link)?); let link = Some(Url::parse(&r.link)?);
let mut body = r.summary.unwrap_or("NO SUMMARY".to_string()); let mut body = r.summary.unwrap_or("NO SUMMARY".to_string());
@ -197,6 +196,8 @@ pub async fn thread(
let body_tranformers: Vec<Box<dyn Transformer>> = vec![ let body_tranformers: Vec<Box<dyn Transformer>> = vec![
Box::new(SlurpContents { Box::new(SlurpContents {
cacher, cacher,
// TODO: make this true when bulma is finally removed
inline_css: false,
site_selectors: hashmap![ site_selectors: hashmap![
"atmeta.com".to_string() => vec![ "atmeta.com".to_string() => vec![
Selector::parse("div.entry-content").unwrap(), Selector::parse("div.entry-content").unwrap(),
@ -224,6 +225,9 @@ pub async fn thread(
"ingowald.blog".to_string() => vec![ "ingowald.blog".to_string() => vec![
Selector::parse("article").unwrap(), Selector::parse("article").unwrap(),
], ],
"jvns.ca".to_string() => vec![
Selector::parse("article").unwrap(),
],
"mitchellh.com".to_string() => vec![Selector::parse("div.w-full").unwrap()], "mitchellh.com".to_string() => vec![Selector::parse("div.w-full").unwrap()],
"natwelch.com".to_string() => vec![ "natwelch.com".to_string() => vec![
Selector::parse("article div.prose").unwrap(), Selector::parse("article div.prose").unwrap(),
@ -235,6 +239,9 @@ pub async fn thread(
Selector::parse("span.story-byline").unwrap(), Selector::parse("span.story-byline").unwrap(),
Selector::parse("div.p").unwrap(), Selector::parse("div.p").unwrap(),
], ],
"trofi.github.io".to_string() => vec![
Selector::parse("#content").unwrap(),
],
"www.redox-os.org".to_string() => vec![ "www.redox-os.org".to_string() => vec![
Selector::parse("div.content").unwrap(), Selector::parse("div.content").unwrap(),
], ],
@ -246,12 +253,12 @@ pub async fn thread(
}), }),
Box::new(FrameImages), Box::new(FrameImages),
Box::new(AddOutlink), Box::new(AddOutlink),
Box::new(EscapeHtml), // TODO: causes doubling of images in cloudflare blogs
//Box::new(EscapeHtml),
Box::new(SanitizeHtml { Box::new(SanitizeHtml {
cid_prefix: "", cid_prefix: "",
base_url: &link, base_url: &link,
}), }),
Box::new(InlineStyle),
]; ];
for t in body_tranformers.iter() { for t in body_tranformers.iter() {
if t.should_run(&link, &body) { if t.should_run(&link, &body) {

View File

@ -9,7 +9,6 @@ use log::{error, info, warn};
use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail}; use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail};
use memmap::MmapOptions; use memmap::MmapOptions;
use notmuch::Notmuch; use notmuch::Notmuch;
use rocket::http::uri::error::PathError;
use sqlx::PgPool; use sqlx::PgPool;
use tracing::instrument; use tracing::instrument;

View File

@ -1,5 +1,5 @@
[build] [build]
release = true release = false
[serve] [serve]
# The address to serve on. # The address to serve on.

View File

@ -4,21 +4,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!--
-->
<link rel="stylesheet" href="https://jenil.github.io/bulmaswatch/cyborg/bulmaswatch.min.css">
<!-- Pretty checkboxes from https://justboil.github.io/bulma-checkbox/ -->
<link data-trunk rel="css" href="static/main.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css"
integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ==" integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
crossorigin="anonymous" referrerpolicy="no-referrer" /> crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" /> <link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" />
<link data-trunk rel="css" href="static/style.css" />
<!-- tall thin font for user icon --> <!-- tall thin font for user icon -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet">
<link data-trunk rel="css" href="static/site-specific.css" /> <!-- <link data-trunk rel="css" href="static/site-specific.css" /> -->
<link data-trunk rel="tailwind-css" href="./src/tailwind.css" /> <link data-trunk rel="tailwind-css" href="./src/tailwind.css" />
</head> </head>

View File

@ -507,6 +507,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}); });
} }
Msg::WindowScrolled => { Msg::WindowScrolled => {
info!("WindowScrolled");
if let Some(el) = model.content_el.get() { if let Some(el) = model.content_el.get() {
let ih = window() let ih = window()
.inner_height() .inner_height()
@ -515,6 +516,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
.value_of(); .value_of();
let r = el.get_bounding_client_rect(); let r = el.get_bounding_client_rect();
info!("r {r:?} ih {ih}");
if r.height() < ih { if r.height() < ih {
// The whole content fits in the window, no scrollbar // The whole content fits in the window, no scrollbar
orders.send_msg(Msg::SetProgress(0.)); orders.send_msg(Msg::SetProgress(0.));

View File

@ -1,49 +0,0 @@
use seed::{prelude::*, *};
use seed_hooks::topo;
use crate::{
graphql::show_thread_query::*,
state::{Context, Model, Msg},
view::{self, reading_progress, view_header, view_search_results},
};
#[topo::nested]
pub(super) fn view(model: &Model) -> Node<Msg> {
let show_icon_text = true;
// 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: ShowThreadQueryThread::EmailThread(thread),
open_messages,
} => view::thread(thread, open_messages, show_icon_text, &model.content_el),
Context::ThreadResult {
thread: ShowThreadQueryThread::NewsPost(post),
..
} => view::news_post(post, show_icon_text, &model.content_el),
Context::SearchResult {
query,
results,
count,
pager,
selected_threads,
} => view_search_results(
&query,
results.as_slice(),
*count,
pager,
selected_threads,
show_icon_text,
),
};
div![
C!["main-content"],
reading_progress(model.read_completion_ratio),
div![view::tags(model), view::versions(&model.versions)],
div![
view_header(&model.query, &model.refreshing_state),
content,
view_header(&model.query, &model.refreshing_state),
]
]
}

View File

@ -1,121 +0,0 @@
use std::collections::HashSet;
use seed::{prelude::*, *};
use crate::{
api::urls,
graphql::{front_page_query::*, show_thread_query::*},
state::{Context, Model, Msg},
view::{
self, human_age, pretty_authors, reading_progress, search_toolbar, set_title, tags_chiclet,
view_header,
},
};
pub(super) fn view(model: &Model) -> Node<Msg> {
let show_icon_text = false;
let content = match &model.context {
Context::None => div![h1!["Loading"]],
Context::ThreadResult {
thread: ShowThreadQueryThread::EmailThread(thread),
open_messages,
} => view::thread(thread, open_messages, show_icon_text, &model.content_el),
Context::ThreadResult {
thread: ShowThreadQueryThread::NewsPost(post),
..
} => view::news_post(post, show_icon_text, &model.content_el),
Context::SearchResult {
query,
results,
count,
pager,
selected_threads,
} => search_results(
&query,
results.as_slice(),
*count,
pager,
selected_threads,
show_icon_text,
),
};
div![
reading_progress(model.read_completion_ratio),
view_header(&model.query, &model.refreshing_state),
content,
view_header(&model.query, &model.refreshing_state),
div![view::tags(model), view::versions(&model.versions)]
]
}
fn 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 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"]),
attrs! {
At::Href => urls::thread(&tid)
},
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", "float-right", "date"], datetime]
]
]
]
});
let show_bulk_edit = !selected_threads.is_empty();
div![
C!["search-results"],
search_toolbar(count, pager, show_bulk_edit, show_icon_text),
div![C!["index"], rows],
search_toolbar(count, pager, show_bulk_edit, show_icon_text),
]
}

View File

@ -12,17 +12,140 @@ use web_sys::HtmlElement;
use crate::{ use crate::{
api::urls, api::urls,
graphql::{front_page_query::*, show_thread_query::*}, graphql::{front_page_query::*, show_thread_query::*},
state::{unread_query, Model, Msg, RefreshingState, Tag}, state::{unread_query, Context, 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 // 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 // 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. // format!() calls all over with magic strings representing notmuch specific syntax.
const MAX_RAW_MESSAGE_SIZE: usize = 100_000; const MAX_RAW_MESSAGE_SIZE: usize = 100_000;
pub fn view(model: &Model) -> Node<Msg> {
let show_icon_text = true;
let content = match &model.context {
Context::None => div![h1!["Loading"]],
Context::ThreadResult {
thread: ShowThreadQueryThread::EmailThread(thread_data),
open_messages,
} => thread(
thread_data,
open_messages,
show_icon_text,
&model.content_el,
),
Context::ThreadResult {
thread: ShowThreadQueryThread::NewsPost(post),
..
} => news_post(post, show_icon_text, &model.content_el),
Context::SearchResult {
query,
results,
count,
pager,
selected_threads,
} => search_results(
&query,
results.as_slice(),
*count,
pager,
selected_threads,
show_icon_text,
),
};
div![
C!["flex", "flex-wrap-reverse"],
div![
C!["w-48", "flex-none", "flex", "flex-col"],
tags(model),
versions(&model.versions)
],
reading_progress(model.read_completion_ratio),
div![
C!["flex-auto"],
view_header(&model.query, &model.refreshing_state),
content,
view_header(&model.query, &model.refreshing_state),
]
]
}
fn 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 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!["NOTPORTED", "row"],
label![
C!["NOTPORTED", "b-checkbox", "checkbox", "is-large"],
input![attrs! {
At::Type=>"checkbox",
At::Checked=>selected_threads.contains(&tid).as_at_value(),
}],
span![C!["NOTPORTED", "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!["NOTPORTED", "has-text-light", "summary"],
IF!(unread_idx.is_some() => C!["NOTPORTED","unread"]),
attrs! {
At::Href => urls::thread(&tid)
},
div![C!["NOTPORTED", "subject"], &r.subject],
span![
C!["NOTPORTED", "from", "is-size-7"],
pretty_authors(&r.authors)
],
div![
span![C!["NOTPORTED", "is-size-7"], tags_chiclet(&tags, true)],
span![
C!["NOTPORTED", "is-size-7", "float-right", "date"],
datetime
]
]
]
]
});
let show_bulk_edit = !selected_threads.is_empty();
div![
C!["NOTPORTED", "search-results"],
search_toolbar(count, pager, show_bulk_edit, show_icon_text),
div![C!["NOTPORTED", "index"], rows],
search_toolbar(count, pager, show_bulk_edit, show_icon_text),
]
}
fn set_title(title: &str) { fn set_title(title: &str) {
seed::document().set_title(&format!("lb: {}", title)); seed::document().set_title(&format!("lb: {}", title));
} }
@ -31,11 +154,11 @@ fn tags_chiclet(tags: &[String], is_mobile: bool) -> impl Iterator<Item = Node<M
tags.iter().map(move |tag| { tags.iter().map(move |tag| {
let hex = compute_color(tag); let hex = compute_color(tag);
let style = style! {St::BackgroundColor=>hex}; let style = style! {St::BackgroundColor=>hex};
let classes = C!["tag", IF!(is_mobile => "is-small")]; let classes = C!["NOTPORTED", "tag", IF!(is_mobile => "is-small")];
let tag = tag.clone(); let tag = tag.clone();
a![match tag.as_str() { a![match tag.as_str() {
"attachment" => span![classes, style, "📎"], "attachment" => span![classes, style, "📎"],
"replied" => span![classes, style, i![C!["fa-solid", "fa-reply"]]], "replied" => span![classes, style, i![C!["NOTPORTED", "fa-solid", "fa-reply"]]],
_ => span![classes, style, &tag], _ => span![classes, style, &tag],
},] },]
}) })
@ -57,23 +180,23 @@ fn removable_tags_chiclet<'a>(
let thread_id = thread_id.to_string(); let thread_id = thread_id.to_string();
let hex = compute_color(tag); let hex = compute_color(tag);
let style = style! {St::BackgroundColor=>hex}; let style = style! {St::BackgroundColor=>hex};
let classes = C!["tag", IF!(is_mobile => "is-small")]; let classes = C!["NOTPORTED", "tag", IF!(is_mobile => "is-small")];
let attrs = attrs! { let attrs = attrs! {
At::Href => urls::search(&format!("tag:{tag}"), 0) At::Href => urls::search(&format!("tag:{tag}"), 0)
}; };
let tag = tag.clone(); let tag = tag.clone();
let rm_tag = tag.clone(); let rm_tag = tag.clone();
div![ div![
C!["control"], C!["NOTPORTED", "control"],
div![ div![
C!["tags", "has-addons"], C!["NOTPORTED", "tags", "has-addons"],
a![ a![
classes, classes,
attrs, attrs,
style, style,
match tag.as_str() { match tag.as_str() {
"attachment" => span!["📎"], "attachment" => span!["📎"],
"replied" => span![i![C!["fa-solid", "fa-reply"]]], "replied" => span![i![C!["NOTPORTED", "fa-solid", "fa-reply"]]],
_ => span![&tag], _ => span![&tag],
}, },
ev(Ev::Click, move |_| Msg::FrontPageRequest { ev(Ev::Click, move |_| Msg::FrontPageRequest {
@ -85,7 +208,7 @@ fn removable_tags_chiclet<'a>(
}) })
], ],
a![ a![
C!["tag", "is-delete"], C!["NOTPORTED", "tag", "is-delete"],
ev(Ev::Click, move |_| Msg::RemoveTag(thread_id, rm_tag)) ev(Ev::Click, move |_| Msg::RemoveTag(thread_id, rm_tag))
] ]
] ]
@ -177,14 +300,14 @@ fn view_search_results(
&r.subject &r.subject
}; };
tr![ tr![
IF!(unread_idx.is_some() => C!["unread"]), IF!(unread_idx.is_some() => C!["NOTPORTED","unread"]),
td![label![ td![label![
C!["b-checkbox", "checkbox"], C!["NOTPORTED", "b-checkbox", "checkbox"],
input![attrs! { input![attrs! {
At::Type=>"checkbox", At::Type=>"checkbox",
At::Checked=>selected_threads.contains(&tid).as_at_value(), At::Checked=>selected_threads.contains(&tid).as_at_value(),
}], }],
span![C!["check"]], span![C!["NOTPORTED", "check"]],
ev(Ev::Input, move |e| { ev(Ev::Input, move |e| {
if let Some(input) = e if let Some(input) = e
.target() .target()
@ -203,9 +326,9 @@ fn view_search_results(
}), }),
]], ]],
td![ td![
C!["from", format!("corpus-{:?} ", r.corpus)], C!["NOTPORTED", "from", format!("corpus-{:?} ", r.corpus)],
a![ a![
C!["has-text-light", "text"], C!["NOTPORTED", "has-text-light", "text"],
attrs! { attrs! {
At::Href => urls::thread(&tid) At::Href => urls::thread(&tid)
}, },
@ -214,11 +337,11 @@ fn view_search_results(
] ]
], ],
td![ td![
C!["subject"], C!["NOTPORTED", "subject"],
a![ a![
tags_chiclet(&tags, false), tags_chiclet(&tags, false),
" ", " ",
C!["has-text-light", "text", "subject-link"], C!["NOTPORTED", "has-text-light", "text", "subject-link"],
attrs! { attrs! {
At::Href => urls::thread(&tid) At::Href => urls::thread(&tid)
}, },
@ -226,9 +349,9 @@ fn view_search_results(
] ]
], ],
td![ td![
C!["date"], C!["NOTPORTED", "date"],
a![ a![
C!["has-text-light", "text"], C!["NOTPORTED", "has-text-light", "text"],
attrs! { attrs! {
At::Href => urls::thread(&tid) At::Href => urls::thread(&tid)
}, },
@ -239,7 +362,7 @@ fn view_search_results(
}); });
div![ div![
C!["search-results"], C!["NOTPORTED", "search-results"],
search_toolbar(count, pager, show_bulk_edit, show_icon_text), search_toolbar(count, pager, show_bulk_edit, show_icon_text),
table![ table![
C![ C![
@ -252,17 +375,17 @@ fn view_search_results(
], ],
thead![tr![ thead![tr![
th![ th![
C!["edit"], C!["NOTPORTED", "edit"],
label![ label![
C!["b-checkbox", "checkbox"], C!["NOTPORTED", "b-checkbox", "checkbox"],
input![ input![
IF!(partially_checked => C!["is-indeterminate"]), IF!(partially_checked => C!["NOTPORTED","is-indeterminate"]),
attrs! { attrs! {
At::Type=>"checkbox", At::Type=>"checkbox",
At::Checked=>all_checked.as_at_value(), At::Checked=>all_checked.as_at_value(),
} }
], ],
span![C!["check"]], span![C!["NOTPORTED", "check"]],
ev(Ev::Click, move |_| if all_checked { ev(Ev::Click, move |_| if all_checked {
Msg::SelectionSetNone Msg::SelectionSetNone
} else { } else {
@ -270,9 +393,9 @@ fn view_search_results(
}) })
] ]
], ],
th![C!["from"], "From"], th![C!["NOTPORTED", "from"], "From"],
th![C!["subject"], "Subject"], th![C!["NOTPORTED", "subject"], "Subject"],
th![C!["date"], "Date"] th![C!["NOTPORTED", "date"], "Date"]
]], ]],
tbody![rows] tbody![rows]
], ],
@ -287,24 +410,24 @@ fn search_toolbar(
show_icon_text: bool, show_icon_text: bool,
) -> Node<Msg> { ) -> Node<Msg> {
nav![ nav![
C!["level", "is-mobile"], C!["NOTPORTED", "level", "is-mobile"],
div![ div![
C!["level-left"], C!["NOTPORTED", "level-left"],
IF!(show_bulk_edit => IF!(show_bulk_edit =>
div![ div![
C!["level-item"], C!["NOTPORTED","level-item"],
div![C!["buttons", "has-addons"], div![C!["NOTPORTED","buttons", "has-addons"],
button![ button![
C!["button", "mark-read"], C!["NOTPORTED","button", "mark-read"],
attrs!{At::Title => "Mark as read"}, attrs!{At::Title => "Mark as read"},
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]], span![C!["NOTPORTED","icon", "is-small"], i![C!["NOTPORTED","far", "fa-envelope-open"]]],
IF!(show_icon_text=>span!["Read"]), IF!(show_icon_text=>span!["Read"]),
ev(Ev::Click, |_| Msg::SelectionMarkAsRead) ev(Ev::Click, |_| Msg::SelectionMarkAsRead)
], ],
button![ button![
C!["button", "mark-unread"], C!["NOTPORTED","button", "mark-unread"],
attrs!{At::Title => "Mark as unread"}, attrs!{At::Title => "Mark as unread"},
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]], span![C!["NOTPORTED","icon", "is-small"], i![C!["NOTPORTED","far", "fa-envelope"]]],
IF!(show_icon_text=>span!["Unread"]), IF!(show_icon_text=>span!["Unread"]),
ev(Ev::Click, |_| Msg::SelectionMarkAsUnread) ev(Ev::Click, |_| Msg::SelectionMarkAsUnread)
] ]
@ -312,12 +435,12 @@ fn search_toolbar(
]), ]),
IF!(show_bulk_edit => IF!(show_bulk_edit =>
div![ div![
C!["level-item"], C!["NOTPORTED","level-item"],
div![C!["buttons", "has-addons"], div![C!["NOTPORTED","buttons", "has-addons"],
button![ button![
C!["button", "spam"], C!["NOTPORTED","button", "spam"],
attrs!{At::Title => "Mark as spam"}, attrs!{At::Title => "Mark as spam"},
span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]], span![C!["NOTPORTED","icon", "is-small"], i![C!["NOTPORTED","far", "fa-hand"]]],
IF!(show_icon_text=>span!["Spam"]), IF!(show_icon_text=>span!["Spam"]),
ev(Ev::Click, |_| ev(Ev::Click, |_|
Msg::MultiMsg(vec![ Msg::MultiMsg(vec![
@ -330,9 +453,9 @@ fn search_toolbar(
]) ])
], ],
div![ div![
C!["level-right"], C!["NOTPORTED", "level-right"],
nav![ nav![
C!["level-item", "pagination"], C!["NOTPORTED", "level-item", "pagination"],
a![ a![
C![ C![
"pagination-previous", "pagination-previous",
@ -353,7 +476,10 @@ fn search_toolbar(
">", ">",
IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage)) IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage))
], ],
ul![C!["pagination-list"], li![format!("{count} results")],], ul![
C!["NOTPORTED", "pagination-list"],
li![format!("{count} results")],
],
] ]
] ]
] ]
@ -368,7 +494,11 @@ fn raw_text_message(contents: &str) -> Node<Msg> {
} else { } else {
(contents, None) (contents, None)
}; };
div![C!["view-part-text-plain"], contents, truncated_msg,] div![
C!["NOTPORTED", "view-part-text-plain"],
contents,
truncated_msg,
]
} }
fn has_unread(tags: &[String]) -> bool { fn has_unread(tags: &[String]) -> bool {
@ -418,7 +548,7 @@ fn render_avatar(photo_url: Option<String>, from: &str, big: bool) -> Node<Msg>
fn copy_text_widget(text: &str) -> Node<Msg> { fn copy_text_widget(text: &str) -> Node<Msg> {
let text = text.to_string(); let text = text.to_string();
span![ span![
i![C!["far", "fa-clone"]], i![C!["NOTPORTED", "far", "fa-clone"]],
ev(Ev::Click, move |e| { ev(Ev::Click, move |e| {
e.stop_propagation(); e.stop_propagation();
Msg::CopyToClipboard(text) Msg::CopyToClipboard(text)
@ -616,9 +746,9 @@ fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Nod
fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool) -> Node<Msg> { fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool) -> Node<Msg> {
let expand_id = msg.id.clone(); let expand_id = msg.id.clone();
div![ div![
C!["message", "bg-grey-900"], C!["NOTPORTED", "message", "bg-grey-900"],
div![ div![
C!["header"], C!["NOTPORTED", "header"],
if open { if open {
render_open_header(&msg) render_open_header(&msg)
} else { } else {
@ -635,13 +765,13 @@ fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool)
], ],
IF!(open => IF!(open =>
div![ div![
C!["body", "mail"], C!["NOTPORTED","body", "mail"],
match &msg.body { match &msg.body {
ShowThreadQueryThreadOnEmailThreadMessagesBody::UnhandledContentType( ShowThreadQueryThreadOnEmailThreadMessagesBody::UnhandledContentType(
ShowThreadQueryThreadOnEmailThreadMessagesBodyOnUnhandledContentType { contents ,content_tree}, ShowThreadQueryThreadOnEmailThreadMessagesBodyOnUnhandledContentType { contents ,content_tree},
) => div![ ) => div![
raw_text_message(&contents), raw_text_message(&contents),
div![C!["error"], div![C!["NOTPORTED","error"],
view_content_tree(&content_tree), view_content_tree(&content_tree),
] ]
], ],
@ -660,14 +790,14 @@ fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool)
content_tree, content_tree,
}, },
) => div![ ) => div![
C!["view-part-text-html"], C!["NOTPORTED","view-part-text-html"],
raw![contents], raw![contents],
IF!(!msg.attachments.is_empty() => IF!(!msg.attachments.is_empty() =>
div![ div![
C!["attachments"], C!["NOTPORTED","attachments"],
hr![], hr![],
h2!["Attachments"], h2!["NOTPORTED","Attachments"],
div![C!["grid","is-col-min-6"], div![C!["NOTPORTED","grid","is-col-min-6"],
msg.attachments msg.attachments
.iter() .iter()
.map(|a| { .map(|a| {
@ -680,12 +810,12 @@ fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool)
fmtr.with_scales(Scales::Binary()); fmtr.with_scales(Scales::Binary());
div![ div![
C!["attachment", "card"], C!["NOTPORTED","attachment", "card"],
a.content_type.as_ref().map(|content_type| a.content_type.as_ref().map(|content_type|
IF!(content_type.starts_with("image/") => IF!(content_type.starts_with("image/") =>
div![C!["card-image","is-1by1"], div![C!["NOTPORTED","card-image","is-1by1"],
div![ div![
C!["image","is-1by1"], C!["NOTPORTED","image","is-1by1"],
style!{ style!{
St::BackgroundImage=>format!(r#"url("{url}");"#), St::BackgroundImage=>format!(r#"url("{url}");"#),
St::BackgroundSize=>"cover", St::BackgroundSize=>"cover",
@ -694,15 +824,15 @@ fn message_render(msg: &ShowThreadQueryThreadOnEmailThreadMessages, open: bool)
] ]
] ]
)), )),
div![C!["card-content"], div![C!["NOTPORTED","card-content"],
div![C!["content"], div![C!["NOTPORTED","content"],
&a.filename, br![], &a.filename, br![],
small![ fmtr.format(a.size as f64),"B"] small![ fmtr.format(a.size as f64),"B"]
] ]
], ],
footer![ footer![
C!["card-footer"], C!["NOTPORTED","card-footer"],
a![C!["card-footer-item"],span![C!["icon"], i![C!["fas", "fa-download"]]], a![C!["NOTPORTED","card-footer-item"],span![C!["NOTPORTED","icon"], i![C!["NOTPORTED","fas", "fa-download"]]],
ev(Ev::Click, move |_| { ev(Ev::Click, move |_| {
seed::window().location().set_href(&url seed::window().location().set_href(&url
).expect("failed to set URL"); ).expect("failed to set URL");
@ -753,22 +883,25 @@ fn thread(
let spam_add_thread_id = thread.thread_id.clone(); let spam_add_thread_id = thread.thread_id.clone();
let spam_unread_thread_id = thread.thread_id.clone(); let spam_unread_thread_id = thread.thread_id.clone();
div![ div![
C!["thread"], C!["NOTPORTED", "thread"],
h3![C!["is-size-5"], subject], h3![C!["NOTPORTED", "is-size-5"], subject],
span![ span![
C!["tags"], C!["NOTPORTED", "tags"],
removable_tags_chiclet(&thread.thread_id, &tags, false) removable_tags_chiclet(&thread.thread_id, &tags, false)
], ],
div![ div![
C!["level", "is-mobile"], C!["NOTPORTED", "level", "is-mobile"],
div![ div![
C!["level-item"], C!["NOTPORTED", "level-item"],
div![ div![
C!["buttons", "has-addons"], C!["NOTPORTED", "buttons", "has-addons"],
button![ button![
C!["button", "mark-read"], C!["NOTPORTED", "button", "mark-read"],
attrs! {At::Title => "Mark as read"}, attrs! {At::Title => "Mark as read"},
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]], span![
C!["NOTPORTED", "icon", "is-small"],
i![C!["NOTPORTED", "far", "fa-envelope-open"]]
],
IF!(show_icon_text=>span!["Read"]), IF!(show_icon_text=>span!["Read"]),
ev(Ev::Click, move |_| Msg::MultiMsg(vec![ ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::SetUnread(read_thread_id, false), Msg::SetUnread(read_thread_id, false),
@ -776,9 +909,12 @@ fn thread(
])), ])),
], ],
button![ button![
C!["button", "mark-unread"], C!["NOTPORTED", "button", "mark-unread"],
attrs! {At::Title => "Mark as unread"}, attrs! {At::Title => "Mark as unread"},
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]], span![
C!["NOTPORTED", "icon", "is-small"],
i![C!["NOTPORTED", "far", "fa-envelope"]]
],
IF!(show_icon_text=>span!["Unread"]), IF!(show_icon_text=>span!["Unread"]),
ev(Ev::Click, move |_| Msg::MultiMsg(vec![ ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::SetUnread(unread_thread_id, true), Msg::SetUnread(unread_thread_id, true),
@ -788,13 +924,16 @@ fn thread(
], ],
], ],
div![ div![
C!["level-item"], C!["NOTPORTED", "level-item"],
div![ div![
C!["buttons", "has-addons"], C!["NOTPORTED", "buttons", "has-addons"],
button![ button![
C!["button", "spam"], C!["NOTPORTED", "button", "spam"],
attrs! {At::Title => "Spam"}, attrs! {At::Title => "Spam"},
span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]], span![
C!["NOTPORTED", "icon", "is-small"],
i![C!["NOTPORTED", "far", "fa-hand"]]
],
IF!(show_icon_text=>span!["Spam"]), IF!(show_icon_text=>span!["Spam"]),
ev(Ev::Click, move |_| Msg::MultiMsg(vec![ ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::AddTag(spam_add_thread_id, "Spam".to_string()), Msg::AddTag(spam_add_thread_id, "Spam".to_string()),
@ -834,7 +973,7 @@ fn view_content_tree(content_tree: &str) -> Node<Msg> {
}) })
], ],
IF!(debug_open.get() => IF!(debug_open.get() =>
pre![C!["content-tree"], content_tree]), pre![C!["NOTPORTED","content-tree"], content_tree]),
] ]
} }
@ -848,12 +987,17 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
}; };
let query = Url::decode_uri_component(query).unwrap_or("".to_string()); let query = Url::decode_uri_component(query).unwrap_or("".to_string());
nav![ nav![
C!["navbar"], C!["NOTPORTED", "navbar"],
attrs! {At::Role=>"navigation"}, attrs! {At::Role=>"navigation"},
div![ div![
C!["navbar-start"], C!["NOTPORTED", "navbar-start"],
a![ a![
C!["navbar-item", "button", IF![is_error => "is-danger"]], C![
"NOTPORTED",
"navbar-item",
"button",
IF![is_error => "is-danger"]
],
span![i![C![ span![i![C![
"fa-solid", "fa-solid",
"fa-arrow-rotate-right", "fa-arrow-rotate-right",
@ -863,21 +1007,21 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
ev(Ev::Click, |_| Msg::RefreshStart), ev(Ev::Click, |_| Msg::RefreshStart),
], ],
a![ a![
C!["navbar-item", "button"], C!["NOTPORTED", "navbar-item", "button"],
attrs! { attrs! {
At::Href => urls::search(unread_query(), 0) At::Href => urls::search(unread_query(), 0)
}, },
"Unread", "Unread",
], ],
a![ a![
C!["navbar-item", "button"], C!["NOTPORTED", "navbar-item", "button"],
attrs! { attrs! {
At::Href => urls::search("", 0) At::Href => urls::search("", 0)
}, },
"All", "All",
], ],
input![ input![
C!["navbar-item", "input"], C!["NOTPORTED", "navbar-item", "input"],
attrs! { attrs! {
At::Placeholder => "Search"; At::Placeholder => "Search";
At::AutoFocus => true.as_at_value(); At::AutoFocus => true.as_at_value();
@ -895,25 +1039,6 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
] ]
} }
// `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 tags(model: &Model) -> Node<Msg> { pub fn tags(model: &Model) -> Node<Msg> {
fn view_tag_li(display_name: &str, indent: usize, t: &Tag, search_unread: bool) -> Node<Msg> { fn view_tag_li(display_name: &str, indent: usize, t: &Tag, search_unread: bool) -> Node<Msg> {
let href = if search_unread { let href = if search_unread {
@ -925,9 +1050,9 @@ pub fn tags(model: &Model) -> Node<Msg> {
attrs! { attrs! {
At::Href => href At::Href => href
}, },
(0..indent).map(|_| span![C!["tag-indent"], ""]), (0..indent).map(|_| span![C!["pl-4"], ""]),
i![ i![
C!["tag-tag", "fa-solid", "fa-tag"], C!["px-1", "fa-solid", "fa-tag"],
style! { style! {
//"--fa-primary-color" => t.fg_color, //"--fa-primary-color" => t.fg_color,
St::Color => t.bg_color, St::Color => t.bg_color,
@ -956,7 +1081,7 @@ pub fn tags(model: &Model) -> Node<Msg> {
for t in tags { for t in tags {
let parts: Vec<_> = t.name.split('/').collect(); let parts: Vec<_> = t.name.split('/').collect();
let mut n = matches(&last, &parts); let mut n = matches(&last, &parts);
if n <= parts.len() - 2 && parts.len() > 1 { if n + 2 <= parts.len() && parts.len() > 1 {
// Synthesize fake tags for proper indenting. // Synthesize fake tags for proper indenting.
for i in n..parts.len() - 1 { for i in n..parts.len() - 1 {
let display_name = parts[n]; let display_name = parts[n];
@ -987,11 +1112,9 @@ pub fn tags(model: &Model) -> Node<Msg> {
let tags_open = use_state(|| false); let tags_open = use_state(|| false);
let force_tags_open = unread.is_empty(); let force_tags_open = unread.is_empty();
aside![ aside![
C!["tags-menu", "menu"], IF!(!unread.is_empty() => p![ "Unread"]),
IF!(!unread.is_empty() => p![C!["menu-label"], "Unread"]), IF!(!unread.is_empty() => ul![view_tag_list(unread.into_iter(), true)]),
IF!(!unread.is_empty() => ul![C!["menu-list"], view_tag_list(unread.into_iter(),true)]),
p![ p![
C!["menu-label"],
IF!(!force_tags_open => IF!(!force_tags_open =>
i![C![ i![C![
"fa-solid", "fa-solid",
@ -1007,7 +1130,6 @@ pub fn tags(model: &Model) -> Node<Msg> {
}) })
], ],
ul![ ul![
C!["menu-list"],
IF!(force_tags_open||tags_open.get() => model.tags.as_ref().map(|tags| view_tag_list(tags.iter(),false))), IF!(force_tags_open||tags_open.get() => model.tags.as_ref().map(|tags| view_tag_list(tags.iter(),false))),
] ]
] ]
@ -1024,29 +1146,30 @@ fn news_post(
fn tag(tag: String, is_mobile: bool) -> Node<Msg> { fn tag(tag: String, is_mobile: bool) -> Node<Msg> {
let hex = compute_color(&tag); let hex = compute_color(&tag);
let style = style! {St::BackgroundColor=>hex}; let style = style! {St::BackgroundColor=>hex};
let classes = C!["tag", IF!(is_mobile => "is-small")]; let classes = C!["NOTPORTED", "tag", IF!(is_mobile => "is-small")];
let attrs = attrs! { let attrs = attrs! {
At::Href => urls::search(&format!("tag:{tag}"), 0) At::Href => urls::search(&format!("tag:{tag}"), 0)
}; };
let tag = tag.clone(); let tag = tag.clone();
div![ div![
C![ C![
"NOTPORTED",
"message-tags", "message-tags",
"field", "field",
"is-grouped", "is-grouped",
"is-grouped-multiline" "is-grouped-multiline"
], ],
div![ div![
C!["control"], C!["NOTPORTED", "control"],
div![ div![
C!["tags", "has-addons"], C!["NOTPORTED", "tags", "has-addons"],
a![ a![
classes, classes,
attrs, attrs,
style, style,
match tag.as_str() { match tag.as_str() {
"attachment" => span!["📎"], "attachment" => span!["📎"],
"replied" => span![i![C!["fa-solid", "fa-reply"]]], "replied" => span![i![C!["NOTPORTED", "fa-solid", "fa-reply"]]],
_ => span![&tag], _ => span![&tag],
}, },
ev(Ev::Click, move |_| Msg::FrontPageRequest { ev(Ev::Click, move |_| Msg::FrontPageRequest {
@ -1063,19 +1186,22 @@ fn news_post(
} }
div![ div![
C!["thread"], C!["NOTPORTED", "thread"],
h3![C!["is-size-5"], subject], h3![C!["NOTPORTED", "is-size-5"], subject],
tag(format!("News/{}", post.slug), false), tag(format!("News/{}", post.slug), false),
div![ div![
C!["level", "is-mobile"], C!["NOTPORTED", "level", "is-mobile"],
div![ div![
C!["level-item"], C!["NOTPORTED", "level-item"],
div![ div![
C!["buttons", "has-addons"], C!["NOTPORTED", "buttons", "has-addons"],
button![ button![
C!["button", "mark-read"], C!["NOTPORTED", "button", "mark-read"],
attrs! {At::Title => "Mark as read"}, attrs! {At::Title => "Mark as read"},
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]], span![
C!["NOTPORTED", "icon", "is-small"],
i![C!["NOTPORTED", "far", "fa-envelope-open"]]
],
IF!(show_icon_text=>span!["Read"]), IF!(show_icon_text=>span!["Read"]),
ev(Ev::Click, move |_| Msg::MultiMsg(vec![ ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::SetUnread(read_thread_id, false), Msg::SetUnread(read_thread_id, false),
@ -1083,9 +1209,12 @@ fn news_post(
])), ])),
], ],
button![ button![
C!["button", "mark-unread"], C!["NOTPORTED", "button", "mark-unread"],
attrs! {At::Title => "Mark as unread"}, attrs! {At::Title => "Mark as unread"},
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]], span![
C!["NOTPORTED", "icon", "is-small"],
i![C!["NOTPORTED", "far", "fa-envelope"]]
],
IF!(show_icon_text=>span!["Unread"]), IF!(show_icon_text=>span!["Unread"]),
ev(Ev::Click, move |_| Msg::MultiMsg(vec![ ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::SetUnread(unread_thread_id, true), Msg::SetUnread(unread_thread_id, true),
@ -1096,13 +1225,18 @@ fn news_post(
], ],
// This would be the holder for spam buttons on emails, needed to keep layout // This would be the holder for spam buttons on emails, needed to keep layout
// consistent // consistent
div![C!["level-item"], div![]] div![C!["NOTPORTED", "level-item"], div![]]
], ],
div![ div![
C!["message"], C!["NOTPORTED", "message"],
div![C!["header"], render_news_post_header(&post)], div![C!["NOTPORTED", "header"], render_news_post_header(&post)],
div![ div![
C!["body", "news-post", format!("site-{}", post.slug)], C![
"NOTPORTED",
"body",
"news-post",
format!("site-{}", post.slug)
],
el_ref(content_el), el_ref(content_el),
raw![&post.body] raw![&post.body]
] ]
@ -1186,14 +1320,10 @@ fn render_news_post_header(post: &ShowThreadQueryThreadOnNewsPost) -> Node<Msg>
} }
fn reading_progress(ratio: f64) -> Node<Msg> { fn reading_progress(ratio: f64) -> Node<Msg> {
let percent = ratio * 100.; let percent = ratio * 100.;
// TODO: percent broken with no styles
info!("percent {percent}");
progress![ progress![
C![ C!["absolute", "w-screen", IF!(percent<1. => "hidden")],
"read-progress",
"progress",
"is-success",
"is-small",
IF!(percent<1. => "is-invisible")
],
attrs! { attrs! {
At::Value=>percent, At::Value=>percent,
At::Max=>"100" At::Max=>"100"
@ -1204,26 +1334,29 @@ fn reading_progress(ratio: f64) -> Node<Msg> {
pub fn versions(versions: &crate::state::Version) -> Node<Msg> { pub fn versions(versions: &crate::state::Version) -> Node<Msg> {
debug!("versions {versions:?}"); debug!("versions {versions:?}");
aside![ aside![
C!["tags-menu", "menu"], C!["NOTPORTED", "tags-menu", "menu"],
p![C!["menu-label"], "Versions"], p![C!["NOTPORTED", "menu-label"], "Versions"],
ul![ ul![
C!["menu-list"], C!["NOTPORTED", "menu-list"],
li!["Client"], li!["Client"],
li![span![C!["tag-indent"], &versions.client]] li![span![C!["NOTPORTED", "tag-indent"], &versions.client]]
], ],
versions.server.as_ref().map(|v| ul![ versions.server.as_ref().map(|v| ul![
C!["menu-list"], C!["NOTPORTED", "menu-list"],
li!["Server"], li!["Server"],
li![span![C!["tag-indent"], v]] li![span![C!["NOTPORTED", "tag-indent"], v]]
]) ])
] ]
} }
fn click_to_top() -> Node<Msg> { fn click_to_top() -> Node<Msg> {
button![ button![
C!["button", "is-danger", "is-small"], C!["NOTPORTED", "button", "is-danger", "is-small"],
span!["Top"], span!["Top"],
span![C!["icon"], i![C!["fas", "fa-arrow-turn-up"]]], span![
C!["NOTPORTED", "icon"],
i![C!["NOTPORTED", "fas", "fa-arrow-turn-up"]]
],
ev(Ev::Click, |_| web_sys::window() ev(Ev::Click, |_| web_sys::window()
.unwrap() .unwrap()
.scroll_to_with_x_and_y(0., 0.)) .scroll_to_with_x_and_y(0., 0.))

View File

@ -1,48 +0,0 @@
use seed::{prelude::*, *};
use crate::{
graphql::show_thread_query::*,
state::{Context, Model, Msg},
view::{self, reading_progress, view_header, view_search_results},
};
pub(super) fn view(model: &Model) -> Node<Msg> {
let show_icon_text = false;
// 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: ShowThreadQueryThread::EmailThread(thread),
open_messages,
} => view::thread(thread, open_messages, show_icon_text, &model.content_el),
Context::ThreadResult {
thread: ShowThreadQueryThread::NewsPost(post),
..
} => view::news_post(post, show_icon_text, &model.content_el),
Context::SearchResult {
query,
results,
count,
pager,
selected_threads,
} => view_search_results(
&query,
results.as_slice(),
*count,
pager,
selected_threads,
show_icon_text,
),
};
div![
C!["main-content"],
div![
reading_progress(model.read_completion_ratio),
view_header(&model.query, &model.refreshing_state),
content,
view_header(&model.query, &model.refreshing_state),
view::tags(model),
view::versions(&model.versions)
]
]
}

View File

@ -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;
}