Pages
Contents
Chat Message – Concept
Status: CONCEPT · Erstellt April 2026 Übergeordnet:
TOOL_chat_roadmap.mdVerwandt:TOOL_chat_concept-peer-sync.md
Warum ein eigenes Konzept?
Die Message-Struktur ist die Grundlage für alles – Rendering, Sync, Export, Backup-Import und Migration. Dieses Konzept legt fest wie eine Nachricht aufgebaut ist, wie sie dargestellt wird, wie gelöschte Nachrichten behandelt werden, und wie alte Formate abwärtskompatibel migriert werden.
Message Format (v1)
| { |
| id: String, // UUID v4 – eindeutig, client-seitig generiert beim Senden |
| senderId: String, // Peer-ID des Absenders |
| receiverId: String, // Peer-ID des Empfängers |
| sentAt: Number, // Unix ms – gesetzt beim Senden |
| editedAt: Number, // Unix ms – gesetzt beim Bearbeiten; null wenn nie bearbeitet |
| type: String, // 'text' | erweiterbar: 'image', 'system', ... |
| content: String, // Nachrichteninhalt (lokal im Klartext, über DataChannel E2EE) |
| v: Number, // Message-Format-Version (aktuell: 1) |
| meta: { |
| receivedAt: Number, // Unix ms – gesetzt beim Empfang; null wenn noch nicht empfangen |
| viewedAt: Number, // Unix ms – gesetzt wenn Empfänger die Nachricht gesehen hat; null wenn ungesehen |
| signature: String, // Base64 – Ed25519 Signatur des Senders; null bei migrierten v0 Messages |
| // erweiterbar |
| } |
| } |
Warum UUID v4? Eindeutig ohne Server, kein Koordinationsaufwand, kein Kollisionsrisiko.
Warum v?
Ermöglicht spätere Migration beim Lesen aus localStorage, Backup-Import oder
Peer-Sync – ohne dass alte Clients brechen.
Warum editedAt außerhalb von meta?
Der Comparator braucht editedAt direkt um zu entscheiden ob eine eingehende
Message neuer ist als die lokale. receivedAt, viewedAt und signature sind
UI/Tracking/Crypto-Werte ohne direkte Sync-Relevanz – sie gehören in meta.
Hinweis zu signature:
Migrierte v0 Messages haben keine Signatur – signature: null ist akzeptiert.
Neue Messages ab v1.11.0 sind immer signiert.
→ Details: TOOL_chat_concept-crypto.md
Hinweis zu content:
Lokal in localStorage liegt content im Klartext. At-rest Encryption via
@xpulse/crypto ist eine mögliche zukünftige Erweiterung – bewusst offen gelassen.
Edit- und Lösch-Zeitbegrenzung
Bearbeiten und „Für alle löschen" ist nur innerhalb eines konfigurierbaren
Zeitfensters ab sentAt möglich. Nach Ablauf sind diese Aktionen gesperrt.
Konkrete Dauer: TBD (Referenz: WhatsApp 60h, Telegram 48h).
UI-Darstellung
| Zustand | Anzeige |
|---|---|
| Normal | Nachrichtentext |
Bearbeitet (editedAt !== null) |
✏️ Icon neben der Nachricht |
Gesehen (meta.viewedAt !== null) |
👁 Icon – mit Hover zeigt Zeitangabe |
Ungesehen (meta.viewedAt === null) |
👁 durchgestrichen – mit Hover „noch nicht gesehen" |
| Gelöscht (ID im Graveyard) | Ausgegraut: „Nachricht gelöscht" |
Graveyard
Gelöschte Nachrichten werden nicht einfach entfernt – sie hinterlassen einen Grabstein. Der Graveyard hat zwei Aufgaben:
- UI – beim Rendern: ID im Graveyard → ausgegraut „Nachricht gelöscht" anzeigen
- Sync – verhindert dass gelöschte Nachrichten beim Sync wiederhergestellt werden
localStorage Key
| xp_graveyard_{chatId} |
Format
| { |
| [messageId]: deletedAt, // Unix ms |
| [messageId]: deletedAt, |
| ... |
| } |
Lösch-Modi
„Für mich löschen" → Message aus localStorage entfernen, ID in lokalen Graveyard → Kein Sync an Peer → Peer hat die Message noch → wiederherstellbar über Peer History ✅
„Für alle löschen"
→ Message aus localStorage entfernen, ID in lokalen Graveyard
→ sync:delete Event an Peer schicken (sofort wenn online, sonst gepuffert)
→ Peer legt ID ebenfalls in seinen Graveyard
→ Wiederherstellung nicht möglich ❌ – beide Seiten haben den Grabstein
→ Best-effort: wenn Peer offline ist und die Queue noch nicht abgearbeitet wurde,
könnte er die Message theoretisch noch sehen – technisch nicht vermeidbar
Zeitbegrenzung: „Für alle löschen" ist nur innerhalb des konfigurierten
Zeitfensters ab sentAt möglich (gleiche Grenze wie Edit).
Pending Delete Queue
Wenn der Peer beim „Für alle löschen" offline ist:
| xp_pending_delete_{chatId}: [ |
| { id: messageId, requestedAt: timestamp }, |
| ... |
| ] |
Beim nächsten Connect: Queue abarbeiten → sync:delete schicken → Queue leeren.
Ist requestedAt außerhalb des Zeitlimits → still aus Queue entfernen,
Peer behält die Message.
Wiederherstellung
Wenn der Peer online ist und Nachrichten hat die im lokalen Graveyard stehen (nur „für mich gelöscht"), kann der User aktiv entscheiden diese wiederherzustellen. Im Chat Info Panel wird angezeigt wie viele Nachrichten wiederherstellbar wären – nur sichtbar wenn Peer online ist.
Wiederherstellung = betroffene IDs aus dem Graveyard entfernen → beim nächsten
sync() werden sie normal gemergt.
Nicht wiederherstellbar: Messages die mit „Für alle löschen" entfernt wurden – der Peer hat den Grabstein ebenfalls, Sync ignoriert sie auf beiden Seiten.
Graveyard wächst unbegrenzt – UUIDs + Timestamps sind klein, kein Cleanup nötig.
Migration – Abwärtskompatibilität
Altes Message Format v0 (vor v1.11.0)
| // localStorage: chats[chatId][n] |
| { ts, text, mine } |
Mapping v0 → v1
| Alt | Neu | Hinweis |
|---|---|---|
ts |
sentAt |
1:1 |
text |
content |
1:1 |
mine |
senderId / receiverId |
mine === true → senderId = ownId, sonst senderId = peerId |
| – | id |
neu generierte UUID v4 – keine alte ID vorhanden |
| – | editedAt |
null |
| – | type |
'text' |
| – | v |
1 nach Migration |
| – | meta.receivedAt |
Annäherung: ts (exakter Wert unbekannt) |
| – | meta.viewedAt |
null |
migrateV0() – im Converter
migrateV0() lebt im ChatConverter – nicht im localStorage-Layer.
Der Converter ist der universelle Eingangs-Normalizer: egal woher die Daten
kommen (localStorage, Backup-Import, Peer-Sync), raus kommt immer sauberes v1.
Der Rest der Pipeline bekommt nie eine v0 Message zu sehen.
| localStorage laden → ChatConverter → migrateV0() wenn nötig |
| Backup importieren → ChatConverter → migrateV0() wenn nötig |
| Sync empfangen → ChatConverter → migrateV0() wenn nötig |
Fehlendes v oder v === 0 → Migration:
| function migrateV0(msg, ownId, peerId) { |
| return { |
| id: crypto.randomUUID(), |
| senderId: msg.mine ? ownId : peerId, |
| receiverId: msg.mine ? peerId : ownId, |
| sentAt: msg.ts, |
| editedAt: null, |
| type: 'text', |
| content: msg.text, |
| v: 1, |
| meta: { |
| receivedAt: msg.ts, |
| viewedAt: null |
| } |
| }; |
| } |
Die migrierten Messages werden direkt nach dem Laden zurück in localStorage geschrieben – Migration wird nur einmalig durchgeführt.
Comparator – Fallback für migrierte Messages
Da beim Migrieren neue UUIDs generiert werden, haben beide Peers nach der Migration unterschiedliche IDs für dieselben Nachrichten. Ein reiner ID-Vergleich würde migrierte Messages als „neu" behandeln und doppelt einfügen.
Daher: Fallback-Vergleich via content + sentAt für Messages ohne gemeinsame ID-Basis:
| Message kommt rein → |
| v === 1, ID-Match lokal → normaler ID-Vergleich (Graveyard, Duplikat, Merge) |
| v === 1, kein lokaler ID-Match → |
| Fallback: content + sentAt vergleichen |
| Match gefunden → Duplikat; lokale Message mit eingehender ID aktualisieren |
| Kein Match → mergen |
Nach der ersten erfolgreichen Sync-Runde sind alle Messages auf beiden Seiten mit echten UUIDs versehen – der Fallback greift danach nicht mehr. Der Übergangsmechanismus ist einmalig und selbstheilend.
Offene Punkte
| Thema | Stand |
|---|---|
At-rest Encryption für content in localStorage |
Bewusst offen – @xpulse/crypto |
| Zeitfenster für Edit + „Für alle löschen" | TBD – Richtwert: 48–60h ab sentAt |
sync:delete Format (DataChannel Message-Type) |
TBD bei Implementierung |