From 30c917b3a9c87930fcfa7f123c0720cabf3ee227 Mon Sep 17 00:00:00 2001
From: cyph3rasi
Date: Wed, 15 Jul 2026 10:22:52 -0700
Subject: [PATCH] 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
---
src/app/settings/notifications/page.tsx | 62 ++++++++++++-
src/components/BrowserNotificationBridge.tsx | 92 +++++++++++++++++++-
src/lib/notifications/browser.test.ts | 16 ++++
src/lib/notifications/browser.ts | 35 +++++++-
4 files changed, 202 insertions(+), 3 deletions(-)
diff --git a/src/app/settings/notifications/page.tsx b/src/app/settings/notifications/page.tsx
index 01b97ef..ef4ca6f 100644
--- a/src/app/settings/notifications/page.tsx
+++ b/src/app/settings/notifications/page.tsx
@@ -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('loading');
const [enabled, setEnabled] = useState(false);
+ const [preferences, setPreferences] = useState(
+ 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.
+
+ {enabled && (
+
+
Notify me about
+
+ Choose which interactions can send a browser notification.
+
+
+ {notificationCategories.map(({ type, label }) => (
+
+ ))}
+
+
+ )}
);
}
diff --git a/src/components/BrowserNotificationBridge.tsx b/src/components/BrowserNotificationBridge.tsx
index 5851c54..45a1bf9 100644
--- a/src/components/BrowserNotificationBridge.tsx
+++ b/src/components/BrowserNotificationBridge.tsx
@@ -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 (
+
+
+
+
+
+ Stay up to date
+
+
+ Allow browser notifications for new follows, replies, mentions, and reactions.
+
+
+
+
+
+
+
+
+ );
}
diff --git a/src/lib/notifications/browser.test.ts b/src/lib/notifications/browser.test.ts
index 933210b..079ede9 100644
--- a/src/lib/notifications/browser.test.ts
+++ b/src/lib/notifications/browser.test.ts
@@ -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',
diff --git a/src/lib/notifications/browser.ts b/src/lib/notifications/browser.ts
index 3f80039..4ed5eff 100644
--- a/src/lib/notifications/browser.ts
+++ b/src/lib/notifications/browser.ts
@@ -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;
+
+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;
+ 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';