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.
345 lines
11 KiB
TypeScript
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;
|
|
}
|