Skip to content

Chat-Architektur — Technische Dokumentation

Uebersicht

Der Prilog Web-Client kommuniziert direkt mit dem Matrix-Server (Synapse) fuer Echtzeit-Chat. Es wird kein matrix-js-sdk verwendet — stattdessen ein eigener, minimaler Client (~400 Zeilen), der exakt auf die Anforderungen von Prilog zugeschnitten ist.

Warum kein matrix-js-sdk / Element?

Element/matrix-js-sdk umfasst ~15.000 Zeilen fuer die Persistenz- und Sync-Schicht. Der Grossteil davon ist fuer Prilog nicht relevant:

FeatureElement (~15K LoC)Prilog (~400 LoC)Grund
E2E-Verschluesselung (Olm/Megolm)~4000 ZeilenNicht implementiertSynapse verwaltet Verschluesselung intern
Multi-Tab-Sync (BroadcastChannel)~800 ZeilenNicht implementiertEinzelner Tab genuegt
Full-Text-Suche in IndexedDB~600 ZeilenNicht implementiertServerseitige Suche
Media/Thumbnail-Cache~1200 ZeilenNicht implementiertKein Offline-Modus noetig
Room State Resolution (DAG)~2000 ZeilenNicht implementiertSynapse loest Konflikte auf
Pagination + Gap-Filling~1500 Zeilen50 Messages pro LoadEinfache Pagination genuegt
Account Data Sync (alle Typen)~800 ZeilenNur m.directNur DM-Zuordnung benoetigt
Read Receipts + Unread Counts~1000 ZeilenServer-seitigSynapse liefert notification_count
Push/Notification Rules~1200 ZeilenNoch nichtBrowser-native spaeter
Cross-Signing / Key Backup~1500 ZeilenNicht implementiertNicht relevant

Kernargument: Element ist ein generischer Matrix-Client fuer beliebige Server und Konfigurationen. Prilog kontrolliert den gesamten Stack (Synapse, Raeume, User) — deshalb genuegen ~3% des Codes fuer denselben Funktionsumfang.


Architektur

┌─────────────────────────────────────────────────────┐
│                    React Components                  │
│         (chat-module.tsx, dm-chat.tsx)               │
│                        │                             │
│                  useChatRoom()                       │
│                        │                             │
│              useSyncExternalStore()                  │
└────────────────────────┬────────────────────────────┘

┌────────────────────────┴────────────────────────────┐
│                    chat-store.ts                      │
│         (In-Memory State, Single Source of Truth)     │
│                                                      │
│  rooms: Map<roomId, RoomState>                       │
│  directRooms: Map<userId, roomId>                    │
│  syncState: 'idle' | 'initial' | 'syncing' | 'error'│
│                        │                             │
│              emit() → React (synchron)               │
└──────────────┬──────────────────────────────────────┘

┌──────────────┴───────┐
│    chat-sync.ts      │
│  (Matrix /sync API)  │
│                      │
│  Long-Poll Loop      │
│  Full Initial Sync   │
│  sinceToken Mgmt     │
└──────────┬───────────┘

┌──────────┴───────────┐
│   Synapse (Matrix)   │
│  /_matrix/client/v3  │
└──────────────────────┘

Datenfluss

  1. Sync-Loop (chat-sync.ts) pollt den Matrix-Server via /sync (30s Long-Poll)
  2. Store (chat-store.ts) verarbeitet die Sync-Response:
    • Neue Nachrichten → in-memory Messages-Array
    • Member-Updates → displayName Map
    • Typing-Events → typingUsers Array
    • Reactions → reactions Map (m.annotation)
    • m.direct Account Data → directRooms Map
    • Unread/Highlight Counts → aus unread_notifications
  3. React wird via useSyncExternalStore synchron benachrichtigt
  4. IndexedDB ist optional — wird parallel geoeffnet, nie blockierend (siehe Cache-Strategie)

Sync-Strategie

Full Initial Sync (seit April 2026)

Bei jedem Seitenlade wird ein vollstaendiger Initial-Sync durchgefuehrt (sinceToken = null). Synapse liefert dabei alle Raeume mit bis zu 50 Nachrichten pro Raum.

Warum kein inkrementeller Sync?

Der urspruengliche Ansatz (sinceToken in IndexedDB speichern, beim Reload inkrementell weitersyncen) hatte mehrere Probleme:

  1. IndexedDB kann blockierenopenDB() haengt endlos wenn eine alte Verbindung offen ist oder ein deleteDatabase pending ist. Da der Sync auf die DB wartete, blockierte ein IndexedDB-Problem den gesamten Chat.
  2. Race ConditionsloadRoomFromDb() feuerte vor openChatDb() fertig war. Der Raum wurde als "geladen" markiert (mit 0 Nachrichten), historische Messages kamen nie nach.
  3. Store-BenachrichtigungrequestAnimationFrame-basiertes Batching fuehrte dazu, dass useSyncExternalStore Aenderungen nicht erkannte. React renderte nie mit den neuen Nachrichten.

Aktueller Ansatz:

Page Load
  → sessionStore liest Token aus localStorage
  → ShellLayout mounted
    → startSync() startet SOFORT (kein await auf IndexedDB)
      → sinceToken = null (immer Full Initial Sync)
      → fetch /_matrix/client/v3/sync
      → Synapse liefert alle Raeume + 50 Messages pro Raum
      → applySync() → emit() → React rendert Nachrichten
    → openChatDb() laeuft PARALLEL (3s Timeout, optional)

Performance

MetrikWert (Testinstanz: 95 Raeume)
Initial-Sync-Dauer~500ms
Events im Initial-Sync~560
Raeume mit Nachrichten27 von 95
Bandbreite~150 KB (JSON, komprimiert ~40 KB)
Folge-Syncs (Long-Poll)~0 Bytes bei Inaktivitaet

Wann wird inkrementeller Sync wieder relevant?

Ab ~500+ Raeumen dauert der Full Initial Sync merklich laenger (2-5 Sekunden). Dann wird ein Cache-Layer eingefuehrt (siehe Abschnitt unten). Der Full-Sync bleibt als Fallback erhalten.


Cache-Strategie (3 Stufen)

Der Cache ist optional — der Chat funktioniert immer auch ohne. Ziel ist Beschleunigung, nicht Korrektheit.

Prinzip: Sync ist Wahrheit, Cache ist Beschleunigung

Seitenlade
  ├── Sync startet SOFORT → Nachrichten nach ~500ms (immer)

  └── Cache hydrate (parallel, optional)
        ├── Erfolg → Nachrichten nach ~50ms (vor Sync)
        │             Sync merged dann nur Deltas
        └── Fehlschlag → ignoriert, Sync liefert alles

Stufe 1 — Kein Cache (aktuell, bis ~500 Raeume)

  • Full Initial Sync bei jedem Seitenlade
  • Kein IndexedDB, kein sinceToken
  • ~500ms fuer 95 Raeume — nicht spuerbar

Stufe 2 — Cache als Vorschau (ab ~500 Raeume, ~1 Tag Aufwand)

  • IndexedDB speichert die letzten N Nachrichten pro Raum + Room-Metadaten
  • Beim Laden: Cache sofort anzeigen, Sync im Hintergrund
  • Sync-Response merged mit Cache (neue Nachrichten anhaengen, Members aktualisieren)
  • Cache-Writes sind fire-and-forget via Web Worker — nie blockierend
  • sinceToken wird NICHT gecacht (immer Full Sync) — vermeidet stale-token-Probleme
  • IndexedDB-Open mit 1s Timeout, bei Fehlschlag → reiner Sync-Betrieb

Stufe 3 — Inkrementeller Sync (ab ~5.000 Raeume, ~1 Woche Aufwand)

  • sinceToken-Persistierung in IndexedDB
  • Messages und Token werden atomar geschrieben (gleiche IndexedDB-Transaktion)
  • Auto-Fallback: wenn Sync HTTP 400 gibt (stale token) → automatisch Full Initial Sync
  • Background-Sync via Service Worker fuer Tab-uebergreifende Konsistenz
  • Cache-Invalidierung per Tenant-Version (Backend sendet Version im Bootstrap, bei Mismatch → Cache verwerfen)

Warum kein Service Worker als Cache?

Service Worker eignen sich fuer statische Assets, nicht fuer dynamische Chat-Daten. Ein SW muesste den gesamten Sync-Zustand verwalten (Token, Messages, Members) — das waere eine zweite Chat-Engine neben der im Main Thread. Stattdessen bleibt IndexedDB der Cache-Store, und der Main Thread die einzige Sync-Engine.


IndexedDB-Schema (Referenz)

Das Schema existiert weiterhin im Code, wird aber seit Stufe 1 nicht aktiv genutzt. Es dient als Grundlage fuer Stufe 2.

DB-Name: prilog-chat-{userId} — pro User isoliert.

┌─────────────────────────────────────────────────────┐
│ Object Store: messages                               │
│ keyPath: eventId                                     │
│ Indexes:                                             │
│   - roomId (non-unique)                              │
│   - [roomId, timestamp] (compound, fuer Sortierung)  │
│   - [roomId, threadId] (compound, fuer Threads)      │
│                                                      │
│ Record: {                                            │
│   eventId, roomId, sender, body, timestamp,          │
│   txnId?, threadId?, replyTo?                        │
│ }                                                    │
│ Hinweis: pending/failed werden NICHT persistiert      │
├─────────────────────────────────────────────────────┤
│ Object Store: rooms                                  │
│ keyPath: roomId                                      │
│                                                      │
│ Record: {                                            │
│   roomId, prevBatch, hasMore,                        │
│   members: [userId, {displayName, avatarMxc}][]      │
│ }                                                    │
├─────────────────────────────────────────────────────┤
│ Object Store: syncState                              │
│ keyPath: key (immer "sync")                          │
│                                                      │
│ Record: {                                            │
│   key: "sync",                                       │
│   sinceToken: string | null,                         │
│   directRooms: [userId, roomId][]                    │
│ }                                                    │
└─────────────────────────────────────────────────────┘

Dateien

DateiZeilenVerantwortlichkeit
src/features/chat/chat-db.ts~190IndexedDB-Wrapper, alle DB-Zugriffe, isChatDbOpen()
src/features/chat/chat-store.ts~550In-Memory State, Pub/Sub, Sync-Verarbeitung, DB-Merge
src/features/chat/chat-sync.ts~130Matrix /sync Long-Poll Loop, Auto-Join Invites
src/features/chat/chat-types.ts~80TypeScript Interfaces (Runtime + DB)
src/features/chat/use-chat-room.ts~310React Hook (Messages, Send, Files, Reactions, Typing)
src/features/chat/use-mark-room-as-read.ts~80Read-Markers (m.fully_read + m.read)
src/features/modules/chat-module.tsx~400Space-Chat UI (Gruppen-Chat, Infotafel, Threads)
src/features/modules/dm-chat.tsx~170Direct Message UI (1:1 Chat)
src/components/chat/chat-bubble.tsx~700Nachricht-Rendering (Text, Bilder, Videos, PDFs, Audio)
src/components/chat/chat-composer.tsx~120Eingabefeld mit Typing-Indicator + File-Upload
src/components/chat/chat-thread-panel.tsx~150Thread-Seitenpanel
src/core/settings/chat-settings.ts~25Design-Einstellung (localStorage)

Chat-Designs

Zwei Designs sind implementiert, waehlbar unter Einstellungen > Chat:

Slack-Design (Standard)

  • Alle Nachrichten links-buendig
  • Avatar + Name + Zeitstempel ueber jeder Nachricht
  • Neutrale Bubble-Farben

WhatsApp-Design

  • Eigene Nachrichten rechts (gruene Bubbles)
  • Fremde Nachrichten links (weisse/graue Bubbles)
  • Beiger Hintergrund (#e5ddd5, Dark Mode: #0b141a)
  • Kompakter, keine Avatare

Die Einstellung wird in localStorage gespeichert (prilog.chat.design).


Matrix-API-Nutzung

Der Web-Client nutzt folgende Matrix Client-Server API Endpunkte:

EndpunktMethodeVerwendung
/client/v3/loginPOSTLogin mit Username/Passwort
/client/v3/account/whoamiGETToken-Validierung
/client/v3/syncGETEvent-Sync (Long-Poll, 30s)
/client/v3/rooms/{id}/messagesGETAeltere Nachrichten laden
/client/v3/rooms/{id}/send/m.room.message/{txnId}PUTNachricht senden
/client/v3/rooms/{id}/send/m.reaction/{txnId}PUTReaktion senden
/client/v3/createRoomPOSTDM-Raum erstellen
/client/v3/join/{roomId}POSTRaum beitreten (Auto-Accept Invites)
/client/v3/rooms/{id}/leavePOSTRaum verlassen
/client/v3/rooms/{id}/typing/{userId}PUTTyping-Indicator
/client/v3/rooms/{id}/read_markersPOSTRead Markers (m.fully_read + m.read)
/client/v1/media/configGETUpload-Limits abfragen
/media/v3/uploadPOSTDatei-Upload (Bilder, Videos, Audio, PDFs)
/client/v1/media/thumbnail/{server}/{mediaId}GETAuthenticated Thumbnails
/client/v1/media/download/{server}/{mediaId}GETAuthenticated Downloads
/client/v3/user/{userId}/account_data/{type}GET/PUTAccount Data (m.direct)
/client/v3/profile/{userId}GETProfil (Name, Avatar)
/client/v3/profile/{userId}/displaynamePUTName aendern
/client/v3/profile/{userId}/avatar_urlPUTAvatar aendern

Sync-Filter

json
{
  "room": {
    "timeline": {
      "limit": 50,
      "types": ["m.room.message", "m.room.member", "m.reaction"]
    },
    "state": {
      "types": ["m.room.member"],
      "lazy_load_members": true
    },
    "ephemeral": { "types": ["m.typing"] }
  },
  "presence": { "types": [] }
}
  • timeline.limit: 50 — Letzte 50 Events pro Raum beim Initial-Sync
  • timeline.types — Messages, Member-Joins, Reactions
  • lazy_load_members — Nur Members laden die tatsaechlich aktiv waren
  • presence: [] — Kein Online-Status (spart Bandbreite)

Skalierungs-Betrachtung

Wichtig: Was skaliert und was nicht

Echtzeit-Performance ist immer schnell, unabhaengig von der Raum-Anzahl:

  • Nachricht senden/empfangen: ~50ms
  • Reaktion senden: ~50ms
  • Typing-Indicator: ~50ms
  • Neue Nachricht im Long-Poll: 1 Event, sofort

Das Skalierungsproblem betrifft ausschliesslich den Seitenlade (Reload/Neuladen). Dabei holt der Client einmalig alle Raeume mit ihren letzten Nachrichten von Synapse. Sobald das geladen ist, laeuft alles in Echtzeit weiter.

Seitenlade-Dauer nach Raum-Anzahl (Full Initial Sync)

RaeumeSeitenlade-DauerEchtzeit danachHandlungsbedarf
~100~500ms~50msKeiner
~500~2s~50msAkzeptabel
~1.000~4s~50msStufe 2 einplanen
~5.000~15s~50msStufe 2 zwingend
~10.000+Nicht praktikabel~50msStufe 3 erforderlich

Mit Stufe 2 (Cache als Vorschau) sinkt die gefuehlte Ladezeit auf ~100ms, weil gecachte Nachrichten sofort angezeigt werden waehrend der Sync im Hintergrund laeuft.

Weitere Optimierungen bei Bedarf

  1. LRU-Cache im RAM — Nur 3-5 Raeume gleichzeitig im Speicher
  2. Timeline-Limit reduzierenlimit: 1 fuer Initial-Sync (Vorschau), volle History on-demand
  3. BroadcastChannel — Multi-Tab-Synchronisation
  4. Service Worker Background Sync — Sync wenn Tab inaktiv

Bekannte Einschraenkungen

EinschraenkungStatusWorkaround
Kein Offline-ModusBewusste EntscheidungNachricht wird als "fehlgeschlagen" markiert
Kein Multi-Tab-SyncV1Jeder Tab hat eigenen Sync-Loop
Kein E2E-EncryptionGeplant (eigener Umsetzungsplan)Synapse-seitige Verschluesselung
Max 50 Messages pro Raum im Initial-SyncReicht fuer Vorschau"Aeltere laden" Button fuer History