From 38b8c92899ce3fddec86e2ddfe8a44724502d19b Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 21:21:55 +0100 Subject: [PATCH] feat(notify): web push as a notification channel - 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 --- .env.example | 7 + CHANGELOG.md | 1 + Doku/api/rest.md | 5 + Doku/architecture.md | 4 +- Doku/database.md | 4 +- README.md | 3 +- docker-compose.yml | 2 + frontend/public/sw.js | 32 +++ .../features/settings/UserSettingsView.tsx | 138 ++++++++++- .../UserSettingsView.alarms.test.tsx | 10 +- frontend/src/lib/__tests__/push.test.ts | 14 ++ frontend/src/lib/api.ts | 22 ++ frontend/src/lib/push.ts | 76 ++++++ frontend/src/lib/types.ts | 10 + go.mod | 1 + go.sum | 71 +++++- internal/api/alarms.go | 7 + internal/api/notify.go | 33 ++- internal/api/notify_test.go | 2 +- internal/api/push.go | 217 ++++++++++++++++++ internal/api/push_crypto_test.go | 87 +++++++ internal/api/push_test.go | 79 +++++++ internal/api/server.go | 8 + internal/config/config.go | 8 + internal/domain/models.go | 4 +- internal/storage/migrations.go | 1 + internal/storage/push.go | 99 ++++++++ 27 files changed, 918 insertions(+), 27 deletions(-) create mode 100644 frontend/src/lib/__tests__/push.test.ts create mode 100644 frontend/src/lib/push.ts create mode 100644 internal/api/push.go create mode 100644 internal/api/push_crypto_test.go create mode 100644 internal/api/push_test.go create mode 100644 internal/storage/push.go diff --git a/.env.example b/.env.example index 098f01b..ce413a9 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,13 @@ SMTP_TLS=true # true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver). SMTP_SKIP_VERIFY=false +# Web-Push (Benachrichtigungen auf Handy und Desktop, auch bei geschlossener App). +# Ohne Angabe erzeugt MindSift beim ersten Bedarf ein Schlüsselpaar und speichert es in der Datenbank. +# VAPID_SUBJECT: Kontakt für die Push-Dienste (mailto: oder https://); Standard ist BASE_URL, wenn https. +# VAPID_PUBLIC_KEY= +# VAPID_PRIVATE_KEY= +# VAPID_SUBJECT=mailto:admin@example.com + # ─── Impressum und Datenschutz (optional) ────────────────────────────────────────────────────────── # Werden auf der Anmeldeseite und in den Einstellungen verlinkt. Entweder als Link auf eine externe # Seite (nur http/https) oder als Markdown-Text, direkt hier oder als Datei (per Volume einbinden). diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f7efe..c02b249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] ### Added +- **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`. - Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar. ### Changed diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 074558a..e68443e 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -125,6 +125,11 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) | | DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen | | POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt | +| GET | `/push/key` | `{enabled, publicKey}` – öffentlicher VAPID-Schlüssel für `pushManager.subscribe` (in der Demo `enabled: false`) | +| GET | `/push/devices` | Eigene Geräte mit Push → `{devices: [{id, endpoint, device, createdAt}]}` | +| POST | `/push/subscribe` | `{endpoint, keys: {p256dh, auth}, device}` – Gerät anmelden (nur `https`-Endpunkte) | +| POST | `/push/unsubscribe` | `{endpoint}` – Gerät abmelden | +| POST | `/push/test` | Test-Benachrichtigung an alle eigenen Geräte → `{devices}`; `400` ohne Gerät | | GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) | | POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) | | POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | diff --git a/Doku/architecture.md b/Doku/architecture.md index 46b60f2..e6d43d5 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -71,6 +71,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" `, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | | `NOTIFY_DIGEST_MINUTES` | `5` | Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele Minuten gebündelt verschickt | +| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | – | Schlüsselpaar für Web-Push; ohne Angabe einmal erzeugt und in `push_keys` gespeichert | +| `VAPID_SUBJECT` | `BASE_URL` (wenn https) | Kontakt für die Push-Dienste (`mailto:` oder `https://`) | | `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB | | `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) | | `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB | @@ -106,7 +108,7 @@ Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in ## Benachrichtigungen -`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook und eigener Webhook (`customWebhookUrl`, JSON per POST) aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht. +`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook, eigener Webhook (`customWebhookUrl`, JSON per POST) und Web-Push an die eigenen Geräte (`internal/api/push.go`, Migration 9, Tabelle `push_subscriptions`; Geräte, die der Push-Dienst nicht mehr kennt (404/410), werden entfernt) –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht. ## Link-Vorschau diff --git a/Doku/database.md b/Doku/database.md index 9a98c4f..1096e76 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -14,6 +14,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j | `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` | | `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` | | `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` | +| `push_subscriptions` | Geräte mit Web-Push (Migration 9) | `user_id`, `endpoint` (eindeutig), `p256dh`, `auth`, `device`, `created_at` | +| `push_keys` | VAPID-Schlüsselpaar, falls nicht per Umgebung gesetzt (Migration 9) | eine Zeile: `public_key`, `private_key` | | `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` | | `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | @@ -85,7 +87,7 @@ teams 1─n team_members n─1 users - `users.username` wird aus dem E-Mail-Präfix aufgefüllt. - Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt. - Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer. -- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 8: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank. +- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 9: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank. ## Sicherung diff --git a/README.md b/README.md index 80c084c..66fb122 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**. * **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden. * **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken. -* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail/Discord. +* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail, Discord, eigenem Webhook oder Push. +* **Push-Benachrichtigungen** auf Handy und Desktop (auch bei geschlossener App; iPhone/iPad als Home-Bildschirm-App), als eigener Kanal je Ereignis. * **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.

diff --git a/docker-compose.yml b/docker-compose.yml index e00922c..7b26412 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,8 @@ services: - NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} + # Web-Push: ohne Angabe erzeugt MindSift die Schlüssel selbst + # - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@example.com} # Impressum / Datenschutz - LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-} - LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-} diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 1328369..6ecbc80 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -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); + }) + ); +}); diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 293d513..7e54294 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -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 = ({ const [savingAlarms, setSavingAlarms] = useState(false); const [testingChannel, setTestingChannel] = useState(null); + // Web push: devices of this account and whether this browser is one of them + const [pushDevices, setPushDevices] = useState([]); + 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 = ({ }, [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 = ({ {(() => { const hasDiscordWebhook = Boolean(discordWebhookUrl.trim()); const hasCustomWebhook = Boolean(customWebhookUrl.trim()); + const hasPush = pushDevices.length > 0; return ( <>

-
+
Ereignis - + E-Mail - + Discord - + Webhook + + + Push +
@@ -1165,17 +1216,17 @@ export const UserSettingsView: React.FC = ({ return (
{row.label} -
+
setEventChannel(row.key, 'email', v)} title={`${row.label} – E-Mail`} />
-
+
= ({ } />
-
+
= ({ } />
+
+ setEventChannel(row.key, 'push', v)} + title={hasPush ? `${row.label} – Push` : 'Bitte zuerst unten Push auf einem Gerät einschalten'} + /> +
); })} @@ -1307,6 +1366,20 @@ export const UserSettingsView: React.FC = ({ } />
+
+ Push + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, push: v }, + })) + } + title={hasPush ? 'Tageszusammenfassung – Push' : 'Bitte zuerst unten Push auf einem Gerät einschalten'} + /> +
@@ -1458,6 +1531,49 @@ export const UserSettingsView: React.FC = ({
+ {/* Web push: this browser or installed app */} +
+
+
+ +
+ Ziel: Push-Benachrichtigungen + + Systembenachrichtigung auf diesem Gerät, auch wenn MindSift geschlossen ist. Auf iPhone und + iPad nur, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. + +
+
+
+ Dieses Gerät + void togglePushHere(v)} + title={pushSupported() ? 'Push auf diesem Gerät' : 'Dieser Browser unterstützt keine Push-Benachrichtigungen'} + /> + +
+
+ {pushDevices.length > 0 && ( +

+ Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '} + {pushDevices.map((d) => d.device || 'Gerät').join(', ')} +

+ )} + {pushMsg && ( +

{pushMsg.text}

+ )} +
+