web: add basic graphql view thread, no body support.

This commit is contained in:
Bill Thiede 2023-11-26 15:27:19 -08:00
parent 447a4a3387
commit 0ae72b63d0
5 changed files with 416 additions and 22 deletions

View File

@ -7,8 +7,8 @@ use async_graphql::{
connection::{self, Connection, Edge},
Context, EmptyMutation, EmptySubscription, Error, FieldResult, Object, Schema, SimpleObject,
};
use log::info;
use mailparse::{addrparse, parse_mail, MailHeaderMap, ParsedMail};
use log::{info, warn};
use mailparse::{parse_mail, MailHeaderMap, ParsedMail};
use memmap::MmapOptions;
use notmuch::Notmuch;
use rayon::prelude::*;
@ -39,6 +39,7 @@ pub struct ThreadSummary {
#[derive(Debug, SimpleObject)]
pub struct Thread {
subject: String,
messages: Vec<Message>,
}
@ -174,13 +175,18 @@ impl QueryRoot {
let file = File::open(&path)?;
let mmap = unsafe { MmapOptions::new().map(&file)? };
let m = parse_mail(&mmap)?;
let from = if let Some(from) = m.headers.get_first_value("from") {
addrparse(&from)?.extract_single_info().map(|si| Email {
name: si.display_name,
addr: Some(si.addr),
})
} else {
None
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")?;
@ -198,7 +204,15 @@ impl QueryRoot {
});
}
messages.reverse();
Ok(Thread { messages })
// 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 })
}
}
@ -228,8 +242,8 @@ fn email_addresses(path: &str, m: &ParsedMail, header_name: &str) -> Result<Vec<
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];
let name = &v[..idx];
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()),

View File

@ -69,6 +69,41 @@
"name": "Boolean",
"possibleTypes": null
},
{
"description": null,
"enumValues": null,
"fields": [
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "name",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "addr",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
],
"inputFields": null,
"interfaces": [],
"kind": "OBJECT",
"name": "Email",
"possibleTypes": null
},
{
"description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).",
"enumValues": null,
@ -99,6 +134,101 @@
"name": "Int",
"possibleTypes": null
},
{
"description": null,
"enumValues": null,
"fields": [
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "from",
"type": {
"kind": "OBJECT",
"name": "Email",
"ofType": null
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "to",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Email",
"ofType": null
}
}
}
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "cc",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Email",
"ofType": null
}
}
}
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "subject",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "timestamp",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
}
],
"inputFields": null,
"interfaces": [],
"kind": "OBJECT",
"name": "Message",
"possibleTypes": null
},
{
"description": "Information about pagination in a connection",
"enumValues": null,
@ -295,6 +425,37 @@
}
}
}
},
{
"args": [
{
"defaultValue": null,
"description": null,
"name": "threadId",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "thread",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Thread",
"ofType": null
}
}
}
],
"inputFields": null,
@ -388,6 +549,57 @@
"name": "Tag",
"possibleTypes": null
},
{
"description": null,
"enumValues": null,
"fields": [
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "subject",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "messages",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Message",
"ofType": null
}
}
}
}
}
],
"inputFields": null,
"interfaces": [],
"kind": "OBJECT",
"name": "Thread",
"possibleTypes": null
},
{
"description": null,
"enumValues": null,

View File

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

View File

@ -3,7 +3,7 @@ use seed::{
fetch,
fetch::{Header, Method, Request},
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
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
@ -15,6 +15,14 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize};
)]
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,

View File

@ -17,7 +17,7 @@ use serde::de::Deserialize;
use thiserror::Error;
use wasm_timer::Instant;
use crate::graphql::{front_page_query::*, send_graphql};
use crate::graphql::{front_page_query::*, send_graphql, show_thread_query::*};
mod graphql;
@ -67,7 +67,15 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
);
let hpp = url.remaining_hash_path_parts();
match hpp.as_slice() {
["t", tid] => Msg::ShowPrettyRequest(tid.to_string()),
["t", tid] => {
if USE_GRAPHQL {
Msg::ShowThreadRequest {
thread_id: tid.to_string(),
}
} else {
Msg::ShowPrettyRequest(tid.to_string())
}
}
["s", query] => {
let query = Url::decode_uri_component(query).unwrap_or("".to_string());
if USE_GRAPHQL {
@ -156,6 +164,7 @@ enum Context {
pager: FrontPageQuerySearchPageInfo,
},
Thread(ThreadSet),
ThreadResult(ShowThreadQueryThread),
}
// `Model` describes our app state.
@ -210,6 +219,12 @@ enum Msg {
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`.
@ -294,8 +309,9 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}
});
}
Context::Thread(_) => (), // do nothing (yet?)
Context::None => (), // do nothing (yet?)
Context::Thread(_) => (), // do nothing (yet?)
Context::ThreadResult(_) => (), // do nothing (yet?)
Context::None => (), // do nothing (yet?)
};
}
Msg::PreviousPage => {
@ -317,8 +333,9 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
});
}
Context::Thread(_) => (), // do nothing (yet?)
Context::None => (), // do nothing (yet?)
Context::Thread(_) => (), // do nothing (yet?)
Context::ThreadResult(_) => (), // do nothing (yet?)
Context::None => (), // do nothing (yet?)
};
}
@ -372,6 +389,25 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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.context = Context::ThreadResult(data.thread);
}
Msg::ShowThreadResult(bad) => {
error!("show_thread_query error: {bad:?}");
}
}
}
@ -923,7 +959,108 @@ fn view_search_pager_legacy(start: usize, count: usize, total: usize) -> Node<Ms
]
}
fn view_thread(thread_set: &ThreadSet) -> Node<Msg> {
trait Email {
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!["container"],
h1![C!["title"], &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);
let thread = &thread_set.0[0];
assert_eq!(thread.0.len(), 1);
@ -1058,7 +1195,8 @@ 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 {
Context::None => div![h1!["Loading"]],
Context::Thread(thread_set) => view_thread(thread_set),
Context::Thread(thread_set) => view_thread_legacy(thread_set),
Context::ThreadResult(thread) => view_thread(thread),
Context::Search(search_results) => view_search_results_legacy(&model.query, search_results),
Context::SearchResult {
query,
@ -1097,7 +1235,8 @@ fn view_desktop(model: &Model) -> Node<Msg> {
fn view_mobile(model: &Model) -> Node<Msg> {
let content = match &model.context {
Context::None => div![h1!["Loading"]],
Context::Thread(thread_set) => view_thread(thread_set),
Context::Thread(thread_set) => view_thread_legacy(thread_set),
Context::ThreadResult(thread) => view_thread(thread),
Context::Search(search_results) => {
view_mobile_search_results_legacy(&model.query, search_results)
}