diff --git a/server/migrations/20250630023836_snooze.down.sql b/server/migrations/20250630023836_snooze.down.sql new file mode 100644 index 0000000..0b2991b --- /dev/null +++ b/server/migrations/20250630023836_snooze.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE IF EXISTS snooze; diff --git a/server/migrations/20250630023836_snooze.up.sql b/server/migrations/20250630023836_snooze.up.sql new file mode 100644 index 0000000..07837e7 --- /dev/null +++ b/server/migrations/20250630023836_snooze.up.sql @@ -0,0 +1,6 @@ +-- Add up migration script here +CREATE TABLE IF NOT EXISTS snooze ( + id integer NOT NULL GENERATED ALWAYS AS IDENTITY, + message_id text NOT NULL, + wake timestamptz NOT NULL +); diff --git a/server/src/graphql.rs b/server/src/graphql.rs index 83d4444..ac77c90 100644 --- a/server/src/graphql.rs +++ b/server/src/graphql.rs @@ -637,6 +637,17 @@ impl MutationRoot { wake_time: DateTime, ) -> Result { info!("TODO snooze {query} until {wake_time})"); + let pool = ctx.data_unchecked::(); + sqlx::query!( + r#" + INSERT INTO snooze (message_id, wake) + VALUES ($1, $2) + "#, + query, + wake_time + ) + .execute(pool) + .await?; Ok(true) } /// Drop and recreate tantivy index. Warning this is slow diff --git a/server/src/lib.rs b/server/src/lib.rs index 9a97585..a2fa2b3 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -19,6 +19,7 @@ use std::{ use async_trait::async_trait; use cacher::{Cacher, FilesystemCacher}; +use chrono::NaiveDateTime; use css_inline::{CSSInliner, InlineError, InlineOptions}; pub use error::ServerError; use linkify::{LinkFinder, LinkKind}; @@ -30,7 +31,6 @@ use maplit::{hashmap, hashset}; use regex::Regex; use reqwest::StatusCode; use scraper::{Html, Selector}; -use sqlx::types::time::PrimitiveDateTime; use thiserror::Error; use tracing::{debug, error, info, warn}; use url::Url; @@ -896,7 +896,7 @@ impl FromStr for Query { } pub struct ThreadSummaryRecord { pub site: Option, - pub date: Option, + pub date: Option, pub is_read: Option, pub title: Option, pub uid: String, @@ -914,11 +914,7 @@ async fn thread_summary_from_row(r: ThreadSummaryRecord) -> ThreadSummary { title = clean_title(&title).await.expect("failed to clean title"); ThreadSummary { thread: format!("{NEWSREADER_THREAD_PREFIX}{}", r.uid), - timestamp: r - .date - .expect("post missing date") - .assume_utc() - .unix_timestamp() as isize, + timestamp: r.date.expect("post missing date").and_utc().timestamp() as isize, date_relative: format!("{:?}", r.date), //date_relative: "TODO date_relative".to_string(), matched: 0, diff --git a/server/src/newsreader.rs b/server/src/newsreader.rs index c5a7290..b56c96e 100644 --- a/server/src/newsreader.rs +++ b/server/src/newsreader.rs @@ -211,11 +211,7 @@ pub async fn thread( } let title = clean_title(&r.title.unwrap_or("NO TITLE".to_string())).await?; let is_read = r.is_read.unwrap_or(false); - let timestamp = r - .date - .expect("post missing date") - .assume_utc() - .unix_timestamp(); + let timestamp = r.date.expect("post missing date").and_utc().timestamp(); Ok(Thread::News(NewsPost { thread_id, is_read, diff --git a/web/graphql/schema.json b/web/graphql/schema.json index 3167f32..69b9e79 100644 --- a/web/graphql/schema.json +++ b/web/graphql/schema.json @@ -51,7 +51,7 @@ }, { "args": [], - "description": "Indicates that an Input Object is a OneOf Input Object (and thus requires\n exactly one of its field be provided)", + "description": "Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its field be provided)", "locations": [ "INPUT_OBJECT" ], @@ -107,12 +107,14 @@ } ], "mutationType": { - "name": "Mutation" + "name": "MutationRoot" }, "queryType": { "name": "QueryRoot" }, - "subscriptionType": null, + "subscriptionType": { + "name": "SubscriptionRoot" + }, "types": [ { "description": null, @@ -314,6 +316,16 @@ "name": "Corpus", "possibleTypes": null }, + { + "description": "Implement the DateTime scalar\n\nThe input/output is a string in RFC3339 format.", + "enumValues": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "SCALAR", + "name": "DateTime", + "possibleTypes": null + }, { "description": null, "enumValues": [ @@ -969,6 +981,51 @@ } } }, + { + "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": "wakeTime", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "snooze", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, @@ -989,7 +1046,7 @@ "inputFields": null, "interfaces": [], "kind": "OBJECT", - "name": "Mutation", + "name": "MutationRoot", "possibleTypes": null }, { @@ -1474,6 +1531,33 @@ "name": "String", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "values", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "SubscriptionRoot", + "possibleTypes": null + }, { "description": null, "enumValues": null, diff --git a/web/graphql/snooze.graphql b/web/graphql/snooze.graphql new file mode 100644 index 0000000..52e73fa --- /dev/null +++ b/web/graphql/snooze.graphql @@ -0,0 +1,4 @@ + +mutation SnoozeMutation($query: String!, $wakeTime: DateTime!) { + snooze(query: $query, wakeTime: $wakeTime) +} diff --git a/web/graphql/update_schema.sh b/web/graphql/update_schema.sh index 4a4e51b..5ebd759 100755 --- a/web/graphql/update_schema.sh +++ b/web/graphql/update_schema.sh @@ -1,4 +1,4 @@ DEV_HOST=localhost DEV_PORT=9345 -graphql-client introspect-schema http://${DEV_HOST:?}:${DEV_PORT:?}/api/graphql --output schema.json +graphql-client introspect-schema http://${DEV_HOST:?}:${DEV_PORT:?}/api/graphql/ --output schema.json git diff schema.json diff --git a/web/src/graphql.rs b/web/src/graphql.rs index 3f6d06b..b8e12a6 100644 --- a/web/src/graphql.rs +++ b/web/src/graphql.rs @@ -1,7 +1,9 @@ +use chrono::Utc; use gloo_net::{http::Request, Error}; use graphql_client::GraphQLQuery; use serde::{de::DeserializeOwned, Serialize}; +type DateTime = chrono::DateTime; // 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 #[derive(GraphQLQuery)] @@ -52,6 +54,14 @@ pub struct AddTagMutation; )] pub struct RemoveTagMutation; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "graphql/schema.json", + query_path = "graphql/snooze.graphql", + response_derives = "Debug" +)] +pub struct SnoozeMutation; + #[derive(GraphQLQuery)] #[graphql( schema_path = "graphql/schema.json", diff --git a/web/src/state.rs b/web/src/state.rs index bd9e162..202e432 100644 --- a/web/src/state.rs +++ b/web/src/state.rs @@ -260,8 +260,28 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { Msg::GoToSearchResults }); } - Msg::Snooze(message_id, snooze_time) => { - info!("TODO: Snoozing {message_id} until {snooze_time}"); + Msg::Snooze(query, wake_time) => { + let is_catchup = model.catchup.is_some(); + orders.skip().perform_cmd(async move { + let res: Result< + graphql_client::Response, + gloo_net::Error, + > = send_graphql(graphql::SnoozeMutation::build_query( + graphql::snooze_mutation::Variables { + query: query.clone(), + wake_time, + }, + )) + .await; + if let Err(e) = res { + error!("Failed to snooze {query} until {wake_time}: {e}"); + } + if is_catchup { + Msg::CatchupMarkAsRead + } else { + Msg::GoToSearchResults + } + }); } Msg::FrontPageRequest { diff --git a/web/src/view/mod.rs b/web/src/view/mod.rs index f268807..6525b27 100644 --- a/web/src/view/mod.rs +++ b/web/src/view/mod.rs @@ -727,15 +727,19 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node< C!["flex", "p-4", "bg-neutral-800"], div![avatar], div![ - C!["px-4", "mr-auto"], - span![ - C!["font-semibold", "text-sm"], - from_detail.as_ref().map(|addr| attrs! { - At::Title => addr - }), - &from, - " ", - from_detail.as_ref().map(|text| copy_text_widget(&text)) + C!["px-4", "flex-1"], + div![ + C!["flex"], + div![ + C!["font-semibold", "text-sm", "flex-1"], + from_detail.as_ref().map(|addr| attrs! { + At::Title => addr + }), + &from, + " ", + from_detail.as_ref().map(|text| copy_text_widget(&text)) + ], + snooze_buttons(&id), ], IF!(!msg.to.is_empty() =>div![ C!["text-xs"], @@ -799,43 +803,6 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node< }) ] ]), - div![ - C!["text-xs"], - span!["Snooze:"], - " ", - a![ - "1d", - ev(Ev::Click, { - let id = id.clone(); - move |e| { - e.stop_propagation(); - Msg::Snooze(id, Utc::now() + chrono::Days::new(1)) - } - }) - ], - " ", - a![ - "7d", - ev(Ev::Click, { - let id = id.clone(); - move |e| { - e.stop_propagation(); - Msg::Snooze(id, Utc::now() + chrono::Days::new(7)) - } - }) - ], - " ", - a![ - "6m", - ev(Ev::Click, { - let id = id.clone(); - move |e| { - e.stop_propagation(); - Msg::Snooze(id, Utc::now() + chrono::Days::new(180)) - } - }) - ], - ] ], span![ C!["text-right"], @@ -1203,6 +1170,7 @@ fn thread( let open = open_messages.contains(&msg.id); message_render(&msg, open) }); + let id = &thread.thread_id; let read_thread_id = thread.thread_id.clone(); let unread_thread_id = thread.thread_id.clone(); let spam_add_thread_id = thread.thread_id.clone(); @@ -1631,9 +1599,13 @@ fn render_news_post_header(post: &ShowThreadQueryThreadOnNewsPost) -> Node C!["flex", "p-4", "bg-neutral-800"], div![favicon], div![ - C!["px-4", "mr-auto"], + C!["px-4", "mr-auto", "flex-1"], div![ - div![C!["font-semibold", "text-sm"], from], + div![ + C!["flex"], + div![C!["font-semibold", "text-sm", "flex-1"], from], + snooze_buttons(&id), + ], div![ C!["flex", "gap-2", "pt-2", "text-sm"], a![ @@ -1728,3 +1700,45 @@ fn click_to_top() -> Node { ev(Ev::Click, |_| Msg::ScrollToTop) ] } + +fn snooze_buttons(id: &str) -> Node { + div![ + span![C!["px-2"], "⏰"], + button![ + tw_classes::button(), + C!["rounded-r-none"], + "1d", + ev(Ev::Click, { + let id = id.to_string(); + move |e| { + e.stop_propagation(); + Msg::Snooze(id, Utc::now() + chrono::Days::new(1)) + } + }) + ], + button![ + tw_classes::button(), + C!["rounded-none"], + "7d", + ev(Ev::Click, { + let id = id.to_string(); + move |e| { + e.stop_propagation(); + Msg::Snooze(id, Utc::now() + chrono::Days::new(7)) + } + }) + ], + button![ + tw_classes::button(), + C!["rounded-l-none"], + "6m", + ev(Ev::Click, { + let id = id.to_string(); + move |e| { + e.stop_propagation(); + Msg::Snooze(id, Utc::now() + chrono::Days::new(180)) + } + }) + ], + ] +}