feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s
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:
1 parent
1a49e6b304
commit
38b8c92899
27 files changed
+918
-27
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user