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:
@@ -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 */
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user