SkateSpots/src/components/OsNotificationBridge.tsx
SkateSpots d966bca148 feat(notify): scheduled event reminders (Phase 1b) + v1.5.7
An OS alarm an hour before each upcoming event the user RSVP'd going or
interested to. Because the OS holds the alarm it fires with the app fully
closed — the only closed-app notification achievable without a server.

- src/lib/reminders.ts: schedule/cancel via @capacitor/local-notifications,
  tracking handed-out ids in localStorage so withdrawn RSVPs and passed events
  are cancelled without disturbing one-off notifications. Web is a no-op.
- src/hooks/useLocalReminders.ts: reuses the existing upcoming-events and
  my-RSVPs queries; no new relay destination, no signing.
- Bridge mounts the hook only on native and only when the new 'reminders'
  pref is on; unmount cancels everything (opt-out/logout).
- Skipped the Pro-expiry reminder on purpose: it would drag the heavy
  useSasquatchPro query into a nudge.

Bumps to v1.5.7 (versionCode 10507); APK rebuilt.
2026-09-29 14:12:55 +07:00

345 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useActivityNotifications, type ActivityNotification } from '@/hooks/useActivityNotifications';
import { useCrewNotifications } from '@/hooks/useCrewNotifications';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
import { useLocalReminders } from '@/hooks/useLocalReminders';
import { isNativePlatform } from '@/lib/platform';
import {
getNotificationPermission,
listenForNotificationTaps,
showOsNotification,
} from '@/lib/osNotify';
import type { NotificationCategory, NotificationPrefs } from '@/lib/notificationPrefs';
import type { CollectiveJoinRequest, CrewInvite, CrewJoinRequest, SkateCrew, SkateNewsletterIssue } from '@/lib/types';
/**
* Turns the in-app activity the app already computes (bell, crews, DMs,
* newsletter) into OS notifications — **without adding any new relay traffic or
* any new network destination**.
*
* Design constraints, all deliberate:
* - **Opt-in only.** The engine is not even mounted until the user enables
* notifications, and nothing is requested from the OS until then.
* - **Never decrypts.** DM notifications come from the encrypted gift-wrap
* ENVELOPE, so an Amber user is never prompted to decrypt just to be told
* they have a message.
* - **No history replay.** Each source is "primed" silently the first time it
* returns data; only items that arrive afterwards are ever announced.
* - **Only when you're not looking.** A notification is suppressed while the
* app/tab is visible — the bell already covers that case.
*
* Delivery is best-effort and local: a closed app/browser is not reachable
* without a server-side push, which this intentionally is not.
*/
type Source = NotificationCategory;
interface Candidate {
/** Stable identity — event/wrap id, prefixed by kind. */
key: string;
source: Source;
createdAt: number;
title: string;
body: string;
path: string;
}
interface SeenState {
primed: Partial<Record<Source, boolean>>;
ids: string[];
}
const SEEN_KEY = 'skatespots:notifications:seen';
const MAX_SEEN_IDS = 600;
const MAX_PER_BURST = 3;
function loadSeen(): SeenState {
try {
const raw = localStorage.getItem(SEEN_KEY);
if (raw) {
const parsed = JSON.parse(raw) as SeenState;
if (Array.isArray(parsed?.ids)) {
return { primed: parsed.primed ?? {}, ids: parsed.ids };
}
}
} catch {
// Corrupt/blocked storage — start fresh.
}
return { primed: {}, ids: [] };
}
function persistSeen(state: SeenState): void {
try {
localStorage.setItem(
SEEN_KEY,
JSON.stringify({ primed: state.primed, ids: state.ids.slice(-MAX_SEEN_IDS) }),
);
} catch {
// Storage full/blocked — notifications still work for this session.
}
}
/** `/crew/<pubkey>/<identifier>` from a `35006:pubkey:identifier` coordinate. */
function coordPath(coord: string, route: string, fallback: string): string {
const parts = coord.split(':');
if (parts.length >= 3 && parts[1] && parts[2]) return `/${route}/${parts[1]}/${parts[2]}`;
return fallback;
}
function activityCandidate(n: ActivityNotification): Candidate {
let title: string;
switch (n.type) {
case 'zap':
title = n.amountSats ? `You got zapped ${n.amountSats.toLocaleString()} sats` : 'You got zapped';
break;
case 'reaction':
title = 'Someone liked your post';
break;
case 'repost':
title = 'Someone reposted you';
break;
case 'mention':
title = 'Someone mentioned you';
break;
case 'reply':
title = 'Someone replied to you';
break;
case 'rsvp':
title = "Someone RSVP'd to your event";
break;
case 'event-update':
title = "An event you're going to was updated";
break;
default:
title = 'New activity';
break;
}
return {
key: `activity:${n.id}`,
source: 'social',
createdAt: n.createdAt,
title,
body: 'Tap to open SkateSpots',
path: '/',
};
}
function buildCrewCandidates(
invites: CrewInvite[],
listedCrews: SkateCrew[],
joinRequests: Array<{ request: CrewJoinRequest }>,
collectiveRequests: CollectiveJoinRequest[],
): Candidate[] {
const out: Candidate[] = [];
for (const inv of invites) {
out.push({
key: `crew-invite:${inv.id}`,
source: 'crew',
createdAt: inv.createdAt,
title: 'Crew invite',
body: `You're invited to ${inv.crewName}`,
path: coordPath(inv.crewCoord, 'crew', '/crews'),
});
}
for (const crew of listedCrews) {
out.push({
key: `crew-confirm:${crew.pubkey}:${crew.identifier}`,
source: 'crew',
createdAt: crew.createdAt ?? 0,
title: "You've been added to a crew",
body: `${crew.name} — confirm to join`,
path: `/crew/${crew.pubkey}/${crew.identifier}`,
});
}
for (const { request } of joinRequests) {
out.push({
key: `crew-join:${request.id}`,
source: 'crew',
createdAt: request.createdAt,
title: 'Join request',
body: `Someone wants to join ${request.crewName}`,
path: coordPath(request.crewCoord, 'crew', '/crews'),
});
}
for (const req of collectiveRequests) {
out.push({
key: `crew-collective:${req.id}`,
source: 'crew',
createdAt: req.createdAt,
title: 'Join request',
body: `Someone wants to join ${req.collectiveName}`,
path: '/crews',
});
}
return out;
}
function dmCandidate(envelope: DmEnvelope): Candidate {
return {
key: `dm:${envelope.id}`,
source: 'dms',
createdAt: envelope.createdAt,
title: 'New direct message',
body: 'Tap to read it',
path: '/messages',
};
}
function newsletterCandidate(issue: SkateNewsletterIssue): Candidate {
return {
key: `newsletter:${issue.id}`,
source: 'newsletter',
createdAt: issue.publishedAt,
title: issue.title,
body: 'New SkateSpots drop',
path: '/newsletter',
};
}
async function dispatchNotifications(items: Candidate[]): Promise<void> {
const permission = await getNotificationPermission();
if (permission !== 'granted') return;
const sorted = [...items].sort((a, b) => b.createdAt - a.createdAt);
const shown = sorted.slice(0, MAX_PER_BURST);
for (const candidate of shown) {
await showOsNotification({
id: candidate.key,
title: candidate.title,
body: candidate.body,
path: candidate.path,
});
}
if (sorted.length > shown.length) {
await showOsNotification({
id: `summary:${Date.now()}`,
title: `${sorted.length - shown.length} more updates`,
body: 'Open SkateSpots to catch up',
path: '/',
});
}
}
/**
* Mounted once in `Layout`. Renders nothing; the inner engine only exists once
* notifications are enabled, so disabling them costs zero queries.
*/
export function OsNotificationBridge() {
const { user } = useCurrentUser();
const [prefs] = useNotificationPrefs();
if (!user || !prefs.enabled) return null;
return <NotificationEngine prefs={prefs} />;
}
function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) {
const navigate = useNavigate();
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
const { invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
useCrewNotifications();
const { data: issues = [], isLoading: newsletterLoading } = useNewsletterIssues();
const { isSubscribed } = useNewsletterSubscription();
const { data: envelopes = [], isFetched: dmFetched } = useDmEnvelopes(true);
// Loaded lazily and mutated directly (never state) so bookkeeping can't cause
// a render loop.
const seenRef = useRef<SeenState | null>(null);
if (seenRef.current === null) seenRef.current = loadSeen();
// Native notification taps → in-app navigation. No-op on the web.
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void listenForNotificationTaps((path) => navigate(path)).then((fn) => {
if (cancelled) fn();
else unsubscribe = fn;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [navigate]);
const socialItems = useMemo(() => activity.map(activityCandidate), [activity]);
const crewItems = useMemo(
() => buildCrewCandidates(invites, listedCrews, joinRequests, collectiveRequests),
[invites, listedCrews, joinRequests, collectiveRequests],
);
const newsletterItems = useMemo(
() => (isSubscribed ? issues.map(newsletterCandidate) : []),
[issues, isSubscribed],
);
const dmItems = useMemo(() => envelopes.map(dmCandidate), [envelopes]);
useEffect(() => {
const seen = seenRef.current;
if (!seen) return;
const sources: Array<{ name: Source; ready: boolean; items: Candidate[] }> = [
{ name: 'social', ready: !activityLoading, items: socialItems },
{ name: 'crew', ready: !crewLoading, items: crewItems },
{ name: 'newsletter', ready: !newsletterLoading, items: newsletterItems },
{ name: 'dms', ready: dmFetched, items: dmItems },
];
let changed = false;
const fresh: Candidate[] = [];
for (const source of sources) {
// Wait for this source's first real result — priming on an empty default
// would make the eventual data look "new" and flood the shade.
if (!source.ready) continue;
if (!seen.primed[source.name]) {
seen.primed[source.name] = true;
for (const item of source.items) seen.ids.push(item.key);
changed = true;
continue;
}
const seenSet = new Set(seen.ids);
for (const item of source.items) {
if (seenSet.has(item.key)) continue;
seenSet.add(item.key);
seen.ids.push(item.key);
fresh.push(item);
changed = true;
}
}
if (changed) persistSeen(seen);
if (fresh.length === 0) return;
// If the user is looking at the app, the bell already told them.
if (typeof document !== 'undefined' && document.visibilityState === 'visible') return;
const allowed = fresh.filter((item) => prefs[item.source]);
if (allowed.length === 0) return;
void dispatchNotifications(allowed);
}, [
socialItems,
crewItems,
newsletterItems,
dmItems,
activityLoading,
crewLoading,
newsletterLoading,
dmFetched,
prefs,
]);
// Scheduled reminders are native-only (the web has no scheduler), so only
// mount the hook — and therefore only run its event/RSVP queries — when the
// platform can actually use them.
return prefs.reminders && isNativePlatform() ? <LocalReminders /> : null;
}
function LocalReminders() {
useLocalReminders();
return null;
}