Default browser notifications on through a first-login permission prompt with category controls

Hop-State: A_06FPDKC36EGSF1DQQ929QZR
Hop-Proposal: R_06FPDKBGHD5F0RARPQPEGTG
Hop-Task: T_06FPDJYX8ADKQPCJRTD1TS8
Hop-Attempt: AT_06FPDJYX89JABJ674EYANCR
This commit is contained in:
2026-07-15 10:22:52 -07:00
committed by Hop
parent db5d8faba0
commit 30c917b3a9
4 changed files with 202 additions and 3 deletions
+61 -1
View File
@@ -8,7 +8,12 @@ import { ArrowLeftIcon } from '@/components/Icons';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { import {
BROWSER_NOTIFICATIONS_CHANGED_EVENT, BROWSER_NOTIFICATIONS_CHANGED_EVENT,
browserNotificationPreferencesKey,
browserNotificationsEnabledKey, browserNotificationsEnabledKey,
DEFAULT_BROWSER_NOTIFICATION_PREFERENCES,
parseBrowserNotificationPreferences,
type BrowserNotificationPreferences,
type BrowserNotificationType,
} from '@/lib/notifications/browser'; } from '@/lib/notifications/browser';
type NotificationSupport = 'loading' | 'unsupported' | NotificationPermission; type NotificationSupport = 'loading' | 'unsupported' | NotificationPermission;
@@ -18,6 +23,9 @@ export default function NotificationSettingsPage() {
const userId = user?.id; const userId = user?.id;
const [support, setSupport] = useState<NotificationSupport>('loading'); const [support, setSupport] = useState<NotificationSupport>('loading');
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [preferences, setPreferences] = useState<BrowserNotificationPreferences>(
DEFAULT_BROWSER_NOTIFICATION_PREFERENCES,
);
useEffect(() => { useEffect(() => {
if (!userId) return; if (!userId) return;
@@ -29,6 +37,9 @@ export default function NotificationSettingsPage() {
setSupport(Notification.permission); setSupport(Notification.permission);
setEnabled(localStorage.getItem(browserNotificationsEnabledKey(userId)) === 'true' setEnabled(localStorage.getItem(browserNotificationsEnabledKey(userId)) === 'true'
&& Notification.permission === 'granted'); && Notification.permission === 'granted');
setPreferences(parseBrowserNotificationPreferences(
localStorage.getItem(browserNotificationPreferencesKey(userId)),
));
}, 0); }, 0);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [userId]); }, [userId]);
@@ -53,11 +64,27 @@ export default function NotificationSettingsPage() {
const disableNotifications = () => { const disableNotifications = () => {
if (!userId) return; if (!userId) return;
localStorage.removeItem(browserNotificationsEnabledKey(userId)); localStorage.setItem(browserNotificationsEnabledKey(userId), 'false');
setEnabled(false); setEnabled(false);
window.dispatchEvent(new Event(BROWSER_NOTIFICATIONS_CHANGED_EVENT)); 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' const statusText = support === 'unsupported'
? 'Browser notifications are not supported on this device.' ? 'Browser notifications are not supported on this device.'
: support === 'denied' : 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. Notifications appear while Synapsis is open in a browser tab. Permission and this setting apply only to this device.
</p> </p>
</div> </div>
{enabled && (
<div className="card" style={{ padding: '20px', marginTop: '16px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '4px' }}>Notify me about</h2>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', marginBottom: '14px' }}>
Choose which interactions can send a browser notification.
</p>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{notificationCategories.map(({ type, label }) => (
<label
key={type}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
padding: '12px 0',
borderTop: '1px solid var(--border)',
cursor: 'pointer',
}}
>
<span style={{ fontSize: '14px' }}>{label}</span>
<input
type="checkbox"
checked={preferences[type]}
onChange={(event) => setPreference(type, event.target.checked)}
aria-label={`${label} browser notifications`}
/>
</label>
))}
</div>
</div>
)}
</div> </div>
); );
} }
+91 -1
View File
@@ -1,13 +1,17 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Bell } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { import {
BROWSER_NOTIFICATIONS_CHANGED_EVENT, BROWSER_NOTIFICATIONS_CHANGED_EVENT,
browserNotificationsEnabledKey, browserNotificationsEnabledKey,
browserNotificationPreferencesKey,
browserNotificationsPromptedKey,
browserNotificationsSeenKey, browserNotificationsSeenKey,
getBrowserNotificationContent, getBrowserNotificationContent,
parseBrowserNotificationPreferences,
type BrowserNotificationItem, type BrowserNotificationItem,
} from '@/lib/notifications/browser'; } from '@/lib/notifications/browser';
@@ -29,6 +33,7 @@ export function BrowserNotificationBridge() {
const { user } = useAuth(); const { user } = useAuth();
const userId = user?.id; const userId = user?.id;
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [showPermissionPrompt, setShowPermissionPrompt] = useState(false);
useEffect(() => { useEffect(() => {
const syncEnabledState = () => { const syncEnabledState = () => {
@@ -47,6 +52,30 @@ export function BrowserNotificationBridge() {
}; };
}, [userId]); }, [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(() => { useEffect(() => {
if (!enabled || !userId) return; if (!enabled || !userId) return;
@@ -67,6 +96,9 @@ export function BrowserNotificationBridge() {
: []) as BrowserNotificationItem[]; : []) as BrowserNotificationItem[];
const remembered = readSeenNotifications(seenKey); const remembered = readSeenNotifications(seenKey);
const seen = new Set(remembered); const seen = new Set(remembered);
const preferences = parseBrowserNotificationPreferences(
localStorage.getItem(browserNotificationPreferencesKey(userId)),
);
// Enabling notifications should not dump the user's entire unread // Enabling notifications should not dump the user's entire unread
// backlog into the OS. The first poll establishes a baseline. // backlog into the OS. The first poll establishes a baseline.
@@ -84,6 +116,7 @@ export function BrowserNotificationBridge() {
for (const item of unseen) { for (const item of unseen) {
seen.add(item.id); seen.add(item.id);
if (!preferences[item.type]) continue;
const content = getBrowserNotificationContent(item); const content = getBrowserNotificationContent(item);
const browserNotification = new Notification(content.title, { const browserNotification = new Notification(content.title, {
body: content.body, body: content.body,
@@ -117,5 +150,62 @@ export function BrowserNotificationBridge() {
}; };
}, [enabled, userId]); }, [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 (
<div
role="dialog"
aria-labelledby="browser-notification-prompt-title"
style={{
position: 'fixed',
left: '50%',
bottom: '24px',
transform: 'translateX(-50%)',
width: 'min(calc(100% - 32px), 420px)',
padding: '18px',
background: 'var(--background)',
border: '1px solid var(--border)',
borderRadius: '14px',
boxShadow: '0 16px 48px rgba(0, 0, 0, 0.35)',
zIndex: 1000,
}}
>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
<Bell size={22} style={{ flexShrink: 0, marginTop: '2px' }} />
<div>
<div id="browser-notification-prompt-title" style={{ fontWeight: 650 }}>
Stay up to date
</div>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.45, marginTop: '5px' }}>
Allow browser notifications for new follows, replies, mentions, and reactions.
</p>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '16px' }}>
<button type="button" className="btn btn-ghost" onClick={() => void finishPermissionPrompt(false)}>
Not Now
</button>
<button type="button" className="btn btn-primary" onClick={() => void finishPermissionPrompt(true)}>
Allow Notifications
</button>
</div>
</div>
);
} }
+16
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { import {
browserNotificationsEnabledKey, browserNotificationsEnabledKey,
getBrowserNotificationContent, getBrowserNotificationContent,
parseBrowserNotificationPreferences,
} from './browser'; } from './browser';
describe('browser notification presentation', () => { describe('browser notification presentation', () => {
@@ -11,6 +12,21 @@ describe('browser notification presentation', () => {
.toBe('synapsis:browser-notifications:user-1'); .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', () => { it('links post interactions to the relevant post', () => {
expect(getBrowserNotificationContent({ expect(getBrowserNotificationContent({
id: 'notification-1', id: 'notification-1',
+34 -1
View File
@@ -1,8 +1,20 @@
export const BROWSER_NOTIFICATIONS_CHANGED_EVENT = 'synapsis:browser-notifications-changed'; export const BROWSER_NOTIFICATIONS_CHANGED_EVENT = 'synapsis:browser-notifications-changed';
export type BrowserNotificationType = 'follow' | 'like' | 'repost' | 'mention' | 'reply';
export type BrowserNotificationPreferences = Record<BrowserNotificationType, boolean>;
export const DEFAULT_BROWSER_NOTIFICATION_PREFERENCES: BrowserNotificationPreferences = {
follow: true,
like: true,
repost: true,
mention: true,
reply: true,
};
export interface BrowserNotificationItem { export interface BrowserNotificationItem {
id: string; id: string;
type: 'follow' | 'like' | 'repost' | 'mention' | 'reply'; type: BrowserNotificationType;
actor: { actor: {
handle: string; handle: string;
displayName: string | null; displayName: string | null;
@@ -21,6 +33,27 @@ export function browserNotificationsSeenKey(userId: string): string {
return `synapsis:browser-notifications-seen:${userId}`; 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<BrowserNotificationPreferences>;
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 { function interactionText(type: BrowserNotificationItem['type']): string {
switch (type) { switch (type) {
case 'follow': return 'followed you'; case 'follow': return 'followed you';