Compare commits

..

No commits in common. "1cdabc348bb0206b4bdd61ae3390adae9163ce47" and "2a24a20529a856e8b36531d2cddd356447f88dfd" have entirely different histories.

14 changed files with 102 additions and 2698 deletions

169
Cargo.lock generated
View File

@ -66,12 +66,6 @@ version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
[[package]]
name = "ascii"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e"
[[package]] [[package]]
name = "ascii_utils" name = "ascii_utils"
version = "0.9.3" version = "0.9.3"
@ -97,7 +91,6 @@ dependencies = [
"handlebars", "handlebars",
"http", "http",
"indexmap 2.0.0", "indexmap 2.0.0",
"log 0.4.20",
"mime 0.3.17", "mime 0.3.17",
"multer", "multer",
"num-traits", "num-traits",
@ -342,16 +335,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "charset"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e9079d1a12a2cc2bffb5db039c43661836ead4082120d5844f02555aca2d46"
dependencies = [
"base64 0.13.1",
"encoding_rs",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.31" version = "0.4.31"
@ -366,19 +349,6 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "combine"
version = "3.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680"
dependencies = [
"ascii",
"byteorder",
"either",
"memchr",
"unreachable",
]
[[package]] [[package]]
name = "console_error_panic_hook" name = "console_error_panic_hook"
version = "0.1.7" version = "0.1.7"
@ -467,6 +437,16 @@ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
dependencies = [
"cfg-if 1.0.0",
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.3" version = "0.8.3"
@ -606,12 +586,6 @@ dependencies = [
"syn 2.0.29", "syn 2.0.29",
] ]
[[package]]
name = "data-encoding"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]] [[package]]
name = "dbg" name = "dbg"
version = "1.0.4" version = "1.0.4"
@ -1129,64 +1103,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "graphql-introspection-query"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d"
dependencies = [
"serde",
]
[[package]]
name = "graphql-parser"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ebc8013b4426d5b81a4364c419a95ed0b404af2b82e2457de52d9348f0e474"
dependencies = [
"combine",
"thiserror",
]
[[package]]
name = "graphql_client"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cdf7b487d864c2939b23902291a5041bc4a84418268f25fda1c8d4e15ad8fa"
dependencies = [
"graphql_query_derive",
"serde",
"serde_json",
]
[[package]]
name = "graphql_client_codegen"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a40f793251171991c4eb75bd84bc640afa8b68ff6907bc89d3b712a22f700506"
dependencies = [
"graphql-introspection-query",
"graphql-parser",
"heck",
"lazy_static",
"proc-macro2 1.0.66",
"quote 1.0.33",
"serde",
"serde_json",
"syn 1.0.109",
]
[[package]]
name = "graphql_query_derive"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00bda454f3d313f909298f626115092d348bc231025699f557b27e248475f48c"
dependencies = [
"graphql_client_codegen",
"proc-macro2 1.0.66",
"syn 1.0.109",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.21" version = "0.3.21"
@ -1554,7 +1470,6 @@ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
"css-inline", "css-inline",
"graphql_client",
"itertools", "itertools",
"log 0.4.20", "log 0.4.20",
"notmuch", "notmuch",
@ -1562,7 +1477,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"shared", "shared",
"thiserror",
"wasm-bindgen-test", "wasm-bindgen-test",
"wasm-timer", "wasm-timer",
"web-sys", "web-sys",
@ -1626,17 +1540,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mailparse"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b56570f5f8c0047260d1c8b5b331f62eb9c660b9dd4071a8c46f8c7d3f280aa"
dependencies = [
"charset",
"data-encoding",
"quoted_printable",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.10.1" version = "0.10.1"
@ -1672,16 +1575,6 @@ version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e"
[[package]]
name = "memmap"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6585fd95e7bb50d6cc31e20d4cf9afb4e2ba16c5846fc76793f11218da9c475b"
dependencies = [
"libc",
"winapi 0.3.9",
]
[[package]] [[package]]
name = "memoffset" name = "memoffset"
version = "0.6.5" version = "0.6.5"
@ -2259,12 +2152,6 @@ dependencies = [
"proc-macro2 1.0.66", "proc-macro2 1.0.66",
] ]
[[package]]
name = "quoted_printable"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.7.3" version = "0.7.3"
@ -2348,9 +2235,9 @@ dependencies = [
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.8.0" version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c27db03db7734835b3f53954b534c91069375ce6ccaa2e065441e07d9b6cdb1" checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b"
dependencies = [ dependencies = [
"either", "either",
"rayon-core", "rayon-core",
@ -2358,12 +2245,14 @@ dependencies = [
[[package]] [[package]]
name = "rayon-core" name = "rayon-core"
version = "1.12.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ce3fb6ad83f861aac485e76e1985cd109d9a3713802152be56c3b1f0e0658ed" checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d"
dependencies = [ dependencies = [
"crossbeam-channel",
"crossbeam-deque", "crossbeam-deque",
"crossbeam-utils", "crossbeam-utils",
"num_cpus",
] ]
[[package]] [[package]]
@ -2840,10 +2729,7 @@ dependencies = [
"async-graphql-rocket", "async-graphql-rocket",
"glog", "glog",
"log 0.4.20", "log 0.4.20",
"mailparse",
"memmap",
"notmuch", "notmuch",
"rayon",
"rocket 0.5.0", "rocket 0.5.0",
"rocket_contrib", "rocket_contrib",
"rocket_cors", "rocket_cors",
@ -3119,18 +3005,18 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.50" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "1.0.50" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b"
dependencies = [ dependencies = [
"proc-macro2 1.0.66", "proc-macro2 1.0.66",
"quote 1.0.33", "quote 1.0.33",
@ -3476,15 +3362,6 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
[[package]]
name = "unreachable"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56"
dependencies = [
"void",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"
@ -3552,12 +3429,6 @@ version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "void"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.3.3" version = "2.3.3"

View File

@ -491,8 +491,6 @@ impl Notmuch {
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 +559,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

View File

@ -17,12 +17,9 @@ 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 = "6.0.11"
async-graphql-rocket = "6.0.11" async-graphql-rocket = "6.0.11"
rocket_cors = "0.6.0" rocket_cors = "0.6.0"
rayon = "1.8.0"
memmap = "0.7.0"
mailparse = "0.14.0"
[dependencies.rocket_contrib] [dependencies.rocket_contrib]
version = "0.4.11" version = "0.4.11"

View File

@ -167,7 +167,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription) let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription)
.data(Notmuch::default()) .data(Notmuch::default())
.extension(async_graphql::extensions::Logger)
.finish(); .finish();
let _ = rocket::build() let _ = rocket::build()

View File

@ -1,17 +1,11 @@
use std::{ use std::hash::{DefaultHasher, Hash, Hasher};
fs::File,
hash::{DefaultHasher, Hash, Hasher},
};
use async_graphql::{ use async_graphql::{
connection::{self, Connection, Edge}, connection::{self, Connection, Edge},
Context, EmptyMutation, EmptySubscription, Error, FieldResult, Object, Schema, SimpleObject, Context, EmptyMutation, EmptySubscription, Error, FieldResult, Object, Schema, SimpleObject,
}; };
use log::{info, warn}; use log::info;
use mailparse::{parse_mail, MailHeaderMap, ParsedMail};
use memmap::MmapOptions;
use notmuch::Notmuch; use notmuch::Notmuch;
use rayon::prelude::*;
pub struct QueryRoot; pub struct QueryRoot;
@ -37,38 +31,11 @@ pub struct ThreadSummary {
pub tags: Vec<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>,
}
#[derive(Debug, SimpleObject)]
pub struct Email {
pub name: Option<String>,
pub addr: Option<String>,
}
#[derive(SimpleObject)] #[derive(SimpleObject)]
struct Tag { struct Tag {
name: String, name: String,
fg_color: String, fg_color: String,
bg_color: String, bg_color: String,
unread: usize,
} }
#[Object] #[Object]
@ -95,26 +62,31 @@ impl QueryRoot {
last, last,
|after, before, first, last| async move { |after, before, first, last| async move {
info!("{after:?} {before:?} {first:?} {last:?} {query}"); info!("{after:?} {before:?} {first:?} {last:?} {query}");
let mut start = 0usize;
let total = nm.count(&query)?; let total = nm.count(&query)?;
let (first, last) = if let (None, None) = (first, last) { let page_size = first.unwrap_or(20);
info!("neither first nor last set, defaulting to 20");
(Some(20), Some(20))
} else {
(first, last)
};
let mut start = after.map(|after| after + 1).unwrap_or(0); if let Some(after) = after {
let mut end = before.unwrap_or(total); if after >= total {
if let Some(first) = first { return Ok(Connection::new(false, false));
end = (start + first).min(end);
} }
if let Some(last) = last { start = after + 1;
start = if last > end - start { end } else { end - last }; }
let mut end = start + page_size;
if end > total {
end = total;
}
// TODO(wathiede): handle last/end.
if let Some(before) = before {
if before == 0 {
return Ok(Connection::new(false, false));
}
end = before;
} }
let count = end - start;
let slice: Vec<ThreadSummary> = nm let slice: Vec<ThreadSummary> = nm
.search(&query, start, count)? .search(&query, start, first.unwrap_or(20))?
.0 .0
.into_iter() .into_iter()
.map(|ts| ThreadSummary { .map(|ts| ThreadSummary {
@ -128,6 +100,17 @@ impl QueryRoot {
tags: ts.tags, tags: ts.tags,
}) })
.collect(); .collect();
//let mut slice = &res[..];
/*
if let Some(first) = first {
slice = &slice[..first.min(slice.len())];
end -= first.min(slice.len());
} else if let Some(last) = last {
slice = &slice[slice.len() - last.min(slice.len())..];
start = end - last.min(slice.len());
}
*/
let mut connection = Connection::new(start > 0, end < total); let mut connection = Connection::new(start > 0, end < total);
connection.edges.extend( connection.edges.extend(
@ -146,117 +129,19 @@ impl QueryRoot {
let nm = ctx.data_unchecked::<Notmuch>(); let nm = ctx.data_unchecked::<Notmuch>();
Ok(nm Ok(nm
.tags()? .tags()?
.into_par_iter() .into_iter()
.map(|tag| { .map(|tag| {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
tag.hash(&mut hasher); tag.hash(&mut hasher);
let hex = format!("#{:06x}", hasher.finish() % (1 << 24)); 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 { Tag {
name: tag, name: tag,
fg_color: "white".to_string(), fg_color: "white".to_string(),
bg_color: hex, bg_color: hex,
unread,
} }
}) })
.collect()) .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 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());
messages.push(Message {
from,
to,
cc,
subject,
timestamp,
});
}
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 })
}
} }
pub type GraphqlSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>; 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)
}

View File

@ -28,8 +28,6 @@ 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" 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']

View File

@ -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"

View File

@ -1,23 +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
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +0,0 @@
query ShowThreadQuery($threadId: String!) {
thread(threadId: $threadId) {
subject
messages {
subject
from {
name
addr
}
to {
name
addr
}
cc {
name
addr
}
timestamp
}
}
tags {
name
bgColor
fgColor
}
}

View File

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

View File

@ -47,7 +47,7 @@ iframe {
white-space: nowrap; white-space: nowrap;
} }
.index .date { .index .date {
width: 10em; width: 7em;
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: right;
} }
@ -95,11 +95,6 @@ input::placeholder, .input::placeholder{
color: #555; color: #555;
} }
.mobile .search-results,
.mobile .thread {
padding: 1em;
}
.search-results .row { .search-results .row {
border-bottom: 1px #444 solid; border-bottom: 1px #444 solid;
padding-bottom: .5em; padding-bottom: .5em;
@ -123,26 +118,6 @@ input::placeholder, .input::placeholder{
.float-right { .float-right {
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;
}
.navbar {
border: none;
}
</style> </style>
</head> </head>

View File

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

View File

@ -8,33 +8,14 @@ use std::{
}; };
use chrono::{DateTime, Datelike, Duration, Local, Utc}; 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 +34,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,65 +46,28 @@ 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::FrontPageRequest {
query,
after: None,
before: None,
first: None,
last: None,
}
} else {
Msg::SearchRequest { Msg::SearchRequest {
query, query,
page: 0, page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE, 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::FrontPageRequest {
query,
after: Some(page.to_string()),
before: None,
first: None,
last: None,
}
} else {
Msg::SearchRequest { Msg::SearchRequest {
query, query,
page, page,
results_per_page: SEARCH_RESULTS_PER_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::FrontPageRequest {
query: "".to_string(),
after: None,
before: None,
first: None,
last: None,
}
} else {
Msg::SearchRequest { Msg::SearchRequest {
query: "".to_string(), query: "".to_string(),
page: 0, page: 0,
@ -133,7 +75,6 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
} }
} }
} }
}
} }
mod urls { mod urls {
@ -157,14 +98,7 @@ mod urls {
enum Context { enum Context {
None, None,
Search(shared::SearchResult), Search(shared::SearchResult),
SearchResult {
query: String,
results: Vec<FrontPageQuerySearchNodes>,
count: usize,
pager: FrontPageQuerySearchPageInfo,
},
Thread(ThreadSet), Thread(ThreadSet),
ThreadResult(ShowThreadQueryThread),
} }
// `Model` describes our app state. // `Model` describes our app state.
@ -172,14 +106,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,
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
@ -214,23 +140,6 @@ enum Msg {
ShowPrettyResult(fetch::Result<ThreadSet>), ShowPrettyResult(fetch::Result<ThreadSet>),
NextPage, NextPage,
PreviousPage, PreviousPage,
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`.
@ -302,21 +211,7 @@ 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, .. } => {
let query = query.to_string();
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::Thread(_) => (), // do nothing (yet?)
Context::ThreadResult(_) => (), // do nothing (yet?)
Context::None => (), // do nothing (yet?) Context::None => (), // do nothing (yet?)
}; };
} }
@ -325,114 +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, .. } => {
let query = query.to_string();
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::Thread(_) => (), // do nothing (yet?)
Context::ThreadResult(_) => (), // do nothing (yet?)
Context::None => (), // do nothing (yet?) Context::None => (), // do nothing (yet?)
}; };
} }
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,
})
.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,
})
.collect(),
);
model.context = Context::ThreadResult(data.thread);
}
Msg::ShowThreadResult(bad) => {
error!("show_thread_query error: {bad:?}");
}
} }
} }
@ -708,56 +499,13 @@ fn human_age(timestamp: i64) -> String {
ts.format("Yest. %H:%M").to_string() ts.format("Yest. %H:%M").to_string()
} else if age < Duration::weeks(1) { } else if age < Duration::weeks(1) {
ts.format("%a %H:%M").to_string() ts.format("%a %H:%M").to_string()
} else if ts.year() == now.year() {
ts.format("%b %d %H:%M").to_string()
} else { } else {
ts.format("%b %d, %Y %H:%M").to_string() ts.format("%b %e %H:%M").to_string()
}; };
datetime datetime
} }
fn view_mobile_search_results( fn view_mobile_search_results(query: &str, search_results: &shared::SearchResult) -> Node<Msg> {
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 {
@ -780,93 +528,31 @@ fn view_mobile_search_results_legacy(
*/ */
let tid = r.thread.clone(); let tid = r.thread.clone();
let datetime = human_age(r.timestamp as i64); let datetime = human_age(r.timestamp as i64);
a![
C!["has-text-light"],
attrs! {
At::Href => urls::thread(&tid)
},
div![ div![
C!["row"], C!["row"],
div![C!["subject"], &r.subject], div![
C!["subject"],
&r.subject,
ev(Ev::Click, move |_| Msg::ShowPrettyRequest(tid)),
],
span![C!["from", "is-size-7"], pretty_authors(&r.authors)], span![C!["from", "is-size-7"], pretty_authors(&r.authors)],
div![ div![
span![C!["is-size-7"], tags_chiclet(&r.tags, true)], span![C!["is-size-7"], tags_chiclet(&r.tags, true)],
span![C!["is-size-7", "float-right", "date"], datetime] span![C!["is-size-7", "float-right", "date"], datetime]
] ]
] ]
]
}); });
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"], C!["search-results"],
view_search_pager_legacy(first, summaries.len(), search_results.total), h1!["Search results"],
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 {
@ -899,7 +585,7 @@ fn view_search_results_legacy(query: &str, search_results: &shared::SearchResult
}); });
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",
@ -916,51 +602,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![
@ -984,108 +630,7 @@ fn view_search_pager_legacy(start: usize, count: usize, total: usize) -> Node<Ms
] ]
} }
trait Email { fn view_thread(thread_set: &ThreadSet) -> Node<Msg> {
fn name(&self) -> &Option<String>;
fn addr(&self) -> &Option<String>;
}
impl<T: Email> Email for &'_ T {
fn name(&self) -> &Option<String> {
return (*self).name();
}
fn addr(&self) -> &Option<String> {
return (*self).addr();
}
}
impl Email for ShowThreadQueryThreadMessagesCc {
fn name(&self) -> &Option<String> {
return &self.name;
}
fn addr(&self) -> &Option<String> {
return &self.addr;
}
}
impl Email for ShowThreadQueryThreadMessagesFrom {
fn name(&self) -> &Option<String> {
return &self.name;
}
fn addr(&self) -> &Option<String> {
return &self.addr;
}
}
impl Email for ShowThreadQueryThreadMessagesTo {
fn name(&self) -> &Option<String> {
return &self.name;
}
fn addr(&self) -> &Option<String> {
return &self.addr;
}
}
fn view_addresses<E: Email>(addrs: &[E]) -> Vec<Node<Msg>> {
addrs
.into_iter()
.map(|address| {
span![
C!["tag", "is-black"],
address.addr().as_ref().map(|a| attrs! {At::Title=>a}),
address
.name()
.as_ref()
.unwrap_or(address.addr().as_ref().unwrap_or(&"(UNKNOWN)".to_string()))
]
})
.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_addresses(&[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 &message.body {
Some(body) => view_body(body.as_slice()),
None => div!["<no body>"],
},
],
*/
]
});
div![
C!["thread"],
p![C!["is-size-4"], &thread.subject],
messages,
/* TODO(wathiede): plumb in orignal id
a![
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
"Original"
],
*/
/*
div![
C!["debug"],
"Add zippy for debug dump",
view_debug_thread_set(thread_set)
] /* pre![format!("Thread: {:#?}", thread_set).replace(" ", " ")] */
*/
]
}
fn view_thread_legacy(thread_set: &ThreadSet) -> Node<Msg> {
assert_eq!(thread_set.0.len(), 1); assert_eq!(thread_set.0.len(), 1);
let thread = &thread_set.0[0]; let thread = &thread_set.0[0];
assert_eq!(thread.0.len(), 1); assert_eq!(thread.0.len(), 1);
@ -1183,11 +728,7 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
At::Value => query, At::Value => query,
}, },
input_ev(Ev::Input, |q| Msg::SearchRequest { input_ev(Ev::Input, |q| Msg::SearchRequest {
query: Url::encode_uri_component(if q.is_empty() { query: Url::encode_uri_component(q),
"*".to_string()
} else {
q
}),
page: 0, page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE, results_per_page: SEARCH_RESULTS_PER_PAGE,
}), }),
@ -1217,64 +758,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(thread_set) => view_thread(thread_set),
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),
}; };
div![
C!["desktop-main-content"],
aside![
C!["tags-menu", "menu"],
p![C!["menu-label"], "Tags"],
ul![
C!["menu-list"],
model.tags.as_ref().map(|tags| tags.iter().map(|t| li![a![
attrs! {
At::Href => urls::search(&format!("tag:{}", t.name), 0)
},
style! {
St::BackgroundColor => t.bg_color,
St::Color => t.fg_color,
},
&t.name
]]))
]
],
div![ div![
view_header(&model.query, &model.refreshing_state), view_header(&model.query, &model.refreshing_state),
content, section![C!["section"], content],
view_header(&model.query, &model.refreshing_state), 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(thread_set) => view_thread(thread_set),
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),
] ]
} }
@ -1291,11 +795,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 {