diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 84e7cdf8d..51b59f94c 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -104,6 +104,12 @@ jest.mock('@tauri-apps/plugin-shell', () => ({ open: jest.fn().mockResolvedValue(undefined), })); +jest.mock('@tauri-apps/plugin-notification', () => ({ + isPermissionGranted: jest.fn().mockResolvedValue(true), + requestPermission: jest.fn().mockResolvedValue('granted'), + sendNotification: jest.fn(), +})); + jest.mock('@tauri-apps/plugin-store', () => ({ Store: jest.fn().mockImplementation(() => ({ get: jest.fn().mockResolvedValue(null), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4dad1553a..94f46e293 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "@tauri-apps/api": ">=2.0.0-beta.0", "@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-fs": "^2.2.0", + "@tauri-apps/plugin-notification": "^2.3.1", "@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-process": "^2.3.0", "@tauri-apps/plugin-shell": "^2.2.0", @@ -5475,6 +5476,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index c79c5db49..5969fe901 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "@tauri-apps/api": ">=2.0.0-beta.0", "@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-fs": "^2.2.0", + "@tauri-apps/plugin-notification": "^2.3.1", "@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-process": "^2.3.0", "@tauri-apps/plugin-shell": "^2.2.0", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 635f9a569..09d59ca5a 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -830,7 +830,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1022,7 +1022,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2239,6 +2239,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2411,6 +2425,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "ntapi" version = "0.4.2" @@ -2451,7 +2479,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.114", @@ -2776,7 +2804,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -3015,6 +3043,7 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-shell", @@ -3263,7 +3292,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -3633,7 +3662,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4542,6 +4571,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.2" @@ -4744,6 +4792,17 @@ dependencies = [ "toml 0.9.10+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.17", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -4754,7 +4813,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5619,7 +5678,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index e25427b89..647a561ba 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -12,7 +12,7 @@ tauri-build = { version = "2.0.6", features = [] } [dependencies] tauri = { version = "2.9.1", features = ["protocol-asset", "devtools", "tray-icon"] } -reqwest = { version = "0.12", features = ["blocking"] } +reqwest = { version = "0.12", features = ["blocking", "json"] } walkdir = "2.3" sysinfo = "0.37.2" serde = { version = "1.0", features = ["derive"] } @@ -21,7 +21,7 @@ anyhow = "1.0" image = "0.24.6" ring = "0.16.20" data-encoding = "2.3.2" -tokio = { version = "1", features = ["macros"] } +tokio = { version = "1", features = ["macros", "time"] } tempfile = "3" arrayref = "0.3.6" directories = "4.0" @@ -37,6 +37,7 @@ tauri-plugin-process = "2.3.1" tauri-plugin-store = "2.4.1" tauri-plugin-updater = "2.9.0" tauri-plugin-opener = "2.5.2" +tauri-plugin-notification = "2.3.3" tauri-plugin-autostart = "2" [features] diff --git a/frontend/src-tauri/capabilities/migrated.json b/frontend/src-tauri/capabilities/migrated.json index b47fbeeb4..957739db0 100644 --- a/frontend/src-tauri/capabilities/migrated.json +++ b/frontend/src-tauri/capabilities/migrated.json @@ -92,6 +92,7 @@ "fs:default", "dialog:default", "store:default", - "opener:allow-reveal-item-in-dir" + "opener:allow-reveal-item-in-dir", + "notification:default" ] } diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 3bad56856..e1227bbde 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -1,6 +1,7 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod memories; mod services; use sysinfo::System; @@ -52,6 +53,13 @@ fn is_process_alive() -> bool { } fn on_window_event(window: &Window, event: &WindowEvent) { + // Covers the app being left open across midnight, and coming back from the + // tray. Throttled inside. + if matches!(event, WindowEvent::Focused(true)) && window.label() == "main" { + memories::check_on_resume(window.app_handle().clone()); + return; + } + if let WindowEvent::CloseRequested { api, .. } = event { // Secondary windows (e.g. model-manager) close normally. if window.label() != "main" { @@ -284,12 +292,16 @@ fn main() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_notification::init()) .setup(|app| { let resource_path = app.path().resolve("resources", BaseDirectory::Resource)?; println!("Resource path: {:?}", resource_path); prod(app.handle(), &resource_path)?; + app.manage(memories::MemoryTaskState::default()); + memories::spawn_memory_task(app.handle().clone()); + // When auto-started at boot (--minimized flag), keep the window hidden if std::env::args().any(|a| a == "--minimized") { if let Some(window) = app.get_webview_window("main") { @@ -347,6 +359,8 @@ fn main() { .invoke_handler(tauri::generate_handler![ services::get_resources_folder_path, open_model_manager, + memories::open_memory, + memories::take_pending_memory, enable_autostart, disable_autostart, is_autostart_enabled, diff --git a/frontend/src-tauri/src/memories.rs b/frontend/src-tauri/src/memories.rs new file mode 100644 index 000000000..68236e72c --- /dev/null +++ b/frontend/src-tauri/src/memories.rs @@ -0,0 +1,389 @@ +//! Daily memory curation, driven from the desktop shell. +//! +//! The backend only curates when something asks it to, so nothing would ever +//! surface on a day with no import. This task asks once per launch (and again +//! on resume), then reports the result through the OS notification centre. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_notification::NotificationExt; +use tokio::time::sleep; + +const BACKEND_URL: &str = "http://localhost:52123"; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// The backend is spawned alongside the window and takes a while to bind. +const HEALTH_POLL: Duration = Duration::from_secs(2); +const HEALTH_TIMEOUT: Duration = Duration::from_secs(180); +/// Curating mid-index would score a half-written library, so wait it out. +const INDEXING_POLL: Duration = Duration::from_secs(20); +const INDEXING_TIMEOUT: Duration = Duration::from_secs(30 * 60); +const RUN_POLL: Duration = Duration::from_secs(5); +const RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// How long the app must have gone unchecked before a focus re-runs the task. +const RESUME_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); + +/// Guards against overlapping runs and throttles the resume check. +#[derive(Default)] +pub struct MemoryTaskState { + running: AtomicBool, + last_check: Mutex>, + /// The last announcement, held until the frontend collects it. An event + /// with nobody listening is simply lost, and the listener mounts after + /// this task can already have emitted. + pending: Mutex>, +} + +impl MemoryTaskState { + fn is_due(&self) -> bool { + match self.last_check.lock() { + Ok(last) => last.is_none_or(|at| at.elapsed() >= RESUME_INTERVAL), + Err(_) => false, + } + } + + fn mark_checked(&self) { + if let Ok(mut last) = self.last_check.lock() { + *last = Some(Instant::now()); + } + } +} + +/// `{success, message, data}` — the envelope every backend route returns. +#[derive(Deserialize)] +struct Envelope { + data: Option, +} + +#[derive(Deserialize)] +struct StatusData { + run_status: Option, + /// Identifies *which* run is being reported; run_date is only ever today. + run_started_at: Option, + indexing_busy: bool, + memories_enabled: bool, + notifications_enabled: bool, +} + +#[derive(Deserialize)] +struct GenerateData { + status: String, + queued: bool, +} + +#[derive(Deserialize)] +struct TodayData { + memory: Option, +} + +#[derive(Deserialize)] +struct MemorySummary { + memory_id: String, + title: String, + subtitle: Option, + image_count: i64, + video_count: i64, + notified_at: Option, +} + +/// Run the curation check now, unless one is already in flight. +pub fn spawn_memory_task(app: AppHandle) { + start(app, false); +} + +/// Run the check on window focus, at most once per `RESUME_INTERVAL`. Covers +/// the app being left open across midnight. +pub fn check_on_resume(app: AppHandle) { + start(app, true); +} + +fn start(app: AppHandle, throttled: bool) { + // try_state, not state: a window event must not panic the app if setup + // never got as far as managing this. + let Some(state) = app.try_state::() else { + return; + }; + if throttled && !state.is_due() { + return; + } + if state.running.swap(true, Ordering::SeqCst) { + return; + } + state.mark_checked(); + + tauri::async_runtime::spawn(async move { + run(&app).await; + if let Some(state) = app.try_state::() { + state.running.store(false, Ordering::SeqCst); + } + }); +} + +async fn run(app: &AppHandle) { + let client = match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() { + Ok(client) => client, + Err(e) => { + eprintln!("[MEMORIES] Could not build an HTTP client: {e}"); + return; + } + }; + + if !wait_for_backend(&client).await { + println!("[MEMORIES] Backend never came up; skipping curation"); + return; + } + + let status = match get_status(&client).await { + Some(status) => status, + None => return, + }; + if !status.memories_enabled { + return; + } + + let status = match wait_while_indexing(&client, status).await { + Some(status) => status, + None => { + println!("[MEMORIES] Library still indexing; skipping curation"); + return; + } + }; + + let previous_run = status.run_started_at.clone(); + let queued = match generate(&client).await { + Some(queued) => queued, + None => return, + }; + + // Nothing was queued when today's run is already complete or already in + // flight. Only our own run can be told apart by its start time; someone + // else's is simply waited out. + let status = if queued.queued { + wait_for_run(&client, previous_run).await + } else if queued.status == "running" { + wait_for_run(&client, None).await + } else { + get_status(&client).await + }; + + let status = match status { + Some(status) => status, + None => return, + }; + + surface(app, &client, &status).await; +} + +/// Announce the memory, once, through the card and the notification centre. +async fn surface(app: &AppHandle, client: &reqwest::Client, status: &StatusData) { + let memory = match get_today(client).await { + Some(memory) => memory, + None => return, + }; + + // notified_at covers the card as well as the notification, since both say + // the same thing. `/today` keeps returning a memory until it is viewed or + // dismissed, so without this guard every launch and every resume announces + // it again. + if memory.notified_at.is_some() { + return; + } + + // Not gated on notifications_enabled: that preference is about OS alerts, + // and it ships off. Announced whether or not the notification lands, + // because desktop notification clicks are not dependable across platforms, + // so the card is the path that always works. + let announcement = json!({ + "memory_id": memory.memory_id, + "title": memory.title, + "subtitle": memory.subtitle, + "image_count": memory.image_count, + "video_count": memory.video_count, + }); + + // Stored before it is emitted, and collected by the listener on mount. + // On a launch where the backend is already up this whole task can finish + // before React has mounted, and the event alone would be lost while + // notified_at below still recorded it as delivered. + if let Some(state) = app.try_state::() { + if let Ok(mut pending) = state.pending.lock() { + *pending = Some(announcement.clone()); + } + } + let _ = app.emit("memory:pending", announcement); + + if status.notifications_enabled { + notify(app, &memory); + } + + // Recorded even when the notification failed: the card still went out. + mark_notified(client, &memory.memory_id).await; +} + +fn notify(app: &AppHandle, memory: &MemorySummary) { + let count = memory.image_count + memory.video_count; + let body = match memory.subtitle.as_deref() { + Some(subtitle) => format!("{} · {} · {} items", memory.title, subtitle, count), + None => format!("{} · {} items", memory.title, count), + }; + + // No permission check: the plugin's desktop implementation returns Granted + // unconditionally, and the prompt that does exist is the webview one the + // settings switch raises. A mobile target would need one here. + if let Err(e) = app + .notification() + .builder() + .title("Your Daily Memory is Ready") + .body(body) + .show() + { + eprintln!("[MEMORIES] Could not show the notification: {e}"); + } +} + +/// Hand over an announcement the frontend was not yet listening for. Called by +/// the listener on mount, which is what makes delivery independent of whether +/// the webview beat the task. +#[tauri::command] +pub fn take_pending_memory(app: AppHandle) -> Option { + let state = app.try_state::()?; + let mut pending = state.pending.lock().ok()?; + pending.take() +} + +/// Focus the window and route the app to a memory. Invoked by the in-app card, +/// and the single entry point any notification-click handler should use. +#[tauri::command] +pub fn open_memory(app: AppHandle, memory_id: String) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + app.emit("memory:open", json!({ "memory_id": memory_id })) + .map_err(|e| e.to_string()) +} + +async fn wait_for_backend(client: &reqwest::Client) -> bool { + let deadline = Instant::now() + HEALTH_TIMEOUT; + loop { + if let Ok(response) = client.get(format!("{BACKEND_URL}/health")).send().await { + if response.status().is_success() { + return true; + } + } + if Instant::now() >= deadline { + return false; + } + sleep(HEALTH_POLL).await; + } +} + +/// Poll until the library is idle. `None` means it never settled. +async fn wait_while_indexing(client: &reqwest::Client, status: StatusData) -> Option { + let mut status = status; + let deadline = Instant::now() + INDEXING_TIMEOUT; + while status.indexing_busy { + if Instant::now() >= deadline { + return None; + } + sleep(INDEXING_POLL).await; + status = get_status(client).await?; + if !status.memories_enabled { + return None; + } + } + Some(status) +} + +/// Poll until the run we queued reaches a terminal state. `None` means it +/// never did, so there is nothing to announce. +async fn wait_for_run( + client: &reqwest::Client, + previous_run: Option, +) -> Option { + let deadline = Instant::now() + RUN_TIMEOUT; + loop { + sleep(RUN_POLL).await; + let status = get_status(client).await?; + + // A run that finished before we polled reports 'complete' either way, + // so the start time is what tells our run from an earlier one today. + let is_ours = status.run_started_at != previous_run; + let settled = matches!( + status.run_status.as_deref(), + Some("complete") | Some("failed") + ); + if is_ours && settled { + return Some(status); + } + if Instant::now() >= deadline { + return None; + } + } +} + +async fn get_status(client: &reqwest::Client) -> Option { + get(client, "/memories/status").await +} + +async fn get_today(client: &reqwest::Client) -> Option { + let today: TodayData = get(client, "/memories/today").await?; + today.memory +} + +async fn get Deserialize<'de>>(client: &reqwest::Client, path: &str) -> Option { + let response = client + .get(format!("{BACKEND_URL}{path}")) + .send() + .await + .map_err(|e| eprintln!("[MEMORIES] GET {path} failed: {e}")) + .ok()?; + + response + .json::>() + .await + .map_err(|e| eprintln!("[MEMORIES] GET {path} returned unreadable JSON: {e}")) + .ok()? + .data +} + +async fn generate(client: &reqwest::Client) -> Option { + // No `force`: the user's preference decides, and a run already complete + // for today is not worth redoing. + let response = client + .post(format!("{BACKEND_URL}/memories/generate")) + .json(&json!({})) + .send() + .await + .map_err(|e| eprintln!("[MEMORIES] Could not queue a curation run: {e}")) + .ok()?; + + if !response.status().is_success() { + eprintln!("[MEMORIES] Generate returned {}", response.status()); + return None; + } + + response + .json::>() + .await + .map_err(|e| eprintln!("[MEMORIES] Generate returned unreadable JSON: {e}")) + .ok()? + .data +} + +async fn mark_notified(client: &reqwest::Client, memory_id: &str) { + if let Err(e) = client + .patch(format!("{BACKEND_URL}/memories/{memory_id}")) + .json(&json!({ "notified": true })) + .send() + .await + { + eprintln!("[MEMORIES] Could not record the notification: {e}"); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 31d47def1..8fbd3c943 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { GlobalLoader } from './components/Loader/GlobalLoader'; import { InfoDialog } from './components/Dialog/InfoDialog'; import GlobalAlert from './components/GlobalAlert/GlobalAlert'; import { LibraryProcessingIndicator } from './components/BackgroundTasks/LibraryProcessingIndicator'; +import { MemoryNotificationListener } from './components/Memories/MemoryNotificationListener'; import { useSelector } from 'react-redux'; import { RootState } from './app/store'; const App: React.FC = () => { @@ -25,6 +26,7 @@ const App: React.FC = () => { + + [title, subtitle, `${image_count + video_count} items`] + .filter(Boolean) + .join(' · '); + +/** + * Bridges the desktop shell's memory events into the app. + * + * The Rust task emits `memory:pending` whether or not it raised a system + * notification, because desktop notification clicks are not dependable across + * platforms - this in-app card is the path that always works. Needs router + * context, so it lives inside the router in App. + */ +export const MemoryNotificationListener: React.FC = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [pending, setPending] = useState(null); + + const show = useCallback( + (memoryId: string) => { + setPending(null); + // Curation ran while the grid was on screen, so its list is stale. + void queryClient.invalidateQueries({ + queryKey: [...MEMORIES_QUERY_KEY, 'list'], + }); + dispatch(openMemory(memoryId)); + navigate(`/${ROUTES.MEMORIES}`); + }, + [dispatch, navigate, queryClient], + ); + + useEffect(() => { + let cancelled = false; + + const subscriptions = [ + listen('memory:pending', (event) => + setPending(event.payload), + ), + listen<{ memory_id: string }>('memory:open', (event) => + show(event.payload.memory_id), + ), + ]; + + // The task can finish before React mounts when the backend is already up, + // and an event with nobody listening is lost. Collect anything waiting. + void invoke('take_pending_memory') + .then((memory) => { + if (!cancelled && memory) setPending(memory); + }) + .catch(() => {}); + + return () => { + cancelled = true; + subscriptions.forEach((subscription) => { + void subscription.then((unlisten) => unlisten()).catch(() => {}); + }); + }; + }, [show]); + + // Routed through Rust so the window is focused first and every entry point + // lands on the same event. Falls back to routing here if the command is + // unavailable, as it is outside the desktop shell. + const handleOpen = useCallback( + (memoryId: string) => { + void invoke('open_memory', { memoryId }).catch(() => show(memoryId)); + }, + [show], + ); + + if (!pending) return null; + + return ( +
+ + + Your Daily Memory is Ready + + {summarize(pending)} + + + + +
+ ); +}; diff --git a/frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx b/frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx new file mode 100644 index 000000000..9e36d3bc4 --- /dev/null +++ b/frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { act, screen, fireEvent } from '@testing-library/react'; +import { useLocation } from 'react-router'; +import { render } from '@/test-utils'; +import { + MemoryNotificationListener, + type PendingMemory, +} from '../MemoryNotificationListener'; + +type Handler = (event: { payload: unknown }) => void; + +const handlers: Record = {}; +const mockUnlisten = jest.fn(); +const mockInvoke = jest.fn(); + +jest.mock('@tauri-apps/api/event', () => ({ + listen: jest.fn((name: string, handler: Handler) => { + handlers[name] = handler; + return Promise.resolve(mockUnlisten); + }), +})); + +jest.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => mockInvoke(...args), +})); + +const pending: PendingMemory = { + memory_id: 'mem-9', + title: 'Beach', + subtitle: '11 July 2026', + image_count: 9, + video_count: 1, +}; + +/** Reports the current route, so navigation is observable. */ +const Probe: React.FC = () => ( +
{useLocation().pathname}
+); + +const renderListener = () => + render( + <> + + + , + ); + +const emit = (name: string, payload: unknown) => + act(() => { + handlers[name]({ payload }); + }); + +beforeEach(() => { + mockInvoke.mockReset().mockResolvedValue(null); + mockUnlisten.mockClear(); +}); + +describe('MemoryNotificationListener', () => { + it('shows nothing until a memory is announced', () => { + renderListener(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('surfaces the card when the desktop task announces a memory', () => { + renderListener(); + emit('memory:pending', pending); + + expect(screen.getByText('Your Daily Memory is Ready')).toBeInTheDocument(); + expect( + screen.getByText('Beach · 11 July 2026 · 10 items'), + ).toBeInTheDocument(); + }); + + // The task can finish before React mounts, and an event with nobody + // listening is lost while notified_at still records it as delivered. + it('collects an announcement made before it mounted', async () => { + mockInvoke.mockImplementation((command: string) => + Promise.resolve(command === 'take_pending_memory' ? pending : null), + ); + renderListener(); + + expect( + await screen.findByText('Your Daily Memory is Ready'), + ).toBeInTheDocument(); + }); + + it('shows nothing when no announcement is waiting', async () => { + renderListener(); + await act(async () => {}); + + expect(mockInvoke).toHaveBeenCalledWith('take_pending_memory'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('routes through the open_memory command when the card is clicked', () => { + renderListener(); + emit('memory:pending', pending); + + fireEvent.click(screen.getByRole('button', { name: 'View memory' })); + + expect(mockInvoke).toHaveBeenCalledWith('open_memory', { + memoryId: 'mem-9', + }); + }); + + it('opens the viewer on memory:open', () => { + const { store } = renderListener(); + emit('memory:pending', pending); + emit('memory:open', { memory_id: 'mem-9' }); + + expect(store.getState().memories.activeMemoryId).toBe('mem-9'); + expect(screen.getByTestId('path')).toHaveTextContent('/memories'); + // The card has done its job; leaving it over the viewer would be noise. + expect(screen.queryByText('Your Daily Memory is Ready')).toBeNull(); + }); + + // Outside the desktop shell there is no command to invoke, and the card + // would otherwise do nothing at all. + it('falls back to routing here when the command is unavailable', async () => { + mockInvoke.mockRejectedValue(new Error('no such command')); + const { store } = renderListener(); + emit('memory:pending', pending); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'View memory' })); + }); + + expect(store.getState().memories.activeMemoryId).toBe('mem-9'); + expect(screen.getByTestId('path')).toHaveTextContent('/memories'); + }); + + it('dismisses without opening anything', () => { + const { store } = renderListener(); + emit('memory:pending', pending); + + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss memory notification' }), + ); + + expect(screen.queryByText('Your Daily Memory is Ready')).toBeNull(); + expect(store.getState().memories.activeMemoryId).toBeNull(); + }); + + it('unsubscribes both listeners on unmount', async () => { + const { unmount } = renderListener(); + unmount(); + await act(async () => {}); + + expect(mockUnlisten).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx b/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx index d7f3a7dd5..ff8f24786 100644 --- a/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx +++ b/frontend/src/pages/SettingsPage/components/UserPreferencesCard.tsx @@ -24,6 +24,10 @@ import { Button } from '@/components/ui/button'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { getCurrentWindow } from '@tauri-apps/api/window'; +import { + isPermissionGranted, + requestPermission, +} from '@tauri-apps/plugin-notification'; import { useUserPreferences } from '@/hooks/useUserPreferences'; import type { UpdateUserPreferencesRequest } from '@/api/api-functions/user_preferences'; @@ -77,6 +81,24 @@ const UserPreferencesCard: React.FC = () => { [updateMemoriesPreferences], ); + // macOS and Linux gate notifications behind an OS prompt, and switching this + // on is the only moment a user expects one. The OS stays the final say: the + // desktop task re-checks the permission before every notification. + const toggleMemoryNotifications = useCallback( + async (checked: boolean) => { + patchMemories({ notifications_enabled: checked }); + if (!checked) return; + try { + if (!(await isPermissionGranted())) { + await requestPermission(); + } + } catch (err) { + console.warn('Could not request notification permission', err); + } + }, + [patchMemories], + ); + const handlePurgeFrameCache = useCallback(async () => { setPurgeState('purging'); try { @@ -460,9 +482,7 @@ const UserPreferencesCard: React.FC = () => { id="memories-notifications" checked={memoriesPreferences.notifications_enabled} disabled={isUpdating || !memoriesPreferences.enabled} - onCheckedChange={(checked) => - patchMemories({ notifications_enabled: checked }) - } + onCheckedChange={toggleMemoryNotifications} /> diff --git a/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx b/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx index 87799263b..7917b6843 100644 --- a/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx +++ b/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx @@ -3,6 +3,13 @@ import userEvent from '@testing-library/user-event'; import UserPreferencesCard from '../UserPreferencesCard'; import type { MemoriesPreferences } from '@/api/api-functions/user_preferences'; +import { + isPermissionGranted, + requestPermission, +} from '@tauri-apps/plugin-notification'; + +const mockIsPermissionGranted = jest.mocked(isPermissionGranted); +const mockRequestPermission = jest.mocked(requestPermission); const mockUpdateMemoriesPreferences = jest.fn().mockResolvedValue(undefined); let mockMemories: MemoriesPreferences; @@ -69,6 +76,8 @@ const choose = async ( beforeEach(() => { mockUpdateMemoriesPreferences.mockClear(); + mockIsPermissionGranted.mockReset().mockResolvedValue(true); + mockRequestPermission.mockReset().mockResolvedValue('granted'); mockMemories = memoriesWith(); mockIsUpdating = false; }); @@ -99,6 +108,91 @@ describe('UserPreferencesCard memories panel', () => { ).toBeDisabled(); }); + it('asks the OS for permission when notifications are turned on', async () => { + mockIsPermissionGranted.mockResolvedValue(false); + const user = userEvent.setup(); + render(); + await openPanel(user); + + await user.click( + screen.getByRole('switch', { name: /Desktop Notifications/i }), + ); + + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + notifications_enabled: true, + }); + expect(mockRequestPermission).toHaveBeenCalled(); + }); + + it('does not ask again once permission is held', async () => { + const user = userEvent.setup(); + render(); + await openPanel(user); + + await user.click( + screen.getByRole('switch', { name: /Desktop Notifications/i }), + ); + + expect(mockIsPermissionGranted).toHaveBeenCalled(); + expect(mockRequestPermission).not.toHaveBeenCalled(); + }); + + it('turns notifications off without touching the OS', async () => { + mockMemories = memoriesWith({ notifications_enabled: true }); + const user = userEvent.setup(); + render(); + await openPanel(user); + + await user.click( + screen.getByRole('switch', { name: /Desktop Notifications/i }), + ); + + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + notifications_enabled: false, + }); + expect(mockIsPermissionGranted).not.toHaveBeenCalled(); + }); + + // A refused or unavailable prompt is the OS having the last word, not a + // reason to lose the preference the user just set. + it('keeps the preference when the permission call fails', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockIsPermissionGranted.mockRejectedValue(new Error('unavailable')); + const user = userEvent.setup(); + render(); + await openPanel(user); + + await user.click( + screen.getByRole('switch', { name: /Desktop Notifications/i }), + ); + + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + notifications_enabled: true, + }); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('keeps the preference when the prompt itself is refused', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockIsPermissionGranted.mockResolvedValue(false); + mockRequestPermission.mockRejectedValue(new Error('refused')); + const user = userEvent.setup(); + render(); + await openPanel(user); + + await user.click( + screen.getByRole('switch', { name: /Desktop Notifications/i }), + ); + + expect(mockRequestPermission).toHaveBeenCalled(); + expect(mockUpdateMemoriesPreferences).toHaveBeenCalledWith({ + notifications_enabled: true, + }); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + it('raises the maximum when a larger minimum is chosen', async () => { // Reachable only from values the old sliders allowed; the dropdown // options alone cannot put min above max.