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>
|
</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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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