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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -131,19 +131,21 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="card" style={{
|
||||
<Link href="/settings/notifications" className="card" style={{
|
||||
display: 'block',
|
||||
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' }}>
|
||||
<Bell size={18} />
|
||||
Notifications
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
|
||||
Notification preferences (coming soon)
|
||||
Browser notification preferences
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</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;
|
||||
}
|
||||
@@ -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 }) {
|
||||
</main>
|
||||
{!hideRightSidebar && <RightSidebar />}
|
||||
<GlobalPostComposer />
|
||||
<BrowserNotificationBridge />
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user