Compare commits

..

4 Commits

11 changed files with 613 additions and 291 deletions

View File

@ -3,7 +3,6 @@ use std::{
collections::HashMap, collections::HashMap,
fs::File, fs::File,
hash::{DefaultHasher, Hash, Hasher}, hash::{DefaultHasher, Hash, Hasher},
str::FromStr,
}; };
use async_graphql::{ use async_graphql::{
@ -427,6 +426,28 @@ impl Mutation {
} }
Ok(true) Ok(true)
} }
async fn tag_add<'ctx>(
&self,
ctx: &Context<'ctx>,
query: String,
tag: String,
) -> Result<bool, Error> {
let nm = ctx.data_unchecked::<Notmuch>();
info!("tag_add({tag}, {query})");
nm.tag_add(&tag, &query)?;
Ok(true)
}
async fn tag_remove<'ctx>(
&self,
ctx: &Context<'ctx>,
query: String,
tag: String,
) -> Result<bool, Error> {
let nm = ctx.data_unchecked::<Notmuch>();
info!("tag_remove({tag}, {query})");
nm.tag_remove(&tag, &query)?;
Ok(true)
}
} }
pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>; pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>;

View File

@ -83,6 +83,8 @@ pub fn sanitize_html(html: &str) -> Result<String, SanitizeError> {
} }
}; };
// Default's don't allow style, but we want to preserve that. // Default's don't allow style, but we want to preserve that.
// TODO: remove 'class' if rendering mails moves to a two phase process where abstract message
// types are collected, santized, and then grouped together as one big HTML doc
let attributes = hashset![ let attributes = hashset![
"align", "bgcolor", "class", "color", "height", "lang", "title", "width", "style", "align", "bgcolor", "class", "color", "height", "lang", "title", "width", "style",
]; ];

View File

@ -0,0 +1,3 @@
mutation AddTagMutation($query: String!, $tag: String!) {
tagAdd(query:$query, tag:$tag)
}

View File

@ -0,0 +1,3 @@
mutation RemoveTagMutation($query: String!, $tag: String!) {
tagRemove(query:$query, tag:$tag)
}

View File

@ -65,6 +65,38 @@
"description": null, "description": null,
"enumValues": null, "enumValues": null,
"fields": [ "fields": [
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "id",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "idx",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
{ {
"args": [], "args": [],
"deprecationReason": null, "deprecationReason": null,
@ -84,9 +116,13 @@
"isDeprecated": false, "isDeprecated": false,
"name": "size", "name": "size",
"type": { "type": {
"kind": "SCALAR", "kind": "NON_NULL",
"name": "Int", "name": null,
"ofType": null "ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
} }
}, },
{ {
@ -657,6 +693,96 @@
"ofType": null "ofType": null
} }
} }
},
{
"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": "tag",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "tagAdd",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
}
},
{
"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": "tag",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "tagRemove",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
}
} }
], ],
"inputFields": null, "inputFields": null,

View File

@ -35,9 +35,12 @@ query ShowThreadQuery($threadId: String!) {
} }
path path
attachments { attachments {
id
idx
filename filename
contentType contentType
contentId contentId
size
} }
} }
} }

View File

@ -4,296 +4,24 @@
<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://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
-->
<!--
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.0/css/bulma.min.css">
-->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.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==" integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
crossorigin="anonymous" referrerpolicy="no-referrer" /> crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" /> <link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" />
<link data-trunk rel="css" href="static/style.css" />
<!-- Pretty checkboxes from https://justboil.github.io/bulma-checkbox/ --> <!-- Pretty checkboxes from https://justboil.github.io/bulma-checkbox/ -->
<link data-trunk rel="css" href="static/main.css" /> <link data-trunk rel="css" href="static/main.css" />
<!-- tall thin font for user icon --> <!-- tall thin font for user icon -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet">
<style>
<style>.message {
display: inline-block;
padding: 0.5em;
width: 100%;
}
.message .header table td {
border: 0;
padding: 0;
}
.message .header .media-right {
padding: 1rem;
}
.message .headers {
position: relative;
width: 100%;
}
.message .headers .read-status {
position: absolute;
right: 1em;
top: 0em;
}
.message .headers .header {
overflow: clip;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
background: white;
color: black;
margin-top: 0.5em;
padding: 1em;
width: 0;
min-width: 100%;
overflow: auto;
overflow-wrap: break-word;
}
.message .body .attachments hr {
border: none;
border-top: 1px dashed #888;
background-color: #f000;
margin: 0.5rem 0;
}
.error {
background-color: red;
}
.view-part-text-plain {
padding: 0.5em;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
word-wrap: break-word;
}
iframe {
height: 100%;
width: 100%;
}
.index {
table-layout: fixed;
width: 100%;
}
.index .edit {
width: 2em;
}
.index .unread {
font-weight: bold;
}
.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: 7em;
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 {
padding: 1em;
}
.mobile .thread h3 {
overflow-wrap: break-word;
padding: 1em 1em 0;
}
.mobile .thread .tags {
padding: 0 1em;
}
.search-results .row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
border-bottom: 1px #444 solid;
padding-bottom: .5em;
padding-top: .5em;
width: 100%;
}
.search-results .row .checkbox {}
.search-results .row .summary {
min-width: 0;
width: 100%;
}
.search-results .row .subject {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-results td.subject {
display: flex;
}
.search-results .subject .tag {}
.search-results .subject .text {
padding-left: 0.5rem;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-results .row .from {
display: inline-block;
width: 100%;
}
.search-results .from a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-results .from a {
display: inline-block;
width: 100%;
}
.search-results .row .tag {
height: 1.5em;
padding-left: .5em;
padding-right: .5em;
}
.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;
}
.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-wrap;
word-break: break-word;
word-wrap: break-word;
}
</style>
</head> </head>
<body> <body>

View File

@ -28,6 +28,22 @@ pub struct ShowThreadQuery;
)] )]
pub struct MarkReadMutation; pub struct MarkReadMutation;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/add_tag.graphql",
response_derives = "Debug"
)]
pub struct AddTagMutation;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/remove_tag.graphql",
response_derives = "Debug"
)]
pub struct RemoveTagMutation;
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

@ -184,6 +184,52 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::Noop Msg::Noop
}); });
} }
Msg::AddTag(query, tag) => {
let search_url = urls::search(&model.query, 0).to_string();
orders.skip().perform_cmd(async move {
let res: Result<
graphql_client::Response<graphql::add_tag_mutation::ResponseData>,
gloo_net::Error,
> = send_graphql(graphql::AddTagMutation::build_query(
graphql::add_tag_mutation::Variables {
query: query.clone(),
tag: tag.clone(),
},
))
.await;
if let Err(e) = res {
error!("Failed to add tag {tag} to {query}: {e}");
}
seed::window()
.location()
.set_href(&search_url)
.expect("failed to change location");
Msg::Noop
});
}
Msg::RemoveTag(query, tag) => {
let search_url = urls::search(&model.query, 0).to_string();
orders.skip().perform_cmd(async move {
let res: Result<
graphql_client::Response<graphql::remove_tag_mutation::ResponseData>,
gloo_net::Error,
> = send_graphql(graphql::RemoveTagMutation::build_query(
graphql::remove_tag_mutation::Variables {
query: query.clone(),
tag: tag.clone(),
},
))
.await;
if let Err(e) = res {
error!("Failed to remove tag {tag} to {query}: {e}");
}
seed::window()
.location()
.set_href(&search_url)
.expect("failed to change location");
Msg::Noop
});
}
Msg::FrontPageRequest { Msg::FrontPageRequest {
query, query,
@ -311,6 +357,36 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
*selected_threads = results.iter().map(|node| node.thread.clone()).collect(); *selected_threads = results.iter().map(|node| node.thread.clone()).collect();
} }
} }
Msg::SelectionAddTag(tag) => {
if let Context::SearchResult {
selected_threads, ..
} = &mut model.context
{
let threads = selected_threads
.iter()
.map(|tid| format!("thread:{tid}"))
.collect::<Vec<_>>()
.join(" ");
orders
.skip()
.perform_cmd(async move { Msg::AddTag(threads, tag) });
}
}
Msg::SelectionRemoveTag(tag) => {
if let Context::SearchResult {
selected_threads, ..
} = &mut model.context
{
let threads = selected_threads
.iter()
.map(|tid| format!("thread:{tid}"))
.collect::<Vec<_>>()
.join(" ");
orders
.skip()
.perform_cmd(async move { Msg::RemoveTag(threads, tag) });
}
}
Msg::SelectionMarkAsRead => { Msg::SelectionMarkAsRead => {
if let Context::SearchResult { if let Context::SearchResult {
selected_threads, .. selected_threads, ..
@ -435,6 +511,8 @@ pub enum Msg {
SearchQuery(String), SearchQuery(String),
SetUnread(String, bool), SetUnread(String, bool),
AddTag(String, String),
RemoveTag(String, String),
FrontPageRequest { FrontPageRequest {
query: String, query: String,
@ -455,6 +533,8 @@ pub enum Msg {
SelectionSetNone, SelectionSetNone,
SelectionSetAll, SelectionSetAll,
SelectionAddTag(String),
SelectionRemoveTag(String),
SelectionMarkAsRead, SelectionMarkAsRead,
SelectionMarkAsUnread, SelectionMarkAsUnread,
SelectionAddThread(String), SelectionAddThread(String),

View File

@ -261,6 +261,13 @@ fn search_toolbar(
span![ span![
// TODO(wathiede): add "Mark as spam" // TODO(wathiede): add "Mark as spam"
C!["level-item", "buttons", "has-addons"], C!["level-item", "buttons", "has-addons"],
button![
C!["button"],
attrs!{At::Title => "Mark as spam"},
span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]],
span!["Spam"],
ev(Ev::Click, |_| Msg::SelectionAddTag("Spam".to_string()))
],
button![ button![
C!["button"], C!["button"],
attrs!{At::Title => "Mark as read"}, attrs!{At::Title => "Mark as read"},
@ -642,14 +649,53 @@ fn message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<Msg>
C!["view-part-text-html"], C!["view-part-text-html"],
raw![contents], raw![contents],
IF!(!msg.attachments.is_empty() => IF!(!msg.attachments.is_empty() =>
div![ div![
C!["attachments"], C!["attachments"],
hr![], hr![],
h2!["Attachments"], h2!["Attachments"],
div![C!["grid","is-col-min-6"],
msg.attachments msg.attachments
.iter() .iter()
.map(|a| div!["Filename: ", &a.filename, " ", &a.content_type]) .map(|a| {
]), let default = "UNKNOWN_FILE".to_string();
let filename = a.filename.as_ref().unwrap_or(&default);
let host = seed::window().location().host().expect("couldn't get host");
let url = format!("//{host}/download/attachment/{}/{}/{}", a.id,a.idx, filename);
div![
C!["attachment", "card"],
a.content_type.as_ref().map(|content_type|
IF!(content_type.starts_with("image/") =>
div![C!["card-image","is-1by1"],
div![
C!["image","is-1by1"],
style!{
St::BackgroundImage=>format!(r#"url("{url}");"#),
St::BackgroundSize=>"cover",
St::BackgroundPosition=>"center",
}
]
]
)),
div![C!["card-content"],
div![C!["content"],
&a.filename, br![],
small![&a.size, " bytes"]
]
],
footer![
C!["card-footer"],
a![C!["card-footer-item"],span![C!["icon"], i![C!["fas", "fa-download"]]],
ev(Ev::Click, move |_| {
seed::window().location().set_href(&url
).expect("failed to set URL");
})
]
]
]
})
]
]),
view_content_tree(&content_tree), view_content_tree(&content_tree),
], ],
} }
@ -682,13 +728,23 @@ fn thread(thread: &ShowThreadQueryThread, open_messages: &HashSet<String>) -> No
}); });
let read_thread_id = thread.thread_id.clone(); let read_thread_id = thread.thread_id.clone();
let unread_thread_id = thread.thread_id.clone(); let unread_thread_id = thread.thread_id.clone();
let spam_thread_id = thread.thread_id.clone();
div![ div![
C!["thread"], C!["thread"],
h3![C!["is-size-5"], subject], h3![C!["is-size-5"], subject],
span![C!["tags"], tags_chiclet(&tags, false)], span![C!["tags"], tags_chiclet(&tags, false)],
span![ span![
// TODO(wathiede): add "Mark as spam"
C!["level-item", "buttons", "has-addons"], C!["level-item", "buttons", "has-addons"],
button![
C!["button"],
attrs! {At::Title => "Spam"},
span![C!["icon", "is-small"], i![C!["far", "fa-hand"]]],
span!["Spam"],
ev(Ev::Click, move |_| Msg::AddTag(
format!("thread:{spam_thread_id}"),
"Spam".to_string()
)),
],
button![ button![
C!["button"], C!["button"],
attrs! {At::Title => "Mark as read"}, attrs! {At::Title => "Mark as read"},

284
web/static/style.css Normal file
View File

@ -0,0 +1,284 @@
.message {
display: inline-block;
padding: 0.5em;
width: 100%;
}
.message .header table td {
border: 0;
padding: 0;
}
.message .header .media-right {
padding: 1rem;
}
.message .headers {
position: relative;
width: 100%;
}
.message .headers .read-status {
position: absolute;
right: 1em;
top: 0em;
}
.message .headers .header {
overflow: clip;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
background: white;
color: black;
margin-top: 0.5em;
padding: 1em;
width: 0;
min-width: 100%;
overflow: auto;
overflow-wrap: break-word;
}
.message .body .attachments hr {
border: none;
border-top: 1px dashed #888;
background-color: #f000;
margin: 0.5rem 0;
}
.error {
background-color: red;
}
.view-part-text-plain {
padding: 0.5em;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
word-wrap: break-word;
}
iframe {
height: 100%;
width: 100%;
}
.index {
table-layout: fixed;
width: 100%;
}
.index .edit {
width: 2em;
}
.index .unread {
font-weight: bold;
}
.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: 7em;
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 {
padding: 1em;
}
.mobile .thread h3 {
overflow-wrap: break-word;
padding: 1em 1em 0;
}
.mobile .thread .tags {
padding: 0 1em;
}
.search-results .row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
border-bottom: 1px #444 solid;
padding-bottom: .5em;
padding-top: .5em;
width: 100%;
}
.search-results .row .checkbox {}
.search-results .row .summary {
min-width: 0;
width: 100%;
}
.search-results .row .subject {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-results td.subject {
}
.search-results .subject .tag {}
.search-results .subject .text {
padding-left: 0.5rem;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-results .row .from {
display: inline-block;
width: 100%;
}
.search-results .from a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-results .from a {
display: inline-block;
width: 100%;
}
.search-results .row .tag {
height: 1.5em;
padding-left: .5em;
padding-right: .5em;
}
.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;
}
.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-wrap;
word-break: break-word;
word-wrap: break-word;
}
.attachment {
margin: .25em;
display: inline-block;
}
.attachment .card-content {
padding: 0.5rem 1.5rem;
}