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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user