Compare commits
48 Commits
letterbox-
...
letterbox-
| Author | SHA1 | Date | |
|---|---|---|---|
| 01e1ca927e | |||
| 1cc52d6c96 | |||
| e6b3a5b5a9 | |||
| bc4b15a5aa | |||
| 00f61cf6be | |||
| 52e24437bd | |||
| 393ffc8506 | |||
| 2b6cb6ec6e | |||
| 0cba3a624c | |||
| 73433711ca | |||
| 965afa6871 | |||
| e70dbaf917 | |||
| 6b4ce11743 | |||
| d1980a55a7 | |||
| 8b78b39d4c | |||
| ae17651eb5 | |||
| 22fd8409f6 | |||
| d0a4ba417f | |||
| 7b09b098a4 | |||
| bd4c10a8fb | |||
| ed3c5f152e | |||
| 63232d1e92 | |||
| 4a3eba80d5 | |||
| 71d3745342 | |||
| 5fdc98633d | |||
| 57877f268d | |||
| 871a93d58f | |||
| 4b7cbd4f9b | |||
| aa2a9815df | |||
| 2e5b18a008 | |||
| d0a38114cc | |||
| ccc1d516c7 | |||
| 246b710fdd | |||
| 1a21c9fa8e | |||
| 9fd912b1d4 | |||
| 9ded32f97b | |||
| 10aac046bc | |||
| f4527baf89 | |||
| 11ec5bf747 | |||
| 6a53679755 | |||
| 7bedec0692 | |||
| 78feb95811 | |||
| 3aad2bb80e | |||
| 0df8de3661 | |||
| 83ecc73fbd | |||
| c10313cd12 | |||
| 4c98bcd9cb | |||
| 004de235a8 |
@@ -49,3 +49,19 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
- run: cargo build
|
- run: cargo build
|
||||||
|
|
||||||
|
udeps:
|
||||||
|
name: Disallow unused dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: nightly
|
||||||
|
|
||||||
|
- name: Run cargo-udeps
|
||||||
|
uses: aig787/cargo-udeps-action@v1
|
||||||
|
with:
|
||||||
|
version: 'latest'
|
||||||
|
args: '--all-targets'
|
||||||
|
|
||||||
|
|||||||
533
Cargo.lock
generated
533
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,14 @@ resolver = "2"
|
|||||||
default-members = ["server"]
|
default-members = ["server"]
|
||||||
members = ["web", "server", "notmuch", "procmail2notmuch", "shared"]
|
members = ["web", "server", "notmuch", "procmail2notmuch", "shared"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
authors = ["Bill Thiede <git@xinu.tv>"]
|
||||||
|
edition = "2021"
|
||||||
|
license = "UNLICENSED"
|
||||||
|
publish = ["xinu"]
|
||||||
|
version = "0.8.6"
|
||||||
|
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
|
|
||||||
|
|||||||
7
Justfile
7
Justfile
@@ -8,6 +8,9 @@ patch: (_release "patch")
|
|||||||
sqlx-prepare:
|
sqlx-prepare:
|
||||||
cd server; cargo sqlx prepare && git add .sqlx; git commit -m "cargo sqlx prepare" .sqlx || true
|
cd server; cargo sqlx prepare && git add .sqlx; git commit -m "cargo sqlx prepare" .sqlx || true
|
||||||
|
|
||||||
|
pull:
|
||||||
|
git pull
|
||||||
|
|
||||||
_release level: sqlx-prepare
|
|
||||||
cargo-release release -x {{ level }} --workspace --no-confirm
|
_release level: pull sqlx-prepare
|
||||||
|
cargo-release release -x {{ level }} --workspace --no-confirm --registry=xinu
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "letterbox-notmuch"
|
name = "letterbox-notmuch"
|
||||||
version = "0.6.0"
|
|
||||||
edition = "2021"
|
|
||||||
exclude = ["/testdata"]
|
exclude = ["/testdata"]
|
||||||
description = "Wrapper for calling notmuch cli"
|
description = "Wrapper for calling notmuch cli"
|
||||||
license = "UNLICENSED"
|
authors.workspace = true
|
||||||
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
edition.workspace = true
|
||||||
publish = ["xinu"]
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = "0.4.14"
|
log = "0.4.14"
|
||||||
|
mailparse = "0.16.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = { version = "1.0", features = ["unbounded_depth"] }
|
serde_json = { version = "1.0", features = ["unbounded_depth"] }
|
||||||
thiserror = "2.0.0"
|
thiserror = "2.0.0"
|
||||||
|
|||||||
@@ -207,6 +207,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
io::{self},
|
io::{self},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -270,6 +271,12 @@ pub struct Headers {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub bcc: Option<String>,
|
pub bcc: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(alias = "Delivered-To")]
|
||||||
|
pub delivered_to: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(alias = "X-Original-To")]
|
||||||
|
pub x_original_to: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub reply_to: Option<String>,
|
pub reply_to: Option<String>,
|
||||||
pub date: String,
|
pub date: String,
|
||||||
}
|
}
|
||||||
@@ -459,6 +466,8 @@ pub enum NotmuchError {
|
|||||||
StringUtf8Error(#[from] std::string::FromUtf8Error),
|
StringUtf8Error(#[from] std::string::FromUtf8Error),
|
||||||
#[error("failed to parse str as int")]
|
#[error("failed to parse str as int")]
|
||||||
ParseIntError(#[from] std::num::ParseIntError),
|
ParseIntError(#[from] std::num::ParseIntError),
|
||||||
|
#[error("failed to parse mail: {0}")]
|
||||||
|
MailParseError(#[from] mailparse::MailParseError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -605,6 +614,80 @@ impl Notmuch {
|
|||||||
Ok(serde_json::from_slice(&res)?)
|
Ok(serde_json::from_slice(&res)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip_all)]
|
||||||
|
pub fn unread_recipients(&self) -> Result<HashMap<String, usize>, NotmuchError> {
|
||||||
|
let slice = self.run_notmuch([
|
||||||
|
"show",
|
||||||
|
"--include-html=false",
|
||||||
|
"--entire-thread=false",
|
||||||
|
"--body=false",
|
||||||
|
"--format=json",
|
||||||
|
// Arbitrary limit to prevent too much work
|
||||||
|
"--limit=1000",
|
||||||
|
"is:unread",
|
||||||
|
])?;
|
||||||
|
// Notmuch returns JSON with invalid unicode. So we lossy convert it to a string here and
|
||||||
|
// use that for parsing in rust.
|
||||||
|
let s = String::from_utf8_lossy(&slice);
|
||||||
|
let mut deserializer = serde_json::Deserializer::from_str(&s);
|
||||||
|
deserializer.disable_recursion_limit();
|
||||||
|
let ts: ThreadSet = serde::de::Deserialize::deserialize(&mut deserializer)?;
|
||||||
|
deserializer.end()?;
|
||||||
|
let mut r = HashMap::new();
|
||||||
|
fn collect_from_thread_node(
|
||||||
|
r: &mut HashMap<String, usize>,
|
||||||
|
tn: &ThreadNode,
|
||||||
|
) -> Result<(), NotmuchError> {
|
||||||
|
let Some(msg) = &tn.0 else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut addrs = vec![];
|
||||||
|
let hdr = &msg.headers.to;
|
||||||
|
if let Some(to) = hdr {
|
||||||
|
addrs.push(to);
|
||||||
|
} else {
|
||||||
|
let hdr = &msg.headers.x_original_to;
|
||||||
|
if let Some(to) = hdr {
|
||||||
|
addrs.push(to);
|
||||||
|
} else {
|
||||||
|
let hdr = &msg.headers.delivered_to;
|
||||||
|
if let Some(to) = hdr {
|
||||||
|
addrs.push(to);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let hdr = &msg.headers.cc;
|
||||||
|
if let Some(cc) = hdr {
|
||||||
|
addrs.push(cc);
|
||||||
|
};
|
||||||
|
for recipient in addrs {
|
||||||
|
mailparse::addrparse(&recipient)?
|
||||||
|
.into_inner()
|
||||||
|
.iter()
|
||||||
|
.for_each(|a| {
|
||||||
|
let mailparse::MailAddr::Single(si) = a else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let addr = &si.addr;
|
||||||
|
|
||||||
|
if addr == "couchmoney@gmail.com" || addr.ends_with("@xinu.tv") {
|
||||||
|
*r.entry(addr.to_lowercase()).or_default() += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
for t in ts.0 {
|
||||||
|
for tn in t.0 {
|
||||||
|
collect_from_thread_node(&mut r, &tn)?;
|
||||||
|
for sub_tn in tn.1 {
|
||||||
|
collect_from_thread_node(&mut r, &sub_tn)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>, NotmuchError>
|
fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>, NotmuchError>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "letterbox-procmail2notmuch"
|
name = "letterbox-procmail2notmuch"
|
||||||
version = "0.6.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Tool for generating notmuch rules from procmail"
|
description = "Tool for generating notmuch rules from procmail"
|
||||||
license = "UNLICENSED"
|
authors.workspace = true
|
||||||
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
edition.workspace = true
|
||||||
publish = ["xinu"]
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "letterbox-server"
|
name = "letterbox-server"
|
||||||
version = "0.6.0"
|
|
||||||
edition = "2021"
|
|
||||||
default-run = "letterbox-server"
|
default-run = "letterbox-server"
|
||||||
description = "Backend for letterbox"
|
description = "Backend for letterbox"
|
||||||
license = "UNLICENSED"
|
authors.workspace = true
|
||||||
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
edition.workspace = true
|
||||||
publish = ["xinu"]
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
@@ -46,9 +47,9 @@ url = "2.5.2"
|
|||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
#xtracing = { path = "../../xtracing" }
|
#xtracing = { path = "../../xtracing" }
|
||||||
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
|
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
|
||||||
xtracing = { version = "0.2.0", registry = "xinu" }
|
xtracing = { version = "0.3.0", registry = "xinu" }
|
||||||
letterbox-notmuch = { version = "0.1.0", registry = "xinu" }
|
letterbox-notmuch = { version = "0.8.6", path = "../notmuch", registry = "xinu" }
|
||||||
letterbox-shared = { version = "0.3.0", registry = "xinu" }
|
letterbox-shared = { version = "0.8.6", path = "../shared", registry = "xinu" }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
build-info-build = "0.0.39"
|
build-info-build = "0.0.39"
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ pub struct Message {
|
|||||||
pub to: Vec<Email>,
|
pub to: Vec<Email>,
|
||||||
// All CC headers found in email
|
// All CC headers found in email
|
||||||
pub cc: Vec<Email>,
|
pub cc: Vec<Email>,
|
||||||
|
// X-Original-To header found in email
|
||||||
|
pub x_original_to: Option<Email>,
|
||||||
|
// Delivered-To header found in email
|
||||||
|
pub delivered_to: Option<Email>,
|
||||||
// First Subject header found in email
|
// First Subject header found in email
|
||||||
pub subject: Option<String>,
|
pub subject: Option<String>,
|
||||||
// Parsed Date header, if found and valid
|
// Parsed Date header, if found and valid
|
||||||
@@ -286,7 +290,7 @@ impl QueryRoot {
|
|||||||
Ok(letterbox_shared::build_version(bi))
|
Ok(letterbox_shared::build_version(bi))
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(query=query))]
|
#[instrument(skip_all, fields(query=query))]
|
||||||
#[instrument(skip_all, fields(query=query, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, rid=request_id()))]
|
||||||
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
|
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
let pool = ctx.data_unchecked::<PgPool>();
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
@@ -309,7 +313,7 @@ impl QueryRoot {
|
|||||||
|
|
||||||
// TODO: this function doesn't get parallelism, possibly because notmuch is sync and blocks,
|
// TODO: this function doesn't get parallelism, possibly because notmuch is sync and blocks,
|
||||||
// rewrite that with tokio::process:Command
|
// rewrite that with tokio::process:Command
|
||||||
#[instrument(skip_all, fields(query=query, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, rid=request_id()))]
|
||||||
async fn search<'ctx>(
|
async fn search<'ctx>(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Context<'ctx>,
|
ctx: &Context<'ctx>,
|
||||||
@@ -467,7 +471,7 @@ impl QueryRoot {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all, fields(request_id=request_id()))]
|
#[instrument(skip_all, fields(rid=request_id()))]
|
||||||
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {
|
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
let pool = ctx.data_unchecked::<PgPool>();
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
@@ -476,7 +480,7 @@ impl QueryRoot {
|
|||||||
tags.append(&mut nm::tags(nm, needs_unread)?);
|
tags.append(&mut nm::tags(nm, needs_unread)?);
|
||||||
Ok(tags)
|
Ok(tags)
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(thread_id=thread_id, request_id=request_id()))]
|
#[instrument(skip_all, fields(thread_id=thread_id, rid=request_id()))]
|
||||||
async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result<Thread, Error> {
|
async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result<Thread, Error> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
let cacher = ctx.data_unchecked::<FilesystemCacher>();
|
let cacher = ctx.data_unchecked::<FilesystemCacher>();
|
||||||
@@ -553,7 +557,7 @@ async fn tantivy_search(
|
|||||||
pub struct Mutation;
|
pub struct Mutation;
|
||||||
#[Object]
|
#[Object]
|
||||||
impl Mutation {
|
impl Mutation {
|
||||||
#[instrument(skip_all, fields(query=query, unread=unread, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, unread=unread, rid=request_id()))]
|
||||||
async fn set_read_status<'ctx>(
|
async fn set_read_status<'ctx>(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Context<'ctx>,
|
ctx: &Context<'ctx>,
|
||||||
@@ -572,7 +576,7 @@ impl Mutation {
|
|||||||
nm::set_read_status(nm, &query, unread).await?;
|
nm::set_read_status(nm, &query, unread).await?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(query=query, tag=tag, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, tag=tag, rid=request_id()))]
|
||||||
async fn tag_add<'ctx>(
|
async fn tag_add<'ctx>(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Context<'ctx>,
|
ctx: &Context<'ctx>,
|
||||||
@@ -584,7 +588,7 @@ impl Mutation {
|
|||||||
nm.tag_add(&tag, &query)?;
|
nm.tag_add(&tag, &query)?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(query=query, tag=tag, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, tag=tag, rid=request_id()))]
|
||||||
async fn tag_remove<'ctx>(
|
async fn tag_remove<'ctx>(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Context<'ctx>,
|
ctx: &Context<'ctx>,
|
||||||
@@ -607,7 +611,7 @@ impl Mutation {
|
|||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(request_id=request_id()))]
|
#[instrument(skip_all, fields(rid=request_id()))]
|
||||||
async fn refresh<'ctx>(&self, ctx: &Context<'ctx>) -> Result<bool, Error> {
|
async fn refresh<'ctx>(&self, ctx: &Context<'ctx>) -> Result<bool, Error> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
let cacher = ctx.data_unchecked::<FilesystemCacher>();
|
let cacher = ctx.data_unchecked::<FilesystemCacher>();
|
||||||
|
|||||||
@@ -773,7 +773,19 @@ impl Query {
|
|||||||
for uid in &self.uids {
|
for uid in &self.uids {
|
||||||
parts.push(uid.clone());
|
parts.push(uid.clone());
|
||||||
}
|
}
|
||||||
parts.extend(self.remainder.clone());
|
for r in &self.remainder {
|
||||||
|
// Rewrite "to:" to include ExtraTo:. ExtraTo: is configured in
|
||||||
|
// notmuch-config to index Delivered-To and X-Original-To headers.
|
||||||
|
if r.starts_with("to:") {
|
||||||
|
parts.push("(".to_string());
|
||||||
|
parts.push(r.to_string());
|
||||||
|
parts.push("OR".to_string());
|
||||||
|
parts.push(r.replace("to:", "ExtraTo:"));
|
||||||
|
parts.push(")".to_string());
|
||||||
|
} else {
|
||||||
|
parts.push(r.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
parts.join(" ")
|
parts.join(" ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -793,7 +805,17 @@ impl FromStr for Query {
|
|||||||
if word == "is:unread" {
|
if word == "is:unread" {
|
||||||
unread_only = true
|
unread_only = true
|
||||||
} else if word.starts_with("tag:") {
|
} else if word.starts_with("tag:") {
|
||||||
tags.push(word["tag:".len()..].to_string());
|
let t = &word["tag:".len()..];
|
||||||
|
// Per-address emails are faked as `tag:@<domain>/<username>`, rewrite to `to:` form
|
||||||
|
if t.starts_with('@') && t.contains('.') {
|
||||||
|
let t = match t.split_once('/') {
|
||||||
|
None => format!("to:{t}"),
|
||||||
|
Some((domain, user)) => format!("to:{user}{domain}"),
|
||||||
|
};
|
||||||
|
remainder.push(t);
|
||||||
|
} else {
|
||||||
|
tags.push(t.to_string());
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
} else if word.starts_with("tag:") {
|
} else if word.starts_with("tag:") {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
use std::{
|
use std::{collections::HashMap, fs::File};
|
||||||
collections::HashMap,
|
|
||||||
fs::File,
|
|
||||||
hash::{DefaultHasher, Hash, Hasher},
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
|
|
||||||
use letterbox_notmuch::Notmuch;
|
use letterbox_notmuch::Notmuch;
|
||||||
|
use letterbox_shared::compute_color;
|
||||||
use log::{error, info, warn};
|
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;
|
||||||
@@ -107,7 +103,6 @@ pub async fn search(
|
|||||||
|
|
||||||
#[instrument(name="nm::tags", skip_all, fields(needs_unread=needs_unread))]
|
#[instrument(name="nm::tags", skip_all, fields(needs_unread=needs_unread))]
|
||||||
pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
||||||
let now = Instant::now();
|
|
||||||
let unread_msg_cnt: HashMap<String, usize> = if needs_unread {
|
let unread_msg_cnt: HashMap<String, usize> = if needs_unread {
|
||||||
// 10000 is an arbitrary number, if there's more than 10k unread messages, we'll
|
// 10000 is an arbitrary number, if there's more than 10k unread messages, we'll
|
||||||
// get an inaccurate count.
|
// get an inaccurate count.
|
||||||
@@ -123,13 +118,11 @@ pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
|||||||
} else {
|
} else {
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
};
|
};
|
||||||
let tags = nm
|
let tags: Vec<_> = nm
|
||||||
.tags()?
|
.tags()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|tag| {
|
.map(|tag| {
|
||||||
let mut hasher = DefaultHasher::new();
|
let hex = compute_color(&tag);
|
||||||
tag.hash(&mut hasher);
|
|
||||||
let hex = format!("#{:06x}", hasher.finish() % (1 << 24));
|
|
||||||
let unread = if needs_unread {
|
let unread = if needs_unread {
|
||||||
*unread_msg_cnt.get(&tag).unwrap_or(&0)
|
*unread_msg_cnt.get(&tag).unwrap_or(&0)
|
||||||
} else {
|
} else {
|
||||||
@@ -142,8 +135,24 @@ pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
|||||||
unread,
|
unread,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.chain(
|
||||||
|
nm.unread_recipients()?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(name, unread)| {
|
||||||
|
let Some(idx) = name.find('@') else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let name = format!("{}/{}", &name[idx..], &name[..idx]);
|
||||||
|
let bg_color = compute_color(&name);
|
||||||
|
Some(Tag {
|
||||||
|
name,
|
||||||
|
fg_color: "white".to_string(),
|
||||||
|
bg_color,
|
||||||
|
unread,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
info!("Fetching tags took {} seconds", now.elapsed().as_secs_f32());
|
|
||||||
Ok(tags)
|
Ok(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +196,8 @@ pub async fn thread(
|
|||||||
|
|
||||||
let to = email_addresses(&path, &m, "to")?;
|
let to = email_addresses(&path, &m, "to")?;
|
||||||
let cc = email_addresses(&path, &m, "cc")?;
|
let cc = email_addresses(&path, &m, "cc")?;
|
||||||
|
let delivered_to = email_addresses(&path, &m, "delivered-to")?.pop();
|
||||||
|
let x_original_to = email_addresses(&path, &m, "x-original-to")?.pop();
|
||||||
let subject = m.headers.get_first_value("subject");
|
let subject = m.headers.get_first_value("subject");
|
||||||
let timestamp = m
|
let timestamp = m
|
||||||
.headers
|
.headers
|
||||||
@@ -306,6 +317,8 @@ pub async fn thread(
|
|||||||
body,
|
body,
|
||||||
path,
|
path,
|
||||||
attachments,
|
attachments,
|
||||||
|
delivered_to,
|
||||||
|
x_original_to,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
messages.reverse();
|
messages.reverse();
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "letterbox-shared"
|
name = "letterbox-shared"
|
||||||
version = "0.6.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Shared module for letterbox"
|
description = "Shared module for letterbox"
|
||||||
license = "UNLICENSED"
|
authors.workspace = true
|
||||||
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
edition.workspace = true
|
||||||
publish = ["xinu"]
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
build-info = "0.0.39"
|
build-info = "0.0.39"
|
||||||
letterbox-notmuch = { version = "0.1.0", registry = "xinu" }
|
letterbox-notmuch = { version = "0.8.6", path = "../notmuch", registry = "xinu" }
|
||||||
serde = { version = "1.0.147", features = ["derive"] }
|
serde = { version = "1.0.147", features = ["derive"] }
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
version = "0.6.0"
|
|
||||||
name = "letterbox-web"
|
name = "letterbox-web"
|
||||||
authors = ["Bill Thiede <git@xinu.tv>"]
|
|
||||||
edition = "2021"
|
|
||||||
description = "Web frontend for letterbox"
|
description = "Web frontend for letterbox"
|
||||||
license = "UNLICENSED"
|
authors.workspace = true
|
||||||
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
edition.workspace = true
|
||||||
publish = ["xinu"]
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
build-info-build = "0.0.39"
|
build-info-build = "0.0.39"
|
||||||
@@ -33,9 +33,9 @@ wasm-bindgen = "=0.2.100"
|
|||||||
uuid = { version = "1.13.1", features = [
|
uuid = { version = "1.13.1", features = [
|
||||||
"js",
|
"js",
|
||||||
] } # direct dep to set js feature, prevents Rng issues
|
] } # direct dep to set js feature, prevents Rng issues
|
||||||
letterbox-shared = { version = "0.3.0", registry = "xinu" }
|
letterbox-shared = { version = "0.8.6", path = "../shared", registry = "xinu" }
|
||||||
letterbox-notmuch = { version = "0.3.0", registry = "xinu" }
|
letterbox-notmuch = { version = "0.8.6", path = "../notmuch", registry = "xinu" }
|
||||||
seed_hooks = { version = "0.1.13", registry = "xinu" }
|
seed_hooks = { version = "0.4.0", registry = "xinu" }
|
||||||
|
|
||||||
[package.metadata.wasm-pack.profile.release]
|
[package.metadata.wasm-pack.profile.release]
|
||||||
wasm-opt = ['-Os']
|
wasm-opt = ['-Os']
|
||||||
|
|||||||
@@ -671,6 +671,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"args": [],
|
||||||
|
"deprecationReason": null,
|
||||||
|
"description": null,
|
||||||
|
"isDeprecated": false,
|
||||||
|
"name": "xOriginalTo",
|
||||||
|
"type": {
|
||||||
|
"kind": "OBJECT",
|
||||||
|
"name": "Email",
|
||||||
|
"ofType": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"args": [],
|
||||||
|
"deprecationReason": null,
|
||||||
|
"description": null,
|
||||||
|
"isDeprecated": false,
|
||||||
|
"name": "deliveredTo",
|
||||||
|
"type": {
|
||||||
|
"kind": "OBJECT",
|
||||||
|
"name": "Email",
|
||||||
|
"ofType": null
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"args": [],
|
"args": [],
|
||||||
"deprecationReason": null,
|
"deprecationReason": null,
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ query ShowThreadQuery($threadId: String!) {
|
|||||||
name
|
name
|
||||||
addr
|
addr
|
||||||
}
|
}
|
||||||
|
xOriginalTo {
|
||||||
|
name
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
deliveredTo {
|
||||||
|
name
|
||||||
|
addr
|
||||||
|
}
|
||||||
timestamp
|
timestamp
|
||||||
body {
|
body {
|
||||||
__typename
|
__typename
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashSet;
|
use std::{cmp::Ordering, collections::HashSet};
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, Duration, Local, Utc};
|
use chrono::{DateTime, Datelike, Duration, Local, Utc};
|
||||||
use human_format::{Formatter, Scales};
|
use human_format::{Formatter, Scales};
|
||||||
@@ -6,8 +6,8 @@ use itertools::Itertools;
|
|||||||
use letterbox_shared::compute_color;
|
use letterbox_shared::compute_color;
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use seed::{prelude::*, *};
|
use seed::{prelude::*, *};
|
||||||
use seed_hooks::{state_access::CloneState, topo, use_state};
|
use seed_hooks::{state_access::CloneState, topo, use_state, StateAccessEventHandlers};
|
||||||
use web_sys::HtmlElement;
|
use web_sys::{HtmlElement, HtmlInputElement};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::urls,
|
api::urls,
|
||||||
@@ -60,6 +60,7 @@ mod tw_classes {
|
|||||||
"accent-green-600",
|
"accent-green-600",
|
||||||
"appearance-none",
|
"appearance-none",
|
||||||
"checked:appearance-auto",
|
"checked:appearance-auto",
|
||||||
|
"indeterminate:appearance-auto",
|
||||||
"rounded",
|
"rounded",
|
||||||
"border",
|
"border",
|
||||||
"border-neutral-500",
|
"border-neutral-500",
|
||||||
@@ -95,7 +96,6 @@ pub fn view(model: &Model) -> Node<Msg> {
|
|||||||
"lg:flex-nowrap",
|
"lg:flex-nowrap",
|
||||||
"w-full"
|
"w-full"
|
||||||
],
|
],
|
||||||
reading_progress(model.read_completion_ratio),
|
|
||||||
div![
|
div![
|
||||||
C!["w-full", "lg:w-48", "flex-none", "flex", "flex-col"],
|
C!["w-full", "lg:w-48", "flex-none", "flex", "flex-col"],
|
||||||
tags(model),
|
tags(model),
|
||||||
@@ -109,7 +109,8 @@ pub fn view(model: &Model) -> Node<Msg> {
|
|||||||
view_header(&model.query, &model.refreshing_state, true),
|
view_header(&model.query, &model.refreshing_state, true),
|
||||||
content,
|
content,
|
||||||
view_header(&model.query, &model.refreshing_state, false),
|
view_header(&model.query, &model.refreshing_state, false),
|
||||||
]
|
],
|
||||||
|
reading_progress(model.read_completion_ratio),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +136,6 @@ fn search_results(
|
|||||||
tags.remove(idx);
|
tags.remove(idx);
|
||||||
};
|
};
|
||||||
let is_unread = unread_idx.is_some();
|
let is_unread = unread_idx.is_some();
|
||||||
// TODO: add check-all button, and tristate indicator
|
|
||||||
div![
|
div![
|
||||||
C![
|
C![
|
||||||
"flex",
|
"flex",
|
||||||
@@ -189,11 +189,12 @@ fn search_results(
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
let show_bulk_edit = !selected_threads.is_empty();
|
let show_bulk_edit = !selected_threads.is_empty();
|
||||||
|
let all_selected = selected_threads.len() == results.len();
|
||||||
div![
|
div![
|
||||||
C!["flex", "flex-col", "flex-auto", "p-4"],
|
C!["flex", "flex-col", "flex-auto", "p-4"],
|
||||||
search_toolbar(count, pager, show_bulk_edit),
|
search_toolbar(count, pager, show_bulk_edit, all_selected),
|
||||||
div![rows],
|
div![rows],
|
||||||
search_toolbar(count, pager, show_bulk_edit),
|
search_toolbar(count, pager, show_bulk_edit, all_selected),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,47 +311,71 @@ fn human_age(timestamp: i64) -> String {
|
|||||||
datetime
|
datetime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[topo::nested]
|
||||||
fn search_toolbar(
|
fn search_toolbar(
|
||||||
count: usize,
|
count: usize,
|
||||||
pager: &FrontPageQuerySearchPageInfo,
|
pager: &FrontPageQuerySearchPageInfo,
|
||||||
show_bulk_edit: bool,
|
show_bulk_edit: bool,
|
||||||
|
all_selected: bool,
|
||||||
) -> Node<Msg> {
|
) -> Node<Msg> {
|
||||||
|
let indeterminate = show_bulk_edit && !all_selected;
|
||||||
|
let tristate_el: ElRef<HtmlInputElement> = use_state(|| Default::default()).get();
|
||||||
|
let tri = el_ref(&tristate_el);
|
||||||
|
if let Some(tri) = tri.get() {
|
||||||
|
info!(
|
||||||
|
"setting tristate to {indeterminate}, current {}",
|
||||||
|
tri.indeterminate()
|
||||||
|
);
|
||||||
|
tri.set_indeterminate(indeterminate);
|
||||||
|
}
|
||||||
nav![
|
nav![
|
||||||
C!["p-4", "flex", "w-full", "justify-between"],
|
C!["py-4", "flex", "w-full", "justify-between"],
|
||||||
div![
|
div![
|
||||||
C!["gap-2", "flex"],
|
C!["gap-2", "flex", IF!(!show_bulk_edit => "invisible")],
|
||||||
IF!(show_bulk_edit =>
|
|
||||||
div![
|
div![
|
||||||
button![
|
C!["flex", "items-center", "mr-4"],
|
||||||
C![&tw_classes::BUTTON, "rounded-r-none"],
|
input![
|
||||||
attrs!{At::Title => "Mark as read"},
|
tri,
|
||||||
span![i![C!["far", "fa-envelope-open"]]],
|
C![&tw_classes::CHECKBOX],
|
||||||
span![C!["pl-2", "hidden", "md:inline"], "Read"],
|
attrs! {
|
||||||
ev(Ev::Click, |_| Msg::SelectionMarkAsRead)
|
At::Type=>"checkbox",
|
||||||
],
|
At::Checked=>all_selected,
|
||||||
button![
|
}
|
||||||
C![&tw_classes::BUTTON, "rounded-l-none"],
|
],
|
||||||
attrs!{At::Title => "Mark as unread"},
|
ev(Ev::Input, move |_| {
|
||||||
span![i![C!["far", "fa-envelope"]]],
|
if all_selected {
|
||||||
span![C!["pl-2", "hidden", "md:inline"], "Unread"],
|
Msg::SelectionSetNone
|
||||||
ev(Ev::Click, |_| Msg::SelectionMarkAsUnread)
|
} else {
|
||||||
]
|
Msg::SelectionSetAll
|
||||||
]),
|
}
|
||||||
IF!(show_bulk_edit =>
|
}),
|
||||||
|
],
|
||||||
div![
|
div![
|
||||||
button![
|
button![
|
||||||
C![&tw_classes::BUTTON, "text-red-500"],
|
C![&tw_classes::BUTTON, "rounded-r-none"],
|
||||||
attrs!{At::Title => "Mark as spam"},
|
attrs! {At::Title => "Mark as read"},
|
||||||
span![i![C!["far", "fa-hand"]]],
|
span![i![C!["far", "fa-envelope-open"]]],
|
||||||
span![C!["pl-2", "hidden", "md:inline"], "Spam"],
|
span![C!["pl-2", "hidden", "md:inline"], "Read"],
|
||||||
ev(Ev::Click, |_|
|
ev(Ev::Click, |_| Msg::SelectionMarkAsRead)
|
||||||
Msg::MultiMsg(vec![
|
],
|
||||||
Msg::SelectionAddTag("Spam".to_string()),
|
button![
|
||||||
Msg::SelectionMarkAsRead
|
C![&tw_classes::BUTTON, "rounded-l-none"],
|
||||||
])
|
attrs! {At::Title => "Mark as unread"},
|
||||||
)
|
span![i![C!["far", "fa-envelope"]]],
|
||||||
|
span![C!["pl-2", "hidden", "md:inline"], "Unread"],
|
||||||
|
ev(Ev::Click, |_| Msg::SelectionMarkAsUnread)
|
||||||
]
|
]
|
||||||
]),
|
],
|
||||||
|
div![button![
|
||||||
|
C![&tw_classes::BUTTON, "text-red-500"],
|
||||||
|
attrs! {At::Title => "Mark as spam"},
|
||||||
|
span![i![C!["far", "fa-hand"]]],
|
||||||
|
span![C!["pl-2", "hidden", "md:inline"], "Spam"],
|
||||||
|
ev(Ev::Click, |_| Msg::MultiMsg(vec![
|
||||||
|
Msg::SelectionAddTag("Spam".to_string()),
|
||||||
|
Msg::SelectionMarkAsRead
|
||||||
|
]))
|
||||||
|
]],
|
||||||
],
|
],
|
||||||
div![
|
div![
|
||||||
C!["flex", "gap-2", "items-center"],
|
C!["flex", "gap-2", "items-center"],
|
||||||
@@ -494,6 +519,48 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<
|
|||||||
})
|
})
|
||||||
]
|
]
|
||||||
]),
|
]),
|
||||||
|
IF!(msg.to.is_empty() && msg.x_original_to.is_some()=>div![
|
||||||
|
C!["text-xs"],
|
||||||
|
span![
|
||||||
|
C!["font-semibold"],
|
||||||
|
"Original To: "
|
||||||
|
],
|
||||||
|
span![
|
||||||
|
msg.x_original_to.as_ref().map(|to| {
|
||||||
|
let ShowThreadQueryThreadOnEmailThreadMessagesXOriginalTo { name, addr } = to;
|
||||||
|
span![
|
||||||
|
addr.as_ref().map(|addr| attrs! {
|
||||||
|
At::Title => addr
|
||||||
|
}),
|
||||||
|
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
|
||||||
|
" ",
|
||||||
|
addr.as_ref().map(|addr| copy_text_widget(&addr)),
|
||||||
|
" "
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
]),
|
||||||
|
IF!(msg.to.is_empty() && msg.x_original_to.is_none() && msg.delivered_to.is_some() => div![
|
||||||
|
C!["text-xs"],
|
||||||
|
span![
|
||||||
|
C!["font-semibold"],
|
||||||
|
"Delivered To: "
|
||||||
|
],
|
||||||
|
span![
|
||||||
|
msg.delivered_to.as_ref().map(|to| {
|
||||||
|
let ShowThreadQueryThreadOnEmailThreadMessagesDeliveredTo { name, addr } = to;
|
||||||
|
span![
|
||||||
|
addr.as_ref().map(|addr| attrs! {
|
||||||
|
At::Title => addr
|
||||||
|
}),
|
||||||
|
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
|
||||||
|
" ",
|
||||||
|
addr.as_ref().map(|addr| copy_text_widget(&addr)),
|
||||||
|
" "
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
]),
|
||||||
IF!(!msg.cc.is_empty() =>div![
|
IF!(!msg.cc.is_empty() =>div![
|
||||||
C!["text-xs"],
|
C!["text-xs"],
|
||||||
span![
|
span![
|
||||||
@@ -589,6 +656,48 @@ fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Nod
|
|||||||
],
|
],
|
||||||
" "
|
" "
|
||||||
]),
|
]),
|
||||||
|
IF!(msg.to.is_empty() && msg.x_original_to.is_some()=>div![
|
||||||
|
C!["text-xs"],
|
||||||
|
span![
|
||||||
|
C!["font-semibold"],
|
||||||
|
"Original To: "
|
||||||
|
],
|
||||||
|
span![
|
||||||
|
msg.x_original_to.as_ref().map(|to| {
|
||||||
|
let ShowThreadQueryThreadOnEmailThreadMessagesXOriginalTo { name, addr } = to;
|
||||||
|
span![
|
||||||
|
addr.as_ref().map(|addr| attrs! {
|
||||||
|
At::Title => addr
|
||||||
|
}),
|
||||||
|
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
|
||||||
|
" ",
|
||||||
|
addr.as_ref().map(|addr| copy_text_widget(&addr)),
|
||||||
|
" "
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
]),
|
||||||
|
IF!(msg.to.is_empty() && msg.x_original_to.is_none() && msg.delivered_to.is_some() => div![
|
||||||
|
C!["text-xs"],
|
||||||
|
span![
|
||||||
|
C!["font-semibold"],
|
||||||
|
"Delivered To: "
|
||||||
|
],
|
||||||
|
span![
|
||||||
|
msg.delivered_to.as_ref().map(|to| {
|
||||||
|
let ShowThreadQueryThreadOnEmailThreadMessagesDeliveredTo { name, addr } = to;
|
||||||
|
span![
|
||||||
|
addr.as_ref().map(|addr| attrs! {
|
||||||
|
At::Title => addr
|
||||||
|
}),
|
||||||
|
name.as_ref().unwrap_or_else(|| addr.as_ref().unwrap_or(&unknown)),
|
||||||
|
" ",
|
||||||
|
addr.as_ref().map(|addr| copy_text_widget(&addr)),
|
||||||
|
" "
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
]),
|
||||||
IF!(!msg.cc.is_empty() => div![
|
IF!(!msg.cc.is_empty() => div![
|
||||||
C!["text-xs", "max-w-full", "overflow-clip", "text-ellipsis"],
|
C!["text-xs", "max-w-full", "overflow-clip", "text-ellipsis"],
|
||||||
span![
|
span![
|
||||||
@@ -857,9 +966,7 @@ fn view_content_tree(content_tree: &str) -> Node<Msg> {
|
|||||||
}
|
}
|
||||||
]],
|
]],
|
||||||
" Debug",
|
" Debug",
|
||||||
ev(Ev::Click, move |_| {
|
debug_open.on_click(|d| *d = !*d)
|
||||||
debug_open.set(!debug_open.get());
|
|
||||||
})
|
|
||||||
],
|
],
|
||||||
IF!(debug_open.get() =>
|
IF!(debug_open.get() =>
|
||||||
pre![C!["NOTPORTED","content-tree"], content_tree]),
|
pre![C!["NOTPORTED","content-tree"], content_tree]),
|
||||||
@@ -879,6 +986,7 @@ fn view_header(
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
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!["flex", "px-4", "pt-4", "overflow-hidden"],
|
C!["flex", "px-4", "pt-4", "overflow-hidden"],
|
||||||
a![
|
a![
|
||||||
@@ -1008,11 +1116,23 @@ pub fn tags(model: &Model) -> Node<Msg> {
|
|||||||
}
|
}
|
||||||
tag_els
|
tag_els
|
||||||
}
|
}
|
||||||
let unread = model
|
let mut unread = model
|
||||||
.tags
|
.tags
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|tags| tags.iter().filter(|t| t.unread > 0).collect())
|
.map(|tags| tags.iter().filter(|t| t.unread > 0).collect())
|
||||||
.unwrap_or(Vec::new());
|
.unwrap_or(Vec::new());
|
||||||
|
unread.sort_by(|a, b| {
|
||||||
|
let r = if a.name.starts_with('@') && b.name.starts_with('@') {
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
} else if a.name.starts_with('@') {
|
||||||
|
Ordering::Less
|
||||||
|
} else if b.name.starts_with('@') {
|
||||||
|
Ordering::Greater
|
||||||
|
} else {
|
||||||
|
a.name.cmp(&b.name)
|
||||||
|
};
|
||||||
|
return r;
|
||||||
|
});
|
||||||
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![
|
||||||
@@ -1030,9 +1150,7 @@ pub fn tags(model: &Model) -> Node<Msg> {
|
|||||||
"fa-angle-down"
|
"fa-angle-down"
|
||||||
}
|
}
|
||||||
]]),
|
]]),
|
||||||
ev(Ev::Click, move |_| {
|
tags_open.on_click(|t| *t = !*t)
|
||||||
tags_open.set(!tags_open.get());
|
|
||||||
})
|
|
||||||
],
|
],
|
||||||
div![
|
div![
|
||||||
IF!(force_tags_open||tags_open.get() => model.tags.as_ref().map(|tags| view_tags(tags.iter(),false))),
|
IF!(force_tags_open||tags_open.get() => model.tags.as_ref().map(|tags| view_tags(tags.iter(),false))),
|
||||||
|
|||||||
Reference in New Issue
Block a user