+ {/* 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) */}
+
+ {pinned ? (
+
+ ) : (
+
+ )}
+
+
{/* Minimize (playing) or Close (paused) */}
)}
+ {/* Pin — keeps the player expanded (no auto-minimize) */}
+
+ {pinned ? (
+
+ ) : (
+
+ )}
+
+
{/* Minimize (playing) or Close (paused) */}
directly
+ * in the DOM, so it works against server-rendered content (including
+ * dangerouslySetInnerHTML blocks) without needing the text as props.
+ *
+ * Matching is case- AND diacritic-insensitive — "krsna" finds "Kṛṣṇa",
+ * "sanjaya" finds "Sañjaya" — essential for IAST transliteration content
+ * that users can't easily type.
+ *
+ * The bar is portaled to : the sticky PageHeader animates with a
+ * transform when it collapses on scroll, which would hijack any
+ * position:fixed descendant and drag the bar off-screen with it.
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const MIN_QUERY = 2;
+const DEBOUNCE_MS = 250;
+const HIT_CLASS = "bg-primary/20 text-inherit rounded-[2px]";
+const CURRENT_CLASS = "bg-primary text-primary-foreground rounded-[2px]";
+
+/** Case + diacritic fold: "Kṛṣṇa" → "krsna". */
+function fold(s: string): string {
+ return s
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase();
+}
+
+/**
+ * Fold a string keeping a map from each folded char back to the index of
+ * the original char it came from, so match offsets translate back.
+ */
+function foldWithMap(s: string): { folded: string; map: number[] } {
+ let folded = "";
+ const map: number[] = [];
+ for (let i = 0; i < s.length; i++) {
+ const f = fold(s[i]);
+ for (let j = 0; j < f.length; j++) {
+ folded += f[j];
+ map.push(i);
+ }
+ }
+ return { folded, map };
+}
+
+/** Unwrap every mark this component added and re-merge the text nodes. */
+function clearHighlights(container: HTMLElement) {
+ container.querySelectorAll("mark[data-find]").forEach((mark) => {
+ const parent = mark.parentNode;
+ if (!parent) return;
+ parent.replaceChild(
+ document.createTextNode(mark.textContent ?? ""),
+ mark,
+ );
+ parent.normalize();
+ });
+}
+
+/**
+ * Highlight every occurrence of `query` inside `container`, returning the
+ * created marks in document order. Searches each text node independently
+ * (matches spanning inline-tag boundaries are not found — acceptable for
+ * prose where emphasis runs are whole words/phrases).
+ */
+function applyHighlights(container: HTMLElement, query: string): HTMLElement[] {
+ const foldedQuery = fold(query);
+ if (!foldedQuery) return [];
+
+ // Snapshot the nodes first — wrapping mutates the tree under the walker.
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
+ const textNodes: Text[] = [];
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
+ textNodes.push(n as Text);
+ }
+
+ const marks: HTMLElement[] = [];
+ for (const node of textNodes) {
+ const text = node.textContent ?? "";
+ if (!text.trim()) continue;
+ const { folded, map } = foldWithMap(text);
+
+ // Collect match ranges in original-string coordinates.
+ const ranges: Array<[number, number]> = [];
+ let from = 0;
+ for (;;) {
+ const at = folded.indexOf(foldedQuery, from);
+ if (at === -1) break;
+ const endFolded = at + foldedQuery.length;
+ const start = map[at];
+ const end = endFolded < map.length ? map[endFolded] : text.length;
+ if (end > start) ranges.push([start, end]);
+ from = endFolded;
+ }
+ if (ranges.length === 0) continue;
+
+ // Rebuild the node as text/mark fragments.
+ const frag = document.createDocumentFragment();
+ let cursor = 0;
+ for (const [start, end] of ranges) {
+ if (start > cursor) {
+ frag.appendChild(document.createTextNode(text.slice(cursor, start)));
+ }
+ const mark = document.createElement("mark");
+ mark.setAttribute("data-find", "");
+ mark.className = HIT_CLASS;
+ mark.textContent = text.slice(start, end);
+ frag.appendChild(mark);
+ marks.push(mark);
+ cursor = end;
+ }
+ if (cursor < text.length) {
+ frag.appendChild(document.createTextNode(text.slice(cursor)));
+ }
+ node.parentNode?.replaceChild(frag, node);
+ }
+ return marks;
+}
+
+/** Style the active mark and bring it into view. */
+function focusMark(marks: HTMLElement[], index: number) {
+ marks.forEach((m, i) => {
+ m.className = i === index ? CURRENT_CLASS : HIT_CLASS;
+ });
+ marks[index]?.scrollIntoView({ block: "center", behavior: "smooth" });
+}
+
+export function FindInPage({
+ targetId,
+ className,
+}: {
+ /** id of the element whose text content is searched. */
+ targetId: string;
+ className?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [count, setCount] = useState(0);
+ const [current, setCurrent] = useState(0);
+ const marksRef = useRef([]);
+ const inputRef = useRef(null);
+
+ const close = useCallback(() => {
+ const el = document.getElementById(targetId);
+ if (el) clearHighlights(el);
+ marksRef.current = [];
+ setOpen(false);
+ setQuery("");
+ setCount(0);
+ setCurrent(0);
+ }, [targetId]);
+
+ // Debounced (re)search on every query change while open.
+ useEffect(() => {
+ if (!open) return;
+ const id = window.setTimeout(() => {
+ const el = document.getElementById(targetId);
+ if (!el) return;
+ clearHighlights(el);
+ const q = query.trim();
+ const marks = q.length >= MIN_QUERY ? applyHighlights(el, q) : [];
+ marksRef.current = marks;
+ setCount(marks.length);
+ setCurrent(0);
+ if (marks.length > 0) focusMark(marks, 0);
+ }, DEBOUNCE_MS);
+ return () => window.clearTimeout(id);
+ }, [query, open, targetId]);
+
+ // Focus the input when the bar opens.
+ useEffect(() => {
+ if (open) inputRef.current?.focus();
+ }, [open]);
+
+ // Drop any leftover highlights if the component unmounts mid-search.
+ useEffect(() => {
+ return () => {
+ const el = document.getElementById(targetId);
+ if (el) clearHighlights(el);
+ };
+ }, [targetId]);
+
+ const step = useCallback(
+ (dir: 1 | -1) => {
+ const marks = marksRef.current;
+ if (marks.length === 0) return;
+ setCurrent((c) => {
+ const next = (c + dir + marks.length) % marks.length;
+ focusMark(marks, next);
+ return next;
+ });
+ },
+ [],
+ );
+
+ return (
+ <>
+ setOpen(true)}
+ aria-label="Find in page"
+ className={cn(
+ "inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
+ className,
+ )}
+ >
+
+ Find in page
+
+
+ {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"
+ : ""}
+
+ step(-1)}
+ disabled={count === 0}
+ aria-label="Previous match"
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
+ >
+
+
+ step(1)}
+ disabled={count === 0}
+ aria-label="Next match"
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
+ >
+
+
+
+
+
+
+
,
+ 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 (
-
- );
-}
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 (
-
+ )
+ }
+ />
);
}
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 (
-
- );
-}
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 () => {