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
This commit is contained in:
2026-07-15 10:18:26 -07:00
committed by Hop
parent c44e33e582
commit db5d8faba0
6 changed files with 334 additions and 4 deletions
+112
View File
@@ -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<NotificationSupport>('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 (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings" style={{ color: 'var(--foreground)' }} aria-label="Back to settings">
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Notifications</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Control notifications on this device
</p>
</div>
</header>
<div className="card" style={{ padding: '20px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
{enabled ? <Bell size={22} /> : <BellOff size={22} />}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>
{enabled ? 'Browser notifications on' : 'Browser notifications off'}
</div>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.5, marginTop: '6px' }}>
{statusText}
</p>
</div>
</div>
{support !== 'loading' && support !== 'unsupported' && support !== 'denied' && (
<button
type="button"
className={`btn ${enabled ? 'btn-ghost' : 'btn-primary'}`}
onClick={enabled ? disableNotifications : enableNotifications}
style={{ marginTop: '18px' }}
>
{enabled ? 'Turn Off' : 'Enable Browser Notifications'}
</button>
)}
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '12px', lineHeight: 1.5, marginTop: '14px' }}>
Notifications appear while Synapsis is open in a browser tab. Permission and this setting apply only to this device.
</p>
</div>
</div>
);
}
+6 -4
View File
@@ -131,19 +131,21 @@ export default function SettingsPage() {
</div> </div>
</Link> </Link>
<div className="card" style={{ <Link href="/settings/notifications" className="card" style={{
display: 'block', display: 'block',
padding: '20px', padding: '20px',
opacity: 0.5, textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}> }}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bell size={18} /> <Bell size={18} />
Notifications Notifications
</div> </div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}> <div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Notification preferences (coming soon) Browser notification preferences
</div> </div>
</div> </Link>
</div> </div>
</div> </div>
</> </>
@@ -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<typeof setTimeout> | 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;
}
+2
View File
@@ -7,6 +7,7 @@ import { useAuth } from '@/lib/contexts/AuthContext';
import { useRuntimeConfig } from '@/lib/contexts/ConfigContext'; import { useRuntimeConfig } from '@/lib/contexts/ConfigContext';
import { isAppBootstrapReady } from '@/lib/bootstrap/readiness'; import { isAppBootstrapReady } from '@/lib/bootstrap/readiness';
import { GlobalPostComposer } from './GlobalPostComposer'; import { GlobalPostComposer } from './GlobalPostComposer';
import { BrowserNotificationBridge } from './BrowserNotificationBridge';
export function LayoutWrapper({ children }: { children: React.ReactNode }) { export function LayoutWrapper({ children }: { children: React.ReactNode }) {
const { loading } = useAuth(); const { loading } = useAuth();
@@ -60,6 +61,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
</main> </main>
{!hideRightSidebar && <RightSidebar />} {!hideRightSidebar && <RightSidebar />}
<GlobalPostComposer /> <GlobalPostComposer />
<BrowserNotificationBridge />
</div> </div>
); );
+39
View File
@@ -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',
});
});
});
+54
View File
@@ -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}`,
};
}