From db5d8faba01608b3c3315c722d709be0ea7be797 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 10:18:26 -0700 Subject: [PATCH] Add opt-in per-device browser notifications for account interactions Hop-State: A_06FPDJBMCPFBTW71R7MJ6A0 Hop-Proposal: R_06FPDJB4CE0WHS7ZKV9KFS0 Hop-Task: T_06FPDHJ54908E4K96KJA0K0 Hop-Attempt: AT_06FPDHJ54AYSSF1D1H293N0 --- src/app/settings/notifications/page.tsx | 112 +++++++++++++++++ src/app/settings/page.tsx | 10 +- src/components/BrowserNotificationBridge.tsx | 121 +++++++++++++++++++ src/components/LayoutWrapper.tsx | 2 + src/lib/notifications/browser.test.ts | 39 ++++++ src/lib/notifications/browser.ts | 54 +++++++++ 6 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 src/app/settings/notifications/page.tsx create mode 100644 src/components/BrowserNotificationBridge.tsx create mode 100644 src/lib/notifications/browser.test.ts create mode 100644 src/lib/notifications/browser.ts diff --git a/src/app/settings/notifications/page.tsx b/src/app/settings/notifications/page.tsx new file mode 100644 index 0000000..01b97ef --- /dev/null +++ b/src/app/settings/notifications/page.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Bell, BellOff } from 'lucide-react'; + +import { ArrowLeftIcon } from '@/components/Icons'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { + BROWSER_NOTIFICATIONS_CHANGED_EVENT, + browserNotificationsEnabledKey, +} from '@/lib/notifications/browser'; + +type NotificationSupport = 'loading' | 'unsupported' | NotificationPermission; + +export default function NotificationSettingsPage() { + const { user } = useAuth(); + const userId = user?.id; + const [support, setSupport] = useState('loading'); + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + if (!userId) return; + const timeout = window.setTimeout(() => { + if (typeof Notification === 'undefined') { + setSupport('unsupported'); + return; + } + setSupport(Notification.permission); + setEnabled(localStorage.getItem(browserNotificationsEnabledKey(userId)) === 'true' + && Notification.permission === 'granted'); + }, 0); + return () => window.clearTimeout(timeout); + }, [userId]); + + const enableNotifications = async () => { + if (!userId || typeof Notification === 'undefined') return; + const permission = Notification.permission === 'default' + ? await Notification.requestPermission() + : Notification.permission; + setSupport(permission); + if (permission !== 'granted') return; + + localStorage.setItem(browserNotificationsEnabledKey(userId), 'true'); + setEnabled(true); + window.dispatchEvent(new Event(BROWSER_NOTIFICATIONS_CHANGED_EVENT)); + new Notification('Synapsis notifications enabled', { + body: 'New interactions can now appear as browser notifications.', + icon: '/api/favicon', + tag: 'synapsis-notifications-enabled', + }); + }; + + const disableNotifications = () => { + if (!userId) return; + localStorage.removeItem(browserNotificationsEnabledKey(userId)); + setEnabled(false); + window.dispatchEvent(new Event(BROWSER_NOTIFICATIONS_CHANGED_EVENT)); + }; + + const statusText = support === 'unsupported' + ? 'Browser notifications are not supported on this device.' + : support === 'denied' + ? 'Notifications are blocked in your browser settings.' + : enabled + ? 'Browser notifications are on for this account and device.' + : 'Get notified about new follows, likes, reposts, mentions, and replies.'; + + return ( +
+
+ + + +
+

Notifications

+

+ Control notifications on this device +

+
+
+ +
+
+ {enabled ? : } +
+
+ {enabled ? 'Browser notifications on' : 'Browser notifications off'} +
+

+ {statusText} +

+
+
+ + {support !== 'loading' && support !== 'unsupported' && support !== 'denied' && ( + + )} +

+ Notifications appear while Synapsis is open in a browser tab. Permission and this setting apply only to this device. +

+
+
+ ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index f9e0dd2..f7e0c35 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -131,19 +131,21 @@ export default function SettingsPage() { -
Notifications
- Notification preferences (coming soon) + Browser notification preferences
-
+ diff --git a/src/components/BrowserNotificationBridge.tsx b/src/components/BrowserNotificationBridge.tsx new file mode 100644 index 0000000..5851c54 --- /dev/null +++ b/src/components/BrowserNotificationBridge.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useAuth } from '@/lib/contexts/AuthContext'; +import { + BROWSER_NOTIFICATIONS_CHANGED_EVENT, + browserNotificationsEnabledKey, + browserNotificationsSeenKey, + getBrowserNotificationContent, + type BrowserNotificationItem, +} from '@/lib/notifications/browser'; + +const POLL_INTERVAL_MS = 30_000; +const MAX_REMEMBERED_NOTIFICATIONS = 100; + +function readSeenNotifications(key: string): string[] { + try { + const parsed = JSON.parse(localStorage.getItem(key) || '[]'); + return Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === 'string') + : []; + } catch { + return []; + } +} + +export function BrowserNotificationBridge() { + const { user } = useAuth(); + const userId = user?.id; + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + const syncEnabledState = () => { + setEnabled(Boolean(userId) + && typeof Notification !== 'undefined' + && Notification.permission === 'granted' + && localStorage.getItem(browserNotificationsEnabledKey(userId!)) === 'true'); + }; + + syncEnabledState(); + window.addEventListener(BROWSER_NOTIFICATIONS_CHANGED_EVENT, syncEnabledState); + window.addEventListener('storage', syncEnabledState); + return () => { + window.removeEventListener(BROWSER_NOTIFICATIONS_CHANGED_EVENT, syncEnabledState); + window.removeEventListener('storage', syncEnabledState); + }; + }, [userId]); + + useEffect(() => { + if (!enabled || !userId) return; + + let stopped = false; + let timeout: ReturnType | null = null; + const seenKey = browserNotificationsSeenKey(userId); + + const poll = async () => { + try { + const response = await fetch('/api/notifications?unread=true&limit=20', { + cache: 'no-store', + }); + if (!response.ok || stopped) return; + + const data = await response.json(); + const notifications = (Array.isArray(data.notifications) + ? data.notifications + : []) as BrowserNotificationItem[]; + const remembered = readSeenNotifications(seenKey); + const seen = new Set(remembered); + + // Enabling notifications should not dump the user's entire unread + // backlog into the OS. The first poll establishes a baseline. + if (!localStorage.getItem(seenKey)) { + localStorage.setItem( + seenKey, + JSON.stringify(notifications.map((notification) => notification.id)), + ); + return; + } + + const unseen = notifications + .filter((notification) => !seen.has(notification.id)) + .reverse(); + + for (const item of unseen) { + seen.add(item.id); + const content = getBrowserNotificationContent(item); + const browserNotification = new Notification(content.title, { + body: content.body, + icon: '/api/favicon', + tag: content.tag, + }); + browserNotification.onclick = () => { + window.focus(); + window.location.assign(content.url); + browserNotification.close(); + }; + } + + if (unseen.length > 0) { + localStorage.setItem( + seenKey, + JSON.stringify([...seen].slice(-MAX_REMEMBERED_NOTIFICATIONS)), + ); + } + } catch (error) { + console.warn('[Notifications] Browser notification poll failed:', error); + } finally { + if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS); + } + }; + + void poll(); + return () => { + stopped = true; + if (timeout) clearTimeout(timeout); + }; + }, [enabled, userId]); + + return null; +} diff --git a/src/components/LayoutWrapper.tsx b/src/components/LayoutWrapper.tsx index 8ce253e..ea62559 100644 --- a/src/components/LayoutWrapper.tsx +++ b/src/components/LayoutWrapper.tsx @@ -7,6 +7,7 @@ import { useAuth } from '@/lib/contexts/AuthContext'; import { useRuntimeConfig } from '@/lib/contexts/ConfigContext'; import { isAppBootstrapReady } from '@/lib/bootstrap/readiness'; import { GlobalPostComposer } from './GlobalPostComposer'; +import { BrowserNotificationBridge } from './BrowserNotificationBridge'; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const { loading } = useAuth(); @@ -60,6 +61,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { {!hideRightSidebar && } + ); diff --git a/src/lib/notifications/browser.test.ts b/src/lib/notifications/browser.test.ts new file mode 100644 index 0000000..933210b --- /dev/null +++ b/src/lib/notifications/browser.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { + browserNotificationsEnabledKey, + getBrowserNotificationContent, +} from './browser'; + +describe('browser notification presentation', () => { + it('scopes the opt-in to the signed-in account', () => { + expect(browserNotificationsEnabledKey('user-1')) + .toBe('synapsis:browser-notifications:user-1'); + }); + + it('links post interactions to the relevant post', () => { + expect(getBrowserNotificationContent({ + id: 'notification-1', + type: 'like', + actor: { handle: 'alice', displayName: 'Alice' }, + post: { id: 'post-1', content: 'A post worth liking' }, + })).toEqual({ + title: 'Alice liked your post', + body: 'A post worth liking', + url: '/posts/post-1', + tag: 'synapsis-notification-notification-1', + }); + }); + + it('uses the notifications page when there is no post', () => { + expect(getBrowserNotificationContent({ + id: 'notification-2', + type: 'follow', + actor: { handle: 'bob', displayName: null }, + post: null, + })).toMatchObject({ + title: 'bob followed you', + url: '/notifications', + }); + }); +}); diff --git a/src/lib/notifications/browser.ts b/src/lib/notifications/browser.ts new file mode 100644 index 0000000..3f80039 --- /dev/null +++ b/src/lib/notifications/browser.ts @@ -0,0 +1,54 @@ +export const BROWSER_NOTIFICATIONS_CHANGED_EVENT = 'synapsis:browser-notifications-changed'; + +export interface BrowserNotificationItem { + id: string; + type: 'follow' | 'like' | 'repost' | 'mention' | 'reply'; + actor: { + handle: string; + displayName: string | null; + } | null; + post: { + id: string; + content: string | null; + } | null; +} + +export function browserNotificationsEnabledKey(userId: string): string { + return `synapsis:browser-notifications:${userId}`; +} + +export function browserNotificationsSeenKey(userId: string): string { + return `synapsis:browser-notifications-seen:${userId}`; +} + +function interactionText(type: BrowserNotificationItem['type']): string { + switch (type) { + case 'follow': return 'followed you'; + case 'like': return 'liked your post'; + case 'repost': return 'reposted your post'; + case 'mention': return 'mentioned you'; + case 'reply': return 'replied to your post'; + } +} + +export function getBrowserNotificationContent(notification: BrowserNotificationItem): { + title: string; + body: string; + url: string; + tag: string; +} { + const actorName = notification.actor?.displayName + || notification.actor?.handle + || 'Someone'; + const postContent = notification.post?.content?.replace(/\s+/g, ' ').trim(); + const body = postContent + ? Array.from(postContent).slice(0, 140).join('') + : 'Open Synapsis to view the notification.'; + + return { + title: `${actorName} ${interactionText(notification.type)}`, + body, + url: notification.post ? `/posts/${notification.post.id}` : '/notifications', + tag: `synapsis-notification-${notification.id}`, + }; +}