From 5b636b66e250d4aec8eeca171831510c060be5b5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sat, 31 Oct 2026 11:59:40 +0100 Subject: [PATCH] fix(push): reinstalled app replaces its old subscription; devices listed and removable Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++- README.md | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../features/settings/UserSettingsView.tsx | 52 +++++++++++++++++-- frontend/src/lib/api.ts | 4 +- frontend/src/lib/push.ts | 36 ++++++++++++- internal/api/push.go | 8 ++- internal/api/push_test.go | 21 ++++++++ 9 files changed, 123 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23fc2a8..c000595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.22.1] - 2026-10-31 + +### Fixed +- Push-Benachrichtigungen: Nach dem Neuinstallieren der App zählte die alte Installation als weiteres Gerät mit („2 aktiv“), bis der nächste Push sie aussortierte. Schaltet ein Gerät Push neu ein, meldet es jetzt seine alte Anmeldung mit ab. Außerdem zeigen die Einstellungen alle Geräte einzeln (mit Datum und „dieses Gerät“) und jedes lässt sich entfernen. + ## [0.22.0] - 2026-10-31 ### Added @@ -459,7 +464,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.1...dev +[0.22.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...v0.22.1 [0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0 [0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0 [0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0 diff --git a/README.md b/README.md index 442090a..56fa12a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.22.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.22.1-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 840fd64..bca35c6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.22.0", + "version": "0.22.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.22.0", + "version": "0.22.1", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 031e7a6..802eaad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.22.0", + "version": "0.22.1", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index c09c15f..cfb86db 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -11,6 +11,7 @@ import { MessageSquare, Globe, Send, + X, AlertCircle, Palette, Upload, @@ -50,7 +51,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 { currentPushSubscription, disablePush, enablePush, pushSupported, removePushDevice } from '../../lib/push'; import { FormSheet } from '../../components/FormSheet'; interface UserSettingsViewProps { @@ -301,13 +302,28 @@ export const UserSettingsView: React.FC = ({ // Web push: devices of this account and whether this browser is one of them const [pushDevices, setPushDevices] = useState([]); const [pushHere, setPushHere] = useState(false); + const [pushEndpointHere, setPushEndpointHere] = useState(null); 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))); + setPushEndpointHere(sub?.endpoint ?? null); }, []); + // An old installation or a device you no longer use: remove it from the list + const removeDevice = async (endpoint: string) => { + setPushBusy(true); + setPushMsg(null); + try { + await removePushDevice(endpoint); + await reloadPush(); + } catch (err) { + setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Gerät konnte nicht entfernt werden' }); + } finally { + setPushBusy(false); + } + }; useEffect(() => { void reloadPush(); }, [reloadPush]); @@ -1783,10 +1799,36 @@ export const UserSettingsView: React.FC = ({ {pushDevices.length > 0 && ( -

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

+
+

+ Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}. Alte Installationen + verschwinden beim nächsten Push von selbst oder lassen sich hier entfernen. +

+
    + {pushDevices.map((d) => ( +
  • + + {d.device || 'Gerät'} + + {' · seit '} + {new Date(d.createdAt).toLocaleDateString('de-DE')} + {d.endpoint === pushEndpointHere && ' · dieses Gerät'} + + + +
  • + ))} +
+
)} {pushMsg && (

{pushMsg.text}

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 755e1a8..aa079c8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -734,8 +734,8 @@ export const api = { return res.devices || []; }, - async subscribePush(sub: PushSubscriptionJSON, device: string): Promise { - await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) }); + async subscribePush(sub: PushSubscriptionJSON, device: string, replaces?: string): Promise { + await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device, replaces: replaces || '' }) }); }, async unsubscribePush(endpoint: string): Promise { diff --git a/frontend/src/lib/push.ts b/frontend/src/lib/push.ts index b2a8822..aa1eb44 100644 --- a/frontend/src/lib/push.ts +++ b/frontend/src/lib/push.ts @@ -64,7 +64,40 @@ export async function enablePush(): Promise { 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()); + // The address this browser registered before is dead once it has a new one (e.g. after reinstalling + // the app): the server drops it right away instead of counting it as another device + const previous = readStoredEndpoint(); + await api.subscribePush(sub.toJSON(), deviceName(), previous && previous !== sub.endpoint ? previous : undefined); + storeEndpoint(sub.endpoint); +} + +const ENDPOINT_KEY = 'mindsift_push_endpoint'; + +function readStoredEndpoint(): string | null { + try { + return localStorage.getItem(ENDPOINT_KEY); + } catch { + return null; + } +} + +function storeEndpoint(endpoint: string | null): void { + try { + if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint); + else localStorage.removeItem(ENDPOINT_KEY); + } catch { + // storage blocked: the dead device is removed on the next push instead + } +} + +/** Removes one of the own devices (e.g. an old installation) on the server. */ +export async function removePushDevice(endpoint: string): Promise { + const here = await currentPushSubscription(); + if (here?.endpoint === endpoint) { + await disablePush(); + return; + } + await api.unsubscribePush(endpoint); } export async function disablePush(): Promise { @@ -73,4 +106,5 @@ export async function disablePush(): Promise { const endpoint = sub.endpoint; await sub.unsubscribe().catch(() => false); await api.unsubscribePush(endpoint).catch(() => {}); + storeEndpoint(null); } diff --git a/internal/api/push.go b/internal/api/push.go index f091bc4..3ee07f6 100644 --- a/internal/api/push.go +++ b/internal/api/push.go @@ -148,6 +148,8 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) { Auth string `json:"auth"` } `json:"keys"` Device string `json:"device"` + // The subscription this browser had before (e.g. before a reinstall): it is dead now + Replaces string `json:"replaces"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") @@ -165,10 +167,14 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) { if len(device) > 120 { device = device[:120] } - if err := s.store.SavePushSubscription(auth.GetUserID(r.Context()), req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil { + userID := auth.GetUserID(r.Context()) + if err := s.store.SavePushSubscription(userID, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil { writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gespeichert werden") return } + if req.Replaces != "" && req.Replaces != req.Endpoint { + _ = s.store.DeletePushSubscription(userID, req.Replaces) // only ever one of the own devices + } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } diff --git a/internal/api/push_test.go b/internal/api/push_test.go index 9ab8d9f..2bb12a7 100644 --- a/internal/api/push_test.go +++ b/internal/api/push_test.go @@ -77,3 +77,24 @@ func TestPushDigestText(t *testing.T) { t.Fatalf("many: %+v", many) } } + +// A reinstalled app registers a new subscription and names the old one: it is dropped, not counted twice +func TestPushResubscribeReplacesOldDevice(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + me, token := newTestUser(t, store, "reinstall@example.com") + sub := func(endpoint, replaces string) int { + body := `{"endpoint":"` + endpoint + `","keys":{"p256dh":"k","auth":"a"},"device":"Chrome auf Android","replaces":"` + replaces + `"}` + return sessionCall(router, http.MethodPost, "/api/v1/push/subscribe", token, body, "").Code + } + if code := sub("https://fcm.googleapis.com/fcm/send/alt", ""); code != http.StatusOK { + t.Fatalf("subscribe: %d", code) + } + if code := sub("https://fcm.googleapis.com/fcm/send/neu", "https://fcm.googleapis.com/fcm/send/alt"); code != http.StatusOK { + t.Fatalf("resubscribe: %d", code) + } + subs, _ := store.PushSubscriptions(me.ID) + if len(subs) != 1 || subs[0].Endpoint != "https://fcm.googleapis.com/fcm/send/neu" { + t.Fatalf("old installation must be replaced, got %+v", subs) + } +}