diff --git a/web/graphql/mark_read.graphql b/web/graphql/mark_read.graphql new file mode 100644 index 0000000..d1af012 --- /dev/null +++ b/web/graphql/mark_read.graphql @@ -0,0 +1,3 @@ +mutation MarkReadMutation($query: String!, $unread: Boolean!) { + setReadStatus(query:$query, unread:$unread) +} diff --git a/web/graphql/schema.json b/web/graphql/schema.json index d27b48a..4f009ef 100644 --- a/web/graphql/schema.json +++ b/web/graphql/schema.json @@ -53,7 +53,9 @@ "name": "skip" } ], - "mutationType": null, + "mutationType": { + "name": "Mutation" + }, "queryType": { "name": "QueryRoot" }, @@ -536,6 +538,62 @@ "name": "Message", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "query", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "defaultValue": null, + "description": null, + "name": "unread", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "setReadStatus", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Mutation", + "possibleTypes": null + }, { "description": "Information about pagination in a connection", "enumValues": null, @@ -903,6 +961,22 @@ "description": null, "enumValues": null, "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "threadId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, diff --git a/web/graphql/show_thread.graphql b/web/graphql/show_thread.graphql index fef814a..332dfff 100644 --- a/web/graphql/show_thread.graphql +++ b/web/graphql/show_thread.graphql @@ -1,5 +1,6 @@ query ShowThreadQuery($threadId: String!) { thread(threadId: $threadId) { + threadId, subject messages { id diff --git a/web/index.html b/web/index.html index ce3dc05..bff500b 100644 --- a/web/index.html +++ b/web/index.html @@ -2,142 +2,175 @@ - - - - - - + .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; + } + + .desktop nav.pagination, + .tablet nav.pagination { + margin-left: .5em; + margin-bottom: 0 !important; + } + + .content-tree { + white-space: pre-line; + } + -
+
diff --git a/web/src/graphql.rs b/web/src/graphql.rs index e719986..e46baee 100644 --- a/web/src/graphql.rs +++ b/web/src/graphql.rs @@ -20,6 +20,14 @@ pub struct FrontPageQuery; )] pub struct ShowThreadQuery; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "graphql/schema.json", + query_path = "graphql/mark_read.graphql", + response_derives = "Debug" +)] +pub struct MarkReadMutation; + pub async fn send_graphql(body: Body) -> Result, Error> where Body: Serialize, diff --git a/web/src/state.rs b/web/src/state.rs index 60f5cc8..5ee8828 100644 --- a/web/src/state.rs +++ b/web/src/state.rs @@ -217,6 +217,25 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { orders.request_url(urls::search(&query, 0)); } + Msg::SetUnread(query, unread) => { + orders.skip().perform_cmd(async move { + let res: Result< + graphql_client::Response, + gloo_net::Error, + > = send_graphql(graphql::MarkReadMutation::build_query( + graphql::mark_read_mutation::Variables { + query: query.clone(), + unread, + }, + )) + .await; + if let Err(e) = res { + error!("Failed to set read for {query} to {unread}: {e}"); + } + Msg::RefreshStart + }); + } + Msg::FrontPageRequest { query, after, @@ -380,6 +399,8 @@ pub enum Msg { UpdateQuery(String), SearchQuery(String), + SetUnread(String, bool), + FrontPageRequest { query: String, after: Option, diff --git a/web/src/view/mod.rs b/web/src/view/mod.rs index 62c395e..0215b89 100644 --- a/web/src/view/mod.rs +++ b/web/src/view/mod.rs @@ -7,7 +7,10 @@ use chrono::{DateTime, Datelike, Duration, Local, Utc}; use itertools::Itertools; use log::{error, info}; use seed::{prelude::*, *}; -use seed_hooks::{state_access::CloneState, topo, use_state}; +use seed_hooks::{ + state_access::{CloneState, StateAccess}, + topo, use_state, +}; use wasm_timer::Instant; use crate::{ @@ -262,6 +265,128 @@ fn raw_text_message(contents: &str) -> Node { div![C!["view-part-text-plain"], contents, truncated_msg,] } +fn has_unread(tags: &[String]) -> bool { + for t in tags { + if t == "unread" { + return true; + } + } + false +} + +fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: StateAccess) -> Node { + let id = msg.id.clone(); + let is_unread = has_unread(&msg.tags); + div![ + C!["message"], + div![ + C!["headers"], + span![ + C!["read-status"], + i![ + style! { + St::Color => "gold" + }, + C![if is_unread { "fa-regular" } else { "fa-solid" }, "fa-star"], + ev(Ev::Click, move |e| { + e.stop_propagation(); + Msg::SetUnread(format!("id:{id}"), !is_unread) + }), + ], + ], + " ", + msg.from + .as_ref() + .map(|from| span![C!["header"], view_address(&from)]), + " ", + msg.timestamp.map(|ts| span![C!["header"], human_age(ts)]), + // TODO(wathiede): add first line of message body + ], + ev(Ev::Click, move |e| { + open.set(!open.get()); + e.stop_propagation(); + }), + ] +} +fn unread_message_render( + msg: &ShowThreadQueryThreadMessages, + open: StateAccess, +) -> Node { + let id = msg.id.clone(); + let is_unread = has_unread(&msg.tags); + div![ + C!["message"], + div![ + C!["headers"], + span![ + C!["read-status"], + i![ + style! { + St::Color => "gold" + }, + C![if is_unread { "fa-regular" } else { "fa-solid" }, "fa-star"], + ev(Ev::Click, move |e| { + e.stop_propagation(); + Msg::SetUnread(format!("id:{id}"), !is_unread) + }), + ], + ], + msg.from + .as_ref() + .map(|from| div![C!["header"], "From: ", view_address(&from)]), + msg.timestamp + .map(|ts| div![C!["header"], "Date: ", human_age(ts)]), + div![C!["header"], "Message-ID: ", &msg.id], + div![ + C!["header"], + IF!(!msg.to.is_empty() => span!["To: ", view_addresses(&msg.to)]), + IF!(!msg.cc.is_empty() => span!["CC: ", view_addresses(&msg.cc)]) + ], + ev(Ev::Click, move |e| { + open.set(!open.get()); + e.stop_propagation(); + }), + ], + div![ + C!["body"], + match &msg.body { + ShowThreadQueryThreadMessagesBody::UnhandledContentType( + ShowThreadQueryThreadMessagesBodyOnUnhandledContentType { contents }, + ) => pre![C!["error"], contents], + ShowThreadQueryThreadMessagesBody::PlainText( + ShowThreadQueryThreadMessagesBodyOnPlainText { + contents, + content_tree, + }, + ) => div![ + raw_text_message(&contents), + view_content_tree(&content_tree), + ], + ShowThreadQueryThreadMessagesBody::Html( + ShowThreadQueryThreadMessagesBodyOnHtml { + contents, + content_tree, + }, + ) => div![ + C!["view-part-text-html"], + raw![contents], + IF!(!msg.attachments.is_empty() => + div![ + C!["attachments"], + br![], + h2!["Attachments"], + msg.attachments + .iter() + .map(|a| div!["Filename: ", &a.filename, " ", &a.content_type]) + ]), + view_content_tree(&content_tree), + ], + } + ], + ] +} + +#[topo::nested] fn thread(thread: &ShowThreadQueryThread) -> Node { // TODO(wathiede): show per-message subject if it changes significantly from top-level subject set_title(&thread.subject); @@ -276,65 +401,37 @@ fn thread(thread: &ShowThreadQueryThread) -> Node { .collect(); tags.sort(); let messages = thread.messages.iter().map(|msg| { - div![ - C!["message"], - div![ - C!["headers"], - /* TODO(wathiede): collect all the tags and show them here. */ - msg.from - .as_ref() - .map(|from| div![C!["header"], "From: ", view_address(&from)]), - msg.timestamp - .map(|ts| div![C!["header"], "Date: ", human_age(ts)]), - div![C!["header"], "Message-ID: ", &msg.id], - div![ - C!["header"], - IF!(!msg.to.is_empty() => span!["To: ", view_addresses(&msg.to)]), - IF!(!msg.cc.is_empty() => span!["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![ - raw_text_message(&contents), - view_content_tree(&content_tree), - ], - ShowThreadQueryThreadMessagesBody::Html( - ShowThreadQueryThreadMessagesBodyOnHtml { - contents, - content_tree, - }, - ) => div![ - C!["view-part-text-html"], - raw![contents], - IF!(!msg.attachments.is_empty() => - div![ - C!["attachments"], - br![], - h2!["Attachments"], - msg.attachments - .iter() - .map(|a| div!["Filename: ", &a.filename, " ", &a.content_type]) - ]), - view_content_tree(&content_tree), - ], - } - ], - ] + let is_unread = has_unread(&msg.tags); + let open = use_state(|| is_unread); + if open.get() { + unread_message_render(&msg, open) + } else { + read_message_render(&msg, open) + } }); + let any_unread = thread.messages.iter().any(|msg| has_unread(&msg.tags)); + let thread_id = thread.thread_id.clone(); div![ C!["thread"], p![ C!["is-size-4"], + span![ + C!["read-status"], + i![ + style! { + St::Color => "gold" + }, + C![ + if any_unread { "fa-regular" } else { "fa-solid" }, + "fa-star" + ], + ev(Ev::Click, move |_| Msg::SetUnread( + format!("thread:{}", thread_id), + !any_unread + )), + ], + " ", + ], &thread.subject, " ", tags_chiclet(&tags, false) @@ -475,9 +572,6 @@ pub fn view(model: &Model) -> Node { .expect("window height") .as_f64() .expect("window height f64"); - info!("win: {w}x{h}"); - - info!("view called"); div![ match w { w if w < 800. => div![C!["mobile"], mobile::view(model)],