Compare commits
1 Commits
7102f26c9e
...
pretty
| Author | SHA1 | Date | |
|---|---|---|---|
| 98df04527d |
728
Cargo.lock
generated
728
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
2
dev.sh
2
dev.sh
@@ -3,5 +3,5 @@ tmux new-session -d -s letterbox-dev
|
|||||||
tmux rename-window web
|
tmux rename-window web
|
||||||
tmux send-keys "cd web; trunk serve -w ../shared -w ../notmuch -w ./" C-m
|
tmux send-keys "cd web; trunk serve -w ../shared -w ../notmuch -w ./" C-m
|
||||||
tmux new-window -n server
|
tmux new-window -n server
|
||||||
tmux send-keys "cd server; cargo watch -c -x run -w ../shared -w ../notmuch -w ./" C-m
|
tmux send-keys "cd server; cargo watch -x run -w ../shared -w ../notmuch -w ./" C-m
|
||||||
tmux attach -d -t letterbox-dev
|
tmux attach -d -t letterbox-dev
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ edition = "2021"
|
|||||||
# 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]
|
||||||
|
anyhow = "1.0.75"
|
||||||
|
clap = { version = "4.4.7", features = ["derive"] }
|
||||||
log = "0.4.14"
|
log = "0.4.14"
|
||||||
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"] }
|
||||||
|
|||||||
@@ -480,19 +480,12 @@ impl Notmuch {
|
|||||||
self.run_notmuch(std::iter::empty::<&str>())
|
self.run_notmuch(std::iter::empty::<&str>())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tags(&self) -> Result<Vec<String>, NotmuchError> {
|
|
||||||
let res = self.run_notmuch(["search", "--format=json", "--output=tags", "*"])?;
|
|
||||||
Ok(serde_json::from_slice(&res)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn search(
|
pub fn search(
|
||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<SearchSummary, NotmuchError> {
|
) -> Result<SearchSummary, NotmuchError> {
|
||||||
let query = if query.is_empty() { "*" } else { query };
|
|
||||||
|
|
||||||
let res = self.run_notmuch([
|
let res = self.run_notmuch([
|
||||||
"search",
|
"search",
|
||||||
"--format=json",
|
"--format=json",
|
||||||
@@ -561,10 +554,7 @@ impl Notmuch {
|
|||||||
Ok(BufReader::new(child.stdout.take().unwrap()).lines())
|
Ok(BufReader::new(child.stdout.take().unwrap()).lines())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn files(&self, query: &str) -> Result<Lines<BufReader<ChildStdout>>, NotmuchError> {
|
// TODO(wathiede): implement tags() based on "notmuch search --output=tags '*'"
|
||||||
let mut child = self.run_notmuch_pipe(["search", "--output=files", query])?;
|
|
||||||
Ok(BufReader::new(child.stdout.take().unwrap()).lines())
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
46
notmuch/src/main.rs
Normal file
46
notmuch/src/main.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use notmuch::Notmuch;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
#[command(propagate_version = true)]
|
||||||
|
struct Cli {
|
||||||
|
/// Optional notmuch config file
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Show given search term
|
||||||
|
Show { search_term: String },
|
||||||
|
/// Search for given search term
|
||||||
|
Search { search_term: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let nm = if let Some(config_path) = cli.config {
|
||||||
|
Notmuch::with_config(config_path)
|
||||||
|
} else {
|
||||||
|
Notmuch::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// You can check for the existence of subcommands, and if found use their
|
||||||
|
// matches just as you would the top level cmd
|
||||||
|
match &cli.command {
|
||||||
|
Commands::Search { search_term } => {
|
||||||
|
println!("{:#?}", nm.search(&search_term, 0, 10)?);
|
||||||
|
}
|
||||||
|
Commands::Show { search_term } => {
|
||||||
|
println!("{:#?}", nm.show(&search_term)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ default-bin = "server"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rocket = { version = "0.5.0-rc.2", features = [ "json" ] }
|
rocket = { version = "0.5.0-rc.2", features = [ "json" ] }
|
||||||
|
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", branch = "master" }
|
||||||
notmuch = { path = "../notmuch" }
|
notmuch = { path = "../notmuch" }
|
||||||
shared = { path = "../shared" }
|
shared = { path = "../shared" }
|
||||||
serde_json = "1.0.87"
|
serde_json = "1.0.87"
|
||||||
@@ -17,13 +18,6 @@ log = "0.4.17"
|
|||||||
tokio = "1.26.0"
|
tokio = "1.26.0"
|
||||||
glog = "0.1.0"
|
glog = "0.1.0"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
async-graphql = { version = "6.0.11", features = ["log"] }
|
|
||||||
async-graphql-rocket = "6.0.11"
|
|
||||||
rocket_cors = "0.6.0"
|
|
||||||
rayon = "1.8.0"
|
|
||||||
memmap = "0.7.0"
|
|
||||||
mailparse = "0.14.0"
|
|
||||||
ammonia = "3.3.0"
|
|
||||||
|
|
||||||
[dependencies.rocket_contrib]
|
[dependencies.rocket_contrib]
|
||||||
version = "0.4.11"
|
version = "0.4.11"
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate rocket;
|
extern crate rocket;
|
||||||
|
|
||||||
use std::{error::Error, io::Cursor, str::FromStr};
|
use std::{error::Error, io::Cursor, str::FromStr};
|
||||||
|
|
||||||
use async_graphql::{http::GraphiQLSource, EmptyMutation, EmptySubscription, Schema};
|
|
||||||
use async_graphql_rocket::{GraphQLQuery, GraphQLRequest, GraphQLResponse};
|
|
||||||
use glog::Flags;
|
use glog::Flags;
|
||||||
use notmuch::{Notmuch, NotmuchError, ThreadSet};
|
use notmuch::{Notmuch, NotmuchError, ThreadSet};
|
||||||
use rocket::{
|
use rocket::{
|
||||||
http::{ContentType, Header},
|
http::{ContentType, Header},
|
||||||
request::Request,
|
request::Request,
|
||||||
response::{content, Debug, Responder},
|
response::{Debug, Responder},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Response, State,
|
Response, State,
|
||||||
};
|
};
|
||||||
use rocket_cors::{AllowedHeaders, AllowedOrigins};
|
use rocket_cors::{AllowedHeaders, AllowedOrigins};
|
||||||
use server::{
|
use server::{error::ServerError, nm::threadset_to_messages};
|
||||||
error::ServerError,
|
use shared::Message;
|
||||||
graphql::{GraphqlSchema, QueryRoot},
|
|
||||||
};
|
#[get("/")]
|
||||||
|
fn hello() -> &'static str {
|
||||||
|
"Hello, world!"
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/refresh")]
|
#[get("/refresh")]
|
||||||
async fn refresh(nm: &State<Notmuch>) -> Result<Json<String>, Debug<NotmuchError>> {
|
async fn refresh(nm: &State<Notmuch>) -> Result<Json<String>, Debug<NotmuchError>> {
|
||||||
@@ -39,7 +41,7 @@ async fn search(
|
|||||||
results_per_page: Option<usize>,
|
results_per_page: Option<usize>,
|
||||||
) -> Result<Json<shared::SearchResult>, Debug<NotmuchError>> {
|
) -> Result<Json<shared::SearchResult>, Debug<NotmuchError>> {
|
||||||
let page = page.unwrap_or(0);
|
let page = page.unwrap_or(0);
|
||||||
let results_per_page = results_per_page.unwrap_or(20);
|
let results_per_page = results_per_page.unwrap_or(10);
|
||||||
let query = urlencoding::decode(query).map_err(NotmuchError::from)?;
|
let query = urlencoding::decode(query).map_err(NotmuchError::from)?;
|
||||||
info!(" search '{query}'");
|
info!(" search '{query}'");
|
||||||
let res = shared::SearchResult {
|
let res = shared::SearchResult {
|
||||||
@@ -56,9 +58,9 @@ async fn search(
|
|||||||
async fn show_pretty(
|
async fn show_pretty(
|
||||||
nm: &State<Notmuch>,
|
nm: &State<Notmuch>,
|
||||||
query: &str,
|
query: &str,
|
||||||
) -> Result<Json<ThreadSet>, Debug<ServerError>> {
|
) -> Result<Json<Vec<Message>>, Debug<ServerError>> {
|
||||||
let query = urlencoding::decode(query).map_err(|e| ServerError::from(NotmuchError::from(e)))?;
|
let query = urlencoding::decode(query).map_err(|e| ServerError::from(NotmuchError::from(e)))?;
|
||||||
let res = nm.show(&query).map_err(ServerError::from)?;
|
let res = threadset_to_messages(nm.show(&query).map_err(ServerError::from)?)?;
|
||||||
Ok(Json(res))
|
Ok(Json(res))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,24 +125,6 @@ async fn original(
|
|||||||
Ok((ContentType::Plain, res))
|
Ok((ContentType::Plain, res))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::get("/")]
|
|
||||||
fn graphiql() -> content::RawHtml<String> {
|
|
||||||
content::RawHtml(GraphiQLSource::build().endpoint("/graphql").finish())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/graphql?<query..>")]
|
|
||||||
async fn graphql_query(schema: &State<GraphqlSchema>, query: GraphQLQuery) -> GraphQLResponse {
|
|
||||||
query.execute(schema.inner()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::post("/graphql", data = "<request>", format = "application/json")]
|
|
||||||
async fn graphql_request(
|
|
||||||
schema: &State<GraphqlSchema>,
|
|
||||||
request: GraphQLRequest,
|
|
||||||
) -> GraphQLResponse {
|
|
||||||
request.execute(schema.inner()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::main]
|
#[rocket::main]
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
glog::new()
|
glog::new()
|
||||||
@@ -164,29 +148,21 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
.to_cors()?;
|
.to_cors()?;
|
||||||
|
|
||||||
let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
|
|
||||||
.data(Notmuch::default())
|
|
||||||
.extension(async_graphql::extensions::Logger)
|
|
||||||
.finish();
|
|
||||||
|
|
||||||
let _ = rocket::build()
|
let _ = rocket::build()
|
||||||
.mount(
|
.mount(
|
||||||
"/",
|
"/",
|
||||||
routes![
|
routes![
|
||||||
original_part,
|
original_part,
|
||||||
original,
|
original,
|
||||||
|
hello,
|
||||||
refresh,
|
refresh,
|
||||||
search_all,
|
search_all,
|
||||||
search,
|
search,
|
||||||
show_pretty,
|
show_pretty,
|
||||||
show,
|
show
|
||||||
graphql_query,
|
|
||||||
graphql_request,
|
|
||||||
graphiql
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.attach(cors)
|
.attach(cors)
|
||||||
.manage(schema)
|
|
||||||
.manage(Notmuch::default())
|
.manage(Notmuch::default())
|
||||||
//.manage(Notmuch::with_config("../notmuch/testdata/notmuch.config"))
|
//.manage(Notmuch::with_config("../notmuch/testdata/notmuch.config"))
|
||||||
.launch()
|
.launch()
|
||||||
|
|||||||
@@ -1,464 +0,0 @@
|
|||||||
use std::{
|
|
||||||
fs::File,
|
|
||||||
hash::{DefaultHasher, Hash, Hasher},
|
|
||||||
};
|
|
||||||
|
|
||||||
use async_graphql::{
|
|
||||||
connection::{self, Connection, Edge},
|
|
||||||
Context, EmptyMutation, EmptySubscription, Error, FieldResult, Object, Schema, SimpleObject,
|
|
||||||
Union,
|
|
||||||
};
|
|
||||||
use log::{error, info, warn};
|
|
||||||
use mailparse::{parse_mail, MailHeaderMap, ParsedMail};
|
|
||||||
use memmap::MmapOptions;
|
|
||||||
use notmuch::Notmuch;
|
|
||||||
use rayon::prelude::*;
|
|
||||||
|
|
||||||
pub struct QueryRoot;
|
|
||||||
|
|
||||||
/// # Number of seconds since the Epoch
|
|
||||||
pub type UnixTime = isize;
|
|
||||||
|
|
||||||
/// # Thread ID, sans "thread:"
|
|
||||||
pub type ThreadId = String;
|
|
||||||
|
|
||||||
#[derive(Debug, SimpleObject)]
|
|
||||||
pub struct ThreadSummary {
|
|
||||||
pub thread: ThreadId,
|
|
||||||
pub timestamp: UnixTime,
|
|
||||||
/// user-friendly timestamp
|
|
||||||
pub date_relative: String,
|
|
||||||
/// number of matched messages
|
|
||||||
pub matched: isize,
|
|
||||||
/// total messages in thread
|
|
||||||
pub total: isize,
|
|
||||||
/// comma-separated names with | between matched and unmatched
|
|
||||||
pub authors: String,
|
|
||||||
pub subject: String,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, SimpleObject)]
|
|
||||||
pub struct Thread {
|
|
||||||
subject: String,
|
|
||||||
messages: Vec<Message>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, SimpleObject)]
|
|
||||||
pub struct Message {
|
|
||||||
// First From header found in email
|
|
||||||
pub from: Option<Email>,
|
|
||||||
// All To headers found in email
|
|
||||||
pub to: Vec<Email>,
|
|
||||||
// All CC headers found in email
|
|
||||||
pub cc: Vec<Email>,
|
|
||||||
// First Subject header found in email
|
|
||||||
pub subject: Option<String>,
|
|
||||||
// Parsed Date header, if found and valid
|
|
||||||
pub timestamp: Option<i64>,
|
|
||||||
// The body contents
|
|
||||||
pub body: Body,
|
|
||||||
// On disk location of message
|
|
||||||
pub path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct UnhandledContentType {
|
|
||||||
text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Object]
|
|
||||||
impl UnhandledContentType {
|
|
||||||
async fn contents(&self) -> &str {
|
|
||||||
&self.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PlainText {
|
|
||||||
text: String,
|
|
||||||
content_tree: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Object]
|
|
||||||
impl PlainText {
|
|
||||||
async fn contents(&self) -> &str {
|
|
||||||
&self.text
|
|
||||||
}
|
|
||||||
async fn content_tree(&self) -> &str {
|
|
||||||
&self.content_tree
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Html {
|
|
||||||
html: String,
|
|
||||||
content_tree: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Object]
|
|
||||||
impl Html {
|
|
||||||
async fn contents(&self) -> &str {
|
|
||||||
&self.html
|
|
||||||
}
|
|
||||||
async fn content_tree(&self) -> &str {
|
|
||||||
&self.content_tree
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Union)]
|
|
||||||
pub enum Body {
|
|
||||||
UnhandledContentType(UnhandledContentType),
|
|
||||||
PlainText(PlainText),
|
|
||||||
Html(Html),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Body {
|
|
||||||
fn html(html: String) -> Body {
|
|
||||||
Body::Html(Html {
|
|
||||||
html,
|
|
||||||
content_tree: "".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fn text(text: String) -> Body {
|
|
||||||
Body::PlainText(PlainText {
|
|
||||||
text,
|
|
||||||
content_tree: "".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, SimpleObject)]
|
|
||||||
pub struct Email {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub addr: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(SimpleObject)]
|
|
||||||
struct Tag {
|
|
||||||
name: String,
|
|
||||||
fg_color: String,
|
|
||||||
bg_color: String,
|
|
||||||
unread: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Object]
|
|
||||||
impl QueryRoot {
|
|
||||||
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
|
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
|
||||||
Ok(nm.count(&query)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn search<'ctx>(
|
|
||||||
&self,
|
|
||||||
ctx: &Context<'ctx>,
|
|
||||||
after: Option<String>,
|
|
||||||
before: Option<String>,
|
|
||||||
first: Option<i32>,
|
|
||||||
last: Option<i32>,
|
|
||||||
query: String,
|
|
||||||
) -> Result<Connection<usize, ThreadSummary>, Error> {
|
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
|
||||||
connection::query(
|
|
||||||
after,
|
|
||||||
before,
|
|
||||||
first,
|
|
||||||
last,
|
|
||||||
|after, before, first, last| async move {
|
|
||||||
let total = nm.count(&query)?;
|
|
||||||
let (first, last) = if let (None, None) = (first, last) {
|
|
||||||
info!("neither first nor last set, defaulting first to 20");
|
|
||||||
(Some(20), None)
|
|
||||||
} else {
|
|
||||||
(first, last)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut start = after.map(|after| after + 1).unwrap_or(0);
|
|
||||||
let mut end = before.unwrap_or(total);
|
|
||||||
if let Some(first) = first {
|
|
||||||
end = (start + first).min(end);
|
|
||||||
}
|
|
||||||
if let Some(last) = last {
|
|
||||||
start = if last > end - start { end } else { end - last };
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = end - start;
|
|
||||||
let slice: Vec<ThreadSummary> = nm
|
|
||||||
.search(&query, start, count)?
|
|
||||||
.0
|
|
||||||
.into_iter()
|
|
||||||
.map(|ts| ThreadSummary {
|
|
||||||
thread: ts.thread,
|
|
||||||
timestamp: ts.timestamp,
|
|
||||||
date_relative: ts.date_relative,
|
|
||||||
matched: ts.matched,
|
|
||||||
total: ts.total,
|
|
||||||
authors: ts.authors,
|
|
||||||
subject: ts.subject,
|
|
||||||
tags: ts.tags,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut connection = Connection::new(start > 0, end < total);
|
|
||||||
connection.edges.extend(
|
|
||||||
slice
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, item)| Edge::new(start + idx, item)),
|
|
||||||
);
|
|
||||||
Ok::<_, Error>(connection)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {
|
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
|
||||||
Ok(nm
|
|
||||||
.tags()?
|
|
||||||
.into_par_iter()
|
|
||||||
.map(|tag| {
|
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
tag.hash(&mut hasher);
|
|
||||||
let hex = format!("#{:06x}", hasher.finish() % (1 << 24));
|
|
||||||
let unread = if ctx.look_ahead().field("unread").exists() {
|
|
||||||
nm.count(&format!("tag:{tag} is:unread")).unwrap_or(0)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
Tag {
|
|
||||||
name: tag,
|
|
||||||
fg_color: "white".to_string(),
|
|
||||||
bg_color: hex,
|
|
||||||
unread,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result<Thread, Error> {
|
|
||||||
// TODO(wathiede): normalize all email addresses through an address book with preferred
|
|
||||||
// display names (that default to the most commonly seen name).
|
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
|
||||||
let debug_content_tree = ctx
|
|
||||||
.look_ahead()
|
|
||||||
.field("messages")
|
|
||||||
.field("body")
|
|
||||||
.field("contentTree")
|
|
||||||
.exists();
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
for path in nm.files(&thread_id)? {
|
|
||||||
let path = path?;
|
|
||||||
let file = File::open(&path)?;
|
|
||||||
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
|
||||||
let m = parse_mail(&mmap)?;
|
|
||||||
let from = email_addresses(&path, &m, "from")?;
|
|
||||||
let from = match from.len() {
|
|
||||||
0 => None,
|
|
||||||
1 => from.into_iter().next(),
|
|
||||||
_ => {
|
|
||||||
warn!(
|
|
||||||
"Got {} from addresses in message, truncating: {:?}",
|
|
||||||
from.len(),
|
|
||||||
from
|
|
||||||
);
|
|
||||||
from.into_iter().next()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let to = email_addresses(&path, &m, "to")?;
|
|
||||||
let cc = email_addresses(&path, &m, "cc")?;
|
|
||||||
let subject = m.headers.get_first_value("subject");
|
|
||||||
let timestamp = m
|
|
||||||
.headers
|
|
||||||
.get_first_value("date")
|
|
||||||
.and_then(|d| mailparse::dateparse(&d).ok());
|
|
||||||
let body = match extract_body(&m)? {
|
|
||||||
Body::PlainText(PlainText { text, content_tree }) => Body::PlainText(PlainText {
|
|
||||||
text,
|
|
||||||
content_tree: if debug_content_tree {
|
|
||||||
render_content_type_tree(&m)
|
|
||||||
} else {
|
|
||||||
content_tree
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Body::Html(Html { html, content_tree }) => Body::Html(Html {
|
|
||||||
html: ammonia::clean(&html),
|
|
||||||
content_tree: if debug_content_tree {
|
|
||||||
render_content_type_tree(&m)
|
|
||||||
} else {
|
|
||||||
content_tree
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
b => b,
|
|
||||||
};
|
|
||||||
messages.push(Message {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
subject,
|
|
||||||
timestamp,
|
|
||||||
body,
|
|
||||||
path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
messages.reverse();
|
|
||||||
// Find the first subject that's set. After reversing the vec, this should be the oldest
|
|
||||||
// message.
|
|
||||||
let subject: String = messages
|
|
||||||
.iter()
|
|
||||||
.skip_while(|m| m.subject.is_none())
|
|
||||||
.next()
|
|
||||||
.and_then(|m| m.subject.clone())
|
|
||||||
.unwrap_or("(NO SUBJECT)".to_string());
|
|
||||||
Ok(Thread { subject, messages })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_body(m: &ParsedMail) -> Result<Body, Error> {
|
|
||||||
let body = m.get_body()?;
|
|
||||||
let ret = match m.ctype.mimetype.as_str() {
|
|
||||||
"text/plain" => return Ok(Body::text(body)),
|
|
||||||
"text/html" => return Ok(Body::html(body)),
|
|
||||||
"multipart/mixed" => extract_mixed(m),
|
|
||||||
"multipart/alternative" => extract_alternative(m),
|
|
||||||
_ => extract_unhandled(m),
|
|
||||||
};
|
|
||||||
if let Err(err) = ret {
|
|
||||||
error!("Failed to extract body: {err:?}");
|
|
||||||
return Ok(extract_unhandled(m)?);
|
|
||||||
}
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_unhandled(m: &ParsedMail) -> Result<Body, Error> {
|
|
||||||
let msg = format!(
|
|
||||||
"Unhandled body content type:\n{}",
|
|
||||||
render_content_type_tree(m)
|
|
||||||
);
|
|
||||||
warn!("{}", msg);
|
|
||||||
Ok(Body::UnhandledContentType(UnhandledContentType {
|
|
||||||
text: msg,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
fn extract_alternative(m: &ParsedMail) -> Result<Body, Error> {
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == "text/html" {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
return Ok(Body::html(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == "text/plain" {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
return Ok(Body::text(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err("extract_alternative".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_mixed(m: &ParsedMail) -> Result<Body, Error> {
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == "multipart/alternative" {
|
|
||||||
return extract_alternative(sp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == "multipart/related" {
|
|
||||||
return extract_related(sp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
match sp.ctype.mimetype.as_str() {
|
|
||||||
"text/plain" => return Ok(Body::text(body)),
|
|
||||||
"text/html" => return Ok(Body::html(body)),
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err("extract_mixed".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_related(m: &ParsedMail) -> Result<Body, Error> {
|
|
||||||
// TODO(wathiede): collect related things and change return type to new Body arm.
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == "text/html" {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
return Ok(Body::html(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == "text/plain" {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
return Ok(Body::text(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err("extract_related".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_content_type_tree(m: &ParsedMail) -> String {
|
|
||||||
const WIDTH: usize = 4;
|
|
||||||
fn render_rec(m: &ParsedMail, depth: usize) -> String {
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
|
|
||||||
parts.push(msg);
|
|
||||||
if !m.ctype.charset.is_empty() {
|
|
||||||
parts.push(format!(
|
|
||||||
"{} Character Set: {}",
|
|
||||||
" ".repeat(depth * WIDTH),
|
|
||||||
m.ctype.charset
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for (k, v) in m.ctype.params.iter() {
|
|
||||||
parts.push(format!("{} {k}: {v}", " ".repeat(depth * WIDTH),));
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
parts.push(render_rec(sp, depth + 1))
|
|
||||||
}
|
|
||||||
parts.join("\n")
|
|
||||||
}
|
|
||||||
render_rec(m, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type GraphqlSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>;
|
|
||||||
|
|
||||||
fn email_addresses(path: &str, m: &ParsedMail, header_name: &str) -> Result<Vec<Email>, Error> {
|
|
||||||
let mut addrs = Vec::new();
|
|
||||||
for header_value in m.headers.get_all_values(header_name) {
|
|
||||||
match mailparse::addrparse(&header_value) {
|
|
||||||
Ok(mal) => {
|
|
||||||
for ma in mal.into_inner() {
|
|
||||||
match ma {
|
|
||||||
mailparse::MailAddr::Group(gi) => {
|
|
||||||
if !gi.group_name.contains("ndisclosed") {
|
|
||||||
println!("[{path}][{header_name}] Group: {gi}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mailparse::MailAddr::Single(s) => addrs.push(Email {
|
|
||||||
name: s.display_name,
|
|
||||||
addr: Some(s.addr),
|
|
||||||
}), //println!("Single: {s}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let v = header_value;
|
|
||||||
if v.matches('@').count() == 1 {
|
|
||||||
if v.matches('<').count() == 1 && v.ends_with('>') {
|
|
||||||
let idx = v.find('<').unwrap();
|
|
||||||
let addr = &v[idx + 1..v.len() - 1].trim();
|
|
||||||
let name = &v[..idx].trim();
|
|
||||||
addrs.push(Email {
|
|
||||||
name: Some(name.to_string()),
|
|
||||||
addr: Some(addr.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addrs.push(Email {
|
|
||||||
name: Some(v),
|
|
||||||
addr: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(addrs)
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod graphql;
|
|
||||||
pub mod nm;
|
pub mod nm;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ pub fn threadset_to_messages(
|
|||||||
thread_set: notmuch::ThreadSet,
|
thread_set: notmuch::ThreadSet,
|
||||||
) -> Result<Vec<Message>, error::ServerError> {
|
) -> Result<Vec<Message>, error::ServerError> {
|
||||||
for t in thread_set.0 {
|
for t in thread_set.0 {
|
||||||
for _tn in t.0 {}
|
for tn in t.0 {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,5 @@ pub struct SearchResult {
|
|||||||
pub results_per_page: usize,
|
pub results_per_page: usize,
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct Message {}
|
pub struct Message {}
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ itertools = "0.10.5"
|
|||||||
serde_json = { version = "1.0.93", features = ["unbounded_depth"] }
|
serde_json = { version = "1.0.93", features = ["unbounded_depth"] }
|
||||||
wasm-timer = "0.2.5"
|
wasm-timer = "0.2.5"
|
||||||
css-inline = "0.8.5"
|
css-inline = "0.8.5"
|
||||||
chrono = "0.4.31"
|
|
||||||
graphql_client = "0.13.0"
|
|
||||||
thiserror = "1.0.50"
|
|
||||||
|
|
||||||
[package.metadata.wasm-pack.profile.release]
|
[package.metadata.wasm-pack.profile.release]
|
||||||
wasm-opt = ['-Os']
|
wasm-opt = ['-Os']
|
||||||
|
|||||||
@@ -9,7 +9,3 @@ port = 6758
|
|||||||
[[proxy]]
|
[[proxy]]
|
||||||
backend = "http://localhost:9345/"
|
backend = "http://localhost:9345/"
|
||||||
rewrite= "/api/"
|
rewrite= "/api/"
|
||||||
[[proxy]]
|
|
||||||
backend="http://localhost:9345/graphiql"
|
|
||||||
[[proxy]]
|
|
||||||
backend="http://localhost:9345/graphql"
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
query FrontPageQuery($query: String!, $after: String $before: String, $first: Int, $last: Int) {
|
|
||||||
count(query: $query)
|
|
||||||
search(query: $query, after: $after, before: $before, first: $first, last: $last) {
|
|
||||||
pageInfo {
|
|
||||||
hasPreviousPage
|
|
||||||
hasNextPage
|
|
||||||
startCursor
|
|
||||||
endCursor
|
|
||||||
}
|
|
||||||
nodes {
|
|
||||||
thread
|
|
||||||
timestamp
|
|
||||||
subject
|
|
||||||
authors
|
|
||||||
tags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tags {
|
|
||||||
name
|
|
||||||
bgColor
|
|
||||||
fgColor
|
|
||||||
unread
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
|||||||
query ShowThreadQuery($threadId: String!) {
|
|
||||||
thread(threadId: $threadId) {
|
|
||||||
subject
|
|
||||||
messages {
|
|
||||||
subject
|
|
||||||
from {
|
|
||||||
name
|
|
||||||
addr
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
name
|
|
||||||
addr
|
|
||||||
}
|
|
||||||
cc {
|
|
||||||
name
|
|
||||||
addr
|
|
||||||
}
|
|
||||||
timestamp
|
|
||||||
body {
|
|
||||||
__typename
|
|
||||||
... on UnhandledContentType {
|
|
||||||
contents
|
|
||||||
}
|
|
||||||
... on PlainText {
|
|
||||||
contents
|
|
||||||
contentTree
|
|
||||||
}
|
|
||||||
... on Html {
|
|
||||||
contents
|
|
||||||
contentTree
|
|
||||||
}
|
|
||||||
}
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tags {
|
|
||||||
name
|
|
||||||
bgColor
|
|
||||||
fgColor
|
|
||||||
unread
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
DEV_HOST=localhost
|
|
||||||
DEV_PORT=9345
|
|
||||||
graphql-client introspect-schema http://${DEV_HOST:?}:${DEV_PORT:?}/graphql --output schema.json
|
|
||||||
git diff schema.json
|
|
||||||
@@ -6,9 +6,7 @@
|
|||||||
<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">
|
<link rel="stylesheet", href="https://jenil.github.io/bulmaswatch/cyborg/bulmaswatch.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==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
<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==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" />
|
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 0.5em;*/
|
padding: 0.5em;*/
|
||||||
}
|
}
|
||||||
@@ -39,7 +37,7 @@ iframe {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
width: 10em;
|
width: 15em;
|
||||||
}
|
}
|
||||||
.index .subject {
|
.index .subject {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -47,9 +45,8 @@ iframe {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.index .date {
|
.index .date {
|
||||||
width: 10em;
|
width: 8em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
background-color: #eee;
|
background-color: #eee;
|
||||||
@@ -94,65 +91,6 @@ input, .input {
|
|||||||
input::placeholder, .input::placeholder{
|
input::placeholder, .input::placeholder{
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile .search-results,
|
|
||||||
.mobile .thread {
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-results .row {
|
|
||||||
border-bottom: 1px #444 solid;
|
|
||||||
padding-bottom: .5em;
|
|
||||||
padding-top: .5em;
|
|
||||||
}
|
|
||||||
.search-results .row .subject {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.search-results .row .from {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.search-results .row .tag {
|
|
||||||
height: 1.5em;
|
|
||||||
padding-left: .5em;
|
|
||||||
padding-right: .5em;
|
|
||||||
}
|
|
||||||
.float-right {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
/* Hide quoted emails */
|
|
||||||
/*
|
|
||||||
div[name="quote"],
|
|
||||||
blockquote[type="cite"],
|
|
||||||
.gmail_quote {
|
|
||||||
background-color: red;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
.desktop-main-content {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 12rem 1fr;
|
|
||||||
}
|
|
||||||
.tags-menu {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.tags-menu .menu-list a {
|
|
||||||
padding: 0.25em 0.5em;
|
|
||||||
}
|
|
||||||
.tags-menu .tag-indent {
|
|
||||||
padding-left: .5em;
|
|
||||||
}
|
|
||||||
.tags-menu .tag-tag {
|
|
||||||
margin-left: -1em;
|
|
||||||
padding-right: .25em;
|
|
||||||
}
|
|
||||||
.navbar {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
use graphql_client::GraphQLQuery;
|
|
||||||
use seed::{
|
|
||||||
fetch,
|
|
||||||
fetch::{Header, Method, Request},
|
|
||||||
};
|
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
|
||||||
|
|
||||||
// The paths are relative to the directory where your `Cargo.toml` is located.
|
|
||||||
// Both json and the GraphQL schema language are supported as sources for the schema
|
|
||||||
#[derive(GraphQLQuery)]
|
|
||||||
#[graphql(
|
|
||||||
schema_path = "graphql/schema.json",
|
|
||||||
query_path = "graphql/front_page.graphql",
|
|
||||||
response_derives = "Debug"
|
|
||||||
)]
|
|
||||||
pub struct FrontPageQuery;
|
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
|
||||||
#[graphql(
|
|
||||||
schema_path = "graphql/schema.json",
|
|
||||||
query_path = "graphql/show_thread.graphql",
|
|
||||||
response_derives = "Debug"
|
|
||||||
)]
|
|
||||||
pub struct ShowThreadQuery;
|
|
||||||
|
|
||||||
pub async fn send_graphql<Body, Resp>(body: Body) -> fetch::Result<graphql_client::Response<Resp>>
|
|
||||||
where
|
|
||||||
Body: Serialize,
|
|
||||||
Resp: DeserializeOwned + 'static,
|
|
||||||
{
|
|
||||||
use web_sys::RequestMode;
|
|
||||||
|
|
||||||
Request::new("/graphql/")
|
|
||||||
.method(Method::Post)
|
|
||||||
.header(Header::content_type("application/json"))
|
|
||||||
.mode(RequestMode::Cors)
|
|
||||||
.json(&body)?
|
|
||||||
.fetch()
|
|
||||||
.await?
|
|
||||||
.check_status()?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
778
web/src/lib.rs
778
web/src/lib.rs
@@ -7,34 +7,14 @@ use std::{
|
|||||||
hash::{Hash, Hasher},
|
hash::{Hash, Hasher},
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, Duration, Local, Utc};
|
|
||||||
use graphql_client::GraphQLQuery;
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use log::{debug, error, info, Level};
|
use log::{debug, error, info, Level};
|
||||||
use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet};
|
use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet};
|
||||||
use seed::{prelude::*, *};
|
use seed::{prelude::*, *};
|
||||||
use serde::de::Deserialize;
|
use serde::de::Deserialize;
|
||||||
use thiserror::Error;
|
|
||||||
use wasm_timer::Instant;
|
use wasm_timer::Instant;
|
||||||
|
|
||||||
use crate::graphql::{front_page_query::*, send_graphql, show_thread_query::*};
|
|
||||||
|
|
||||||
mod graphql;
|
|
||||||
|
|
||||||
const SEARCH_RESULTS_PER_PAGE: usize = 20;
|
const SEARCH_RESULTS_PER_PAGE: usize = 20;
|
||||||
const USE_GRAPHQL: bool = true;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
enum UIError {
|
|
||||||
#[error("No error, this should never be presented to user")]
|
|
||||||
NoError,
|
|
||||||
#[error("failed to fetch {0}: {1:?}")]
|
|
||||||
FetchError(&'static str, FetchError),
|
|
||||||
#[error("{0} error decoding: {1:?}")]
|
|
||||||
FetchDecodeError(&'static str, Vec<graphql_client::Error>),
|
|
||||||
#[error("no data or errors for {0}")]
|
|
||||||
NoData(&'static str),
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------ ------
|
// ------ ------
|
||||||
// Init
|
// Init
|
||||||
@@ -53,8 +33,6 @@ fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
|||||||
context: Context::None,
|
context: Context::None,
|
||||||
query: "".to_string(),
|
query: "".to_string(),
|
||||||
refreshing_state: RefreshingState::None,
|
refreshing_state: RefreshingState::None,
|
||||||
ui_error: UIError::NoError,
|
|
||||||
tags: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,70 +45,32 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
|
|||||||
);
|
);
|
||||||
let hpp = url.remaining_hash_path_parts();
|
let hpp = url.remaining_hash_path_parts();
|
||||||
match hpp.as_slice() {
|
match hpp.as_slice() {
|
||||||
["t", tid] => {
|
["t", tid] => Msg::ShowPrettyRequest(tid.to_string()),
|
||||||
if USE_GRAPHQL {
|
|
||||||
Msg::ShowThreadRequest {
|
|
||||||
thread_id: tid.to_string(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Msg::ShowPrettyRequest(tid.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
["s", query] => {
|
["s", query] => {
|
||||||
let query = Url::decode_uri_component(query).unwrap_or("".to_string());
|
let query = Url::decode_uri_component(query).unwrap_or("".to_string());
|
||||||
if USE_GRAPHQL {
|
Msg::SearchRequest {
|
||||||
Msg::FrontPageRequest {
|
query,
|
||||||
query,
|
page: 0,
|
||||||
after: None,
|
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||||
before: None,
|
|
||||||
first: None,
|
|
||||||
last: None,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Msg::SearchRequest {
|
|
||||||
query,
|
|
||||||
page: 0,
|
|
||||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
["s", query, page] => {
|
["s", query, page] => {
|
||||||
let query = Url::decode_uri_component(query).unwrap_or("".to_string());
|
let query = Url::decode_uri_component(query).unwrap_or("".to_string());
|
||||||
let page = page[1..].parse().unwrap_or(0);
|
let page = page[1..].parse().unwrap_or(0);
|
||||||
if USE_GRAPHQL {
|
Msg::SearchRequest {
|
||||||
Msg::FrontPageRequest {
|
query,
|
||||||
query,
|
page,
|
||||||
after: Some(page.to_string()),
|
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||||
before: None,
|
|
||||||
first: None,
|
|
||||||
last: None,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Msg::SearchRequest {
|
|
||||||
query,
|
|
||||||
page,
|
|
||||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p => {
|
p => {
|
||||||
if !p.is_empty() {
|
if !p.is_empty() {
|
||||||
info!("Unhandled path '{p:?}'");
|
info!("Unhandled path '{p:?}'");
|
||||||
}
|
}
|
||||||
if USE_GRAPHQL {
|
Msg::SearchRequest {
|
||||||
Msg::FrontPageRequest {
|
query: "".to_string(),
|
||||||
query: "".to_string(),
|
page: 0,
|
||||||
after: None,
|
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||||
before: None,
|
|
||||||
first: None,
|
|
||||||
last: None,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Msg::SearchRequest {
|
|
||||||
query: "".to_string(),
|
|
||||||
page: 0,
|
|
||||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,14 +97,7 @@ mod urls {
|
|||||||
enum Context {
|
enum Context {
|
||||||
None,
|
None,
|
||||||
Search(shared::SearchResult),
|
Search(shared::SearchResult),
|
||||||
SearchResult {
|
Thread(Vec<shared::Message>),
|
||||||
query: String,
|
|
||||||
results: Vec<FrontPageQuerySearchNodes>,
|
|
||||||
count: usize,
|
|
||||||
pager: FrontPageQuerySearchPageInfo,
|
|
||||||
},
|
|
||||||
Thread(ThreadSet),
|
|
||||||
ThreadResult(ShowThreadQueryThread),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `Model` describes our app state.
|
// `Model` describes our app state.
|
||||||
@@ -172,15 +105,6 @@ struct Model {
|
|||||||
query: String,
|
query: String,
|
||||||
context: Context,
|
context: Context,
|
||||||
refreshing_state: RefreshingState,
|
refreshing_state: RefreshingState,
|
||||||
ui_error: UIError,
|
|
||||||
tags: Option<Vec<Tag>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Tag {
|
|
||||||
name: String,
|
|
||||||
bg_color: String,
|
|
||||||
fg_color: String,
|
|
||||||
unread: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
@@ -212,28 +136,9 @@ enum Msg {
|
|||||||
ShowRequest(String),
|
ShowRequest(String),
|
||||||
ShowResult(fetch::Result<ThreadSet>),
|
ShowResult(fetch::Result<ThreadSet>),
|
||||||
ShowPrettyRequest(String),
|
ShowPrettyRequest(String),
|
||||||
ShowPrettyResult(fetch::Result<ThreadSet>),
|
ShowPrettyResult(fetch::Result<Vec<shared::Message>>),
|
||||||
NextPage,
|
NextPage,
|
||||||
PreviousPage,
|
PreviousPage,
|
||||||
UpdateQuery(String),
|
|
||||||
SearchQuery(String),
|
|
||||||
|
|
||||||
FrontPageRequest {
|
|
||||||
query: String,
|
|
||||||
after: Option<String>,
|
|
||||||
before: Option<String>,
|
|
||||||
first: Option<i64>,
|
|
||||||
last: Option<i64>,
|
|
||||||
},
|
|
||||||
FrontPageResult(
|
|
||||||
fetch::Result<graphql_client::Response<graphql::front_page_query::ResponseData>>,
|
|
||||||
),
|
|
||||||
ShowThreadRequest {
|
|
||||||
thread_id: String,
|
|
||||||
},
|
|
||||||
ShowThreadResult(
|
|
||||||
fetch::Result<graphql_client::Response<graphql::show_thread_query::ResponseData>>,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// `update` describes how to handle each `Msg`.
|
// `update` describes how to handle each `Msg`.
|
||||||
@@ -280,9 +185,10 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
.skip()
|
.skip()
|
||||||
.perform_cmd(async move { Msg::ShowResult(show_request(&tid).await) });
|
.perform_cmd(async move { Msg::ShowResult(show_request(&tid).await) });
|
||||||
}
|
}
|
||||||
|
// TODO(wathiede): remove
|
||||||
Msg::ShowResult(Ok(response_data)) => {
|
Msg::ShowResult(Ok(response_data)) => {
|
||||||
debug!("fetch ok {:#?}", response_data);
|
debug!("fetch ok {:#?}", response_data);
|
||||||
model.context = Context::Thread(response_data);
|
//model.context = Context::Thread(response_data);
|
||||||
}
|
}
|
||||||
Msg::ShowResult(Err(fetch_error)) => {
|
Msg::ShowResult(Err(fetch_error)) => {
|
||||||
error!("fetch failed {:?}", fetch_error);
|
error!("fetch failed {:?}", fetch_error);
|
||||||
@@ -294,7 +200,7 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
.perform_cmd(async move { Msg::ShowPrettyResult(show_pretty_request(&tid).await) });
|
.perform_cmd(async move { Msg::ShowPrettyResult(show_pretty_request(&tid).await) });
|
||||||
}
|
}
|
||||||
Msg::ShowPrettyResult(Ok(response_data)) => {
|
Msg::ShowPrettyResult(Ok(response_data)) => {
|
||||||
debug!("fetch ok {:#?}", response_data);
|
info!("fetch ok {:#?}", response_data);
|
||||||
model.context = Context::Thread(response_data);
|
model.context = Context::Thread(response_data);
|
||||||
}
|
}
|
||||||
Msg::ShowPrettyResult(Err(fetch_error)) => {
|
Msg::ShowPrettyResult(Err(fetch_error)) => {
|
||||||
@@ -305,22 +211,8 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
Context::Search(sr) => {
|
Context::Search(sr) => {
|
||||||
orders.request_url(urls::search(&sr.query, sr.page + 1));
|
orders.request_url(urls::search(&sr.query, sr.page + 1));
|
||||||
}
|
}
|
||||||
Context::SearchResult { query, pager, .. } => {
|
Context::Thread(_) => (), // do nothing (yet?)
|
||||||
let query = query.to_string();
|
Context::None => (), // do nothing (yet?)
|
||||||
let after = pager.end_cursor.clone();
|
|
||||||
orders.perform_cmd(async move {
|
|
||||||
Msg::FrontPageRequest {
|
|
||||||
query,
|
|
||||||
after,
|
|
||||||
before: None,
|
|
||||||
first: Some(SEARCH_RESULTS_PER_PAGE as i64),
|
|
||||||
last: None,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Context::Thread(_) => (), // do nothing (yet?)
|
|
||||||
Context::ThreadResult(_) => (), // do nothing (yet?)
|
|
||||||
Context::None => (), // do nothing (yet?)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Msg::PreviousPage => {
|
Msg::PreviousPage => {
|
||||||
@@ -328,121 +220,10 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
Context::Search(sr) => {
|
Context::Search(sr) => {
|
||||||
orders.request_url(urls::search(&sr.query, sr.page.saturating_sub(1)));
|
orders.request_url(urls::search(&sr.query, sr.page.saturating_sub(1)));
|
||||||
}
|
}
|
||||||
Context::SearchResult { query, pager, .. } => {
|
Context::Thread(_) => (), // do nothing (yet?)
|
||||||
let query = query.to_string();
|
Context::None => (), // do nothing (yet?)
|
||||||
let before = pager.start_cursor.clone();
|
|
||||||
orders.perform_cmd(async move {
|
|
||||||
Msg::FrontPageRequest {
|
|
||||||
query,
|
|
||||||
after: None,
|
|
||||||
before,
|
|
||||||
first: None,
|
|
||||||
last: Some(SEARCH_RESULTS_PER_PAGE as i64),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Context::Thread(_) => (), // do nothing (yet?)
|
|
||||||
Context::ThreadResult(_) => (), // do nothing (yet?)
|
|
||||||
Context::None => (), // do nothing (yet?)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Msg::UpdateQuery(query) => model.query = query,
|
|
||||||
Msg::SearchQuery(query) => {
|
|
||||||
orders.request_url(urls::search(&query, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
Msg::FrontPageRequest {
|
|
||||||
query,
|
|
||||||
after,
|
|
||||||
before,
|
|
||||||
first,
|
|
||||||
last,
|
|
||||||
} => {
|
|
||||||
info!("making FrontPageRequest: {query} after:{after:?} before:{before:?} first:{first:?} last:{last:?}");
|
|
||||||
model.query = query.clone();
|
|
||||||
orders.skip().perform_cmd(async move {
|
|
||||||
Msg::FrontPageResult(
|
|
||||||
send_graphql(graphql::FrontPageQuery::build_query(
|
|
||||||
graphql::front_page_query::Variables {
|
|
||||||
query,
|
|
||||||
after,
|
|
||||||
before,
|
|
||||||
first,
|
|
||||||
last,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.await,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Msg::FrontPageResult(Err(e)) => error!("error FrontPageResult: {e:?}"),
|
|
||||||
Msg::FrontPageResult(Ok(graphql_client::Response {
|
|
||||||
data: None,
|
|
||||||
errors: None,
|
|
||||||
..
|
|
||||||
})) => {
|
|
||||||
error!("FrontPageResult no data or errors, should not happen");
|
|
||||||
}
|
|
||||||
Msg::FrontPageResult(Ok(graphql_client::Response {
|
|
||||||
data: None,
|
|
||||||
errors: Some(e),
|
|
||||||
..
|
|
||||||
})) => {
|
|
||||||
error!("FrontPageResult error: {e:?}");
|
|
||||||
}
|
|
||||||
Msg::FrontPageResult(Ok(graphql_client::Response {
|
|
||||||
data: Some(data), ..
|
|
||||||
})) => {
|
|
||||||
model.tags = Some(
|
|
||||||
data.tags
|
|
||||||
.into_iter()
|
|
||||||
.map(|t| Tag {
|
|
||||||
name: t.name,
|
|
||||||
bg_color: t.bg_color,
|
|
||||||
fg_color: t.fg_color,
|
|
||||||
unread: t.unread,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
model.context = Context::SearchResult {
|
|
||||||
query: model.query.clone(),
|
|
||||||
results: data.search.nodes,
|
|
||||||
count: data.count as usize,
|
|
||||||
pager: data.search.page_info,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Msg::ShowThreadRequest { thread_id } => {
|
|
||||||
orders.skip().perform_cmd(async move {
|
|
||||||
Msg::ShowThreadResult(
|
|
||||||
send_graphql(graphql::ShowThreadQuery::build_query(
|
|
||||||
graphql::show_thread_query::Variables { thread_id },
|
|
||||||
))
|
|
||||||
.await,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Msg::ShowThreadResult(Ok(graphql_client::Response {
|
|
||||||
data: Some(data), ..
|
|
||||||
})) => {
|
|
||||||
model.tags = Some(
|
|
||||||
data.tags
|
|
||||||
.into_iter()
|
|
||||||
.map(|t| Tag {
|
|
||||||
name: t.name,
|
|
||||||
bg_color: t.bg_color,
|
|
||||||
fg_color: t.fg_color,
|
|
||||||
unread: t.unread,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
model.context = Context::ThreadResult(data.thread);
|
|
||||||
}
|
|
||||||
Msg::ShowThreadResult(bad) => {
|
|
||||||
error!("show_thread_query error: {bad:?}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +289,7 @@ async fn show_request(tid: &str) -> fetch::Result<ThreadSet> {
|
|||||||
.map_err(|_| FetchError::JsonError(fetch::JsonError::Serde(JsValue::NULL)))?)
|
.map_err(|_| FetchError::JsonError(fetch::JsonError::Serde(JsValue::NULL)))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_pretty_request(tid: &str) -> fetch::Result<ThreadSet> {
|
async fn show_pretty_request(tid: &str) -> fetch::Result<Vec<shared::Message>> {
|
||||||
Request::new(api::show_pretty(tid))
|
Request::new(api::show_pretty(tid))
|
||||||
.method(Method::Get)
|
.method(Method::Get)
|
||||||
.fetch()
|
.fetch()
|
||||||
@@ -701,73 +482,7 @@ fn pretty_authors(authors: &str) -> impl Iterator<Item = Node<Msg>> + '_ {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn human_age(timestamp: i64) -> String {
|
fn view_mobile_search_results(query: &str, search_results: &shared::SearchResult) -> Node<Msg> {
|
||||||
let now = Local::now();
|
|
||||||
let yesterday = now - Duration::days(1);
|
|
||||||
let ts = DateTime::<Utc>::from_timestamp(timestamp, 0)
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Local);
|
|
||||||
let age = now - ts;
|
|
||||||
let datetime = if age < Duration::minutes(1) {
|
|
||||||
format!("{} min. ago", age.num_seconds())
|
|
||||||
} else if age < Duration::hours(1) {
|
|
||||||
format!("{} min. ago", age.num_minutes())
|
|
||||||
} else if ts.date_naive() == now.date_naive() {
|
|
||||||
ts.format("Today %H:%M").to_string()
|
|
||||||
} else if ts.date_naive() == yesterday.date_naive() {
|
|
||||||
ts.format("Yest. %H:%M").to_string()
|
|
||||||
} else if age < Duration::weeks(1) {
|
|
||||||
ts.format("%a %H:%M").to_string()
|
|
||||||
} else if ts.year() == now.year() {
|
|
||||||
ts.format("%b %d %H:%M").to_string()
|
|
||||||
} else {
|
|
||||||
ts.format("%b %d, %Y %H:%M").to_string()
|
|
||||||
};
|
|
||||||
datetime
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_mobile_search_results(
|
|
||||||
query: &str,
|
|
||||||
results: &[FrontPageQuerySearchNodes],
|
|
||||||
count: usize,
|
|
||||||
pager: &FrontPageQuerySearchPageInfo,
|
|
||||||
) -> 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 datetime = human_age(r.timestamp as i64);
|
|
||||||
a![
|
|
||||||
C!["has-text-light"],
|
|
||||||
attrs! {
|
|
||||||
At::Href => urls::thread(&tid)
|
|
||||||
},
|
|
||||||
div![
|
|
||||||
C!["row"],
|
|
||||||
div![C!["subject"], &r.subject],
|
|
||||||
span![C!["from", "is-size-7"], pretty_authors(&r.authors)],
|
|
||||||
div![
|
|
||||||
span![C!["is-size-7"], tags_chiclet(&r.tags, true)],
|
|
||||||
span![C!["is-size-7", "float-right", "date"], datetime]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
});
|
|
||||||
div![
|
|
||||||
C!["search-results"],
|
|
||||||
view_search_pager(count, pager),
|
|
||||||
rows,
|
|
||||||
view_search_pager(count, pager),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_mobile_search_results_legacy(
|
|
||||||
query: &str,
|
|
||||||
search_results: &shared::SearchResult,
|
|
||||||
) -> Node<Msg> {
|
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
set_title("all mail");
|
set_title("all mail");
|
||||||
} else {
|
} else {
|
||||||
@@ -789,94 +504,29 @@ fn view_mobile_search_results_legacy(
|
|||||||
]
|
]
|
||||||
*/
|
*/
|
||||||
let tid = r.thread.clone();
|
let tid = r.thread.clone();
|
||||||
let datetime = human_age(r.timestamp as i64);
|
div![
|
||||||
a![
|
|
||||||
C!["has-text-light"],
|
|
||||||
attrs! {
|
|
||||||
At::Href => urls::thread(&tid)
|
|
||||||
},
|
|
||||||
div![
|
div![
|
||||||
C!["row"],
|
C!["subject"],
|
||||||
div![C!["subject"], &r.subject],
|
&r.subject,
|
||||||
span![C!["from", "is-size-7"], pretty_authors(&r.authors)],
|
ev(Ev::Click, move |_| Msg::ShowPrettyRequest(tid)),
|
||||||
div![
|
],
|
||||||
span![C!["is-size-7"], tags_chiclet(&r.tags, true)],
|
div![
|
||||||
span![C!["is-size-7", "float-right", "date"], datetime]
|
span![C!["from"], pretty_authors(&r.authors)],
|
||||||
]
|
span![C!["tags"], tags_chiclet(&r.tags, true)],
|
||||||
]
|
],
|
||||||
|
span![C!["date"], &r.date_relative],
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
let first = search_results.page * search_results.results_per_page;
|
let first = search_results.page * search_results.results_per_page;
|
||||||
div![
|
div![
|
||||||
C!["search-results"],
|
h1!["Search results"],
|
||||||
view_search_pager_legacy(first, summaries.len(), search_results.total),
|
view_search_pager(first, summaries.len(), search_results.total),
|
||||||
rows,
|
rows,
|
||||||
view_search_pager_legacy(first, summaries.len(), search_results.total)
|
view_search_pager(first, summaries.len(), search_results.total)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_search_results(
|
fn view_search_results(query: &str, search_results: &shared::SearchResult) -> Node<Msg> {
|
||||||
query: &str,
|
|
||||||
results: &[FrontPageQuerySearchNodes],
|
|
||||||
count: usize,
|
|
||||||
pager: &FrontPageQuerySearchPageInfo,
|
|
||||||
) -> Node<Msg> {
|
|
||||||
info!("pager {pager:?}");
|
|
||||||
if query.is_empty() {
|
|
||||||
set_title("all mail");
|
|
||||||
} else {
|
|
||||||
set_title(query);
|
|
||||||
}
|
|
||||||
let rows = results.iter().map(|r| {
|
|
||||||
let tid = r.thread.clone();
|
|
||||||
let datetime = human_age(r.timestamp as i64);
|
|
||||||
tr![
|
|
||||||
td![
|
|
||||||
C!["from"],
|
|
||||||
pretty_authors(&r.authors),
|
|
||||||
// TODO(wathiede): visualize message count if more than one message is in the
|
|
||||||
// thread
|
|
||||||
//IF!(r.total>1 => small![" ", r.total.to_string()]),
|
|
||||||
],
|
|
||||||
td![
|
|
||||||
C!["subject"],
|
|
||||||
tags_chiclet(&r.tags, false),
|
|
||||||
" ",
|
|
||||||
a![
|
|
||||||
C!["has-text-light"],
|
|
||||||
attrs! {
|
|
||||||
At::Href => urls::thread(&tid)
|
|
||||||
},
|
|
||||||
&r.subject,
|
|
||||||
]
|
|
||||||
],
|
|
||||||
td![C!["date"], datetime]
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
div![
|
|
||||||
view_search_pager(count, pager),
|
|
||||||
table![
|
|
||||||
C![
|
|
||||||
"table",
|
|
||||||
"index",
|
|
||||||
"is-fullwidth",
|
|
||||||
"is-hoverable",
|
|
||||||
"is-narrow",
|
|
||||||
"is-striped",
|
|
||||||
],
|
|
||||||
thead![tr![
|
|
||||||
th![C!["from"], "From"],
|
|
||||||
th![C!["subject"], "Subject"],
|
|
||||||
th![C!["date"], "Date"]
|
|
||||||
]],
|
|
||||||
tbody![rows]
|
|
||||||
],
|
|
||||||
view_search_pager(count, pager)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_search_results_legacy(query: &str, search_results: &shared::SearchResult) -> Node<Msg> {
|
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
set_title("all mail");
|
set_title("all mail");
|
||||||
} else {
|
} else {
|
||||||
@@ -885,7 +535,6 @@ fn view_search_results_legacy(query: &str, search_results: &shared::SearchResult
|
|||||||
let summaries = &search_results.summary.0;
|
let summaries = &search_results.summary.0;
|
||||||
let rows = summaries.iter().map(|r| {
|
let rows = summaries.iter().map(|r| {
|
||||||
let tid = r.thread.clone();
|
let tid = r.thread.clone();
|
||||||
let datetime = human_age(r.timestamp as i64);
|
|
||||||
tr![
|
tr![
|
||||||
td![
|
td![
|
||||||
C!["from"],
|
C!["from"],
|
||||||
@@ -904,12 +553,12 @@ fn view_search_results_legacy(query: &str, search_results: &shared::SearchResult
|
|||||||
&r.subject,
|
&r.subject,
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
td![C!["date"], datetime]
|
td![C!["date"], &r.date_relative]
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
let first = search_results.page * search_results.results_per_page;
|
let first = search_results.page * search_results.results_per_page;
|
||||||
div![
|
div![
|
||||||
view_search_pager_legacy(first, summaries.len(), search_results.total),
|
view_search_pager(first, summaries.len(), search_results.total),
|
||||||
table![
|
table![
|
||||||
C![
|
C![
|
||||||
"table",
|
"table",
|
||||||
@@ -926,51 +575,11 @@ fn view_search_results_legacy(query: &str, search_results: &shared::SearchResult
|
|||||||
]],
|
]],
|
||||||
tbody![rows]
|
tbody![rows]
|
||||||
],
|
],
|
||||||
view_search_pager_legacy(first, summaries.len(), search_results.total)
|
view_search_pager(first, summaries.len(), search_results.total)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_search_pager(count: usize, pager: &FrontPageQuerySearchPageInfo) -> Node<Msg> {
|
fn view_search_pager(start: usize, count: usize, total: usize) -> Node<Msg> {
|
||||||
let start = pager
|
|
||||||
.start_cursor
|
|
||||||
.as_ref()
|
|
||||||
.map(|i| i.parse().unwrap_or(0))
|
|
||||||
.unwrap_or(0);
|
|
||||||
nav![
|
|
||||||
C!["pagination"],
|
|
||||||
a![
|
|
||||||
C![
|
|
||||||
"pagination-previous",
|
|
||||||
"button",
|
|
||||||
//IF!(!pager.has_previous_page => "is-static"),
|
|
||||||
],
|
|
||||||
IF!(!pager.has_previous_page => attrs!{ At::Disabled=>true }),
|
|
||||||
"<",
|
|
||||||
IF!(pager.has_previous_page => ev(Ev::Click, |_| Msg::PreviousPage)),
|
|
||||||
],
|
|
||||||
a![
|
|
||||||
C![
|
|
||||||
"pagination-next",
|
|
||||||
"button",
|
|
||||||
//IF!(!pager.has_next_page => "is-static")
|
|
||||||
],
|
|
||||||
IF!(!pager.has_next_page => attrs!{ At::Disabled=>true }),
|
|
||||||
">",
|
|
||||||
IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage))
|
|
||||||
],
|
|
||||||
ul![
|
|
||||||
C!["pagination-list"],
|
|
||||||
li![format!(
|
|
||||||
"{} - {} of {}",
|
|
||||||
start,
|
|
||||||
count.min(start + SEARCH_RESULTS_PER_PAGE),
|
|
||||||
count
|
|
||||||
)],
|
|
||||||
],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_search_pager_legacy(start: usize, count: usize, total: usize) -> Node<Msg> {
|
|
||||||
let is_first = start <= 0;
|
let is_first = start <= 0;
|
||||||
let is_last = (start + SEARCH_RESULTS_PER_PAGE) >= total;
|
let is_last = (start + SEARCH_RESULTS_PER_PAGE) >= total;
|
||||||
nav![
|
nav![
|
||||||
@@ -994,123 +603,58 @@ fn view_search_pager_legacy(start: usize, count: usize, total: usize) -> Node<Ms
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
trait Email {
|
fn view_thread(messages: &[shared::Message]) -> Node<Msg> {
|
||||||
fn name(&self) -> Option<&str>;
|
div!["TODO(wathiede): view_thread(messages)"]
|
||||||
fn addr(&self) -> Option<&str>;
|
/*
|
||||||
}
|
assert_eq!(thread_set.0.len(), 1);
|
||||||
|
let thread = &thread_set.0[0];
|
||||||
impl<T: Email> Email for &'_ T {
|
assert_eq!(thread.0.len(), 1);
|
||||||
fn name(&self) -> Option<&str> {
|
let thread_node = &thread.0[0];
|
||||||
return (*self).name();
|
let subject = first_subject(&thread_node).unwrap_or("<No subject>".to_string());
|
||||||
}
|
set_title(&subject);
|
||||||
fn addr(&self) -> Option<&str> {
|
div![
|
||||||
return (*self).addr();
|
C!["container"],
|
||||||
}
|
h1![C!["title"], subject],
|
||||||
}
|
view_message(&thread_node),
|
||||||
|
a![
|
||||||
macro_rules! implement_email {
|
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
|
||||||
( $($t:ty),+ ) => {$(
|
"Original"
|
||||||
impl Email for $t {
|
],
|
||||||
fn name(&self) -> Option<&str> {
|
/*
|
||||||
self.name.as_deref()
|
|
||||||
}
|
|
||||||
fn addr(&self) -> Option<&str> {
|
|
||||||
self.addr.as_deref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)+};
|
|
||||||
}
|
|
||||||
|
|
||||||
implement_email!(
|
|
||||||
ShowThreadQueryThreadMessagesTo,
|
|
||||||
ShowThreadQueryThreadMessagesCc,
|
|
||||||
ShowThreadQueryThreadMessagesFrom
|
|
||||||
);
|
|
||||||
|
|
||||||
fn view_address(email: impl Email) -> Node<Msg> {
|
|
||||||
span![
|
|
||||||
C!["tag", "is-black"],
|
|
||||||
email.addr().as_ref().map(|a| attrs! {At::Title=>a}),
|
|
||||||
email
|
|
||||||
.name()
|
|
||||||
.as_ref()
|
|
||||||
.unwrap_or(&email.addr().unwrap_or("(UNKNOWN)"))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_addresses(addrs: &[impl Email]) -> Vec<Node<Msg>> {
|
|
||||||
addrs.into_iter().map(view_address).collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_thread(thread: &ShowThreadQueryThread) -> Node<Msg> {
|
|
||||||
// TODO(wathiede): show per-message subject if it changes significantly from top-level subject
|
|
||||||
set_title(&thread.subject);
|
|
||||||
let messages = thread.messages.iter().map(|msg| {
|
|
||||||
div![
|
|
||||||
C!["message"],
|
|
||||||
/* TODO(wathiede): collect all the tags and show them here. */
|
|
||||||
/* TODO(wathiede): collect all the attachments from all the subparts */
|
|
||||||
msg.from
|
|
||||||
.as_ref()
|
|
||||||
.map(|from| div![C!["header"], "From: ", view_address(&from)]),
|
|
||||||
msg.timestamp
|
|
||||||
.map(|ts| div![C!["header"], "Date: ", human_age(ts)]),
|
|
||||||
IF!(!msg.to.is_empty() => div![C!["header"], "To: ", view_addresses(&msg.to)]),
|
|
||||||
IF!(!msg.cc.is_empty() => div![C!["header"], "CC: ", view_addresses(&msg.cc)]),
|
|
||||||
div![
|
|
||||||
C!["body"],
|
|
||||||
match &msg.body {
|
|
||||||
ShowThreadQueryThreadMessagesBody::UnhandledContentType(
|
|
||||||
ShowThreadQueryThreadMessagesBodyOnUnhandledContentType { contents },
|
|
||||||
) => pre![C!["error"], contents],
|
|
||||||
ShowThreadQueryThreadMessagesBody::PlainText(
|
|
||||||
ShowThreadQueryThreadMessagesBodyOnPlainText {
|
|
||||||
contents,
|
|
||||||
content_tree,
|
|
||||||
},
|
|
||||||
) => div![C!["view-part-text-plain"], contents, pre![content_tree]],
|
|
||||||
ShowThreadQueryThreadMessagesBody::Html(
|
|
||||||
ShowThreadQueryThreadMessagesBodyOnHtml {
|
|
||||||
contents,
|
|
||||||
content_tree,
|
|
||||||
},
|
|
||||||
) => div![
|
|
||||||
C!["view-part-text-html"],
|
|
||||||
raw![contents],
|
|
||||||
pre![content_tree]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
]
|
|
||||||
});
|
|
||||||
div![
|
div![
|
||||||
C!["thread"],
|
C!["debug"],
|
||||||
p![C!["is-size-4"], &thread.subject],
|
"Add zippy for debug dump",
|
||||||
messages,
|
view_debug_thread_set(thread_set)
|
||||||
/* TODO(wathiede): plumb in orignal id
|
] /* pre![format!("Thread: {:#?}", thread_set).replace(" ", " ")] */
|
||||||
a![
|
*/
|
||||||
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
|
|
||||||
"Original"
|
|
||||||
],
|
|
||||||
*/
|
|
||||||
]
|
]
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_thread_legacy(thread_set: &ThreadSet) -> Node<Msg> {
|
fn view_debug_thread_set(thread_set: &ThreadSet) -> Node<Msg> {
|
||||||
assert_eq!(thread_set.0.len(), 1);
|
ul![thread_set
|
||||||
let thread = &thread_set.0[0];
|
.0
|
||||||
assert_eq!(thread.0.len(), 1);
|
.iter()
|
||||||
let thread_node = &thread.0[0];
|
.enumerate()
|
||||||
let subject = first_subject(&thread_node).unwrap_or("<No subject>".to_string());
|
.map(|(i, t)| { li!["t", i, ": ", view_debug_thread(t),] })]
|
||||||
set_title(&subject);
|
}
|
||||||
div![
|
fn view_debug_thread(thread: &Thread) -> Node<Msg> {
|
||||||
C!["container"],
|
ul![thread
|
||||||
h1![C!["title"], subject],
|
.0
|
||||||
view_message(&thread_node),
|
.iter()
|
||||||
a![
|
.enumerate()
|
||||||
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
|
.map(|(i, tn)| { li!["tn", i, ": ", view_debug_thread_node(tn),] })]
|
||||||
"Original"
|
}
|
||||||
],
|
|
||||||
|
fn view_debug_thread_node(thread_node: &ThreadNode) -> Node<Msg> {
|
||||||
|
ul![
|
||||||
|
IF!(thread_node.0.is_some()=>li!["tn id:", &thread_node.0.as_ref().unwrap().id]),
|
||||||
|
thread_node.1.iter().enumerate().map(|(i, tn)| li![
|
||||||
|
"tn",
|
||||||
|
i,
|
||||||
|
": ",
|
||||||
|
view_debug_thread_node(tn)
|
||||||
|
])
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,30 +703,18 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
|
|||||||
At::AutoFocus => true.as_at_value();
|
At::AutoFocus => true.as_at_value();
|
||||||
At::Value => query,
|
At::Value => query,
|
||||||
},
|
},
|
||||||
input_ev(Ev::Input, |q| if USE_GRAPHQL {
|
input_ev(Ev::Input, |q| Msg::SearchRequest {
|
||||||
Msg::UpdateQuery(q)
|
query: Url::encode_uri_component(q),
|
||||||
} else {
|
page: 0,
|
||||||
|
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||||
|
}),
|
||||||
|
// Resend search on enter.
|
||||||
|
keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d {
|
||||||
Msg::SearchRequest {
|
Msg::SearchRequest {
|
||||||
query: Url::encode_uri_component(if q.is_empty() {
|
query: Url::encode_uri_component(query),
|
||||||
"*".to_string()
|
|
||||||
} else {
|
|
||||||
q
|
|
||||||
}),
|
|
||||||
page: 0,
|
page: 0,
|
||||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
// Send search on enter.
|
|
||||||
keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d {
|
|
||||||
if USE_GRAPHQL {
|
|
||||||
Msg::SearchQuery(query)
|
|
||||||
} else {
|
|
||||||
Msg::SearchRequest {
|
|
||||||
query: Url::encode_uri_component(query),
|
|
||||||
page: 0,
|
|
||||||
results_per_page: SEARCH_RESULTS_PER_PAGE,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Msg::Noop
|
Msg::Noop
|
||||||
}),
|
}),
|
||||||
@@ -1202,120 +734,27 @@ fn view_footer(render_time_ms: u128) -> Node<Msg> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view_desktop(model: &Model) -> Node<Msg> {
|
fn view_desktop(model: &Model) -> Node<Msg> {
|
||||||
// Do two queries, one without `unread` so it loads fast, then a second with unread.
|
|
||||||
let content = match &model.context {
|
let content = match &model.context {
|
||||||
Context::None => div![h1!["Loading"]],
|
Context::None => div![h1!["Loading"]],
|
||||||
Context::Thread(thread_set) => view_thread_legacy(thread_set),
|
Context::Thread(messages) => view_thread(messages),
|
||||||
Context::ThreadResult(thread) => view_thread(thread),
|
Context::Search(search_results) => view_search_results(&model.query, search_results),
|
||||||
Context::Search(search_results) => view_search_results_legacy(&model.query, search_results),
|
|
||||||
Context::SearchResult {
|
|
||||||
query,
|
|
||||||
results,
|
|
||||||
count,
|
|
||||||
pager,
|
|
||||||
} => view_search_results(&query, results.as_slice(), *count, pager),
|
|
||||||
};
|
};
|
||||||
fn view_tag_li(display_name: &str, indent: usize, t: &Tag) -> Node<Msg> {
|
|
||||||
li![a![
|
|
||||||
attrs! {
|
|
||||||
At::Href => urls::search(&format!("tag:{}", t.name), 0)
|
|
||||||
},
|
|
||||||
(0..indent).map(|_| span![C!["tag-indent"], ""]),
|
|
||||||
i![
|
|
||||||
C!["tag-tag", "fa-solid", "fa-tag"],
|
|
||||||
style! {
|
|
||||||
//"--fa-primary-color" => t.fg_color,
|
|
||||||
St::Color => t.bg_color,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
display_name,
|
|
||||||
IF!(t.unread>0 => format!(" ({})", t.unread))
|
|
||||||
]]
|
|
||||||
}
|
|
||||||
fn matches(a: &[&str], b: &[&str]) -> usize {
|
|
||||||
std::iter::zip(a.iter(), b.iter())
|
|
||||||
.take_while(|(a, b)| a == b)
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
fn view_tag_list<'a>(tags: impl Iterator<Item = &'a Tag>) -> Vec<Node<Msg>> {
|
|
||||||
let mut lis = Vec::new();
|
|
||||||
let mut last = Vec::new();
|
|
||||||
for t in tags {
|
|
||||||
let parts: Vec<_> = t.name.split('/').collect();
|
|
||||||
let mut n = matches(&last, &parts);
|
|
||||||
if t.name.starts_with("ZZCrap/Free") {
|
|
||||||
info!("n: {n}, parts: {parts:?} last: {last:?}");
|
|
||||||
}
|
|
||||||
if n <= parts.len() - 2 && parts.len() > 1 {
|
|
||||||
// Synthesize fake tags for proper indenting.
|
|
||||||
for i in n..parts.len() - 1 {
|
|
||||||
let display_name = parts[n];
|
|
||||||
lis.push(view_tag_li(
|
|
||||||
&display_name,
|
|
||||||
n,
|
|
||||||
&Tag {
|
|
||||||
name: parts[..i + 1].join("/"),
|
|
||||||
bg_color: "#000".to_string(),
|
|
||||||
fg_color: "#fff".to_string(),
|
|
||||||
unread: 0,
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
last = parts[..parts.len() - 1].to_vec();
|
|
||||||
n = parts.len() - 1;
|
|
||||||
}
|
|
||||||
let display_name = parts[n];
|
|
||||||
lis.push(view_tag_li(&display_name, n, t));
|
|
||||||
last = parts;
|
|
||||||
}
|
|
||||||
lis
|
|
||||||
}
|
|
||||||
let unread = model
|
|
||||||
.tags
|
|
||||||
.as_ref()
|
|
||||||
.map(|tags| tags.iter().filter(|t| t.unread > 0));
|
|
||||||
let read = model
|
|
||||||
.tags
|
|
||||||
.as_ref()
|
|
||||||
.map(|tags| tags.iter().filter(|t| t.unread == 0));
|
|
||||||
div![
|
div![
|
||||||
C!["desktop-main-content"],
|
view_header(&model.query, &model.refreshing_state),
|
||||||
aside![
|
section![C!["section"], content],
|
||||||
C!["tags-menu", "menu"],
|
view_header(&model.query, &model.refreshing_state),
|
||||||
IF!(unread.is_some() => p![C!["menu-label"], "Unread"]),
|
|
||||||
IF!(unread.is_some() => ul![C!["menu-list"], unread.map(view_tag_list)]),
|
|
||||||
p![C!["menu-label"], "Tags"],
|
|
||||||
ul![
|
|
||||||
C!["menu-list"],
|
|
||||||
model.tags.as_ref().map(|tags| view_tag_list(tags.iter()))
|
|
||||||
]
|
|
||||||
],
|
|
||||||
div![
|
|
||||||
view_header(&model.query, &model.refreshing_state),
|
|
||||||
content,
|
|
||||||
view_header(&model.query, &model.refreshing_state),
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_mobile(model: &Model) -> Node<Msg> {
|
fn view_mobile(model: &Model) -> Node<Msg> {
|
||||||
let content = match &model.context {
|
let content = match &model.context {
|
||||||
Context::None => div![h1!["Loading"]],
|
Context::None => div![h1!["Loading"]],
|
||||||
Context::Thread(thread_set) => view_thread_legacy(thread_set),
|
Context::Thread(messages) => view_thread(messages),
|
||||||
Context::ThreadResult(thread) => view_thread(thread),
|
Context::Search(search_results) => view_mobile_search_results(&model.query, search_results),
|
||||||
Context::Search(search_results) => {
|
|
||||||
view_mobile_search_results_legacy(&model.query, search_results)
|
|
||||||
}
|
|
||||||
Context::SearchResult {
|
|
||||||
query,
|
|
||||||
results,
|
|
||||||
count,
|
|
||||||
pager,
|
|
||||||
} => view_mobile_search_results(&query, results.as_slice(), *count, pager),
|
|
||||||
};
|
};
|
||||||
div![
|
div![
|
||||||
view_header(&model.query, &model.refreshing_state),
|
view_header(&model.query, &model.refreshing_state),
|
||||||
content,
|
section![C!["section"], div![C!["content"], content]],
|
||||||
view_header(&model.query, &model.refreshing_state),
|
view_header(&model.query, &model.refreshing_state),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1332,11 +771,6 @@ fn view(model: &Model) -> Node<Msg> {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
info!("view called");
|
info!("view called");
|
||||||
div![
|
div![
|
||||||
if is_mobile {
|
|
||||||
C!["mobile"]
|
|
||||||
} else {
|
|
||||||
C!["desktop"]
|
|
||||||
},
|
|
||||||
if is_mobile {
|
if is_mobile {
|
||||||
view_mobile(model)
|
view_mobile(model)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user