web: add per-message unread control and display

This commit is contained in:
Bill Thiede 2024-02-11 20:29:49 -08:00
parent ce836cd1e8
commit 516eedb086
7 changed files with 462 additions and 221 deletions

View File

@ -0,0 +1,3 @@
mutation MarkReadMutation($query: String!, $unread: Boolean!) {
setReadStatus(query:$query, unread:$unread)
}

View File

@ -53,7 +53,9 @@
"name": "skip" "name": "skip"
} }
], ],
"mutationType": null, "mutationType": {
"name": "Mutation"
},
"queryType": { "queryType": {
"name": "QueryRoot" "name": "QueryRoot"
}, },
@ -536,6 +538,62 @@
"name": "Message", "name": "Message",
"possibleTypes": null "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", "description": "Information about pagination in a connection",
"enumValues": null, "enumValues": null,
@ -903,6 +961,22 @@
"description": null, "description": null,
"enumValues": null, "enumValues": null,
"fields": [ "fields": [
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "threadId",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
{ {
"args": [], "args": [],
"deprecationReason": null, "deprecationReason": null,

View File

@ -1,5 +1,6 @@
query ShowThreadQuery($threadId: String!) { query ShowThreadQuery($threadId: String!) {
thread(threadId: $threadId) { thread(threadId: $threadId) {
threadId,
subject subject
messages { messages {
id id

View File

@ -2,142 +2,175 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<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"
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" /> integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
<style> crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" />
<style>
.message {
display: inline-block;
padding: 0.5em;
width: 100%;
}
.message { .message .headers {
display: inline-block; position: relative;
padding: 0.5em; width: 100%;
width: 100%; }
}
.message .headers {
width: 100%;
}
.message .headers .header {
overflow: clip;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
background: white;
color: black;
margin-left: -0.5em;
margin-right: -0.5em;
margin-top: 0.5em;
width:0;
min-width:100%;
}
.error {
background-color: red;
}
.view-part-text-plain {
padding: 0.5em;
overflow-wrap: break-word;
white-space: pre-line;
}
iframe {
height: 100%;
width: 100%;
}
.index { .message .headers .read-status {
table-layout: fixed; position: absolute;
width: 100%; right: 1em;
} top: 0em;
.index .from { }
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 10em;
}
.index .subject {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.index .date {
width: 10em;
white-space: nowrap;
text-align: right;
}
.lb-footer {
background-color: #eee;
color: #222;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 3em;
padding: 1em;
}
.tag {
margin-right: 2px;
}
.debug ul {
padding-left: 2em;
}
.debug li {
}
.loading {
animation-name: spin;
animation-duration: 1000ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes spin {
from {
transform:rotate(0deg);
}
to {
transform:rotate(360deg);
}
}
@media (max-width: 768px) {
.section {
padding: 1.5em;
}
}
input, .input {
color: #000;
}
input::placeholder, .input::placeholder{
color: #555;
}
.mobile .search-results, .message .headers .header {
.mobile .thread { overflow: clip;
padding: 1em; text-overflow: ellipsis;
} white-space: nowrap;
}
.search-results .row { .body {
border-bottom: 1px #444 solid; background: white;
padding-bottom: .5em; color: black;
padding-top: .5em; margin-left: -0.5em;
} margin-right: -0.5em;
.search-results .row .subject { margin-top: 0.5em;
overflow: hidden; width: 0;
text-overflow: ellipsis; min-width: 100%;
white-space: nowrap; }
}
.search-results .row .from { .error {
overflow: hidden; background-color: red;
text-overflow: ellipsis; }
white-space: nowrap;
} .view-part-text-plain {
.search-results .row .tag { padding: 0.5em;
height: 1.5em; overflow-wrap: break-word;
padding-left: .5em; white-space: pre-line;
padding-right: .5em; }
}
.float-right { iframe {
float: right; height: 100%;
} width: 100%;
/* Hide quoted emails */ }
/*
.index {
table-layout: fixed;
width: 100%;
}
.index .from {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 10em;
}
.index .subject {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.index .date {
width: 10em;
white-space: nowrap;
text-align: right;
}
.lb-footer {
background-color: #eee;
color: #222;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 3em;
padding: 1em;
}
.tag {
margin-right: 2px;
}
.debug ul {
padding-left: 2em;
}
.debug li {}
.loading {
animation-name: spin;
animation-duration: 1000ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.section {
padding: 1.5em;
}
}
input,
.input {
color: #000;
}
input::placeholder,
.input::placeholder {
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"], div[name="quote"],
blockquote[type="cite"], blockquote[type="cite"],
.gmail_quote { .gmail_quote {
@ -146,39 +179,46 @@ blockquote[type="cite"],
} }
*/ */
.desktop .main-content { .desktop .main-content {
display: grid; display: grid;
grid-template-columns: 12rem 1fr; grid-template-columns: 12rem 1fr;
} }
.tags-menu {
padding: 1rem; .tags-menu {
} padding: 1rem;
.tags-menu .menu-list a { }
padding: 0.25em 0.5em;
} .tags-menu .menu-list a {
.tags-menu .tag-indent { padding: 0.25em 0.5em;
padding-left: .5em; }
}
.tags-menu .tag-tag { .tags-menu .tag-indent {
margin-left: -1em; padding-left: .5em;
padding-right: .25em; }
}
.navbar { .tags-menu .tag-tag {
border: none; margin-left: -1em;
} padding-right: .25em;
.desktop nav.pagination, }
.tablet nav.pagination {
margin-left: .5em; .navbar {
margin-bottom: 0 !important; border: none;
} }
.content-tree {
white-space: pre-line; .desktop nav.pagination,
} .tablet nav.pagination {
</style> margin-left: .5em;
margin-bottom: 0 !important;
}
.content-tree {
white-space: pre-line;
}
</style>
</head> </head>
<body> <body>
<section id="app"></section> <section id="app"></section>
</body> </body>
</html> </html>

View File

@ -20,6 +20,14 @@ pub struct FrontPageQuery;
)] )]
pub struct ShowThreadQuery; 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, Resp>(body: Body) -> Result<graphql_client::Response<Resp>, Error> pub async fn send_graphql<Body, Resp>(body: Body) -> Result<graphql_client::Response<Resp>, Error>
where where
Body: Serialize, Body: Serialize,

View File

@ -217,6 +217,25 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
orders.request_url(urls::search(&query, 0)); orders.request_url(urls::search(&query, 0));
} }
Msg::SetUnread(query, unread) => {
orders.skip().perform_cmd(async move {
let res: Result<
graphql_client::Response<graphql::mark_read_mutation::ResponseData>,
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 { Msg::FrontPageRequest {
query, query,
after, after,
@ -380,6 +399,8 @@ pub enum Msg {
UpdateQuery(String), UpdateQuery(String),
SearchQuery(String), SearchQuery(String),
SetUnread(String, bool),
FrontPageRequest { FrontPageRequest {
query: String, query: String,
after: Option<String>, after: Option<String>,

View File

@ -7,7 +7,10 @@ use chrono::{DateTime, Datelike, Duration, Local, Utc};
use itertools::Itertools; use itertools::Itertools;
use log::{error, info}; use log::{error, info};
use seed::{prelude::*, *}; 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 wasm_timer::Instant;
use crate::{ use crate::{
@ -262,6 +265,128 @@ fn raw_text_message(contents: &str) -> Node<Msg> {
div![C!["view-part-text-plain"], contents, truncated_msg,] 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<bool>) -> Node<Msg> {
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<bool>,
) -> Node<Msg> {
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<Msg> { fn thread(thread: &ShowThreadQueryThread) -> Node<Msg> {
// TODO(wathiede): show per-message subject if it changes significantly from top-level subject // TODO(wathiede): show per-message subject if it changes significantly from top-level subject
set_title(&thread.subject); set_title(&thread.subject);
@ -276,65 +401,37 @@ fn thread(thread: &ShowThreadQueryThread) -> Node<Msg> {
.collect(); .collect();
tags.sort(); tags.sort();
let messages = thread.messages.iter().map(|msg| { let messages = thread.messages.iter().map(|msg| {
div![ let is_unread = has_unread(&msg.tags);
C!["message"], let open = use_state(|| is_unread);
div![ if open.get() {
C!["headers"], unread_message_render(&msg, open)
/* TODO(wathiede): collect all the tags and show them here. */ } else {
msg.from read_message_render(&msg, open)
.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 any_unread = thread.messages.iter().any(|msg| has_unread(&msg.tags));
let thread_id = thread.thread_id.clone();
div![ div![
C!["thread"], C!["thread"],
p![ p![
C!["is-size-4"], 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, &thread.subject,
" ", " ",
tags_chiclet(&tags, false) tags_chiclet(&tags, false)
@ -475,9 +572,6 @@ pub fn view(model: &Model) -> Node<Msg> {
.expect("window height") .expect("window height")
.as_f64() .as_f64()
.expect("window height f64"); .expect("window height f64");
info!("win: {w}x{h}");
info!("view called");
div![ div![
match w { match w {
w if w < 800. => div![C!["mobile"], mobile::view(model)], w if w < 800. => div![C!["mobile"], mobile::view(model)],