Files
eranos/src/hooks/useNativeNotifications.ts
T
Chad Curtis b76bf7b198 Condense notifications and add per-type notification preferences
- Group reactions, reposts, and zaps on the same post into a single condensed
  row with stacked actor avatars (up to 5 + overflow count); single-actor
  interactions use the original compact layout
- Deduplicate actors by pubkey within groups
- Sum zap totals across all actors in a zap group
- Add notificationPreferences per-type settings (reactions, reposts, zaps,
  mentions, comments, onlyFollowing) to EncryptedSettings and Zod schema
- Per-type kind toggles and onlyFollowing applied at relay query level (kinds
  array + authors filter), not client-side; query key includes kindsKey and
  authorsKey so preference changes trigger a fresh fetch
- Default push notifications to enabled
- Notification settings UI matches ContentSettings exactly: section headers
  with accent underline, p-4 wrapper, broken border rows (border-b on outer
  wrapper div), sub-section labels, w-[52px] switch column, KindBadges
- Add 'Only from people I follow' toggle under Filter sub-section
- Filter and push notification prefs also respected in useNativeNotifications
  web push subscription via stable ref
2026-03-14 19:06:31 -05:00

170 lines
6.5 KiB
TypeScript

import { useEffect, useMemo, useRef } from 'react';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { LocalNotifications } from '@capacitor/local-notifications';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NPool } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useAppContext } from './useAppContext';
import { useEncryptedSettings } from './useEncryptedSettings';
import { getEffectiveRelays } from '@/lib/appRelays';
/** Interface for the native DittoNotification Capacitor plugin. */
interface DittoNotificationPlugin {
configure(options: { userPubkey?: string; relayUrls?: string[] }): Promise<void>;
}
const DittoNotification = registerPlugin<DittoNotificationPlugin>('DittoNotification');
/** Human-readable label for a notification event kind. */
function notificationTitle(event: NostrEvent): string {
switch (event.kind) {
case 7: return 'New reaction';
case 6:
case 16: return 'New repost';
case 9735: return 'New zap';
case 1111: return 'New comment';
case 1222:
case 1244: return 'New voice message';
default: return 'New mention';
}
}
/**
* Hook that manages device/browser notifications for the Nostr app.
*
* Capacitor (native): passes user pubkey + relay URLs to the native Android
* notification service. Respects the user's notificationsEnabled setting.
*
* Web/PWA: subscribes to Nostr events via a live relay subscription and
* fires browser Notification API notifications when the user has both
* granted browser permission and enabled notifications in their settings.
*/
export function useNativeNotifications(): void {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { settings } = useEncryptedSettings();
const notificationsEnabled = settings?.notificationsEnabled ?? true;
const notifPrefs = useMemo(() => settings?.notificationPreferences ?? {}, [settings?.notificationPreferences]);
// Track the subscription start time so we only surface events that arrive
// after the subscription is opened (avoids replaying historical events).
const subStartRef = useRef<number>(Math.floor(Date.now() / 1000));
// Keep a stable ref to the nostr object to avoid re-subscribing on every render.
const nostrRef = useRef<NPool>(nostr);
useEffect(() => { nostrRef.current = nostr; }, [nostr]);
// Keep a stable ref to per-type prefs so the async loop reads the latest
// values without triggering a reconnect on every preference change.
const notifPrefsRef = useRef(notifPrefs);
useEffect(() => { notifPrefsRef.current = notifPrefs; }, [notifPrefs]);
// Deduplicate: track event IDs that have already triggered a notification.
const seenIdsRef = useRef<Set<string>>(new Set());
// ── Capacitor path ────────────────────────────────────────────────────────
// Request native notification permission on first mount (native only).
useEffect(() => {
if (!Capacitor.isNativePlatform()) return;
(async () => {
try {
const { display } = await LocalNotifications.checkPermissions();
if (display === 'prompt' || display === 'prompt-with-rationale') {
await LocalNotifications.requestPermissions();
}
} catch {
// Permission check failed — ignore
}
})();
}, []);
// Configure / deconfigure the native polling service.
useEffect(() => {
if (!Capacitor.isNativePlatform()) return;
if (!user || !notificationsEnabled) {
// Logged out or user disabled notifications — stop the native service.
DittoNotification.configure({});
return;
}
const effectiveRelays = getEffectiveRelays(config.relayMetadata, config.useAppRelays);
const relayUrls = effectiveRelays.relays
.filter((r) => r.read)
.map((r) => r.url);
if (relayUrls.length === 0) return;
DittoNotification.configure({
userPubkey: user.pubkey,
relayUrls,
});
}, [user, config.relayMetadata, config.useAppRelays, notificationsEnabled]);
// ── Web / PWA path ────────────────────────────────────────────────────────
useEffect(() => {
// Only run on web (not native Capacitor).
if (Capacitor.isNativePlatform()) return;
// Need a logged-in user, notifications enabled in settings, and browser permission.
if (!user || !notificationsEnabled) return;
if (!('Notification' in window) || Notification.permission !== 'granted') return;
// Record when we opened the subscription so old events are ignored.
subStartRef.current = Math.floor(Date.now() / 1000);
const controller = new AbortController();
(async () => {
try {
const stream = nostrRef.current.req(
[{
kinds: [1, 6, 7, 16, 9735, 1111, 1222, 1244],
'#p': [user.pubkey],
since: subStartRef.current,
}],
{ signal: controller.signal },
);
for await (const msg of stream) {
if (msg[0] !== 'EVENT') continue;
const event: NostrEvent = msg[2];
// Skip own events
if (event.pubkey === user.pubkey) continue;
// Skip events older than when the sub opened (relay may send a burst)
if (event.created_at < subStartRef.current) continue;
// Deduplicate: skip if we've already shown a notification for this event
if (seenIdsRef.current.has(event.id)) continue;
seenIdsRef.current.add(event.id);
// Respect per-type preferences (default = enabled when not explicitly false)
const prefs = notifPrefsRef.current;
if (event.kind === 7 && prefs.reactions === false) continue;
if ((event.kind === 6 || event.kind === 16) && prefs.reposts === false) continue;
if (event.kind === 9735 && prefs.zaps === false) continue;
if (event.kind === 1 && prefs.mentions === false) continue;
if (event.kind === 1111 && prefs.comments === false) continue;
new Notification(notificationTitle(event), {
body: event.content.slice(0, 120) || undefined,
tag: event.id,
icon: '/favicon.ico',
});
}
} catch {
// Subscription closed or errored — ignore
}
})();
return () => {
controller.abort();
};
}, [user, notificationsEnabled]);
}