From b3ad8cdcdd11c6cf1adbf4f415cb154ab6953df9 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:18:47 +0530 Subject: [PATCH 1/9] chore(memories): add the Tauri notification plugin reqwest gains the json feature and tokio gains time, both needed by the curation task. notify-rust is pinned to 4.17.0 because 4.18 needs rustc 1.89. Nothing goes under plugins in tauri.conf.json: the plugin takes no config and even an empty map panics at startup. --- frontend/jest.setup.ts | 6 ++ frontend/package-lock.json | 10 +++ frontend/package.json | 1 + frontend/src-tauri/Cargo.lock | 75 +++++++++++++++++-- frontend/src-tauri/Cargo.toml | 5 +- frontend/src-tauri/capabilities/migrated.json | 3 +- 6 files changed, 89 insertions(+), 11 deletions(-) diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 5f43c9c5e..482d9ea35 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -94,6 +94,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 29ea22efc..5be3381f2 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -810,7 +810,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1002,7 +1002,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]] @@ -2219,6 +2219,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" @@ -2391,6 +2405,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" @@ -2431,7 +2459,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", @@ -2756,7 +2784,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]] @@ -2994,6 +3022,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-shell", @@ -3242,7 +3271,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -3612,7 +3641,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4507,6 +4536,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" @@ -4709,6 +4757,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" @@ -4719,7 +4778,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5584,7 +5643,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 e8ebbae8a..a09840aec 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"] } -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" [features] # This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! diff --git a/frontend/src-tauri/capabilities/migrated.json b/frontend/src-tauri/capabilities/migrated.json index 297b9f7be..d0be89b0e 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" ] } From 761e2c66ca8cbdb5805c414e987db73aa67dbbd7 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:19:13 +0530 Subject: [PATCH 2/9] feat(memories): curate and notify from the desktop shell The backend only curates when asked, so nothing surfaced on a day with no import. This waits for the backend and for indexing to settle, requests a run, then raises a native notification for the memory it produced. The generate response decides how to wait: run_started_at only identifies our own run, so a run that was already queued or complete would otherwise burn the full timeout on every launch. Focus re-runs the check at most every 6 hours, which covers the app being left open across midnight. --- frontend/src-tauri/src/main.rs | 12 + frontend/src-tauri/src/memories.rs | 378 +++++++++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 frontend/src-tauri/src/memories.rs diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 7e1b5fbd2..f40ae3d75 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; @@ -45,6 +46,12 @@ fn is_process_alive() -> bool { } fn on_window_event(window: &Window, event: &WindowEvent) { + // Covers the app being left open across midnight; throttled inside. + if matches!(event, WindowEvent::Focused(true)) && window.label() == "main" { + memories::check_on_resume(window.app_handle().clone()); + return; + } + if !matches!(event, WindowEvent::CloseRequested { .. }) { return; } @@ -224,16 +231,21 @@ 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()); Ok(()) }) .invoke_handler(tauri::generate_handler![ services::get_resources_folder_path, open_model_manager, + memories::open_memory, ]) .on_window_event(on_window_event) .run(tauri::generate_context!()) diff --git a/frontend/src-tauri/src/memories.rs b/frontend/src-tauri/src/memories.rs new file mode 100644 index 000000000..df46261c4 --- /dev/null +++ b/frontend/src-tauri/src/memories.rs @@ -0,0 +1,378 @@ +//! 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; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_notification::{NotificationExt, PermissionState}; +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>, +} + +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; +} + +/// Emit the result and, if the user asked for them, raise a system notification. +async fn surface(app: &AppHandle, client: &reqwest::Client, status: &StatusData) { + let memory = match get_today(client).await { + Some(memory) => memory, + None => return, + }; + + // Emitted whether or not we notify, so the in-app card is the reliable + // path: desktop notification clicks are not dependable across platforms. + let _ = app.emit( + "memory:pending", + json!({ + "memory_id": memory.memory_id, + "title": memory.title, + "subtitle": memory.subtitle, + "image_count": memory.image_count, + "video_count": memory.video_count, + }), + ); + + // notified_at is what stops a memory being announced twice across launches. + if !status.notifications_enabled || memory.notified_at.is_some() { + return; + } + + if notify(app, &memory) { + mark_notified(client, &memory.memory_id).await; + } +} + +fn notify(app: &AppHandle, memory: &MemorySummary) -> bool { + if !has_permission(app) { + return false; + } + + 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), + }; + + match app + .notification() + .builder() + .title("Your Daily Memory is Ready") + .body(body) + .show() + { + Ok(()) => true, + Err(e) => { + eprintln!("[MEMORIES] Could not show the notification: {e}"); + false + } + } +} + +fn has_permission(app: &AppHandle) -> bool { + match app.notification().permission_state() { + Ok(PermissionState::Granted) => true, + Ok(_) => matches!( + app.notification().request_permission(), + Ok(PermissionState::Granted) + ), + Err(e) => { + eprintln!("[MEMORIES] Could not read the notification permission: {e}"); + false + } + } +} + +/// 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}"); + } +} From ab23144045ce11a278eb6883c2b26c6a49be7ea4 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:19:33 +0530 Subject: [PATCH 3/9] feat(memories): open the story viewer from a notification Rust emits memory:pending whether or not it notified, because desktop notification clicks are not dependable across platforms, so the in-app card is the path that always works. Clicking it goes through the open_memory command, which focuses the window and emits memory:open. Lives inside the router so a memory opens from any page. --- frontend/src/App.tsx | 2 + .../Memories/MemoryNotificationListener.tsx | 116 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 frontend/src/components/Memories/MemoryNotificationListener.tsx 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(() => { + const subscriptions = [ + listen('memory:pending', (event) => + setPending(event.payload), + ), + listen<{ memory_id: string }>('memory:open', (event) => + show(event.payload.memory_id), + ), + ]; + + return () => { + 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)} + + + + +
+ ); +}; From b9a61b6a995629761636aaa292fc8d39c1b112a7 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:19:48 +0530 Subject: [PATCH 4/9] feat(settings): ask for notification permission when alerts are turned on macOS and Linux gate notifications behind an OS prompt, and the switch is the only moment a user expects one. The preference saves either way; the desktop task re-checks the permission before every notification. --- .../components/UserPreferencesCard.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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} /> From 96af5ba843abdc4ad304c51d46cf00938c36e0ed Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:20:05 +0530 Subject: [PATCH 5/9] test(memories): cover the notification listener Card, dismissal, both events, and the fallback when the command is missing. --- .../MemoryNotificationListener.test.tsx | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx 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..5317c05ab --- /dev/null +++ b/frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx @@ -0,0 +1,130 @@ +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(); + }); + + 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); + }); +}); From 477d996296b43d8cc8b1639c7731c223192b1ce4 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:04:18 +0530 Subject: [PATCH 6/9] fix(memories): announce a memory once, not on every launch memory:pending was emitted before the notified_at guard, and /today keeps returning a memory until it is viewed or dismissed, so the in-app card came back on every launch and every resume. The guard now covers both paths and notified_at is recorded whenever the card goes out. Drops the Rust permission check with it: the plugin's desktop implementation returns Granted unconditionally, so it never did anything. --- frontend/src-tauri/src/memories.rs | 59 ++++++++++++------------------ 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/frontend/src-tauri/src/memories.rs b/frontend/src-tauri/src/memories.rs index df46261c4..2c6d6ddaf 100644 --- a/frontend/src-tauri/src/memories.rs +++ b/frontend/src-tauri/src/memories.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; use serde::Deserialize; use serde_json::json; use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_notification::{NotificationExt, PermissionState}; +use tauri_plugin_notification::NotificationExt; use tokio::time::sleep; const BACKEND_URL: &str = "http://localhost:52123"; @@ -175,15 +175,25 @@ async fn run(app: &AppHandle) { surface(app, &client, &status).await; } -/// Emit the result and, if the user asked for them, raise a system notification. +/// 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, }; - // Emitted whether or not we notify, so the in-app card is the reliable - // path: desktop notification clicks are not dependable across platforms. + // 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. Emitted 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 _ = app.emit( "memory:pending", json!({ @@ -195,53 +205,32 @@ async fn surface(app: &AppHandle, client: &reqwest::Client, status: &StatusData) }), ); - // notified_at is what stops a memory being announced twice across launches. - if !status.notifications_enabled || memory.notified_at.is_some() { - return; + if status.notifications_enabled { + notify(app, &memory); } - if notify(app, &memory) { - mark_notified(client, &memory.memory_id).await; - } + // Recorded even when the notification failed: the card still went out. + mark_notified(client, &memory.memory_id).await; } -fn notify(app: &AppHandle, memory: &MemorySummary) -> bool { - if !has_permission(app) { - return false; - } - +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), }; - match app + // 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() { - Ok(()) => true, - Err(e) => { - eprintln!("[MEMORIES] Could not show the notification: {e}"); - false - } - } -} - -fn has_permission(app: &AppHandle) -> bool { - match app.notification().permission_state() { - Ok(PermissionState::Granted) => true, - Ok(_) => matches!( - app.notification().request_permission(), - Ok(PermissionState::Granted) - ), - Err(e) => { - eprintln!("[MEMORIES] Could not read the notification permission: {e}"); - false - } + eprintln!("[MEMORIES] Could not show the notification: {e}"); } } From 3c0f4450a600bbe6af0519165940695c9cb02e43 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:04:26 +0530 Subject: [PATCH 7/9] test(settings): cover the notification permission request --- .../__tests__/UserPreferencesCard.test.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx b/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx index 87799263b..e3359d783 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,71 @@ 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('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. From bafa80ff385f9bd6db587c72ec7de04b7ed3de84 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:19:27 +0530 Subject: [PATCH 8/9] fix(memories): stop losing the card to a mount race The task can finish before React mounts when the backend is already up, and an event with nobody listening is lost, while notified_at still records it as delivered. The announcement is now held in Rust and the listener collects it on mount, so delivery no longer depends on which side won. --- frontend/src-tauri/src/main.rs | 1 + frontend/src-tauri/src/memories.rs | 50 +++++++++++++------ .../Memories/MemoryNotificationListener.tsx | 11 ++++ .../MemoryNotificationListener.test.tsx | 21 ++++++++ 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index f4f9033d9..e1227bbde 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -360,6 +360,7 @@ fn main() { 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 index 2c6d6ddaf..68236e72c 100644 --- a/frontend/src-tauri/src/memories.rs +++ b/frontend/src-tauri/src/memories.rs @@ -9,7 +9,7 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use serde::Deserialize; -use serde_json::json; +use serde_json::{json, Value}; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_notification::NotificationExt; use tokio::time::sleep; @@ -33,6 +33,10 @@ const RESUME_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); 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 { @@ -191,19 +195,27 @@ async fn surface(app: &AppHandle, client: &reqwest::Client, status: &StatusData) } // Not gated on notifications_enabled: that preference is about OS alerts, - // and it ships off. Emitted 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 _ = app.emit( - "memory:pending", - json!({ - "memory_id": memory.memory_id, - "title": memory.title, - "subtitle": memory.subtitle, - "image_count": memory.image_count, - "video_count": memory.video_count, - }), - ); + // 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); @@ -234,6 +246,16 @@ fn notify(app: &AppHandle, memory: &MemorySummary) { } } +/// 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] diff --git a/frontend/src/components/Memories/MemoryNotificationListener.tsx b/frontend/src/components/Memories/MemoryNotificationListener.tsx index 493ed4303..543cbf5cb 100644 --- a/frontend/src/components/Memories/MemoryNotificationListener.tsx +++ b/frontend/src/components/Memories/MemoryNotificationListener.tsx @@ -59,6 +59,8 @@ export const MemoryNotificationListener: React.FC = () => { ); useEffect(() => { + let cancelled = false; + const subscriptions = [ listen('memory:pending', (event) => setPending(event.payload), @@ -68,7 +70,16 @@ export const MemoryNotificationListener: React.FC = () => { ), ]; + // 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(() => {}); }); diff --git a/frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx b/frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx index 5317c05ab..9e36d3bc4 100644 --- a/frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx +++ b/frontend/src/components/Memories/__tests__/MemoryNotificationListener.test.tsx @@ -71,6 +71,27 @@ describe('MemoryNotificationListener', () => { ).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); From 6d7b94ef3db8e88520e4d7435c0ecf35edfeecfc Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:19:41 +0530 Subject: [PATCH 9/9] test(settings): cover a refused notification prompt --- .../__tests__/UserPreferencesCard.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx b/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx index e3359d783..7917b6843 100644 --- a/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx +++ b/frontend/src/pages/SettingsPage/components/__tests__/UserPreferencesCard.test.tsx @@ -173,6 +173,26 @@ describe('UserPreferencesCard memories panel', () => { 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.