From ead8ad02e8543aafddc02a0407a8ac780222a5d6 Mon Sep 17 00:00:00 2001 From: gosangam Date: Thu, 11 Jun 2026 00:32:04 +0530 Subject: [PATCH] fix: ui fixes --- app/(app)/settings/page.tsx | 8 +- app/(app)/transcriptions/[slug]/page.tsx | 11 +- components/audio-player/floating-player.tsx | 111 ++++++- components/find-in-page.tsx | 284 ++++++++++++++++++ .../notifications/like-email-toggle.tsx | 83 ----- .../notifications/notification-matrix.tsx | 156 ++++++++++ components/push/push-toggle.tsx | 62 ++-- components/sadhna/reminder-toggle.tsx | 104 ------- .../selection-toolbar/collections-drawer.tsx | 9 +- components/settings/setting-row.tsx | 65 ++++ components/text-selection-toolbar.tsx | 30 +- components/ui/drawer.tsx | 8 + lib/push.ts | 28 +- lib/sadhna.ts | 11 +- lib/social.ts | 20 +- public/sw.js | 45 +++ 16 files changed, 769 insertions(+), 266 deletions(-) create mode 100644 components/find-in-page.tsx delete mode 100644 components/notifications/like-email-toggle.tsx create mode 100644 components/notifications/notification-matrix.tsx delete mode 100644 components/sadhna/reminder-toggle.tsx create mode 100644 components/settings/setting-row.tsx diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 8b8f4cda..04e4db8a 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -30,8 +30,7 @@ import { AddEmailModal } from "@/components/auth/add-email-modal"; import { ChangePasswordModal } from "@/components/auth/change-password-modal"; import { ReadingSettings } from "@/components/reading-settings"; import { PushToggle } from "@/components/push/push-toggle"; -import { ReminderToggle } from "@/components/sadhna/reminder-toggle"; -import { LikeEmailToggle } from "@/components/notifications/like-email-toggle"; +import { NotificationMatrix } from "@/components/notifications/notification-matrix"; const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; @@ -61,11 +60,10 @@ export default function SettingsPage() {
- - +
diff --git a/app/(app)/transcriptions/[slug]/page.tsx b/app/(app)/transcriptions/[slug]/page.tsx index d0725eab..3d837b91 100644 --- a/app/(app)/transcriptions/[slug]/page.tsx +++ b/app/(app)/transcriptions/[slug]/page.tsx @@ -15,6 +15,7 @@ import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ChevronLeft, ChevronRight, Clock, MapPin, Users } from "lucide-react"; import { PlayAudioButton } from "./play-audio-button"; +import { FindInPage } from "@/components/find-in-page"; export async function generateMetadata({ params, @@ -88,7 +89,12 @@ export default async function TranscriptionPage({ > ← {backLabel} -

{t.name}

+
+

{t.name}

+ {t.content.length > 0 && ( + + )} +
{t.type === "lecture" ? "Lecture" : "Conversation"} @@ -168,7 +174,8 @@ export default async function TranscriptionPage({ ) : ( -
+ {/* id targeted by the header's FindInPage control */} +
{t.content.map((block, i) => ( ))} diff --git a/components/audio-player/floating-player.tsx b/components/audio-player/floating-player.tsx index 171fffd5..9962e3ed 100644 --- a/components/audio-player/floating-player.tsx +++ b/components/audio-player/floating-player.tsx @@ -23,6 +23,8 @@ import { Volume2, VolumeX, Volume1, + Pin, + PinOff, X, ExternalLink, Loader2, @@ -38,6 +40,8 @@ const MINIMIZE_DELAY = 8000; * Returns [minimized, setMinimized, resetTimer]. * Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call * `resetTimer()` on any user interaction to restart the countdown. + * While `pinned`, the countdown never schedules — the player stays + * expanded until the user minimizes it manually. * * The `hasTrack`-driven reset of `minimized` is done via the * "adjust state during render based on previous prop" pattern from the @@ -46,7 +50,7 @@ const MINIMIZE_DELAY = 8000; * fine because it fires asynchronously (the rule is about *synchronous* * setState during the effect body). */ -function useAutoMinimize(hasTrack: boolean) { +function useAutoMinimize(hasTrack: boolean, pinned: boolean) { const [minimized, setMinimized] = useState(false); const [lastHasTrack, setLastHasTrack] = useState(hasTrack); const timerRef = useRef | null>(null); @@ -68,8 +72,9 @@ function useAutoMinimize(hasTrack: boolean) { const startTimer = useCallback(() => { clearTimer(); + if (pinned) return; timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY); - }, [clearTimer]); + }, [clearTimer, pinned]); // Drive the timer from hasTrack. The effect body itself only calls // start/clear (which schedule, not mutate state); the actual @@ -106,6 +111,28 @@ const MINI_POS_STORAGE_KEY = "spsays:audio-mini-pos"; // the click-to-expand that would otherwise fire on pointer-up. const MINI_DRAG_THRESHOLD = 4; +// Pinned state persistence — pinning prevents both auto-minimize paths +// (inactivity timer + tap-outside), and the choice survives reloads. +const PIN_STORAGE_KEY = "spsays:audio-pinned"; + +function loadPinned(): boolean { + if (typeof window === "undefined") return false; + try { + return localStorage.getItem(PIN_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +function savePinned(pinned: boolean) { + if (typeof window === "undefined") return; + try { + localStorage.setItem(PIN_STORAGE_KEY, pinned ? "1" : "0"); + } catch { + // Storage may be unavailable — the pin still holds for this session. + } +} + type MiniPos = { x: number; y: number }; function clampMiniPos(p: MiniPos): MiniPos { @@ -424,8 +451,22 @@ export function FloatingPlayer() { const playerRef = useRef(null); const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false); - const { minimized, setMinimized, resetTimer } = - useAutoMinimize(!!currentTrack); + const [pinned, setPinned] = useState(loadPinned); + const { minimized, setMinimized, resetTimer } = useAutoMinimize( + !!currentTrack, + pinned, + ); + + const togglePin = useCallback(() => { + setPinned((p) => { + const next = !p; + savePinned(next); + return next; + }); + // Un-minimize + restart/clear the countdown under the new pin state + // (the timer hook re-evaluates when `pinned` flips). + resetTimer(); + }, [resetTimer]); // Minimized pill position lives in motion values so framer's `drag` // can mutate them directly without round-tripping through React state. @@ -496,14 +537,19 @@ export function FloatingPlayer() { return () => observer.disconnect(); }, [currentTrack]); - // Add bottom padding to body when player is visible + // Reserve space under the page content while the FULL bar is on screen so + // it doesn't cover the end of the page. Scoped to the expanded state: the + // minimized pill floats and needs no clearance, and a track stays loaded + // (pill present) until explicitly closed — leaving this padding keyed to + // the track alone parked a permanent 96px dead band at the bottom of every + // page (visible as a gap above the bottom nav) after one use of the player. useEffect(() => { - if (!currentTrack) return; + if (!currentTrack || minimized) return; document.body.style.paddingBottom = "96px"; return () => { document.body.style.paddingBottom = ""; }; - }, [currentTrack]); + }, [currentTrack, minimized]); // Wrap interactions to reset the minimize timer const interact = useCallback( @@ -523,9 +569,9 @@ export function FloatingPlayer() { const iPrevious = interact(previous); const iStop = interact(stop); - // Minimize when clicking/tapping outside the player + // Minimize when clicking/tapping outside the player — unless pinned. useEffect(() => { - if (!currentTrack || minimized) return; + if (!currentTrack || minimized || pinned) return; function handleOutsideClick(e: PointerEvent) { const player = playerRef.current; @@ -537,7 +583,7 @@ export function FloatingPlayer() { document.addEventListener("pointerdown", handleOutsideClick); return () => document.removeEventListener("pointerdown", handleOutsideClick); - }, [currentTrack, minimized, setMinimized]); + }, [currentTrack, minimized, pinned, setMinimized]); // Keyboard shortcuts when player is focused const handleKeyDown = useCallback( @@ -900,6 +946,31 @@ export function FloatingPlayer() { )} + {/* Pin — keeps the player expanded (no auto-minimize) */} + + {/* Minimize (playing) or Close (paused) */} + {/* Minimize (playing) or Close (paused) */} + + {open && + createPortal( +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + step(e.shiftKey ? -1 : 1); + } else if (e.key === "Escape") { + e.preventDefault(); + close(); + } + }} + placeholder="Find in page…" + aria-label="Find in page" + className="min-w-0 flex-1 bg-transparent py-1 text-sm outline-none placeholder:text-muted-foreground" + /> + + {count > 0 + ? `${current + 1}/${count}` + : query.trim().length >= MIN_QUERY + ? "0 results" + : ""} + + + + +
+
, + document.body, + )} + + ); +} diff --git a/components/notifications/like-email-toggle.tsx b/components/notifications/like-email-toggle.tsx deleted file mode 100644 index 0ac198c1..00000000 --- a/components/notifications/like-email-toggle.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -/** - * LikeEmailToggle — settings control for aggregated "someone liked your post" - * emails. Reads the current preference and lets the user opt out (push + - * in-app like notifications are unaffected). Matches the other settings rows. - */ - -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { notifications } from "@/lib/social"; - -export function LikeEmailToggle() { - const [enabled, setEnabled] = useState(null); - const [busy, setBusy] = useState(false); - - useEffect(() => { - let cancelled = false; - notifications - .emailPrefs() - .then((r) => { - if (!cancelled) setEnabled(r.like_emails); - }) - .catch(() => { - if (!cancelled) setEnabled(true); // default-on; toggle still works - }); - return () => { - cancelled = true; - }; - }, []); - - async function toggle() { - if (enabled === null) return; - const next = !enabled; - setBusy(true); - try { - await notifications.setEmailPrefs(next); - setEnabled(next); - toast.success(next ? "Like emails turned on" : "Like emails turned off"); - } catch { - toast.error("Could not update preference"); - } finally { - setBusy(false); - } - } - - return ( -
    -
  • -
    - Like emails -

    - Get an email when someone likes your post (bundled — one email per - post). Push and in-app notifications stay on either way. -

    -
    -
    - {enabled === null ? ( - … - ) : ( - - )} -
    -
  • -
- ); -} diff --git a/components/notifications/notification-matrix.tsx b/components/notifications/notification-matrix.tsx new file mode 100644 index 00000000..c8b5d0b1 --- /dev/null +++ b/components/notifications/notification-matrix.tsx @@ -0,0 +1,156 @@ +"use client"; + +/** + * NotificationMatrix — the Settings grid controlling which events reach the + * user over which channels (in-app, email, push). Account-wide preferences; + * the device-level PushToggle above it governs whether *this* device can + * receive push at all (two layers, like an OS permission vs. app settings). + * + * Cells write through to PUT /notifications/prefs one at a time and re-render + * from the matrix the server returns, so the UI always shows resolved state. + */ + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + notifications, + type NotificationPrefs, + type PrefChannel, + type PrefEvent, +} from "@/lib/social"; +import { ToggleSwitch } from "@/components/settings/setting-row"; +import { + enablePush, + isPushEnabled, + pushPermission, + pushSupported, +} from "@/lib/push"; + +const EVENTS: { key: PrefEvent; label: string; description: string }[] = [ + { + key: "follows", + label: "Follows", + description: "Follow requests and accepted requests.", + }, + { + key: "post_interactions", + label: "Post interactions", + description: "Likes and mentions on your posts.", + }, + { + key: "sadhna_reminders", + label: "Sādhana reminders", + description: "Daily nudges to chant your rounds and share a verse.", + }, +]; + +const CHANNELS: { key: PrefChannel; label: string }[] = [ + { key: "in_app", label: "In-app" }, + { key: "email", label: "Email" }, + { key: "push", label: "Push" }, +]; + +export function NotificationMatrix() { + const [prefs, setPrefs] = useState(null); + const [failed, setFailed] = useState(false); + // The "event/channel" cell currently being written, so only it disables. + const [busyCell, setBusyCell] = useState(null); + + useEffect(() => { + let cancelled = false; + notifications + .prefs() + .then(({ prefs }) => { + if (!cancelled) setPrefs(prefs); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + return () => { + cancelled = true; + }; + }, []); + + async function toggle(event: PrefEvent, channel: PrefChannel) { + if (!prefs) return; + const next = !prefs[event][channel]; + setBusyCell(`${event}/${channel}`); + try { + // Turning a push cell ON only helps if this device can receive push — + // offer the permission/subscription right at the gesture, best-effort + // (the account-wide pref still saves if the user declines). + if ( + channel === "push" && + next && + pushSupported() && + pushPermission() !== "denied" && + !(await isPushEnabled()) + ) { + await enablePush().catch(() => { + /* declined / unavailable — other subscribed devices still get it */ + }); + } + const { prefs: updated } = await notifications.setPref( + event, + channel, + next, + ); + setPrefs(updated); + } catch { + toast.error("Could not update preference"); + } finally { + setBusyCell(null); + } + } + + if (failed) { + return ( +

+ Couldn't load your notification preferences. Reload to try again. +

+ ); + } + + return ( +
+ {/* Header row */} + + {CHANNELS.map((c) => ( + + {c.label} + + ))} + + {EVENTS.map((event) => ( +
+
+ {event.label} +

+ {event.description} +

+
+ {CHANNELS.map((channel) => ( +
+ {prefs ? ( + toggle(event.key, channel.key)} + label={`${event.label} — ${channel.label}`} + /> + ) : ( + … + )} +
+ ))} +
+ ))} +
+ ); +} diff --git a/components/push/push-toggle.tsx b/components/push/push-toggle.tsx index e2cf8f62..3514b585 100644 --- a/components/push/push-toggle.tsx +++ b/components/push/push-toggle.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { SettingRow, ToggleSwitch } from "@/components/settings/setting-row"; import { disablePush, enablePush, @@ -73,48 +74,29 @@ export function PushToggle() { } } - const description = describe(state); - return ( -
    -
  • -
    - - Push notifications + + ) : ( + + {state === "loading" + ? "…" + : state === "ios-install" + ? null + : "Unavailable"} -

    {description}

    -
    -
    - {state === "on" || state === "off" ? ( - - ) : ( - - {state === "loading" - ? "…" - : state === "ios-install" - ? null - : "Unavailable"} - - )} -
    -
  • -
+ ) + } + /> ); } diff --git a/components/sadhna/reminder-toggle.tsx b/components/sadhna/reminder-toggle.tsx deleted file mode 100644 index 2fb32cd5..00000000 --- a/components/sadhna/reminder-toggle.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -/** - * ReminderToggle — the settings control for daily sādhana reminders (email + - * push + websocket). Reads the current preference and lets the user opt out. - * Renders as a settings row matching the rest of /settings. - */ - -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { sadhna } from "@/lib/sadhna"; -import { - enablePush, - isPushEnabled, - pushPermission, - pushSupported, -} from "@/lib/push"; - -export function ReminderToggle() { - const [enabled, setEnabled] = useState(null); - const [busy, setBusy] = useState(false); - - useEffect(() => { - let cancelled = false; - sadhna - .reminderPrefs() - .then((r) => { - if (!cancelled) setEnabled(r.enabled); - }) - .catch(() => { - if (!cancelled) setEnabled(true); // default-on; toggle still works - }); - return () => { - cancelled = true; - }; - }, []); - - async function toggle() { - if (enabled === null) return; - const next = !enabled; - setBusy(true); - try { - // Turning reminders ON also offers push permission on this device, so - // the reminder can actually reach a closed app. Best-effort: if the - // user declines or push isn't available, reminders still go by email - // and in-app. Done before saving the pref so the permission request - // stays close to the toggle gesture. - if ( - next && - pushSupported() && - pushPermission() !== "denied" && - !(await isPushEnabled()) - ) { - await enablePush().catch(() => { - /* declined / unavailable — reminders still work elsewhere */ - }); - } - await sadhna.setReminderPrefs(next); - setEnabled(next); - toast.success(next ? "Reminders turned on" : "Reminders turned off"); - } catch { - toast.error("Could not update reminders"); - } finally { - setBusy(false); - } - } - - return ( -
    -
  • -
    - Sādhana reminders -

    - Daily nudges to chant your rounds and share a verse — by email, - push, and in-app. -

    -
    -
    - {enabled === null ? ( - … - ) : ( - - )} -
    -
  • -
- ); -} diff --git a/components/selection-toolbar/collections-drawer.tsx b/components/selection-toolbar/collections-drawer.tsx index 81359f8b..7de81f9f 100644 --- a/components/selection-toolbar/collections-drawer.tsx +++ b/components/selection-toolbar/collections-drawer.tsx @@ -37,12 +37,17 @@ type Status = "idle" | "saving" | "saved" | "auth" | "limit" | "error"; export function CollectionsDrawer({ open, onClose, + onSaved, isMobile, selectedText, existingPostId, }: { open: boolean; onClose: () => void; + /** Called instead of onClose after a successful save. Lets the selection + * toolbar end the flow (drop the selection) rather than restore it the + * way a cancel/dismiss does. Falls back to onClose when omitted. */ + onSaved?: () => void; isMobile: boolean; /** Provided by the selection toolbar — the drawer will create a fresh * post from this text before adding it to a collection. */ @@ -150,7 +155,9 @@ export function CollectionsDrawer({ c.id === collectionID ? { ...c, item_count: c.item_count + 1 } : c, ), ); - setTimeout(onClose, 800); + // Leave the "Saved ✓" row state visible for a beat, then complete the + // flow — onSaved (don't restore the selection) when provided. + setTimeout(onSaved ?? onClose, 800); } catch (err) { if (err instanceof SocialError) { const tone: Status = diff --git a/components/settings/setting-row.tsx b/components/settings/setting-row.tsx new file mode 100644 index 00000000..2a7e9e56 --- /dev/null +++ b/components/settings/setting-row.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * Shared building blocks for /settings rows: SettingRow lays out a + * label + description with a control on the right, and ToggleSwitch is the + * pill switch every boolean preference uses. Keeps the notification toggles + * (push, reminders, like emails) from each carrying a copy of this markup. + */ + +export function SettingRow({ + label, + description, + control, +}: { + label: string; + description: React.ReactNode; + control: React.ReactNode; +}) { + return ( +
    +
  • +
    + {label} +

    {description}

    +
    +
    {control}
    +
  • +
+ ); +} + +export function ToggleSwitch({ + checked, + disabled, + onToggle, + label, +}: { + checked: boolean; + disabled?: boolean; + onToggle: () => void; + /** Accessible name — the row label isn't programmatically associated. */ + label: string; +}) { + return ( + + ); +} diff --git a/components/text-selection-toolbar.tsx b/components/text-selection-toolbar.tsx index 31785c62..f6ba060b 100644 --- a/components/text-selection-toolbar.tsx +++ b/components/text-selection-toolbar.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { - AtSign, Bookmark as BookmarkIcon, Copy, FolderPlus, @@ -113,6 +112,18 @@ export function TextSelectionToolbar() { setShowMore(false); }, []); + // Completed-action teardown. Unlike clearSelection (which only hides the + // toolbar UI), this also drops the selection in the document — otherwise + // the still-highlighted text re-triggers the toolbar on the very next + // selectionchange/touchend — and forgets the saved range so a drawer's + // onClose can't restore it after the action already succeeded. + const finishAction = useCallback(() => { + savedRangeRef.current = null; + interactingRef.current = false; + window.getSelection()?.removeAllRanges(); + clearSelection(); + }, [clearSelection]); + // Listen for selection changes useEffect(() => { function handleSelectionChange() { @@ -349,7 +360,7 @@ export function TextSelectionToolbar() { }); toast.success("Bookmark saved", { duration: 2000 }); window.dispatchEvent(new CustomEvent("spsays:bookmarks-changed")); - clearSelection(); + finishAction(); } catch (err) { if (err instanceof SocialError && err.status === 402) { toast.error("Bookmark limit reached", { @@ -369,14 +380,14 @@ export function TextSelectionToolbar() { async function onCopyText() { await copy(formatCopyText(selectedText)); toast.success("Copied to clipboard", { duration: 1500 }); - clearSelection(); + finishAction(); } async function onCopyLink() { const url = await buildShortHighlightUrl(selectedText); await copy(url); toast.success("Link copied", { duration: 1500 }); - clearSelection(); + finishAction(); } async function onSaveToCollection() { @@ -400,7 +411,7 @@ export function TextSelectionToolbar() { icon: , onClick: () => { // TODO: Implement Ask Kautilya - clearSelection(); + finishAction(); }, className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300", }; @@ -549,6 +560,9 @@ export function TextSelectionToolbar() { { setShowCompose(false); if (isMobile) restoreSelection(); @@ -560,6 +574,12 @@ export function TextSelectionToolbar() { { + setShowCollections(false); + finishAction(); + }} onClose={() => { setShowCollections(false); if (isMobile) restoreSelection(); diff --git a/components/ui/drawer.tsx b/components/ui/drawer.tsx index fc2dca6a..5d21c474 100644 --- a/components/ui/drawer.tsx +++ b/components/ui/drawer.tsx @@ -7,10 +7,18 @@ import { cn } from "@/lib/utils"; const Drawer = ({ shouldScaleBackground = true, + // On iOS vaul freezes (position:fixed) while the drawer is open and + // restores the old scroll offset on close. When a link inside the drawer + // navigates, that restore fires AFTER the route change and drags the new + // page to the previous page's scroll position — from the bottom of a long + // page it looks like the link simply didn't work. This flag makes vaul + // skip the restore when the URL changed; plain dismissals still restore. + preventScrollRestoration = true, ...props }: React.ComponentProps) => ( ); diff --git a/lib/push.ts b/lib/push.ts index 466fae70..9af0988c 100644 --- a/lib/push.ts +++ b/lib/push.ts @@ -110,10 +110,23 @@ export async function enablePush(): Promise { throw new Error("Notification permission was not granted."); } - const reg = await navigator.serviceWorker.register("/sw.js"); + // The BFF origin rides along as a query param so the service worker can + // re-register rotated subscriptions (pushsubscriptionchange) on its own. + const reg = await navigator.serviceWorker.register( + `/sw.js?bff=${encodeURIComponent(BFF_BASE)}`, + ); await navigator.serviceWorker.ready; let sub = await reg.pushManager.getSubscription(); + // A subscription minted under a previous VAPID key still looks alive + // locally, but every send fails with a credential mismatch — drop it and + // subscribe fresh under the current key. + if (sub && !matchesServerKey(sub, public_key)) { + await sub.unsubscribe().catch(() => { + /* stale anyway — subscribe() below replaces it */ + }); + sub = null; + } if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, @@ -134,6 +147,19 @@ export async function enablePush(): Promise { if (!res.ok) throw new Error("Could not save your subscription."); } +/** + * Whether an existing subscription was created with the server's current + * VAPID public key. Browsers that don't expose applicationServerKey (older + * Safari) return null — treat that as a match rather than churn the sub. + */ +function matchesServerKey(sub: PushSubscription, publicKey: string): boolean { + const current = sub.options?.applicationServerKey; + if (!current) return true; + const a = new Uint8Array(current); + const b = urlBase64ToUint8Array(publicKey); + return a.length === b.length && a.every((v, i) => v === b[i]); +} + /** Disable push: unsubscribe locally and remove the subscription server-side. */ export async function disablePush(): Promise { const reg = await registration(); diff --git a/lib/sadhna.ts b/lib/sadhna.ts index fedd8236..720dab42 100644 --- a/lib/sadhna.ts +++ b/lib/sadhna.ts @@ -143,15 +143,8 @@ export const sadhna = { /** Acknowledge pending celebrations after showing the confetti. */ ackRewards: () => call("/sadhna/rewards/ack", { method: "POST" }), - /** Whether daily sādhana reminders are enabled for the caller. */ - reminderPrefs: () => call<{ enabled: boolean }>("/sadhna/reminders"), - - /** Toggle daily sādhana reminders. */ - setReminderPrefs: (enabled: boolean) => - call<{ enabled: boolean }>("/sadhna/reminders", { - method: "PUT", - body: JSON.stringify({ enabled }), - }), + // Reminder prefs moved to the notification matrix — + // see `notifications.prefs()` in lib/social.ts. /** Report the browser's IANA timezone so reminders fire at local time. */ setTimezone: (timezone: string) => diff --git a/lib/social.ts b/lib/social.ts index 91911e04..8469ad0f 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -505,18 +505,26 @@ export const notifications = { markAllRead() { return call("/notifications/read", { method: "POST" }); }, - /** Email-notification preferences (currently aggregated like emails). */ - emailPrefs() { - return call<{ like_emails: boolean }>("/notifications/email-prefs"); + /** The event × channel notification matrix (Settings → Notifications). */ + prefs() { + return call<{ prefs: NotificationPrefs }>("/notifications/prefs"); }, - setEmailPrefs(likeEmails: boolean) { - return call<{ like_emails: boolean }>("/notifications/email-prefs", { + /** Write one matrix cell; returns the full re-resolved matrix. */ + setPref(event: PrefEvent, channel: PrefChannel, enabled: boolean) { + return call<{ prefs: NotificationPrefs }>("/notifications/prefs", { method: "PUT", - body: JSON.stringify({ like_emails: likeEmails }), + body: JSON.stringify({ event, channel, enabled }), }); }, }; +// ─── Notification preferences ────────────────────────────────────────── + +export type PrefEvent = "follows" | "post_interactions" | "sadhna_reminders"; +export type PrefChannel = "in_app" | "email" | "push"; +/** event → channel → enabled, as resolved by the BFF (defaults on). */ +export type NotificationPrefs = Record>; + export const relationships = { /** Send a follow REQUEST. The target must accept before it counts as a * follow — the returned relationship reflects `follow_requested: true` diff --git a/public/sw.js b/public/sw.js index 8fb13834..2d3e1128 100644 --- a/public/sw.js +++ b/public/sw.js @@ -11,6 +11,51 @@ self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()), ); +// The page registers us as /sw.js?bff= so subscription management +// works without a client tab open (the auth cookie rides on +// credentials:"include"). Same-origin deployments fall back to our origin. +const BFF_BASE = + new URL(self.location.href).searchParams.get("bff") || self.location.origin; + +/* Push services rotate subscriptions (expiry, key migration). Without this + * handler the server keeps pushing to the dead endpoint and the user silently + * stops getting notifications — so re-subscribe with the same application + * server key and swap the registration on the BFF. */ +self.addEventListener("pushsubscriptionchange", (event) => { + event.waitUntil( + (async () => { + const old = event.oldSubscription; + let sub = event.newSubscription || null; + if (!sub) { + const appKey = old && old.options && old.options.applicationServerKey; + if (!appKey) return; // nothing to re-subscribe with + sub = await self.registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: appKey, + }); + } + + const json = sub.toJSON(); + await fetch(`${BFF_BASE}/api/v1/push/subscribe`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }), + }); + if (old && old.endpoint && old.endpoint !== sub.endpoint) { + await fetch(`${BFF_BASE}/api/v1/push/unsubscribe`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: old.endpoint }), + }).catch(() => { + /* best-effort — the server prunes dead endpoints on send anyway */ + }); + } + })(), + ); +}); + self.addEventListener("push", (event) => { event.waitUntil( (async () => {