The daily reminder said one generic line to everyone, because the server
never sees a deck. Now the notification text is written in the browser
(lib/reminder.ts) and travels with the subscription:
- Personal: "3 cards are due in Sky deck", in the app's language, tapping
through to /review. Without notes (or with nothing due) it is the plain
nudge, translated in both languages — push.title / push.due.* / push.generic.
- POST /api/push accepts an optional message {title, body, url}; url must be
a path on this app, so a stolen subscription cannot make the notification
link elsewhere. The store keeps endpoint, keys, the message and createdAt
(a refresh no longer resets the timestamp).
- components/PushReminderSync.tsx refreshes the stored text when the app
opens and whenever the tab becomes visible (throttled to twice a day), so a
count is only ever sent while it is true.
- scripts/send-reminders.mjs sends each device its own text and falls back to
PUSH_REMINDER_TITLE/_BODY for a subscription that has none
(scripts/reminder-payload.mjs, unit-checked).
- /api/push/test previews the real reminder with a "Test:" prefix.
- deploy/freezmo-reminders.{service,timer}: the daily send as a systemd user
timer (18:00 local, Persistent, randomised), documented in the README.
- Settings copy, privacy page (en+cs) and .env.example say exactly what the
server now holds.
- phase2-test grew the whole path with a faked browser subscription: personal
text in en and cs, the generic fallback in both, the URL guard, and the
sender's preference order (81 checks).
Version 0.8.2, SW v104.
222 lines
7.9 KiB
TypeScript
222 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
|
|
import { Button } from "@/components/ui";
|
|
import { haptic } from "@/lib/haptics";
|
|
import { localeHeader, useI18n } from "@/lib/i18n";
|
|
import { buildReminderMessage } from "@/lib/reminder";
|
|
import { authHeaders, saveSettings, settingsStore } from "@/lib/settings";
|
|
|
|
const GOALS = [10, 20, 30, 50];
|
|
|
|
type PushStatus = { enabled: boolean; publicKey?: string };
|
|
|
|
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
|
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
const raw = atob(normalized);
|
|
const bytes = new Uint8Array(raw.length);
|
|
for (let index = 0; index < raw.length; index++) bytes[index] = raw.charCodeAt(index);
|
|
return bytes;
|
|
}
|
|
|
|
/**
|
|
* Settings → Learning: the daily goal, plus push reminders — but only when
|
|
* the server has VAPID keys configured. Without them the reminder block is
|
|
* hidden completely, exactly as if the feature did not exist.
|
|
*/
|
|
/**
|
|
* `level` keeps the heading order right in both places it is used: h2 on the
|
|
* library page (under its h1) and h3 inside the settings dialog (under its h2
|
|
* title).
|
|
*/
|
|
export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) {
|
|
const { t } = useI18n();
|
|
const dailyGoal = useSyncExternalStore(
|
|
settingsStore.subscribe,
|
|
() => settingsStore.getSnapshot().dailyGoal,
|
|
() => 20,
|
|
);
|
|
const [status, setStatus] = useState<PushStatus | null>(null);
|
|
const [subscribed, setSubscribed] = useState(false);
|
|
const [endpoint, setEndpoint] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
|
|
const pushSupported =
|
|
typeof window !== "undefined" &&
|
|
"serviceWorker" in navigator &&
|
|
"PushManager" in window &&
|
|
"Notification" in window;
|
|
|
|
useEffect(() => {
|
|
if (!pushSupported) return;
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const response = await fetch("/api/push", {
|
|
headers: { ...authHeaders(), ...localeHeader() },
|
|
cache: "no-store",
|
|
});
|
|
const body = (await response.json()) as PushStatus;
|
|
if (cancelled) return;
|
|
setStatus(body);
|
|
if (body.enabled) {
|
|
const registration = await navigator.serviceWorker.ready.catch(() => null);
|
|
const subscription = await registration?.pushManager.getSubscription();
|
|
if (!cancelled && subscription) {
|
|
setSubscribed(true);
|
|
setEndpoint(subscription.endpoint);
|
|
}
|
|
}
|
|
} catch {
|
|
if (!cancelled) setStatus({ enabled: false });
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [pushSupported]);
|
|
|
|
const enable = async () => {
|
|
if (!status?.publicKey) return;
|
|
setBusy(true);
|
|
setNotice(null);
|
|
try {
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== "granted") {
|
|
setNotice(t("settings.reminderDenied"));
|
|
return;
|
|
}
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(status.publicKey) as unknown as BufferSource,
|
|
});
|
|
/* The reminder text travels with the subscription: the server has no
|
|
access to the notes, so this is where it becomes personal. */
|
|
const message = await buildReminderMessage();
|
|
const response = await fetch("/api/push", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
|
body: JSON.stringify({ ...subscription.toJSON(), message }),
|
|
});
|
|
if (!response.ok) throw new Error("subscribe failed");
|
|
setSubscribed(true);
|
|
setEndpoint(subscription.endpoint);
|
|
setNotice(t("settings.reminderOn"));
|
|
haptic("toggle");
|
|
} catch {
|
|
setNotice(t("settings.reminderError"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const disable = async () => {
|
|
setBusy(true);
|
|
setNotice(null);
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready.catch(() => null);
|
|
const subscription = await registration?.pushManager.getSubscription();
|
|
if (subscription) {
|
|
await fetch(`/api/push?endpoint=${encodeURIComponent(subscription.endpoint)}`, {
|
|
method: "DELETE",
|
|
headers: { ...authHeaders(), ...localeHeader() },
|
|
});
|
|
await subscription.unsubscribe();
|
|
}
|
|
setSubscribed(false);
|
|
setEndpoint(null);
|
|
setNotice(t("settings.reminderOff"));
|
|
} catch {
|
|
setNotice(t("settings.reminderError"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const test = async () => {
|
|
if (!endpoint) return;
|
|
setBusy(true);
|
|
setNotice(null);
|
|
try {
|
|
const response = await fetch("/api/push/test", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
|
body: JSON.stringify({ endpoint }),
|
|
});
|
|
if (!response.ok) throw new Error("test failed");
|
|
setNotice(t("settings.reminderOn"));
|
|
} catch {
|
|
setNotice(t("settings.reminderError"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const iosNeedsInstall =
|
|
typeof navigator !== "undefined" &&
|
|
/iPhone|iPad|iPod/.test(navigator.userAgent) &&
|
|
!(navigator as Navigator & { standalone?: boolean }).standalone;
|
|
|
|
return (
|
|
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
|
{level === 3 ? (
|
|
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h3>
|
|
) : (
|
|
<h2 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h2>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.dailyGoal")}</p>
|
|
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("settings.dailyGoalNote")}</p>
|
|
<div className="segmented" role="group" aria-label={t("settings.dailyGoal")}>
|
|
{GOALS.map((goal) => (
|
|
<button
|
|
key={goal}
|
|
type="button"
|
|
aria-pressed={dailyGoal === goal}
|
|
onClick={() => {
|
|
haptic("toggle");
|
|
saveSettings({ dailyGoal: goal });
|
|
}}
|
|
>
|
|
<span className="seg-label">{goal}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{pushSupported && status?.enabled ? (
|
|
<div className="space-y-2 border-t border-[var(--hairline-soft)] pt-2.5">
|
|
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.reminders")}</p>
|
|
<p className="text-[11px] leading-snug text-[var(--text-subtle)]">{t("settings.remindersNote")}</p>
|
|
{iosNeedsInstall ? (
|
|
<p className="text-[11px] font-medium leading-snug text-amber-700 dark:text-amber-300">
|
|
{t("settings.remindersIos")}
|
|
</p>
|
|
) : null}
|
|
<div className="flex flex-wrap gap-2">
|
|
{subscribed ? (
|
|
<>
|
|
<Button size="sm" variant="secondary" loading={busy} onClick={() => void test()}>
|
|
{t("settings.reminderTest")}
|
|
</Button>
|
|
<Button size="sm" variant="ghost" loading={busy} onClick={() => void disable()}>
|
|
{t("settings.reminderDisable")}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button size="sm" loading={busy} onClick={() => void enable()}>
|
|
{t("settings.reminderEnable")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{notice ? <p className="text-[12px] font-medium text-[var(--text-muted)]">{notice}</p> : null}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|