diff --git a/src/app/settings/notifications/page.tsx b/src/app/settings/notifications/page.tsx index 01b97ef..ef4ca6f 100644 --- a/src/app/settings/notifications/page.tsx +++ b/src/app/settings/notifications/page.tsx @@ -8,7 +8,12 @@ import { ArrowLeftIcon } from '@/components/Icons'; import { useAuth } from '@/lib/contexts/AuthContext'; import { BROWSER_NOTIFICATIONS_CHANGED_EVENT, + browserNotificationPreferencesKey, browserNotificationsEnabledKey, + DEFAULT_BROWSER_NOTIFICATION_PREFERENCES, + parseBrowserNotificationPreferences, + type BrowserNotificationPreferences, + type BrowserNotificationType, } from '@/lib/notifications/browser'; type NotificationSupport = 'loading' | 'unsupported' | NotificationPermission; @@ -18,6 +23,9 @@ export default function NotificationSettingsPage() { const userId = user?.id; const [support, setSupport] = useState('loading'); const [enabled, setEnabled] = useState(false); + const [preferences, setPreferences] = useState( + DEFAULT_BROWSER_NOTIFICATION_PREFERENCES, + ); useEffect(() => { if (!userId) return; @@ -29,6 +37,9 @@ export default function NotificationSettingsPage() { setSupport(Notification.permission); setEnabled(localStorage.getItem(browserNotificationsEnabledKey(userId)) === 'true' && Notification.permission === 'granted'); + setPreferences(parseBrowserNotificationPreferences( + localStorage.getItem(browserNotificationPreferencesKey(userId)), + )); }, 0); return () => window.clearTimeout(timeout); }, [userId]); @@ -53,11 +64,27 @@ export default function NotificationSettingsPage() { const disableNotifications = () => { if (!userId) return; - localStorage.removeItem(browserNotificationsEnabledKey(userId)); + localStorage.setItem(browserNotificationsEnabledKey(userId), 'false'); setEnabled(false); window.dispatchEvent(new Event(BROWSER_NOTIFICATIONS_CHANGED_EVENT)); }; + const setPreference = (type: BrowserNotificationType, value: boolean) => { + if (!userId) return; + const next = { ...preferences, [type]: value }; + setPreferences(next); + localStorage.setItem(browserNotificationPreferencesKey(userId), JSON.stringify(next)); + window.dispatchEvent(new Event(BROWSER_NOTIFICATIONS_CHANGED_EVENT)); + }; + + const notificationCategories: Array<{ type: BrowserNotificationType; label: string }> = [ + { type: 'follow', label: 'New followers' }, + { type: 'reply', label: 'Replies' }, + { type: 'mention', label: 'Mentions' }, + { type: 'like', label: 'Likes' }, + { type: 'repost', label: 'Reposts' }, + ]; + const statusText = support === 'unsupported' ? 'Browser notifications are not supported on this device.' : support === 'denied' @@ -107,6 +134,39 @@ export default function NotificationSettingsPage() { Notifications appear while Synapsis is open in a browser tab. Permission and this setting apply only to this device.

+ + {enabled && ( +
+

Notify me about

+

+ Choose which interactions can send a browser notification. +

+
+ {notificationCategories.map(({ type, label }) => ( + + ))} +
+
+ )} ); } diff --git a/src/components/BrowserNotificationBridge.tsx b/src/components/BrowserNotificationBridge.tsx index 5851c54..45a1bf9 100644 --- a/src/components/BrowserNotificationBridge.tsx +++ b/src/components/BrowserNotificationBridge.tsx @@ -1,13 +1,17 @@ 'use client'; import { useEffect, useState } from 'react'; +import { Bell } from 'lucide-react'; import { useAuth } from '@/lib/contexts/AuthContext'; import { BROWSER_NOTIFICATIONS_CHANGED_EVENT, browserNotificationsEnabledKey, + browserNotificationPreferencesKey, + browserNotificationsPromptedKey, browserNotificationsSeenKey, getBrowserNotificationContent, + parseBrowserNotificationPreferences, type BrowserNotificationItem, } from '@/lib/notifications/browser'; @@ -29,6 +33,7 @@ export function BrowserNotificationBridge() { const { user } = useAuth(); const userId = user?.id; const [enabled, setEnabled] = useState(false); + const [showPermissionPrompt, setShowPermissionPrompt] = useState(false); useEffect(() => { const syncEnabledState = () => { @@ -47,6 +52,30 @@ export function BrowserNotificationBridge() { }; }, [userId]); + useEffect(() => { + if (!userId) return; + const timeout = window.setTimeout(() => { + if (typeof Notification === 'undefined') return; + const enabledKey = browserNotificationsEnabledKey(userId); + const promptedKey = browserNotificationsPromptedKey(userId); + + if (Notification.permission === 'granted') { + if (localStorage.getItem(enabledKey) === null) { + localStorage.setItem(enabledKey, 'true'); + window.dispatchEvent(new Event(BROWSER_NOTIFICATIONS_CHANGED_EVENT)); + } + localStorage.setItem(promptedKey, 'true'); + return; + } + + if (Notification.permission === 'default' + && localStorage.getItem(promptedKey) !== 'true') { + setShowPermissionPrompt(true); + } + }, 1_200); + return () => window.clearTimeout(timeout); + }, [userId]); + useEffect(() => { if (!enabled || !userId) return; @@ -67,6 +96,9 @@ export function BrowserNotificationBridge() { : []) as BrowserNotificationItem[]; const remembered = readSeenNotifications(seenKey); const seen = new Set(remembered); + const preferences = parseBrowserNotificationPreferences( + localStorage.getItem(browserNotificationPreferencesKey(userId)), + ); // Enabling notifications should not dump the user's entire unread // backlog into the OS. The first poll establishes a baseline. @@ -84,6 +116,7 @@ export function BrowserNotificationBridge() { for (const item of unseen) { seen.add(item.id); + if (!preferences[item.type]) continue; const content = getBrowserNotificationContent(item); const browserNotification = new Notification(content.title, { body: content.body, @@ -117,5 +150,62 @@ export function BrowserNotificationBridge() { }; }, [enabled, userId]); - return null; + const finishPermissionPrompt = async (requestPermission: boolean) => { + if (!userId || typeof Notification === 'undefined') return; + localStorage.setItem(browserNotificationsPromptedKey(userId), 'true'); + setShowPermissionPrompt(false); + if (!requestPermission) return; + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') return; + localStorage.setItem(browserNotificationsEnabledKey(userId), 'true'); + window.dispatchEvent(new Event(BROWSER_NOTIFICATIONS_CHANGED_EVENT)); + new Notification('Synapsis notifications enabled', { + body: 'You can customize them any time in Settings.', + icon: '/api/favicon', + tag: 'synapsis-notifications-enabled', + }); + }; + + if (!showPermissionPrompt) return null; + + return ( +
+
+ +
+
+ Stay up to date +
+

+ Allow browser notifications for new follows, replies, mentions, and reactions. +

+
+
+
+ + +
+
+ ); } diff --git a/src/lib/notifications/browser.test.ts b/src/lib/notifications/browser.test.ts index 933210b..079ede9 100644 --- a/src/lib/notifications/browser.test.ts +++ b/src/lib/notifications/browser.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { browserNotificationsEnabledKey, getBrowserNotificationContent, + parseBrowserNotificationPreferences, } from './browser'; describe('browser notification presentation', () => { @@ -11,6 +12,21 @@ describe('browser notification presentation', () => { .toBe('synapsis:browser-notifications:user-1'); }); + it('defaults every notification category on and preserves explicit choices', () => { + expect(parseBrowserNotificationPreferences(null)).toEqual({ + follow: true, + like: true, + repost: true, + mention: true, + reply: true, + }); + expect(parseBrowserNotificationPreferences('{"like":false}')).toMatchObject({ + follow: true, + like: false, + mention: true, + }); + }); + it('links post interactions to the relevant post', () => { expect(getBrowserNotificationContent({ id: 'notification-1', diff --git a/src/lib/notifications/browser.ts b/src/lib/notifications/browser.ts index 3f80039..4ed5eff 100644 --- a/src/lib/notifications/browser.ts +++ b/src/lib/notifications/browser.ts @@ -1,8 +1,20 @@ export const BROWSER_NOTIFICATIONS_CHANGED_EVENT = 'synapsis:browser-notifications-changed'; +export type BrowserNotificationType = 'follow' | 'like' | 'repost' | 'mention' | 'reply'; + +export type BrowserNotificationPreferences = Record; + +export const DEFAULT_BROWSER_NOTIFICATION_PREFERENCES: BrowserNotificationPreferences = { + follow: true, + like: true, + repost: true, + mention: true, + reply: true, +}; + export interface BrowserNotificationItem { id: string; - type: 'follow' | 'like' | 'repost' | 'mention' | 'reply'; + type: BrowserNotificationType; actor: { handle: string; displayName: string | null; @@ -21,6 +33,27 @@ export function browserNotificationsSeenKey(userId: string): string { return `synapsis:browser-notifications-seen:${userId}`; } +export function browserNotificationsPromptedKey(userId: string): string { + return `synapsis:browser-notifications-prompted:${userId}`; +} + +export function browserNotificationPreferencesKey(userId: string): string { + return `synapsis:browser-notification-preferences:${userId}`; +} + +export function parseBrowserNotificationPreferences(value: string | null): BrowserNotificationPreferences { + if (!value) return { ...DEFAULT_BROWSER_NOTIFICATION_PREFERENCES }; + try { + const parsed = JSON.parse(value) as Partial; + return Object.fromEntries( + Object.entries(DEFAULT_BROWSER_NOTIFICATION_PREFERENCES) + .map(([type, defaultValue]) => [type, parsed[type as BrowserNotificationType] ?? defaultValue]), + ) as BrowserNotificationPreferences; + } catch { + return { ...DEFAULT_BROWSER_NOTIFICATION_PREFERENCES }; + } +} + function interactionText(type: BrowserNotificationItem['type']): string { switch (type) { case 'follow': return 'followed you';