fix: ui fixes
This commit is contained in:
@@ -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() {
|
||||
|
||||
<Section
|
||||
title="Notifications"
|
||||
description="Receive alerts on this device — even when the app is closed."
|
||||
description="Choose what reaches you on each channel. Push goes to every device where you've turned it on."
|
||||
>
|
||||
<PushToggle />
|
||||
<ReminderToggle />
|
||||
<LikeEmailToggle />
|
||||
<NotificationMatrix />
|
||||
</Section>
|
||||
|
||||
<Section title="Authentication">
|
||||
|
||||
@@ -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}
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">{t.name}</h1>
|
||||
<div className="mt-3 flex items-start justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t.name}</h1>
|
||||
{t.content.length > 0 && (
|
||||
<FindInPage targetId="transcript-content" className="shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 flex-wrap text-sm text-muted-foreground">
|
||||
<Badge variant={t.type === "lecture" ? "default" : "secondary"}>
|
||||
{t.type === "lecture" ? "Lecture" : "Conversation"}
|
||||
@@ -168,7 +174,8 @@ export default async function TranscriptionPage({
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 px-6 sm:px-8">
|
||||
<div className="space-y-3">
|
||||
{/* id targeted by the header's FindInPage control */}
|
||||
<div id="transcript-content" className="space-y-3">
|
||||
{t.content.map((block, i) => (
|
||||
<ContentBlockView key={i} block={block} />
|
||||
))}
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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<HTMLDivElement>(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() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Pin — keeps the player expanded (no auto-minimize) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePin}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? "Unpin player" : "Pin player open"}
|
||||
title={
|
||||
pinned
|
||||
? "Unpin — player minimizes on its own again"
|
||||
: "Pin — keep the player from minimizing"
|
||||
}
|
||||
className={cn(
|
||||
"size-7 inline-flex items-center justify-center rounded-md transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
pinned
|
||||
? "text-primary bg-white/10 hover:bg-white/15"
|
||||
: "text-white/50 hover:text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{pinned ? (
|
||||
<PinOff className="size-4" />
|
||||
) : (
|
||||
<Pin className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Minimize (playing) or Close (paused) */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -963,6 +1034,26 @@ export function FloatingPlayer() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Pin — keeps the player expanded (no auto-minimize) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePin}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? "Unpin player" : "Pin player open"}
|
||||
className={cn(
|
||||
"size-8 inline-flex items-center justify-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0",
|
||||
pinned
|
||||
? "text-primary bg-white/10 hover:bg-white/15"
|
||||
: "text-white/50 hover:text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{pinned ? (
|
||||
<PinOff className="size-4" />
|
||||
) : (
|
||||
<Pin className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Minimize (playing) or Close (paused) */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FindInPage — in-page text search for long reading pages (transcripts etc.).
|
||||
*
|
||||
* Renders a "Find in page" trigger for a page header; while open, a floating
|
||||
* find bar with a query input, match counter, and prev/next navigation.
|
||||
* Matches are highlighted by wrapping text nodes in <mark data-find> 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 <body>: 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<HTMLElement[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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,
|
||||
)}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
<span className="hidden sm:inline">Find in page</span>
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div className="fixed top-14 sm:top-16 inset-x-0 z-30 flex justify-center px-4 pointer-events-none">
|
||||
<div className="pointer-events-auto flex w-full max-w-md items-center gap-1 rounded-xl border border-border bg-card px-2 py-1.5 shadow-lg">
|
||||
<Search className="ml-1 size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="shrink-0 select-none text-xs tabular-nums text-muted-foreground"
|
||||
>
|
||||
{count > 0
|
||||
? `${current + 1}/${count}`
|
||||
: query.trim().length >= MIN_QUERY
|
||||
? "0 results"
|
||||
: ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Close find bar"
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<boolean | null>(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 (
|
||||
<ul className="divide-y divide-border">
|
||||
<li className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Like emails</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Get an email when someone likes your post (bundled — one email per
|
||||
post). Push and in-app notifications stay on either way.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{enabled === null ? (
|
||||
<span className="text-xs text-muted-foreground">…</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
aria-pressed={enabled}
|
||||
className={[
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
|
||||
enabled ? "bg-primary" : "bg-muted-foreground/30",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
|
||||
enabled ? "translate-x-5" : "translate-x-0.5",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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<NotificationPrefs | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
// The "event/channel" cell currently being written, so only it disables.
|
||||
const [busyCell, setBusyCell] = useState<string | null>(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 (
|
||||
<p className="py-3 text-xs text-muted-foreground">
|
||||
Couldn't load your notification preferences. Reload to try again.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-x-4 gap-y-0 sm:gap-x-6">
|
||||
{/* Header row */}
|
||||
<span aria-hidden />
|
||||
{CHANNELS.map((c) => (
|
||||
<span
|
||||
key={c.key}
|
||||
className="pb-2 text-center text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{EVENTS.map((event) => (
|
||||
<div key={event.key} className="contents">
|
||||
<div className="min-w-0 border-t border-border py-3">
|
||||
<span className="text-sm text-foreground">{event.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
{CHANNELS.map((channel) => (
|
||||
<div
|
||||
key={channel.key}
|
||||
className="flex justify-center border-t border-border py-3"
|
||||
>
|
||||
{prefs ? (
|
||||
<ToggleSwitch
|
||||
checked={prefs[event.key][channel.key]}
|
||||
disabled={busyCell === `${event.key}/${channel.key}`}
|
||||
onToggle={() => toggle(event.key, channel.key)}
|
||||
label={`${event.label} — ${channel.label}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">…</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ul className="divide-y divide-border">
|
||||
<li className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">
|
||||
Push notifications
|
||||
<SettingRow
|
||||
label="Push notifications"
|
||||
description={describe(state)}
|
||||
control={
|
||||
state === "on" || state === "off" ? (
|
||||
<ToggleSwitch
|
||||
checked={state === "on"}
|
||||
disabled={busy}
|
||||
onToggle={toggle}
|
||||
label="Push notifications"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{state === "loading"
|
||||
? "…"
|
||||
: state === "ios-install"
|
||||
? null
|
||||
: "Unavailable"}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{state === "on" || state === "off" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
aria-pressed={state === "on"}
|
||||
className={[
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
|
||||
state === "on" ? "bg-primary" : "bg-muted-foreground/30",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
|
||||
state === "on" ? "translate-x-5" : "translate-x-0.5",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{state === "loading"
|
||||
? "…"
|
||||
: state === "ios-install"
|
||||
? null
|
||||
: "Unavailable"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean | null>(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 (
|
||||
<ul className="divide-y divide-border">
|
||||
<li className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Sādhana reminders</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Daily nudges to chant your rounds and share a verse — by email,
|
||||
push, and in-app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{enabled === null ? (
|
||||
<span className="text-xs text-muted-foreground">…</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
aria-pressed={enabled}
|
||||
className={[
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
|
||||
enabled ? "bg-primary" : "bg-muted-foreground/30",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
|
||||
enabled ? "translate-x-5" : "translate-x-0.5",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 (
|
||||
<ul className="divide-y divide-border">
|
||||
<li className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{control}</div>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
|
||||
checked ? "bg-primary" : "bg-muted-foreground/30",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
|
||||
checked ? "translate-x-5" : "translate-x-0.5",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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: <KautilyaIcon className="size-3.5" />,
|
||||
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() {
|
||||
|
||||
<ComposePost
|
||||
open={showCompose}
|
||||
// Successful post → end the flow; the onClose that follows finds
|
||||
// no saved range left to restore, so the toolbar stays gone.
|
||||
onPosted={finishAction}
|
||||
onClose={() => {
|
||||
setShowCompose(false);
|
||||
if (isMobile) restoreSelection();
|
||||
@@ -560,6 +574,12 @@ export function TextSelectionToolbar() {
|
||||
|
||||
<CollectionsDrawer
|
||||
open={showCollections}
|
||||
// Successful save → close the drawer and end the flow (cancel still
|
||||
// goes through onClose and restores the selection).
|
||||
onSaved={() => {
|
||||
setShowCollections(false);
|
||||
finishAction();
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowCollections(false);
|
||||
if (isMobile) restoreSelection();
|
||||
|
||||
@@ -7,10 +7,18 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
// On iOS vaul freezes <body> (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<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
preventScrollRestoration={preventScrollRestoration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
+27
-1
@@ -110,10 +110,23 @@ export async function enablePush(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const reg = await registration();
|
||||
|
||||
+2
-9
@@ -143,15 +143,8 @@ export const sadhna = {
|
||||
/** Acknowledge pending celebrations after showing the confetti. */
|
||||
ackRewards: () => call<void>("/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) =>
|
||||
|
||||
+14
-6
@@ -505,18 +505,26 @@ export const notifications = {
|
||||
markAllRead() {
|
||||
return call<void>("/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<PrefEvent, Record<PrefChannel, boolean>>;
|
||||
|
||||
export const relationships = {
|
||||
/** Send a follow REQUEST. The target must accept before it counts as a
|
||||
* follow — the returned relationship reflects `follow_requested: true`
|
||||
|
||||
@@ -11,6 +11,51 @@ self.addEventListener("activate", (event) =>
|
||||
event.waitUntil(self.clients.claim()),
|
||||
);
|
||||
|
||||
// The page registers us as /sw.js?bff=<origin> 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user