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:
@@ -8,7 +8,12 @@ import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import {
|
||||
BROWSER_NOTIFICATIONS_CHANGED_EVENT,
|
||||
browserNotificationPreferencesKey,
|
||||
browserNotificationsEnabledKey,
|
||||
DEFAULT_BROWSER_NOTIFICATION_PREFERENCES,
|
||||
parseBrowserNotificationPreferences,
|
||||
type BrowserNotificationPreferences,
|
||||
type BrowserNotificationType,
|
||||
} from '@/lib/notifications/browser';
|
||||
|
||||
type NotificationSupport = 'loading' | 'unsupported' | NotificationPermission;
|
||||
@@ -18,6 +23,9 @@ export default function NotificationSettingsPage() {
|
||||
const userId = user?.id;
|
||||
const [support, setSupport] = useState<NotificationSupport>('loading');
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [preferences, setPreferences] = useState<BrowserNotificationPreferences>(
|
||||
DEFAULT_BROWSER_NOTIFICATION_PREFERENCES,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
@@ -29,6 +37,9 @@ export default function NotificationSettingsPage() {
|
||||
setSupport(Notification.permission);
|
||||
setEnabled(localStorage.getItem(browserNotificationsEnabledKey(userId)) === 'true'
|
||||
&& Notification.permission === 'granted');
|
||||
setPreferences(parseBrowserNotificationPreferences(
|
||||
localStorage.getItem(browserNotificationPreferencesKey(userId)),
|
||||
));
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [userId]);
|
||||
@@ -53,11 +64,27 @@ export default function NotificationSettingsPage() {
|
||||
|
||||
const disableNotifications = () => {
|
||||
if (!userId) return;
|
||||
localStorage.removeItem(browserNotificationsEnabledKey(userId));
|
||||
localStorage.setItem(browserNotificationsEnabledKey(userId), 'false');
|
||||
setEnabled(false);
|
||||
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'
|
||||
? 'Browser notifications are not supported on this device.'
|
||||
: 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.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell } from 'lucide-react';
|
||||
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import {
|
||||
BROWSER_NOTIFICATIONS_CHANGED_EVENT,
|
||||
browserNotificationsEnabledKey,
|
||||
browserNotificationPreferencesKey,
|
||||
browserNotificationsPromptedKey,
|
||||
browserNotificationsSeenKey,
|
||||
getBrowserNotificationContent,
|
||||
parseBrowserNotificationPreferences,
|
||||
type BrowserNotificationItem,
|
||||
} from '@/lib/notifications/browser';
|
||||
|
||||
@@ -29,6 +33,7 @@ export function BrowserNotificationBridge() {
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id;
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [showPermissionPrompt, setShowPermissionPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const syncEnabledState = () => {
|
||||
@@ -47,6 +52,30 @@ export function BrowserNotificationBridge() {
|
||||
};
|
||||
}, [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(() => {
|
||||
if (!enabled || !userId) return;
|
||||
|
||||
@@ -67,6 +96,9 @@ export function BrowserNotificationBridge() {
|
||||
: []) as BrowserNotificationItem[];
|
||||
const remembered = readSeenNotifications(seenKey);
|
||||
const seen = new Set(remembered);
|
||||
const preferences = parseBrowserNotificationPreferences(
|
||||
localStorage.getItem(browserNotificationPreferencesKey(userId)),
|
||||
);
|
||||
|
||||
// Enabling notifications should not dump the user's entire unread
|
||||
// backlog into the OS. The first poll establishes a baseline.
|
||||
@@ -84,6 +116,7 @@ export function BrowserNotificationBridge() {
|
||||
|
||||
for (const item of unseen) {
|
||||
seen.add(item.id);
|
||||
if (!preferences[item.type]) continue;
|
||||
const content = getBrowserNotificationContent(item);
|
||||
const browserNotification = new Notification(content.title, {
|
||||
body: content.body,
|
||||
@@ -117,5 +150,62 @@ export function BrowserNotificationBridge() {
|
||||
};
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
browserNotificationsEnabledKey,
|
||||
getBrowserNotificationContent,
|
||||
parseBrowserNotificationPreferences,
|
||||
} from './browser';
|
||||
|
||||
describe('browser notification presentation', () => {
|
||||
@@ -11,6 +12,21 @@ describe('browser notification presentation', () => {
|
||||
.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', () => {
|
||||
expect(getBrowserNotificationContent({
|
||||
id: 'notification-1',
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
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 {
|
||||
id: string;
|
||||
type: 'follow' | 'like' | 'repost' | 'mention' | 'reply';
|
||||
type: BrowserNotificationType;
|
||||
actor: {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
@@ -21,6 +33,27 @@ export function browserNotificationsSeenKey(userId: string): string {
|
||||
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 {
|
||||
switch (type) {
|
||||
case 'follow': return 'followed you';
|
||||
|
||||
Reference in New Issue
Block a user