xPulse
🇬🇧 EN

Chat Message – Concept

Status: CONCEPT · Erstellt April 2026 Übergeordnet: TOOL_chat_roadmap.md Verwandt: 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:

  1. UI – beim Rendern: ID im Graveyard → ausgegraut „Nachricht gelöscht" anzeigen
  2. 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 === truesenderId = 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
de/concept/message.md 2026-04-17