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)],