feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s

- VAPID keys from VAPID_* or generated once (push_keys, migration 9);
  devices in push_subscriptions, forgotten on 404/410 from the push service
- push column for every event (also due tasks) and the daily summary,
  bundles as one notification; /push/key, /devices, /subscribe,
  /unsubscribe, /test
- settings: switch push on per device, test it, see the devices; the
  service worker shows notifications and focuses MindSift on click
- tests decrypt a sent push like a browser (RFC 8291) and check VAPID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:21:55 +01:00
1 parent 1a49e6b304
commit 38b8c92899
27 files changed
+918 -27

No files matched your search

+32
View File
@@ -94,3 +94,35 @@ self.addEventListener('fetch', (event) => {
event.respondWith(staleWhileRevalidate(request));
}
});
// Web push: show the notification; a click focuses an open MindSift window or opens a new one
self.addEventListener('push', (event) => {
let msg = {};
try {
msg = event.data ? event.data.json() : {};
} catch {
msg = { body: event.data ? event.data.text() : '' };
}
event.waitUntil(
self.registration.showNotification(msg.title || 'MindSift', {
body: msg.body || '',
icon: '/icon-192x192.png',
badge: '/icon-192x192.png',
tag: msg.tag || undefined,
data: { url: msg.url || '/' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin);
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
for (const w of windows) {
if (new URL(w.url).origin === target.origin && 'focus' in w) return w.focus();
}
return self.clients.openWindow(target.href);
})
);
});
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import {
User as UserIcon,
Bell,
@@ -17,7 +17,7 @@ import {
Pencil,
Copy,
} from 'lucide-react';
import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download';
@@ -33,6 +33,7 @@ import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
import { currentPushSubscription, disablePush, enablePush, pushSupported } from '../../lib/push';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -62,21 +63,25 @@ export function buildNotificationSettings(user: User): NotificationSettings {
email: s?.events?.due?.email ?? dueEmailDefault,
discord: s?.events?.due?.discord ?? dueDiscordDefault,
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
push: s?.events?.due?.push ?? false,
},
shared: {
email: s?.events?.shared?.email ?? true,
discord: s?.events?.shared?.discord ?? false,
webhook: s?.events?.shared?.webhook ?? false,
push: s?.events?.shared?.push ?? false,
},
taskDone: {
email: s?.events?.taskDone?.email ?? false,
discord: s?.events?.taskDone?.discord ?? false,
webhook: s?.events?.taskDone?.webhook ?? false,
push: s?.events?.taskDone?.push ?? false,
},
watched: {
email: s?.events?.watched?.email ?? true,
discord: s?.events?.watched?.discord ?? true,
webhook: s?.events?.watched?.webhook ?? false,
push: s?.events?.watched?.push ?? false,
},
},
dailySummary: {
@@ -85,6 +90,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
email: s?.dailySummary?.email ?? true,
discord: s?.dailySummary?.discord ?? false,
webhook: s?.dailySummary?.webhook ?? false,
push: s?.dailySummary?.push ?? false,
},
};
}
@@ -271,6 +277,46 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// Web push: devices of this account and whether this browser is one of them
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
const [pushHere, setPushHere] = useState(false);
const [pushBusy, setPushBusy] = useState(false);
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const reloadPush = useCallback(async () => {
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
setPushDevices(devices);
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
}, []);
useEffect(() => {
void reloadPush();
}, [reloadPush]);
const togglePushHere = async (on: boolean) => {
setPushBusy(true);
setPushMsg(null);
try {
if (on) await enablePush();
else await disablePush();
await reloadPush();
setPushMsg({ type: 'ok', text: on ? 'Push ist auf diesem Gerät eingeschaltet.' : 'Push ist auf diesem Gerät ausgeschaltet.' });
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Push konnte nicht eingeschaltet werden' });
} finally {
setPushBusy(false);
}
};
const sendTestPush = async () => {
setPushBusy(true);
setPushMsg(null);
try {
const res = await api.testPush();
setPushMsg({ type: 'ok', text: `Test-Benachrichtigung an ${res.devices} ${res.devices === 1 ? 'Gerät' : 'Geräte'} gesendet.` });
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Test fehlgeschlagen' });
} finally {
setPushBusy(false);
}
};
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
@@ -283,7 +329,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}, [currentUser]);
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook', value: boolean) => {
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook' | 'push', value: boolean) => {
setNotificationSettings((prev) => ({
...prev,
events: {
@@ -1140,23 +1186,28 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
{(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
const hasPush = pushDevices.length > 0;
return (
<>
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 pb-2 border-b border-border">
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Mail className="w-3.5 h-3.5 text-accent" />
<span>E-Mail</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Globe className="w-3.5 h-3.5 text-accent" />
<span>Webhook</span>
</span>
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Bell className="w-3.5 h-3.5 text-accent" />
<span>Push</span>
</span>
</div>
<div className="divide-y divide-border">
@@ -1165,17 +1216,17 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
return (
<div
key={row.key}
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 py-2.5"
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 py-2.5"
>
<span className="font-medium text-main">{row.label}</span>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.email}
onChange={(v) => setEventChannel(row.key, 'email', v)}
title={`${row.label} – E-Mail`}
/>
</div>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.discord}
disabled={!hasDiscordWebhook}
@@ -1187,7 +1238,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.webhook}
disabled={!hasCustomWebhook}
@@ -1199,6 +1250,14 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.push}
disabled={!hasPush}
onChange={(v) => setEventChannel(row.key, 'push', v)}
title={hasPush ? `${row.label} – Push` : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
/>
</div>
</div>
);
})}
@@ -1307,6 +1366,20 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Push</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.push}
disabled={!hasPush}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, push: v },
}))
}
title={hasPush ? 'Tageszusammenfassung – Push' : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
/>
</div>
</div>
</div>
</div>
@@ -1458,6 +1531,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
</div>
{/* Web push: this browser or installed app */}
<div className={`p-4 border transition-colors space-y-3 bg-canvas ${pushHere ? 'border-accent' : 'border-border'}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<Bell className="w-4 h-4 text-accent shrink-0" />
<div className="min-w-0">
<span className="font-bold text-main block">Ziel: Push-Benachrichtigungen</span>
<span className="text-[11px] text-muted">
Systembenachrichtigung auf diesem Gerät, auch wenn MindSift geschlossen ist. Auf iPhone und
iPad nur, wenn MindSift zum Home-Bildschirm hinzugefügt wurde.
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted">Dieses Gerät</span>
<ToggleSwitch
checked={pushHere}
disabled={pushBusy || demoMode || !pushSupported()}
onChange={(v) => void togglePushHere(v)}
title={pushSupported() ? 'Push auf diesem Gerät' : 'Dieser Browser unterstützt keine Push-Benachrichtigungen'}
/>
<button
type="button"
onClick={() => void sendTestPush()}
disabled={pushBusy || demoMode || pushDevices.length === 0}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>Test senden</span>
</button>
</div>
</div>
{pushDevices.length > 0 && (
<p className="pl-6 text-[11px] text-faint">
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '}
{pushDevices.map((d) => d.device || 'Gerät').join(', ')}
</p>
)}
{pushMsg && (
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
)}
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
@@ -13,6 +13,7 @@ vi.mock('../../../lib/api', async () => {
...actual.api,
getMe: vi.fn(async () => ({ isLastAdmin: false })),
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
getPushDevices: vi.fn(async () => []),
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
},
};
@@ -88,10 +89,10 @@ describe('UserSettingsView notification matrix', () => {
paused: true,
includeOwnWatched: true,
events: {
due: { email: true, discord: true, webhook: false },
shared: { email: true, discord: false, webhook: false },
taskDone: { email: true, discord: false, webhook: false },
watched: { email: true, discord: false, webhook: false },
due: { email: true, discord: true, webhook: false, push: false },
shared: { email: true, discord: false, webhook: false, push: false },
taskDone: { email: true, discord: false, webhook: false, push: false },
watched: { email: true, discord: false, webhook: false, push: false },
},
dailySummary: {
enabled: true,
@@ -99,6 +100,7 @@ describe('UserSettingsView notification matrix', () => {
email: true,
discord: true,
webhook: false,
push: false,
},
},
})
+14
View File
@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { deviceName, urlBase64ToUint8Array } from '../push';
describe('push helpers', () => {
it('decodes the VAPID key (base64url without padding)', () => {
expect(Array.from(urlBase64ToUint8Array('AQID_-8'))).toEqual([1, 2, 3, 255, 239]);
});
it('names the device', () => {
expect(deviceName('Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0')).toBe('Firefox auf Linux');
expect(deviceName('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36')).toBe('Chrome auf Android');
expect(deviceName('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1')).toBe('Safari auf iOS');
});
});
+22
View File
@@ -5,6 +5,7 @@ import {
Document,
TrashedDocument,
JournalDay,
PushDevice,
BackupFile,
DataSection,
ApiKeyScope,
@@ -724,6 +725,27 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
async getPushKey(): Promise<{ enabled: boolean; publicKey?: string }> {
return request('/api/v1/push/key');
},
async getPushDevices(): Promise<PushDevice[]> {
const res = await request<{ devices: PushDevice[] }>('/api/v1/push/devices');
return res.devices || [];
},
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
},
async unsubscribePush(endpoint: string): Promise<void> {
await request('/api/v1/push/unsubscribe', { method: 'POST', body: JSON.stringify({ endpoint }) });
},
async testPush(): Promise<{ devices: number }> {
return request('/api/v1/push/test', { method: 'POST' });
},
/** Days of the own journal (newest first) with their notes. */
async getJournal(): Promise<JournalDay[]> {
const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
+76
View File
@@ -0,0 +1,76 @@
import { api } from './api';
// Web push in this browser: the service worker shows the notifications, the server sends them over the
// browser's push service (VAPID). iPhones and iPads need MindSift added to the home screen first.
export function pushSupported(): boolean {
return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
/** iOS/iPadOS outside the installed home screen app: push is not offered there */
export function needsHomeScreenInstall(): boolean {
if (typeof navigator === 'undefined') return false;
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const standalone =
window.matchMedia?.('(display-mode: standalone)').matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
return ios && !standalone;
}
export function deviceName(ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''): string {
const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows' : /Mac OS X/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : '';
return os ? `${browser} auf ${os}` : browser;
}
export function urlBase64ToUint8Array(base64: string): Uint8Array {
const padded = (base64 + '='.repeat((4 - (base64.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(padded);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
async function registration(): Promise<ServiceWorkerRegistration> {
return navigator.serviceWorker.ready;
}
/** The subscription of this browser, if push is switched on here. */
export async function currentPushSubscription(): Promise<PushSubscription | null> {
if (!pushSupported()) return null;
try {
return await (await registration()).pushManager.getSubscription();
} catch {
return null;
}
}
export async function enablePush(): Promise<void> {
if (!pushSupported()) throw new Error('Dieser Browser unterstützt keine Push-Benachrichtigungen');
if (needsHomeScreenInstall()) throw new Error('Auf iPhone und iPad zuerst MindSift „Zum Home-Bildschirm“ hinzufügen und von dort öffnen');
const key = await api.getPushKey();
if (!key.enabled || !key.publicKey) throw new Error('Push ist auf diesem Server nicht verfügbar');
const permission = await Notification.requestPermission();
if (permission !== 'granted') throw new Error('Benachrichtigungen wurden im Browser nicht erlaubt');
const reg = await registration();
let sub = await reg.pushManager.getSubscription();
if (!sub) {
try {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key.publicKey) as BufferSource,
});
} catch {
// e.g. "Registration failed – permission denied": blocked for the site or no push service reachable
throw new Error('Der Browser hat die Anmeldung beim Push-Dienst abgelehnt (Berechtigung blockiert oder Push-Dienst nicht erreichbar)');
}
}
await api.subscribePush(sub.toJSON(), deviceName());
}
export async function disablePush(): Promise<void> {
const sub = await currentPushSubscription();
if (!sub) return;
const endpoint = sub.endpoint;
await sub.unsubscribe().catch(() => false);
await api.unsubscribePush(endpoint).catch(() => {});
}
+10
View File
@@ -50,6 +50,7 @@ export interface NotificationChannelRouting {
email: boolean;
discord: boolean;
webhook: boolean;
push: boolean;
}
export interface NotificationEvents {
@@ -65,6 +66,15 @@ export interface DailySummarySettings {
email: boolean;
discord: boolean;
webhook: boolean;
push: boolean;
}
/** A browser or installed app that receives web push */
export interface PushDevice {
id: number;
endpoint: string;
device: string;
createdAt: string;
}
export interface NotificationSettings {