desktop: reminders reach you when the window isn't in front
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
A Rust worker reads due reminders from the local store every 15 s and announces each occurrence once: always to the main window as a toast, and as a system notification (tauri-plugin-notification) when that window isn't focused. The page no longer polls on the desktop; its Notification went nowhere in WebKitGTK and its timer stopped with the window. The Reminders page says what each surface actually does. Core gains store::due_reminders, compared by instant, not by string. Refs #5171 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -295,3 +295,27 @@ export async function onCaptured(handler: () => void): Promise<() => void> {
|
||||
if (!events) return () => {};
|
||||
return events.listen(CAPTURED_EVENT, () => handler());
|
||||
}
|
||||
|
||||
/** A reminder the desktop's reminder worker found due (desktop/src-tauri/src/reminders.rs). */
|
||||
export interface DueReminder {
|
||||
id: string;
|
||||
title: string;
|
||||
remind_at: string;
|
||||
due_ms: number;
|
||||
}
|
||||
|
||||
/** Fired at the main window for each reminder occurrence, once. */
|
||||
const REMINDER_DUE_EVENT = "inkwell://reminder-due";
|
||||
|
||||
/**
|
||||
* Run `handler` for each reminder the desktop announces. On the desktop the Rust
|
||||
* worker decides what is due, and raises the system notification itself when the
|
||||
* window isn't in front; the page only shows its toast.
|
||||
*
|
||||
* Returns an unlisten function, or a no-op where there is no event API.
|
||||
*/
|
||||
export async function onReminderDue(handler: (reminder: DueReminder) => void): Promise<() => void> {
|
||||
const events = window.__TAURI__?.event;
|
||||
if (!events) return () => {};
|
||||
return events.listen<DueReminder>(REMINDER_DUE_EVENT, (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import { isDesktop, onReminderDue } from "../desktop/bridge";
|
||||
import router from "../router";
|
||||
import { useUiStore } from "./ui";
|
||||
import type { Note } from "./notes";
|
||||
|
||||
// Foreground reminder delivery — NO Web Push / service worker (per operator). While
|
||||
// the app is open, poll for due reminders and surface each once as a toast (and, if
|
||||
// the user opted in, a page-context OS notification). Real background delivery is a
|
||||
// native-client concern.
|
||||
// Reminder delivery. In a browser it is foreground only — NO Web Push / service
|
||||
// worker (per operator): while the tab is open, poll for due reminders and surface
|
||||
// each once as a toast (and, if the user opted in, a page-context OS notification).
|
||||
//
|
||||
// The desktop doesn't poll here. Its Rust worker reads the local store, decides
|
||||
// what is due, raises the system notification itself when the window isn't in
|
||||
// front, and tells this page so it can show the toast (#5171). The page's
|
||||
// `Notification` went nowhere in WebKitGTK, and its timer stopped with the window.
|
||||
const POLL_MS = 45_000;
|
||||
const RECENT_MS = 15 * 60 * 1000; // on first load, only announce reminders due this recently
|
||||
|
||||
export const useReminderStore = defineStore("reminders", () => {
|
||||
const osSupported = typeof Notification !== "undefined";
|
||||
const desktop = isDesktop();
|
||||
// The desktop raises its own system notifications; there is nothing to opt into.
|
||||
const osSupported = !desktop && typeof Notification !== "undefined";
|
||||
const osEnabled = ref(osSupported && Notification.permission === "granted");
|
||||
// note id → the remind_at we already fired, so each occurrence notifies at most once.
|
||||
const notified = new Map<string, string>();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
// The desktop's subscription to its reminder worker, held as the promise so stop()
|
||||
// can undo one whose registration hasn't finished yet.
|
||||
let listening: Promise<() => void> | undefined;
|
||||
let primed = false;
|
||||
|
||||
// Single owner of the reminders endpoint — the RemindersView reads its list through
|
||||
@@ -26,8 +36,17 @@ export const useReminderStore = defineStore("reminders", () => {
|
||||
return repo.notes.reminders();
|
||||
}
|
||||
|
||||
/** Show the in-app toast for a due reminder; returns the title it showed. */
|
||||
function toast(id: string, displayTitle: string): string {
|
||||
const title = displayTitle || "Reminder";
|
||||
useUiStore().showToast(`⏰ ${title}`, {
|
||||
label: "Open",
|
||||
run: () => void router.push({ path: "/", query: { open: id } }),
|
||||
});
|
||||
return title;
|
||||
}
|
||||
|
||||
async function check(): Promise<void> {
|
||||
const ui = useUiStore();
|
||||
let notes: Note[];
|
||||
try {
|
||||
notes = await fetchReminders();
|
||||
@@ -42,11 +61,7 @@ export const useReminderStore = defineStore("reminders", () => {
|
||||
notified.set(n.id, n.remind_at);
|
||||
// Silently prime a stale backlog on first load; only announce recently-due ones.
|
||||
if (!primed && now - due > RECENT_MS) continue;
|
||||
const title = n.display_title || "Reminder";
|
||||
ui.showToast(`⏰ ${title}`, {
|
||||
label: "Open",
|
||||
run: () => void router.push({ path: "/", query: { open: n.id } }),
|
||||
});
|
||||
const title = toast(n.id, n.display_title);
|
||||
if (osEnabled.value) {
|
||||
try {
|
||||
new Notification("Inkwell reminder", { body: title });
|
||||
@@ -59,6 +74,10 @@ export const useReminderStore = defineStore("reminders", () => {
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (desktop) {
|
||||
if (!listening) listening = onReminderDue((r) => void toast(r.id, r.title));
|
||||
return;
|
||||
}
|
||||
if (timer) return;
|
||||
void check();
|
||||
timer = setInterval(() => void check(), POLL_MS);
|
||||
@@ -66,6 +85,8 @@ export const useReminderStore = defineStore("reminders", () => {
|
||||
function stop(): void {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = undefined;
|
||||
void listening?.then((off) => off());
|
||||
listening = undefined;
|
||||
}
|
||||
async function enableOs(): Promise<void> {
|
||||
if (!osSupported) return;
|
||||
|
||||
@@ -7,9 +7,16 @@ import AsyncState from "../components/AsyncState.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
|
||||
const reminders = useReminderStore();
|
||||
|
||||
// What this surface actually does with a due reminder. The desktop's worker raises
|
||||
// system notifications while it runs; a browser tab can only alert while it is open.
|
||||
const deliveryNote = isDesktop()
|
||||
? "Due reminders show as system notifications while Inkwell is running, even minimised."
|
||||
: "Due reminders pop up while this tab is open. The desktop and Android apps alert you in the background.";
|
||||
|
||||
const { items, loading, error, load } = useNoteList(() => reminders.fetchReminders(), "Couldn't load reminders.");
|
||||
|
||||
const { editing, open: openEditor, close: closeEditor, navigate: onNavigate } = useNoteEditor({
|
||||
@@ -37,9 +44,7 @@ onMounted(load);
|
||||
</button>
|
||||
<span v-else-if="reminders.osEnabled" class="text-xs text-neutral-400">Notifications on ✓</span>
|
||||
</div>
|
||||
<p class="mb-5 text-xs text-neutral-400">
|
||||
Due reminders pop up while Inkwell is open. Background alerts arrive with the desktop & mobile apps.
|
||||
</p>
|
||||
<p class="mb-5 text-xs text-neutral-400">{{ deliveryNote }}</p>
|
||||
|
||||
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't load reminders" @retry="load">
|
||||
<EmptyState
|
||||
|
||||
Reference in New Issue
Block a user