Wire notifications toggle to encrypted settings; add PWA browser notification support

- Add notificationsEnabled to EncryptedSettings schema and interface
- NotificationSettings now persists the toggle to encrypted settings (synced across devices) instead of ephemeral local state
- useNativeNotifications: Capacitor path skips DittoNotification.configure when notificationsEnabled is false
- useNativeNotifications: web/PWA path opens a live Nostr req subscription and fires browser Notification API events when permission is granted and the setting is enabled
This commit is contained in:
Chad Curtis
2026-02-26 15:05:47 -06:00
parent 8c6747e46f
commit fa250e6a19
4 changed files with 105 additions and 25 deletions
+2
View File
@@ -34,6 +34,8 @@ export interface EncryptedSettings {
contentFilters?: ContentFilter[];
/** How to handle NIP-36 content-warning events */
contentWarningPolicy?: ContentWarningPolicy;
/** Whether the user has enabled push notifications */
notificationsEnabled?: boolean;
/** Timestamp of last viewed notification (Unix timestamp in seconds) */
notificationsCursor?: number;
/** Last sync timestamp */
+86 -14
View File
@@ -1,9 +1,12 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { Capacitor, registerPlugin } from '@capacitor/core';
import { LocalNotifications } from '@capacitor/local-notifications';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } 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. */
@@ -13,21 +16,42 @@ interface DittoNotificationPlugin {
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';
default: return 'New mention';
}
}
/**
* Hook that manages native device notifications for the Nostr app.
* Hook that manages device/browser notifications for the Nostr app.
*
* On login: passes the user's pubkey and relay URLs to the native Android
* notification service, which maintains a persistent WebSocket subscription
* to a Nostr relay for real-time event delivery. No WebView involvement
* for background notifications.
* Capacitor (native): passes user pubkey + relay URLs to the native Android
* notification service. Respects the user's notificationsEnabled setting.
*
* On logout: clears the native config so the relay connection is closed.
* 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();
// Request notification permission on mount
const notificationsEnabled = settings?.notificationsEnabled ?? false;
// 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));
// ── Capacitor path ────────────────────────────────────────────────────────
// Request native notification permission on first mount (native only).
useEffect(() => {
if (!Capacitor.isNativePlatform()) return;
@@ -38,17 +62,17 @@ export function useNativeNotifications(): void {
await LocalNotifications.requestPermissions();
}
} catch {
// Permission check failed
// Permission check failed — ignore
}
})();
}, []);
// Pass user config to native polling service
// Configure / deconfigure the native polling service.
useEffect(() => {
if (!Capacitor.isNativePlatform()) return;
if (!user) {
// User logged out -- clear native config
if (!user || !notificationsEnabled) {
// Logged out or user disabled notifications — stop the native service.
DittoNotification.configure({});
return;
}
@@ -60,11 +84,59 @@ export function useNativeNotifications(): void {
if (relayUrls.length === 0) return;
// Configure the native service with current user + relays
DittoNotification.configure({
userPubkey: user.pubkey,
relayUrls,
});
}, [user, config.relayMetadata, config.useAppRelays]);
}, [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 = nostr.req(
[{
kinds: [1, 6, 7, 16, 9735],
'#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;
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, nostr]);
}
+1
View File
@@ -130,6 +130,7 @@ export const EncryptedSettingsSchema = z.looseObject({
feedSettings: FeedSettingsSchema.optional(),
contentFilters: z.array(ContentFilterSchema).optional(),
contentWarningPolicy: ContentWarningPolicySchema.optional(),
notificationsEnabled: z.boolean().optional(),
notificationsCursor: z.number().optional(),
lastSync: z.number().optional(),
});
+16 -11
View File
@@ -7,10 +7,11 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
export function NotificationSettings() {
const { user } = useCurrentUser();
const [pushEnabled, setPushEnabled] = useState(false);
const { settings, updateSettings } = useEncryptedSettings();
const [permission, setPermission] = useState<NotificationPermission>('default');
useSeoMeta({
@@ -18,26 +19,30 @@ export function NotificationSettings() {
description: 'Configure your notification preferences',
});
// Check current permission state on mount
// Check current browser permission state on mount
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
setPushEnabled(Notification.permission === 'granted');
}
}, []);
const handleTogglePush = async (enabled: boolean) => {
if (!('Notification' in window)) return;
// Persisted preference from encrypted settings
const pushEnabled = settings?.notificationsEnabled ?? false;
const handleTogglePush = async (enabled: boolean) => {
if (enabled) {
if (!('Notification' in window)) return;
// Request browser permission first (no-op if already granted/denied)
const result = await Notification.requestPermission();
setPermission(result);
setPushEnabled(result === 'granted');
} else {
// Browser doesn't allow revoking permissions programmatically,
// but we can track the user's preference
setPushEnabled(false);
// Don't save enabled=true if the browser blocked permission
if (result !== 'granted') return;
}
// Persist the user's preference to encrypted settings (synced across devices)
await updateSettings.mutateAsync({ notificationsEnabled: enabled });
};
if (!user) {
@@ -90,7 +95,7 @@ export function NotificationSettings() {
id="push-notifications"
checked={pushEnabled}
onCheckedChange={handleTogglePush}
disabled={!isSupported || isDenied}
disabled={!isSupported || isDenied || updateSettings.isPending}
/>
</div>