Compare commits

...

13 Commits

Author SHA1 Message Date
c76df0ef90 web: update copy icon in more places 2024-07-24 11:06:38 -07:00
cd77d302df web: small icon tweak for copying email addresses 2024-07-24 11:03:32 -07:00
71348d562d version bump 2024-07-24 11:03:26 -07:00
b6ae46db93 Move cargo config up a directory 2024-07-22 16:56:13 -07:00
6cb84054ed Only build server by default 2024-07-22 16:48:47 -07:00
7b511c1673 Fix cleanhtml build 2024-07-22 16:41:14 -07:00
bfd5e12bea Make URL joining more robust 2024-07-22 16:39:59 -07:00
ad8fb77857 Add copy to clipboard links to from/to/cc addresses 2024-07-22 16:04:25 -07:00
831466ddda Add mark read/unread support for news 2024-07-22 14:43:05 -07:00
4ee34444ae Move thread: and id: prefixing to server side.
This paves way for better news: support
2024-07-22 14:26:48 -07:00
879ddb112e Remove some logging and fix a comment 2024-07-22 14:26:24 -07:00
331fb4f11b Fix build 2024-07-22 12:19:45 -07:00
4e5275ca0e cargo sqlx prepare 2024-07-22 12:19:38 -07:00
17 changed files with 377 additions and 99 deletions

4
.cargo/config.toml Normal file
View File

@@ -0,0 +1,4 @@
[build]
rustflags = [ "--cfg=web_sys_unstable_apis" ]

4
Cargo.lock generated
View File

@@ -4141,9 +4141,9 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "uuid"
version = "1.9.1"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
dependencies = [
"getrandom 0.2.15",
]

View File

@@ -1,5 +1,8 @@
[workspace]
resolver = "2"
default-members = [
"server"
]
members = [
"web",
"server",

View File

@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n date,\n is_read,\n link,\n site,\n summary,\n title,\n name,\n homepage\nFROM\n post p\n JOIN feed f ON p.site = f.slug\nWHERE\n uid = $1\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "date",
"type_info": "Timestamp"
},
{
"ordinal": 1,
"name": "is_read",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "link",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "site",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "summary",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "homepage",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "113694cd5bf0d2582ff3a635776daa608fe88abe1185958c4215646c92335afb"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n site,\n name,\n count (\n NOT is_read\n OR NULL\n ) unread\nFROM\n post AS p\n JOIN feed AS f ON p.site = f.slug --\n -- TODO: figure this out to make the query faster when only looking for unread\n --WHERE\n -- (\n -- NOT $1\n -- OR NOT is_read\n -- )\nGROUP BY\n 1,\n 2\nORDER BY\n site\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "site",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "unread",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
true,
true,
null
]
},
"hash": "2dcbedef656e1b725c5ba4fb67d31ce7962d8714449b2fb630f49a7ed1acc270"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE\n post\nSET\n is_read = $1\nWHERE\n uid = $2\n",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "b39147b9d06171cb742141eda4675688cb702fb284758b1224ed3aa2d7f3b3d9"
}

View File

@@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n date,\n is_read,\n title,\n uid,\n name\nFROM\n post p\n JOIN feed f ON p.site = f.slug\nWHERE\n site = $1\n AND (\n NOT $2\n OR NOT is_read\n )\nORDER BY\n date DESC,\n title OFFSET $3\nLIMIT\n $4\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "date",
"type_info": "Timestamp"
},
{
"ordinal": 1,
"name": "is_read",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "uid",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "name",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Bool",
"Int8",
"Int8"
]
},
"nullable": [
true,
true,
true,
false,
true
]
},
"hash": "d9326384e689f361b24c2cadde57c5a06049c5055e2782f385275dea4540b20b"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*) count\nFROM\n post\nWHERE\n site = $1\n AND (\n NOT $2\n OR NOT is_read\n )\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "f99699f8916bda34faaccf72fdd92b6e36e01600700ee4132e1de974b3aa79dc"
}

View File

@@ -0,0 +1,6 @@
UPDATE
post
SET
is_read = $1
WHERE
uid = $2

View File

@@ -1,6 +1,7 @@
use std::fs;
use server::sanitize_html;
use url::Url;
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
@@ -9,7 +10,7 @@ fn main() -> anyhow::Result<()> {
println!("Sanitizing {src} into {dst}");
let bytes = fs::read(src)?;
let html = String::from_utf8_lossy(&bytes);
let html = sanitize_html(&html, "")?;
let html = sanitize_html(&html, "", &None)?;
fs::write(dst, html)?;
Ok(())

View File

@@ -1,7 +1,6 @@
use async_graphql::{
connection::{Connection},
Context, EmptySubscription, Enum, Error, FieldResult, Object, Schema, SimpleObject, Union,
connection::Connection, Context, EmptySubscription, Enum, Error, FieldResult, Object, Schema,
SimpleObject, Union,
};
use log::info;
use notmuch::Notmuch;
@@ -273,11 +272,14 @@ impl Mutation {
unread: bool,
) -> Result<bool, Error> {
let nm = ctx.data_unchecked::<Notmuch>();
info!("set_read_status({unread})");
if unread {
nm.tag_add("unread", &format!("{query}"))?;
} else {
nm.tag_remove("unread", &format!("{query}"))?;
let pool = ctx.data_unchecked::<PgPool>();
for q in query.split_whitespace() {
if newsreader::is_newsreader_thread(&q) {
newsreader::set_read_status(pool, &q, unread).await?;
} else {
nm::set_read_status(nm, q, unread).await?;
}
}
Ok(true)
}

View File

@@ -5,7 +5,7 @@ pub mod nm;
use css_inline::{CSSInliner, InlineError, InlineOptions};
use linkify::{LinkFinder, LinkKind};
use log::error;
use log::{error, info};
use lol_html::{element, errors::RewritingError, rewrite_str, RewriteStrSettings};
use maplit::{hashmap, hashset};
use thiserror::Error;
@@ -50,31 +50,15 @@ pub fn linkify_html(text: &str) -> String {
pub fn sanitize_html(
html: &str,
cid_prefix: &str,
base_url: &Url,
base_url: &Option<Url>,
) -> Result<String, SanitizeError> {
let element_content_handlers = vec![
let mut element_content_handlers = vec![
// Open links in new tab
element!("a[href]", |el| {
el.set_attribute("target", "_blank").unwrap();
Ok(())
}),
// Make links with relative URLs absolute
element!("a[href]", |el| {
if let Some(Ok(href)) = el.get_attribute("href").map(|href| base_url.join(&href)) {
el.set_attribute("href", &href.as_str()).unwrap();
}
Ok(())
}),
// Make images with relative srcs absolute
element!("img[src]", |el| {
if let Some(Ok(src)) = el.get_attribute("src").map(|src| base_url.join(&src)) {
el.set_attribute("src", &src.as_str()).unwrap();
}
Ok(())
}),
// Replace mixed part CID images with URL
element!("img[src]", |el| {
let src = el
@@ -98,6 +82,30 @@ pub fn sanitize_html(
Ok(())
}),
];
if let Some(base_url) = base_url {
element_content_handlers.extend(vec![
// Make links with relative URLs absolute
element!("a[href]", |el| {
if let Some(Ok(href)) = el.get_attribute("href").map(|href| {
info!("href {href:?}");
base_url.join(&href)
}) {
el.set_attribute("href", &href.as_str()).unwrap();
}
Ok(())
}),
// Make images with relative srcs absolute
element!("img[src]", |el| {
if let Some(Ok(src)) = el.get_attribute("src").map(|src| base_url.join(&src)) {
info!("src {src:?}");
el.set_attribute("src", &src.as_str()).unwrap();
}
Ok(())
}),
]);
}
let inline_opts = InlineOptions {
inline_style_tags: true,

View File

@@ -5,7 +5,6 @@ use std::{
};
use async_graphql::connection::{self, Connection, Edge};
use log::info;
use sqlx::postgres::PgPool;
use url::Url;
@@ -44,7 +43,6 @@ pub async fn search(
query: String,
) -> Result<Connection<usize, ThreadSummary>, async_graphql::Error> {
let query: Query = query.parse()?;
info!("news search query {query:?}");
let site = query.site.expect("search has no site");
connection::query(
after,
@@ -52,7 +50,6 @@ pub async fn search(
first,
last,
|after: Option<usize>, before: Option<usize>, first, last| async move {
info!("search page info {after:#?}, {before:#?}, {first:#?}, {last:#?}");
let default_page_size = 100;
let (offset, limit) = match (after, before, first, last) {
// Reasonable defaults
@@ -86,7 +83,6 @@ pub async fn search(
// The +1 is to see if there are more pages of data available.
let limit = limit + 1;
info!("search page offset {offset} limit {limit}");
let rows = sqlx::query_file!(
"sql/threads.sql",
site,
@@ -188,18 +184,18 @@ pub async fn thread(pool: &PgPool, thread_id: String) -> Result<Thread, ServerEr
})
.unwrap_or(default_homepage.to_string()),
)?;
let link = Url::parse(
&r.link
.as_ref()
.map(|h| {
if h.is_empty() {
default_homepage.to_string()
} else {
h.to_string()
}
})
.unwrap_or(default_homepage.to_string()),
)?;
let link = &r
.link
.as_ref()
.map(|h| {
if h.is_empty() {
default_homepage.to_string()
} else {
h.to_string()
}
})
.map(|h| Url::parse(&h).ok())
.flatten();
let addr = r.link.as_ref().map(|link| {
if link.contains('@') {
link.clone()
@@ -214,7 +210,7 @@ pub async fn thread(pool: &PgPool, thread_id: String) -> Result<Thread, ServerEr
let html = r.summary.unwrap_or("NO SUMMARY".to_string());
// TODO: add site specific cleanups. For example:
// * Grafana does <div class="image-wrapp"><img class="lazyload>"<img src="/media/...>"</img></div>
// * Some sites appear to be HTML encoded, unencode them, i.e. imperialviolet
// * Some sites appear to be HTML encoded, unencode them, i.e. imperialviolent
let html = sanitize_html(&html, "", &link)?;
let body = Body::Html(Html {
html,
@@ -253,6 +249,7 @@ pub async fn thread(pool: &PgPool, thread_id: String) -> Result<Thread, ServerEr
struct Query {
unread_only: bool,
site: Option<String>,
uid: Option<String>,
remainder: Vec<String>,
}
@@ -261,6 +258,7 @@ impl FromStr for Query {
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut unread_only = false;
let mut site = None;
let mut uid = None;
let mut remainder = Vec::new();
let site_prefix = format!("tag:{TAG_PREFIX}");
for word in s.split_whitespace() {
@@ -268,6 +266,8 @@ impl FromStr for Query {
unread_only = true
} else if word.starts_with(&site_prefix) {
site = Some(word[site_prefix.len()..].to_string())
} else if word.starts_with(THREAD_PREFIX) {
uid = Some(word[THREAD_PREFIX.len()..].to_string())
} else {
remainder.push(word.to_string());
}
@@ -275,7 +275,20 @@ impl FromStr for Query {
Ok(Query {
unread_only,
site,
uid,
remainder,
})
}
}
pub async fn set_read_status<'ctx>(
pool: &PgPool,
query: &str,
unread: bool,
) -> Result<bool, ServerError> {
let query: Query = query.parse()?;
sqlx::query_file!("sql/set_unread.sql", !unread, query.uid)
.execute(pool)
.await?;
Ok(true)
}

View File

@@ -80,7 +80,7 @@ pub async fn search(
.0
.into_iter()
.map(|ts| ThreadSummary {
thread: ts.thread,
thread: format!("thread:{}", ts.thread),
timestamp: ts.timestamp,
date_relative: ts.date_relative,
matched: ts.matched,
@@ -179,7 +179,7 @@ pub async fn thread(
.get_first_value("date")
.and_then(|d| mailparse::dateparse(&d).ok());
let cid_prefix = shared::urls::cid_prefix(None, &id);
let base_url = Url::parse("https://there-should-be-no-relative-urls-in-email").unwrap();
let base_url = None;
let body = match extract_body(&m, &id)? {
Body::PlainText(PlainText { text, content_tree }) => {
let text = if text.len() > MAX_RAW_MESSAGE_SIZE {
@@ -248,7 +248,7 @@ pub async fn thread(
// TODO(wathiede): parse message and fill out attachments
let attachments = extract_attachments(&m, &id)?;
messages.push(Message {
id,
id: format!("id:{id}"),
from,
to,
cc,
@@ -752,3 +752,16 @@ fn render_content_type_tree(m: &ParsedMail) -> String {
SKIP_HEADERS.join("\n ")
)
}
pub async fn set_read_status<'ctx>(
nm: &Notmuch,
query: &str,
unread: bool,
) -> Result<bool, ServerError> {
if unread {
nm.tag_add("unread", &format!("{query}"))?;
} else {
nm.tag_remove("unread", &format!("{query}"))?;
}
Ok(true)
}

View File

@@ -36,6 +36,8 @@ wasm-opt = ['-Os']
[dependencies.web-sys]
version = "0.3.58"
features = [
"Clipboard",
"MediaQueryList",
"Navigator",
"Window"
]

View File

@@ -372,7 +372,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
{
let threads = selected_threads
.iter()
.map(|tid| format!("thread:{tid}"))
.map(|tid| tid.to_string())
.collect::<Vec<_>>()
.join(" ");
orders
@@ -387,7 +387,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
{
let threads = selected_threads
.iter()
.map(|tid| format!("thread:{tid}"))
.map(|tid| tid.to_string())
.collect::<Vec<_>>()
.join(" ");
orders
@@ -402,7 +402,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
{
let threads = selected_threads
.iter()
.map(|tid| format!("thread:{tid}"))
.map(|tid| tid.to_string())
.collect::<Vec<_>>()
.join(" ");
orders
@@ -417,7 +417,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
{
let threads = selected_threads
.iter()
.map(|tid| format!("thread:{tid}"))
.map(|tid| tid.to_string())
.collect::<Vec<_>>()
.join(" ");
orders
@@ -452,6 +452,17 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}
}
Msg::MultiMsg(msgs) => msgs.into_iter().for_each(|msg| update(msg, model, orders)),
Msg::CopyToClipboard(text) => {
let clipboard = seed::window()
.navigator()
.clipboard()
.expect("couldn't get clipboard");
orders.perform_cmd(async move {
wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text))
.await
.expect("failed to copy to clipboard");
});
}
}
}
// `Model` describes our app state.
@@ -551,4 +562,6 @@ pub enum Msg {
MessageCollapse(String),
MessageExpand(String),
MultiMsg(Vec<Msg>),
CopyToClipboard(String),
}

View File

@@ -6,7 +6,7 @@ use std::{
use chrono::{DateTime, Datelike, Duration, Local, Utc};
use human_format::{Formatter, Scales};
use itertools::Itertools;
use log::error;
use log::{error, info};
use seed::{prelude::*, *};
use seed_hooks::{state_access::CloneState, topo, use_state};
@@ -73,6 +73,7 @@ fn removable_tags_chiclet<'a>(
"is-grouped-multiline"
],
tags.iter().map(move |tag| {
let thread_id = thread_id.to_string();
let hex = compute_color(tag);
let style = style! {St::BackgroundColor=>hex};
let classes = C!["tag", IF!(is_mobile => "is-small")];
@@ -81,7 +82,6 @@ fn removable_tags_chiclet<'a>(
};
let tag = tag.clone();
let rm_tag = tag.clone();
let thread_id = format!("thread:{thread_id}");
div![
C!["control"],
div![
@@ -122,14 +122,16 @@ fn pretty_authors(authors: &str) -> impl Iterator<Item = Node<Msg>> + '_ {
if one_person {
return Some(span![
attrs! {
At::Title => author.trim()},
At::Title => author.trim()
},
author
]);
}
author.split_whitespace().nth(0).map(|first| {
span![
attrs! {
At::Title => author.trim()},
At::Title => author.trim()
},
first
]
})
@@ -516,7 +518,17 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
p![
strong![from],
br![],
small![from_detail],
small![
&from_detail,
" ",
from_detail.map(|detail| span![
i![C!["far", "fa-clone"]],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::CopyToClipboard(detail.to_string())
})
])
],
table![
IF!(!msg.to.is_empty() =>
tr![
@@ -526,19 +538,31 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
msg.to.iter().enumerate().map(|(i, to)|
small![
if i>0 { ", " }else { "" },
match to {
ShowThreadQueryThreadMessagesTo {
name: Some(name),
addr:Some(addr),
} => format!("{name} <{addr}>"),
ShowThreadQueryThreadMessagesTo {
name: Some(name),
addr:None
} => format!("{name}"),
ShowThreadQueryThreadMessagesTo {
addr: Some(addr), ..
} => format!("{addr}"),
_ => String::from("UNKNOWN"),
{
let to = match to {
ShowThreadQueryThreadMessagesTo {
name: Some(name),
addr:Some(addr),
} => format!("{name} <{addr}>"),
ShowThreadQueryThreadMessagesTo {
name: Some(name),
addr:None
} => format!("{name}"),
ShowThreadQueryThreadMessagesTo {
addr: Some(addr), ..
} => format!("{addr}"),
_ => String::from("UNKNOWN"),
};
span![
&to, " ",
span![
i![C!["far", "fa-clone"]],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::CopyToClipboard(to)
})
]
]
}
])
@@ -551,21 +575,32 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
msg.cc.iter().enumerate().map(|(i, cc)|
small![
if i>0 { ", " }else { "" },
match cc {
ShowThreadQueryThreadMessagesCc {
name: Some(name),
addr:Some(addr),
} => format!("{name} <{addr}>"),
ShowThreadQueryThreadMessagesCc {
name: Some(name),
addr:None
} => format!("{name}"),
ShowThreadQueryThreadMessagesCc {
addr: Some(addr), ..
} => format!("<{addr}>"),
_ => String::from("UNKNOWN"),
{
let cc = match cc {
ShowThreadQueryThreadMessagesCc {
name: Some(name),
addr:Some(addr),
} => format!("{name} <{addr}>"),
ShowThreadQueryThreadMessagesCc {
name: Some(name),
addr:None
} => format!("{name}"),
ShowThreadQueryThreadMessagesCc {
addr: Some(addr), ..
} => format!("<{addr}>"),
_ => String::from("UNKNOWN"),
};
span![
&cc, " ",
span![
i![C!["far", "fa-clone"]],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::CopyToClipboard(cc)
})
]
]
}
])
]
]),
@@ -592,7 +627,7 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::SetUnread(format!("id:{id}"), !is_unread)
Msg::SetUnread(id, !is_unread)
})
]
]
@@ -664,7 +699,7 @@ fn render_closed_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
],
ev(Ev::Click, move |e| {
e.stop_propagation();
Msg::SetUnread(format!("id:{id}"), !is_unread)
Msg::SetUnread(id, !is_unread)
})
]
]
@@ -808,7 +843,8 @@ fn thread(
});
let read_thread_id = thread.thread_id.clone();
let unread_thread_id = thread.thread_id.clone();
let spam_thread_id = thread.thread_id.clone();
let spam_add_thread_id = thread.thread_id.clone();
let spam_unread_thread_id = thread.thread_id.clone();
div![
C!["thread"],
h3![C!["is-size-5"], subject],
@@ -827,20 +863,14 @@ fn thread(
attrs! {At::Title => "Mark as read"},
span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
IF!(show_icon_text=>span!["Read"]),
ev(Ev::Click, move |_| Msg::SetUnread(
format!("thread:{read_thread_id}"),
false
)),
ev(Ev::Click, move |_| Msg::SetUnread(read_thread_id, false)),
],
button![
C!["button", "mark-unread"],
attrs! {At::Title => "Mark as unread"},
span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
IF!(show_icon_text=>span!["Unread"]),
ev(Ev::Click, move |_| Msg::SetUnread(
format!("thread:{unread_thread_id}"),
true
)),
ev(Ev::Click, move |_| Msg::SetUnread(unread_thread_id, true)),
],
],
],
@@ -854,8 +884,8 @@ fn thread(
span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]],
IF!(show_icon_text=>span!["Spam"]),
ev(Ev::Click, move |_| Msg::MultiMsg(vec![
Msg::AddTag(format!("thread:{spam_thread_id}"), "Spam".to_string()),
Msg::SetUnread(format!("thread:{spam_thread_id}"), false)
Msg::AddTag(spam_add_thread_id, "Spam".to_string()),
Msg::SetUnread(spam_unread_thread_id, false)
])),
],
],