wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# Push-Benachrichtigungen & E2EE im Hintergrund
|
||||
|
||||
Stand: 2026-06-04. Dieses Dokument beschreibt, wie Pyramid auf Android
|
||||
verschlüsselte Push-Benachrichtigungen verarbeitet — inklusive Entschlüsselung
|
||||
und verschlüsseltem Antworten **bei komplett gekillter App**, ohne dass sich die
|
||||
App öffnet.
|
||||
|
||||
## Überblick der Kette
|
||||
|
||||
```
|
||||
Matrix-Homeserver (Continuwuity)
|
||||
│ (event_id_only Push)
|
||||
▼
|
||||
Sygnal (self-hosted: push.steggi-matrix.work)
|
||||
▼
|
||||
Firebase Cloud Messaging (FCM)
|
||||
▼
|
||||
PushService.kt (FirebaseMessagingService, nativ)
|
||||
├─ App lebt im Vordergrund → App zeigt selbst, Push wird übersprungen
|
||||
├─ App lebt im Hintergrund → Haupt-Engine entschlüsselt (warm, schnell)
|
||||
└─ App gekillt → BgEngine bootet ein headless Flutter-Isolate
|
||||
```
|
||||
|
||||
Der Flutter-eigene FCM-Dienst (`FlutterFirebaseMessagingService`) ist im
|
||||
`AndroidManifest.xml` **deaktiviert** — alle Pushes laufen über `PushService.kt`.
|
||||
Push-Format ist `event_id_only` (nur die Event-ID wird übertragen, nicht der
|
||||
Inhalt — datensparsam).
|
||||
|
||||
## Warum dieser Aufbau?
|
||||
|
||||
Die E2EE-Schlüssel (olm/megolm) liegen **nur** im Matrix-Client (verschlüsselte
|
||||
SQLite-DB), nicht im nativen Kotlin-Code. Kotlin kann daher Sender + Raumname
|
||||
(unverschlüsselte Room-States) per HTTP holen, aber **nicht** den Nachrichtentext
|
||||
entschlüsseln. Dafür braucht es einen laufenden Matrix-Client.
|
||||
|
||||
**Sicherheitsregel:** Niemals zwei Matrix-Clients gleichzeitig auf derselben
|
||||
olm-DB — das korrumpiert den Ratchet-State und führt zu dauerhaften
|
||||
„Unable to decrypt"-Fehlern. Deshalb läuft der Hintergrund-Client (`BgEngine`)
|
||||
**nur**, wenn die Haupt-App nachweislich tot ist (`FlutterEngineCache.get("main")
|
||||
== null`).
|
||||
|
||||
## Die drei Zustände
|
||||
|
||||
| Zustand | Erkennung | Pfad |
|
||||
|---|---|---|
|
||||
| Vordergrund | Heartbeat < 20s (`flutter.notif_app_heartbeat`) | Push übersprungen, App zeigt selbst |
|
||||
| Hintergrund (lebt) | `FlutterEngineCache.get("main") != null` | Platzhalter sofort → Haupt-Engine entschlüsselt + aktualisiert |
|
||||
| Gekillt | keine Haupt-Engine | `BgEngine` bootet headless Isolate, **decrypt-first** |
|
||||
|
||||
## Gekillter Zustand im Detail (decrypt-first)
|
||||
|
||||
1. `PushService.onMessageReceived` holt Titel (Sender · Raum) per HTTP.
|
||||
2. Statt sofort einen Platzhalter zu zeigen:
|
||||
- `BgEngine.scheduleFallback(…, 6s)` plant einen „Neue Nachricht"-Platzhalter,
|
||||
der **nur** erscheint, falls die Entschlüsselung > 6s dauert oder scheitert.
|
||||
- `BgEngine.run("decryptAndShow", …)` bootet das Isolate.
|
||||
3. `BgEngine` (BgEngine.kt) startet eine headless `FlutterEngine` über den
|
||||
gespeicherten Callback-Handle (`flutter.bg_engine_handle`) und führt das
|
||||
Dart-Entrypoint `notificationEngineMain` (background_push.dart) aus.
|
||||
4. Dart `_bgDecryptAndShow`:
|
||||
- `_buildClient()` öffnet **dieselbe** DB (`pyramid.sqlite`,
|
||||
`MatrixSdkDatabase.init('pyramid')`, `NativeImplementationsDummy`).
|
||||
- `client.getEventByPushNotification(...)` entschlüsselt mit den lokal
|
||||
vorhandenen megolm-Schlüsseln (oneShotSync nur als Fallback).
|
||||
- Gibt Titel + Text per MethodChannel `showDecrypted` an Native zurück.
|
||||
5. `BgEngine` zeigt via `NotificationHelper.show` die echte Nachricht und
|
||||
bricht den Fallback-Platzhalter ab.
|
||||
|
||||
Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit
|
||||
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
|
||||
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
|
||||
|
||||
## Verschlüsselt antworten (gekillt, vollständig still)
|
||||
|
||||
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
|
||||
**nicht** MainActivity — die App öffnet sich also nicht.
|
||||
- `ReplyReceiver`:
|
||||
- App lebt → MethodChannel `replyFromNotification` an die Haupt-Engine.
|
||||
- Gekillt → `BgEngine.run("sendReply", …)` → Dart `_bgSendReply` → oneShotSync +
|
||||
`room.sendTextEvent` (verschlüsselt). `goAsync()` + finish nach 15 s hält den
|
||||
Receiver-Prozess am Leben, bis der Versand durch ist.
|
||||
- RemoteInput-Extraktion hat mehrere Key-Fallbacks (OEM-Eigenheiten) und
|
||||
funktioniert auf Samsung S23 trotz der alten „getBroadcast verschluckt
|
||||
RemoteInput"-Warnung.
|
||||
|
||||
## Gelöste Fallstricke (NICHT wieder einbauen)
|
||||
|
||||
1. **Loader-Reihenfolge:** `FlutterCallbackInformation.lookupCallbackInformation`
|
||||
erst **nach** `flutterLoader.startInitialization` + `ensureInitializationComplete`
|
||||
aufrufen — sonst `UnsatisfiedLinkError` (native Methode, libflutter.so noch
|
||||
nicht geladen).
|
||||
2. **vodozemac doppelt:** Wenn Decrypt- und Reply-Task dasselbe wiederverwendete
|
||||
Isolate nutzen, wirft das zweite `vod.init()` „already initialized". In
|
||||
`_buildClient` in eigenem try/catch schlucken — sonst gibt `_buildClient`
|
||||
still `null` zurück und die Antwort geht nicht raus.
|
||||
3. **`database_closed`-Rauschen:** Vor `client.dispose()` 2 s warten, damit der
|
||||
asynchrone Key-Backup-Upload fertig wird.
|
||||
|
||||
## Relevante Dateien
|
||||
|
||||
| Datei | Rolle |
|
||||
|---|---|
|
||||
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
|
||||
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
|
||||
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
|
||||
| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) |
|
||||
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
|
||||
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
|
||||
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
|
||||
| `lib/core/notification_service.dart` | Vordergrund/Hintergrund-Notifs, Heartbeat |
|
||||
| `lib/main.dart` | `registerBgEngineHandle()` beim Start |
|
||||
|
||||
## Debugging
|
||||
|
||||
`flutter run` verliert die Verbindung, sobald die App gekillt wird — daher direkt
|
||||
`adb logcat` nutzen:
|
||||
|
||||
```
|
||||
adb logcat -v time PYRAMID-PUSH:* PYRAMID-BGENGINE:* PYRAMID-REPLY:* PYRAMID-INTENT:* flutter:* AndroidRuntime:E *:S
|
||||
```
|
||||
|
||||
Erwartete Log-Sequenz (gekillt, Erfolg):
|
||||
```
|
||||
PYRAMID-PUSH: decrypt-first via background engine for <room>
|
||||
PYRAMID-BGENGINE: background engine started
|
||||
PYRAMID-BGENGINE: Dart background engine ready — flushing 1 task(s)
|
||||
PYRAMID-BGENGINE: showDecrypted → shown decrypted notification for <room>
|
||||
```
|
||||
Reply (gekillt):
|
||||
```
|
||||
PYRAMID-REPLY: extracted replyText=… (remoteInput=true)
|
||||
PYRAMID-REPLY: Flutter engine not running → background engine send
|
||||
PYRAMID-BGENGINE: (sendReply → sendTextEvent OK)
|
||||
```
|
||||
|
||||
Hinweis: Kotlin lässt sich nur auf einem Rechner mit JAVA_HOME/Android-SDK bauen.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Pyramid – Fortschritt & offene Punkte
|
||||
|
||||
Stand: 2026-06-04
|
||||
|
||||
## Erledigt
|
||||
|
||||
### Chat / UI
|
||||
- **Reply-Zitat-Karte** im Discord/WhatsApp-Stil (kein „In reply to @user" mehr), medien-bewusste Vorschau, getönter Hintergrund bündig mit Akzent-Strich.
|
||||
- **Per-Message-Selektion & -Hover** — einzelne Nachrichten statt ganzer Sender-Gruppen werden markiert/hervorgehoben.
|
||||
- **Scroll-Stabilität** — Crash durch doppelte Keys behoben, Pagination (kein endloses „Zurückziehen", lädt aber bis zum echten Anfang), Bild-/PDF-Platz wird vorab reserviert (kein Springen beim Laden).
|
||||
- **„Neue Nachrichten"-Divider** — korrekte Position, scrollt zuverlässig hin, blendet beim Erreichen des Endes aus; erscheint nur bei echten ungelesenen Nachrichten von anderen.
|
||||
- **Composer-Höhe** bündig mit dem Profil-Block; **Verbindungs-Statusbanner** bei Sync-Verlust; **fehlgeschlagene Nachrichten** bleiben mit „Wiederholen/Löschen" stehen statt zu verschwinden.
|
||||
- **„Zuletzt online"** nutzt echten Presence-Status + Frische statt `currentlyActive` (kein falsches „online" mehr).
|
||||
- **Members-Panel** mobil schließbar + schmaler.
|
||||
|
||||
### Medien & Dokumente
|
||||
- **Media-Cache** (LRU, byte-budgetiert, geteilt) — zuletzt betrachtete Medien sofort da, ältere automatisch verworfen.
|
||||
- **Datei-Vorschau** — höhere Auflösung (PNG statt JPEG), viele neue Text/Code/Daten-Formate, **immersiver Fullscreen-Viewer** (Pan, +/− 20%-Zoom, Ctrl+Mausrad, Seiten-Navigation, mobil-optimiert, Tap blendet UI aus).
|
||||
- **Eingebettete Link-Vorschauen** (Open-Graph-Karten) — serverseitig in Continuwuity aktiviert + Client-Karte.
|
||||
|
||||
### Suche
|
||||
- **Lokale Suche** über entschlüsselte Events (server-seitige `/search` funktioniert nicht für E2EE) — Nachrichten, Medien (mit **Thumbnail**), Personen/Räume.
|
||||
- **Sprung zur Nachricht** aus dem Suchergebnis inkl. Cross-Chat, mit sanft ausblendendem Highlight.
|
||||
|
||||
### Benachrichtigungen (Android)
|
||||
- Bei **gekillter App**: Hintergrund-Entschlüsselung (decrypt-first) + **verschlüsseltes Antworten** ohne App-Öffnen. Details siehe [NOTIFICATIONS.md](NOTIFICATIONS.md).
|
||||
|
||||
### Einstellungen
|
||||
- **Mobile Settings** im Element-Stil: Kategorie-Liste → Untermenü mit Zurück-Pfeil (statt gedrängtem Icon-Block). Desktop unverändert.
|
||||
|
||||
### Server (Pi 5, steggi@192.168.178.71)
|
||||
- rclone-Mount für Pixeldrain-Medien repariert + Boot-Reihenfolge abgesichert; Medien liegen nur auf Pixeldrain.
|
||||
- URL-Preview-Allowlist in `conduit.toml` aktiviert.
|
||||
|
||||
### Lesebestätigungen (Element-Stil, mit Haken)
|
||||
- **✓✓** an der zuletzt gesehenen Nachricht des Gegenübers (auch dessen eigener Antwort), berechnet aus `room.receiptState`. Wandert mit, statt an der eigenen letzten Antwort zu kleben.
|
||||
- **grauer ✓** nur auf der eigenen neuesten Nachricht, solange sie noch ungelesen ist; verschwindet sobald gelesen (dann nur ✓✓).
|
||||
|
||||
## Offen / geplant
|
||||
- **Weiterleiten** von Nachrichten (Forward) — später.
|
||||
- **Presence-Genauigkeit:** Nutzer, die den PC vor ~10 min unsauber heruntergefahren haben, werden teils noch als „online" angezeigt (Server expired Presence nicht zuverlässig). Außerdem: Online-Status im Chat-Header und in der Chat-Liste sind nicht immer konsistent — sollten dieselbe Quelle nutzen.
|
||||
- PDF-Seiten beim Upload vorrendern — bewusst zurückgestellt (geringer Mehrwert wegen Caches).
|
||||
- Selection-Menü-Politur (kein Bug, optional).
|
||||
+276
-206
@@ -1,6 +1,19 @@
|
||||
# Matrix / Element Feature Checklist für Pyramid
|
||||
|
||||
*Stand: April 2026 — Pyramid v0.1.x (Early Development)*
|
||||
*Letzte Tiefenanalyse: 2026-04-29 (Vergleich mit FluffyChat / Element Web)*
|
||||
|
||||
---
|
||||
|
||||
## Legende
|
||||
|
||||
- ✅ `[x]` — Implementiert und funktioniert korrekt
|
||||
- ⚠️ `[~]` — Teilweise / fehlerhaft implementiert (Details in Technische Analyse)
|
||||
- ❌ `[ ]` — Fehlt komplett
|
||||
- 🔴 P0 — Kritisch (Kernfunktionalität kaputt)
|
||||
- 🟠 P1 — Hoch (wichtige Funktion defekt oder nicht vorhanden)
|
||||
- 🟡 P2 — Mittel
|
||||
- 🔵 P3 — Niedrig / Nice-to-have
|
||||
|
||||
---
|
||||
|
||||
@@ -15,17 +28,19 @@
|
||||
- [x] Verschlüsselte Nachrichten anzeigen (Entschlüsselungsstatus)
|
||||
- [x] Nachrichtenverlauf paginieren (ältere Nachrichten beim Scrollen laden)
|
||||
- [x] Ungelesene-Nachrichten-Tracking
|
||||
- [~] Nachrichtenbearbeitung (Edit-Event erkannt, `getDisplayEvent` wird nicht gerendert) 🟠 P1
|
||||
- [~] Nachrichtenvorschau bei Reply (Zitat-Preview vorhanden, kein Jump-to-original) 🟡 P2
|
||||
|
||||
### Nachrichtenaktionen
|
||||
- [x] Nachrichtentext kopieren
|
||||
- [x] Eigene Nachrichten löschen (redact)
|
||||
- [x] Nachrichten anpinnen / Pinboard anzeigen
|
||||
- [~] Auf Nachrichten antworten (in-reply-to vorhanden, kein visuelles Zitatfeld)
|
||||
- [~] Nachrichten bearbeiten (m.edit im SDK erkannt, kein vollständiges UI)
|
||||
- [x] Auf Nachrichten antworten (in-reply-to)
|
||||
- [ ] Nachrichten weiterleiten
|
||||
- [ ] Zitat-Styling (visuelle Blockquote-Anzeige)
|
||||
|
||||
### Textformatierung
|
||||
### Textformatierung (formatted_body)
|
||||
- [~] Markdown-Rendering — `formatted_body` / `org.matrix.custom.html` wird nicht gesendet und nicht gerendert 🔵 P3
|
||||
- [ ] Fett (**text**)
|
||||
- [ ] Kursiv (*text*)
|
||||
- [ ] Durchgestrichen (~~text~~)
|
||||
@@ -33,7 +48,7 @@
|
||||
- [ ] Code-Block (```code```)
|
||||
- [ ] Aufzählungslisten
|
||||
- [ ] Klickbare Links
|
||||
- [ ] @Erwähnungen (User)
|
||||
- [~] @Erwähnungen — Button vorhanden, `onTap: null` 🟡 P2
|
||||
- [ ] #Kanal-Erwähnungen
|
||||
- [ ] Spoiler-Tags (||text||)
|
||||
|
||||
@@ -62,14 +77,118 @@
|
||||
- [x] Mehrere Reaktionen pro Nachricht
|
||||
- [x] Kategoriebasierte Emoji-Organisation
|
||||
|
||||
### Tipp-Indikatoren (Typing)
|
||||
- [~] `room.setTyping()` wird **nirgendwo aufgerufen** — Einstellung existiert, aber Composer sendet nie "tippt…" 🟡 P2
|
||||
- [ ] "X tippt gerade"-Anzeige empfangen (kein Widget)
|
||||
- [ ] Timeout nach Tipp-Ende
|
||||
|
||||
### Threads
|
||||
- [ ] Thread-Antworten (m.in_reply_to vorhanden, kein Thread-View)
|
||||
- [~] Thread-Button vorhanden (`onPressed: () {}` — leer) 🔵 P3
|
||||
- [ ] Thread aufklappen / eigene Ansicht
|
||||
- [ ] Thread-Benachrichtigungen
|
||||
- [ ] Thread als gelöst markieren
|
||||
|
||||
---
|
||||
|
||||
## Lesebestätigungen (Read Receipts)
|
||||
|
||||
- [x] Senden von `m.read`-Receipts via `room.setReadMarker()`
|
||||
- [x] Privacy-Toggle (kein Senden wenn deaktiviert)
|
||||
- [x] ✓-Icon (Nachricht gesendet/zugestellt) auf eigenen Nachrichten
|
||||
- [x] ✓✓-Icon (gelesen) in DMs
|
||||
- [x] Sende-Status (Uhr/Fehler/gesendet/gelesen)
|
||||
- [~] ✓✓-Logik nur für DMs — in Gruppenräumen wird `receiptState.global.otherUsers` nicht ausgewertet 🟡 P2
|
||||
- [~] **Off-by-one**: Divider erscheint *über* der Gruppe die das `fullyRead`-Event enthält, nicht darunter 🟡 P2
|
||||
- [ ] Gruppen-Read-Receipt-Display (Seen-by-Avatare wie in FluffyChat) 🟡 P2
|
||||
- [ ] Hover-Tooltip "Gelesen von: ..." auf ✓✓
|
||||
- [ ] **Notification-Cancellation**: `cancelNotification(roomId)` beim Setzen des Read-Markers 🟡 P2
|
||||
|
||||
---
|
||||
|
||||
## „Neue Nachrichten" Trennlinie
|
||||
|
||||
- [x] `_UnreadDivider`-Widget korrekt gestylt
|
||||
- [x] `_sessionFullyReadId` einmalig beim Öffnen erfasst (stabil für die Session)
|
||||
- [x] Keine Anzeige wenn Raum bereits gelesen
|
||||
- [~] **Position falsch** — Divider über der Gruppe mit `fullyRead`-Event statt darunter 🟡 P2
|
||||
- [ ] **Auto-Scroll zum Divider** beim Öffnen eines Raums mit ungelesenen Nachrichten 🟡 P2
|
||||
|
||||
---
|
||||
|
||||
## Presence / Zuletzt Online
|
||||
|
||||
- [x] `_PresenceLine` im DM-Header mit Live-Updates
|
||||
- [x] 4s-Debounce gegen Sync-Spike-Fehlmeldungen
|
||||
- [x] Relative Zeitanzeige (gerade eben / vor Xmin / vor Xh)
|
||||
- [x] Guard: eigene Presence wird nie angezeigt
|
||||
- [x] Presence-Dot im Rooms-Panel und Members-Panel
|
||||
- [x] `setPresence(online/offline)` in den Settings
|
||||
- [~] **Kein Auto-Heartbeat** — `setPresence` nur in Settings, nie periodisch während Nutzung 🔵 P3
|
||||
- [~] **Kein `WidgetsBindingObserver`** — App-Lifecycle (Hintergrund/Vordergrund) löst kein Presence-Event aus 🔵 P3
|
||||
- [ ] Presence-Dot in DM-Listen-Items (`room_list_item.dart`) 🔵 P3
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||
### Local Notifications (App im Vordergrund)
|
||||
- [x] Android Local Notifications (`flutter_local_notifications`)
|
||||
- [x] Windows Toast-Notifications (`local_notifier`)
|
||||
- [x] Windows Taskbar-Badge (Progress-Error-Mode)
|
||||
- [x] Tap-Handler navigiert zum richtigen Raum (Android)
|
||||
- [x] Guards: nur fremde Nachrichten, nur aktive Rooms, nur `Membership.join`
|
||||
- [x] `notificationsBlockedProvider` + Banner in `app_shell.dart`
|
||||
- [~] **Alle 4 Notification-Prefs werden ignoriert** — `notifDesktopProvider`, `notifSoundProvider`, `notifPreviewProvider`, `notifMentionOnlyDmProvider` haben keinen Effekt 🟠 P1
|
||||
- [~] Android-Grouping ohne Summary-Notification 🔵 P3
|
||||
|
||||
### Remote Push (App im Hintergrund / geschlossen)
|
||||
- [ ] 🔴 **Keine Remote-Push-Integration** — FCM/UnifiedPush fehlt komplett, App muss laufen
|
||||
- [ ] iOS APNs-Setup
|
||||
- [ ] macOS-Notifications
|
||||
- [ ] Android Icon-Badge (Zahlenbadge)
|
||||
|
||||
---
|
||||
|
||||
## E2EE Verschlüsselungs-Pipeline
|
||||
|
||||
### Implementiert
|
||||
- [x] Vodozemac (Rust-Olm) via Flutter-Isolate — modern, performant
|
||||
- [x] Bootstrap: SSSS, Cross-Signing, Online-Key-Backup
|
||||
- [x] `_decryptLegacyEvents` — entschlüsselt ältere Events in-place
|
||||
- [x] Device-Key-Preload vor erstem Senden
|
||||
- [x] Key-Verification UI (SAS Emoji, QR-Code, alle States)
|
||||
- [x] Megolm Session Export/Import (Element-kompatibles Format)
|
||||
- [x] Encrypted-Attachment-Handling
|
||||
- [x] `m.fully_read`-Marker beim Timeline-Open
|
||||
- [x] `timeline.requestKeys(onlineKeyBackupOnly: false)`
|
||||
|
||||
### Fehlerhaft / Fehlend
|
||||
- [~] **"Entschlüsselt…"-Placeholder bleibt dauerhaft** wenn Key fehlt — kein Recovery-Button 🟠 P1
|
||||
- [~] `loadAllKeysFromRoom` Fehler werden silent geschluckt (`catch (_) {}`) 🟡 P2
|
||||
- [~] **Kein Incoming-Verification-Request-Handler** — `client.onKeyVerificationRequest` wird nicht abonniert 🔴 P0
|
||||
- [~] **Doppelte Bootstrap-Implementierung** (page vs. dialog) mit leicht inkonsistenter Logik 🔵 P3
|
||||
- [ ] Device-Trust-Indicator in der Timeline 🔵 P3
|
||||
- [ ] Unverified-Device-Warning beim Senden 🔵 P3
|
||||
- [ ] "Schlüssel anfordern"-Button bei dauerhaft verschlüsselten Events 🟠 P1
|
||||
|
||||
---
|
||||
|
||||
## Suche
|
||||
|
||||
- [x] Suchdialog öffnen (UI vorhanden)
|
||||
- [x] Server-seitige Volltextsuche via Matrix `/search` API
|
||||
- [x] Filter: Alle / Medien / Personen
|
||||
- [x] Ergebnisse: Raum + Absender + Body + Zeitstempel
|
||||
- [x] Keyboard-Navigation in Ergebnissen
|
||||
- [x] 400ms Debounce
|
||||
- [ ] Jump-to-Message bei Klick auf Suchergebnis
|
||||
- [ ] Suche nach Absender filtern
|
||||
- [ ] Filter nach Datum
|
||||
- [ ] Suchergebnisse im Chat hervorheben
|
||||
- [ ] Suchverlauf
|
||||
|
||||
---
|
||||
|
||||
## Raumverwaltung
|
||||
|
||||
### Grundfunktionen
|
||||
@@ -79,14 +198,12 @@
|
||||
- [x] Raumname & Avatar anzeigen
|
||||
- [x] Verschlüsselungsstatus (Schloss-Icon)
|
||||
- [x] Einladungen annehmen / ablehnen
|
||||
- [ ] Neuen Raum erstellen
|
||||
- [ ] Neuen Raum erstellen (UI-Einstiegspunkt)
|
||||
- [ ] Raumname bearbeiten
|
||||
- [ ] Raumthema/Beschreibung bearbeiten
|
||||
- [ ] Raum-Avatar ändern
|
||||
- [ ] Raumvisibilität (öffentlich / privat)
|
||||
- [ ] Zugriffsregeln (invite/knock/public)
|
||||
- [ ] Verlaufsvisibilität konfigurieren
|
||||
- [ ] Raum-Aliases verwalten
|
||||
|
||||
### Direktnachrichten (DMs)
|
||||
- [x] 1:1 DM-Räume anzeigen & öffnen
|
||||
@@ -94,261 +211,214 @@
|
||||
- [x] Echtzeit-Presence-Updates
|
||||
- [x] Sprachanruf aus DM starten
|
||||
- [x] Videoanruf aus DM starten
|
||||
- [ ] DM starten (neuen Chat beginnen, nur über Suchfeld)
|
||||
- [ ] DM starten (neuen Chat beginnen)
|
||||
|
||||
### Mitglieder & Rollen
|
||||
- [x] Mitgliederliste anzeigen
|
||||
- [x] Mitgliederanzahl anzeigen
|
||||
- [x] Avatar & Anzeigename der Mitglieder
|
||||
- [x] Nutzer in Raum einladen (Raum-Kontextmenü)
|
||||
- [x] Nutzer in Raum einladen
|
||||
- [ ] Nutzer aus Raum entfernen (Kick)
|
||||
- [ ] Nutzer sperren (Ban)
|
||||
- [ ] Power Levels bearbeiten
|
||||
- [ ] Nutzer zum Moderator / Admin befördern
|
||||
- [ ] Bans aufheben
|
||||
|
||||
### Raumeinstellungen
|
||||
- [x] Raumeinstellungen-Dialog (Name, Thema, Moderationsregeln)
|
||||
- [ ] Raumverschlüsselung aktivieren/deaktivieren
|
||||
- [ ] Erweiterte Raumeinstellungen (Fluffychat-Level)
|
||||
- [ ] Widget-/App-Integration in Räume
|
||||
|
||||
---
|
||||
|
||||
## Spaces & Gemeinschaften
|
||||
|
||||
### Spaces
|
||||
- [x] Spaces in der Seitenleiste anzeigen
|
||||
- [x] Zwischen Spaces navigieren
|
||||
- [x] Space-Avatar anzeigen
|
||||
- [x] Space-Banner hochladen & anzeigen (Discord-Style)
|
||||
- [x] Space-Avatar & Banner anzeigen
|
||||
- [x] Räume nach Space filtern
|
||||
- [x] Virtuelle "Rooms"- und "DMs"-Spaces
|
||||
- [x] Unterkategorien (Sub-Spaces) als Kategorien in der Kanalleiste
|
||||
- [x] Unterkategorien (Sub-Spaces)
|
||||
- [x] Drag-and-drop Kanalreihenfolge für Admins
|
||||
- [x] Space-Verwaltungsdialog (Name, Avatar, Banner, Einladungen, Mitglieder, Power Levels)
|
||||
- [x] Space-Verwaltungsdialog
|
||||
- [ ] Neuen Space erstellen
|
||||
- [ ] Räume zum Space hinzufügen / entfernen
|
||||
- [ ] Space-Regeln / Beschreibung
|
||||
- [ ] Space-Entdeckung (öffentliche Spaces durchsuchen)
|
||||
|
||||
### Kanalorganisation
|
||||
- [x] Kanäle nach letzter Aktivität sortieren
|
||||
- [x] Ungelesene-Badge auf Kanälen
|
||||
- [x] Sprachkanal-Teilnehmerliste (Discord-Style, mit Echtzeit-Updates)
|
||||
- [x] Kollabierbare Kategorien
|
||||
- [ ] Favoriten / angeheftete Räume
|
||||
- [ ] Räume stummschalten
|
||||
- [ ] Räume ausblenden / archivieren
|
||||
- [ ] Kanalreihenfolge für reguläre Nutzer
|
||||
- [ ] Space-Entdeckung (öffentliche Spaces)
|
||||
|
||||
---
|
||||
|
||||
## Sprach- & Videoanrufe
|
||||
## Sprach- & Videoanrufe (LiveKit)
|
||||
|
||||
### Sprachanrufe (LiveKit)
|
||||
- [x] Sprachkanal betreten / verlassen (Toggle)
|
||||
- [x] Sprachkanal betreten / verlassen
|
||||
- [x] Mikrofon stummschalten / Deafen
|
||||
- [x] Teilnehmerliste im Sprachkanal
|
||||
- [x] Mikrofon stummschalten
|
||||
- [x] Audio-Ausgabe deaktivieren (Deafen)
|
||||
- [x] Anruf-UI im Profil-Panel (aktiver Anruf)
|
||||
- [x] Anruf-Ansicht öffnen (Vollansicht)
|
||||
- [x] Mini-Call-Widget bei VOIP-Anrufen
|
||||
- [x] Anruf-UI (Vollansicht + Mini-Widget)
|
||||
- [x] Videoanruf mit Kamera-Toggle
|
||||
- [ ] Klingelton bei eingehenden Anrufen
|
||||
- [ ] Anruf annehmen / ablehnen (UI)
|
||||
- [ ] Anrufdauer-Timer
|
||||
- [ ] Anrufverlauf
|
||||
|
||||
### Videoanrufe
|
||||
- [x] Videoanruf starten (aus DM)
|
||||
- [x] Kamera-Toggle
|
||||
- [x] Videoteilnehmer-Anzeige
|
||||
- [ ] Bildschirmfreigabe (Toggle-UI vorhanden, Implementierung unklar)
|
||||
- [ ] Picture-in-Picture-Modus
|
||||
- [ ] Virtueller Hintergrund
|
||||
|
||||
### Gruppenanrufe
|
||||
- [x] Mehrere Teilnehmer im Sprachkanal (LiveKit)
|
||||
- [ ] Gruppen-Videogitter
|
||||
- [ ] Spotlight-/Galerie-Ansicht
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen & Presence
|
||||
|
||||
### Push-Benachrichtigungen
|
||||
- [ ] Desktop-Benachrichtigungen (Windows/macOS/Linux)
|
||||
- [ ] Mobile Push-Benachrichtigungen (Android/iOS)
|
||||
- [ ] Benachrichtigungseinstellungen pro Raum
|
||||
- [ ] Nicht-Stören-Modus
|
||||
- [ ] Benachrichtigungssound konfigurieren
|
||||
|
||||
### Presence & Status
|
||||
- [x] Nutzer-Presence abrufen (online/offline/abwesend)
|
||||
- [x] Presence-Indikator anzeigen
|
||||
- [x] Letzte-Aktivität-Zeitstempel
|
||||
- [x] Echtzeit-Presence-Updates
|
||||
- [ ] Eigenen Status setzen (online/abwesend/unsichtbar)
|
||||
- [ ] Benutzerdefinierte Statusmeldung
|
||||
- [ ] Aktivitätsbasierte Status-Änderung
|
||||
|
||||
### Tipp-Indikatoren
|
||||
- [ ] "X tippt gerade"-Anzeige empfangen
|
||||
- [ ] Eigene Tipp-Benachrichtigung senden
|
||||
- [ ] Timeout nach Tipp-Ende
|
||||
- [ ] Bildschirmfreigabe
|
||||
|
||||
---
|
||||
|
||||
## Nutzereinstellungen & Profil
|
||||
|
||||
### Profilverwaltung
|
||||
- [x] Eigene User-ID anzeigen
|
||||
- [x] Eigenen Avatar anzeigen (Initialen-Fallback)
|
||||
- [x] Anzeigenamen anzeigen
|
||||
- [ ] Anzeigenamen bearbeiten
|
||||
- [ ] Eigenen Avatar hochladen / entfernen
|
||||
- [ ] Status-Nachricht setzen
|
||||
|
||||
### Kontosicherheit
|
||||
- [x] Hell / Dunkel-Theme, Akzentfarbe, Eckradius, Dichte
|
||||
- [x] Eigene User-ID, Avatar (Initialen-Fallback), Anzeigename anzeigen
|
||||
- [ ] Anzeigenamen & Avatar bearbeiten
|
||||
- [ ] Passwort ändern
|
||||
- [ ] E-Mail-Adresse verwalten
|
||||
- [ ] Sitzungsverwaltung (alle Sitzungen anzeigen)
|
||||
- [ ] Andere Sitzungen abmelden
|
||||
- [ ] Konto deaktivieren / löschen
|
||||
- [ ] Zwei-Faktor-Authentifizierung (2FA)
|
||||
|
||||
### App-Einstellungen
|
||||
- [x] Hell / Dunkel-Theme
|
||||
- [x] Akzentfarbe (6 Voreinstellungen)
|
||||
- [x] Eckradius anpassen
|
||||
- [x] UI-Dichte / Kompaktheit
|
||||
- [x] Animationssteuerung
|
||||
- [ ] Schriftgröße anpassen
|
||||
- [ ] Sprachauswahl (i18n)
|
||||
- [ ] Kompaktansicht (kein Avatar in Nachrichten)
|
||||
|
||||
---
|
||||
|
||||
## Datenschutz & Sicherheit
|
||||
|
||||
### Ende-zu-Ende-Verschlüsselung (E2EE)
|
||||
- [x] E2EE-Unterstützung (Olm/Megolm via vodozemac)
|
||||
- [x] E2EE-Schlüssel beim ersten Login einrichten (Bootstrap)
|
||||
- [x] Verschlüsselte Nachrichten-Indikatoren
|
||||
- [x] Entschlüsselungsstatus anzeigen
|
||||
- [x] SSSS (Symmetric Secret Storage)
|
||||
- [x] Recovery-Key erzeugen & anzeigen
|
||||
- [x] Recovery-Key aus bestehender Einrichtung importieren
|
||||
- [x] Schlüssel-Caching (lokal speichern)
|
||||
- [x] Session-Keys von anderen Geräten anfordern
|
||||
- [ ] Schlüssel-Backup (Server-seitig)
|
||||
- [ ] Schlüssel-Backup verifizieren
|
||||
|
||||
### Geräte-Verifizierung
|
||||
- [x] Cross-Signing (Master/Self-Signing/User-Signing Keys)
|
||||
- [x] Gerät per Emoji verifizieren
|
||||
- [x] Gerät per Zahlencodes verifizieren
|
||||
- [x] Geräte als vertrauenswürdig markieren
|
||||
- [ ] Gerätename / Modell anzeigen
|
||||
- [ ] Sitzungsverwaltung (alle Geräte dieses Accounts)
|
||||
- [ ] Fremde Geräte abmelden
|
||||
|
||||
### Vertrauen & Sicherheit
|
||||
- [ ] Geräte-Vertrauensstatus-Indikator in Nachrichten
|
||||
- [ ] Nutzer blockieren / entsperren
|
||||
- [ ] Blockierte Nutzer-Liste
|
||||
- [ ] Verifizierter-Nutzer-Badge
|
||||
|
||||
---
|
||||
|
||||
## Suche
|
||||
|
||||
### Nachrichtensuche
|
||||
- [x] Suchdialog öffnen (UI vorhanden)
|
||||
- [ ] Volltextsuche in Nachrichteninhalten
|
||||
- [ ] Filter nach Absender
|
||||
- [ ] Filter nach Datum
|
||||
- [ ] Filter nach Raum
|
||||
- [ ] Filter nach Medientyp
|
||||
- [ ] Suchergebnisse hervorheben
|
||||
- [ ] Suchverlauf
|
||||
|
||||
### Nutzer- / Raumsuche
|
||||
- [ ] Nutzer nach Anzeigenamen suchen
|
||||
- [ ] Nutzer nach User-ID suchen
|
||||
- [ ] Öffentliche Räume entdecken (Directory)
|
||||
- [ ] Eigene Räume filtern
|
||||
- [ ] Räume nach Name / Thema suchen
|
||||
- [ ] Sitzungsverwaltung
|
||||
- [ ] Konto deaktivieren
|
||||
|
||||
---
|
||||
|
||||
## Auto-Update
|
||||
|
||||
- [x] GitHub-Releases als Update-Quelle
|
||||
- [x] Versions-Vergleich (SemVer)
|
||||
- [x] Rate-Limiting (max. alle 4 Stunden prüfen)
|
||||
- [x] Update-Banner im App-Header (Windows)
|
||||
- [x] Versions-Vergleich (SemVer), Rate-Limiting, Update-Banner
|
||||
- [x] "Diese Version überspringen"-Option
|
||||
- [x] Manueller Update-Check in den Einstellungen
|
||||
- [ ] Automatischer Download & Installation (Squirrel/NSIS)
|
||||
- [ ] Delta-Updates (nur geänderte Dateien)
|
||||
- [ ] Update-Kanal wählen (Stable/Beta/Nightly)
|
||||
- [ ] Automatischer Download & Installation
|
||||
|
||||
---
|
||||
|
||||
## Plattform-spezifisch
|
||||
|
||||
### Desktop (Windows / macOS / Linux)
|
||||
- [x] Native Desktop-App (Flutter Desktop)
|
||||
- [x] Tastenkürzel (Enter = Senden, Shift+Enter = Zeile)
|
||||
- [x] Drag-and-Drop für Dateianhänge
|
||||
### Desktop
|
||||
- [x] Native Desktop-App, Tastenkürzel, Drag-and-Drop
|
||||
- [ ] System-Tray-Integration
|
||||
- [ ] Desktop-Benachrichtigungen
|
||||
- [ ] Minimieren in System-Tray
|
||||
- [ ] Fenster-State speichern (Größe, Position)
|
||||
- [ ] MSIX / Installer-Paketierung
|
||||
|
||||
### Mobile (Android / iOS)
|
||||
- [x] Mobile App (Flutter)
|
||||
- [x] Drawer-Navigation (Hamburger-Menü)
|
||||
- [ ] Touch-optimiertes UI (derzeit Desktop-first)
|
||||
### Mobile
|
||||
- [x] Mobile App (Flutter), Drawer-Navigation
|
||||
- [ ] Touch-optimiertes UI
|
||||
- [ ] Mobile Push-Benachrichtigungen
|
||||
- [ ] Zurück-Geste Navigation
|
||||
- [ ] Bottom-Sheet-Modals
|
||||
- [ ] Share-Sheet-Integration
|
||||
- [ ] Haptisches Feedback
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Technische Tiefenanalyse (2026-04-29)
|
||||
|
||||
*Vergleichsbasis: FluffyChat (krille-chan/fluffychat) und Element Web*
|
||||
*Format: Befund → betroffene Datei:Zeile → Referenz-Implementierung*
|
||||
|
||||
---
|
||||
|
||||
## IMPLEMENTIERUNGS-BACKLOG (nach Priorität)
|
||||
|
||||
### 🔴 P0 — Kritisch
|
||||
|
||||
#### [P0-1] Incoming Key-Verification-Request-Handler fehlt
|
||||
**Problem:** Wenn ein anderes Gerät eine Verifikations-Anfrage (`m.key.verification.request`) an Pyramid schickt, gibt es keinen Listener. Die Anfrage läuft ab, ohne dass der Nutzer sie sieht. Das bedeutet: andere Geräte können Pyramid de facto nicht verifizieren — nur Pyramid kann aktiv verifizieren.
|
||||
**Betroffene Datei:** `lib/widgets/settings_modal.dart` (kein `onKeyVerificationRequest`-Listener)
|
||||
**FluffyChat-Referenz:** `lib/pages/key_verification/key_verification_dialog.dart` + `client.onKeyVerificationRequest.stream.listen(...)` in `lib/pages/matrix.dart`
|
||||
**Fix:** Stream auf `client.onKeyVerificationRequest` in `matrix_client.dart` oder `app_shell.dart` subscriben und `KeyVerificationDialog` öffnen.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 P1 — Hoch
|
||||
|
||||
#### [P1-1] Notification-Prefs werden ignoriert
|
||||
**Problem:** `notifDesktopProvider`, `notifSoundProvider`, `notifPreviewProvider`, `notifMentionOnlyDmProvider` sind in `settings_prefs.dart` gespeichert, werden in `notification_service.dart/_watchEvents()` aber nie ausgelesen. Notifications erscheinen immer, unabhängig von Nutzereinstellungen.
|
||||
**Betroffene Datei:** `lib/core/notification_service.dart`, Zeile 145–168
|
||||
**Fix:** In `_watchEvents()` die Prefs vor `showMessageNotification()` prüfen.
|
||||
|
||||
#### [P1-2] Edits werden nicht gerendert
|
||||
**Problem:** `_isVisible()` filtert Edit-Events korrekt heraus, aber `event.getDisplayEvent(timeline)` wird nirgendwo aufgerufen. Nachrichten zeigen immer die Originalversion, auch wenn sie via `m.replace` bearbeitet wurden. Ein `(bearbeitet)`-Label fehlt ebenfalls.
|
||||
**Betroffene Datei:** `lib/features/chat/message_group.dart` — Event-Rendering-Bereich
|
||||
**FluffyChat-Referenz:** `event.getDisplayEvent(timeline)` vor dem Rendern jedes Events
|
||||
**Fix:** Beim Rendern von Events `event.getDisplayEvent(timeline)` statt `event` verwenden; `(bearbeitet)`-Label wenn `event != displayEvent`.
|
||||
|
||||
#### [P1-3] Encrypted-Placeholder bleibt dauerhaft — kein Recovery-Button
|
||||
**Problem:** `EventTypes.Encrypted` zeigt "Nachricht wird entschlüsselt…" dauerhaft wenn die Entschlüsselung scheitert. Es gibt keinen Button zum Anfordern fehlender Keys.
|
||||
**Betroffene Datei:** `lib/features/chat/message_group.dart`, Zeilen ~408–421
|
||||
**FluffyChat-Referenz:** `_requestMissingKey()` mit `room.client.sendToDeviceEncrypted(...)` / SDK-`requestKey()`
|
||||
**Fix:** Wenn `event.status == EventStatus.error` oder nach Timeout immer noch `EventTypes.Encrypted`: "Schlüssel anfordern"-Button anzeigen, der `timeline.requestKeys()` aufruft.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 P2 — Mittel
|
||||
|
||||
#### [P2-1] Typing-Indikator: `room.setTyping()` nie aufgerufen
|
||||
**Problem:** `privacyTypingProvider` existiert, `chat_composer.dart` ruft aber nie `room.setTyping()` auf. Andere Nutzer sehen nie "X tippt gerade…".
|
||||
**Betroffene Datei:** `lib/features/chat/chat_input.dart` (oder `chat_composer.dart`)
|
||||
**FluffyChat-Referenz:** `onInputBarChanged` → 2s Debounce → `room.setTyping(true, timeout: 30000)`; bei Senden/Leerfeld: `room.setTyping(false)`
|
||||
**Fix:** Im Composer-Input `onChanged`-Handler mit Debounce und `room.setTyping()` ergänzen.
|
||||
|
||||
#### [P2-2] Typing-Indikator: Kein Display-Widget
|
||||
**Problem:** Kein Widget das `room.typingUsers` anzeigt.
|
||||
**Fix:** `_TypingIndicator`-Widget in `chat_view.dart` unter der Nachrichtenliste einfügen, das `room.onUpdate`-Stream abonniert und `room.typingUsers` anzeigt.
|
||||
|
||||
#### [P2-3] Unread-Divider: Position off-by-one
|
||||
**Problem:** Divider erscheint *über* der Gruppe die das `fullyRead`-Event *enthält*, nicht darunter. Das bereits-gelesene Event liegt unter dem "Neue Nachrichten"-Strich.
|
||||
**Betroffene Datei:** `lib/features/chat/chat_view.dart`, Zeile ~715
|
||||
**Fix:** Divider nach der Gruppe einsetzen, die das `fullyReadId`-Event enthält, nicht davor.
|
||||
|
||||
#### [P2-4] Unread-Divider: Kein Auto-Scroll beim Öffnen
|
||||
**Problem:** Beim Öffnen eines Raums mit ungelesenen Nachrichten landet die View unten, nicht beim Divider.
|
||||
**FluffyChat-Referenz:** Initialer `scrollController.jumpTo()` zum Index des `fullyRead`-Events nach dem ersten Frame.
|
||||
**Fix:** In `initState`/`WidgetsBinding.addPostFrameCallback` zum Divider-Index springen wenn `_sessionHasUnread`.
|
||||
|
||||
#### [P2-5] Gruppen-Read-Receipts fehlen
|
||||
**Problem:** ✓✓-Logik greift nur bei `room.isDirectChat`. In Gruppenräumen sehen eigene Nachrichten nie ✓✓.
|
||||
**FluffyChat-Referenz:** `SeenByRow` zeigt Avatare aller Leser unter der letzten Nachricht.
|
||||
**Fix:** Receipt-Prüfung auch in Gruppenräumen aktivieren; ggf. Avatar-Reihe unter letzter Nachricht.
|
||||
|
||||
#### [P2-6] Notification-Cancellation beim Lesen fehlt
|
||||
**Problem:** Android-Notifications bleiben in der Benachrichtigungsleiste stehen, auch wenn der Chat geöffnet und die Nachrichten gelesen wurden.
|
||||
**FluffyChat-Referenz:** `backgroundPush?.cancelNotification(roomId)` in `_markRead()`
|
||||
**Fix:** In `_markRead()` in `chat_view.dart` die Android-Notification für `roomId` canceln.
|
||||
|
||||
---
|
||||
|
||||
### 🔵 P3 — Niedrig
|
||||
|
||||
#### [P3-1] Presence: Kein Auto-Heartbeat
|
||||
**Problem:** `setPresence(online)` nur in Settings — Server setzt Client nach Timeout offline.
|
||||
**Fix:** Periodischer Timer (alle 60s) der `setPresence(online)` sendet solange App im Vordergrund.
|
||||
|
||||
#### [P3-2] Presence: Kein App-Lifecycle-Hook
|
||||
**Problem:** Kein `WidgetsBindingObserver` — kein `unavailable` bei App-Hintergrund.
|
||||
**Fix:** `WidgetsBindingObserver.didChangeAppLifecycleState` in `matrix_client.dart` oder `app_shell.dart`.
|
||||
|
||||
#### [P3-3] Markdown/`formatted_body` nicht gerendert
|
||||
**Problem:** `sendTextEvent` sendet nur plain text. Empfangene `formatted_body`-Felder werden nicht gerendert.
|
||||
**Fix:** Empfang: `flutter_html` oder `flutter_markdown` für `formatted_body`; Senden: simple Markdown-Erkennung im Composer.
|
||||
|
||||
#### [P3-4] `requestFuture()` fehlt
|
||||
**Problem:** Kein Forward-Pagination-Mechanismus. Nach Jump-to-Event in der Vergangenheit kein Weg zurück.
|
||||
**Fix:** `timeline.requestFuture()` im Scroll-Listener für die Vorwärts-Richtung.
|
||||
|
||||
#### [P3-5] `message_bubble.dart` — ungenutzter Code
|
||||
**Problem:** Veraltete WhatsApp-Style-Implementation koexistiert neben aktiver Discord-Style-Implementierung.
|
||||
**Fix:** Datei löschen oder auskommentieren.
|
||||
|
||||
---
|
||||
|
||||
## Statistik
|
||||
|
||||
| Bereich | Implementiert | Teilweise | Fehlend |
|
||||
| Bereich | Implementiert | Fehlerhaft | Fehlend |
|
||||
|---|---|---|---|
|
||||
| Messaging | 14 | 2 | 14 |
|
||||
| Raumverwaltung | 8 | 0 | 14 |
|
||||
| Spaces | 10 | 0 | 6 |
|
||||
| Anrufe | 8 | 1 | 10 |
|
||||
| Benachrichtigungen | 4 | 0 | 8 |
|
||||
| Einstellungen/Profil | 5 | 0 | 11 |
|
||||
| Sicherheit/E2EE | 9 | 0 | 7 |
|
||||
| Suche | 1 | 0 | 10 |
|
||||
| Auto-Update | 6 | 0 | 3 |
|
||||
| Plattform | 5 | 0 | 10 |
|
||||
| **Gesamt** | **~70** | **~3** | **~93** |
|
||||
| Chat-Mechanik | Solide Basis | Edits, Typing, `requestFuture` | Jump-to-quote, Threads, Edited-Badge |
|
||||
| Read Receipts | DM-Logik ✓ | Gruppen, Off-by-one, Notif-Cancel | Seen-by-Avatare, Tooltip |
|
||||
| Presence | Anzeige ✓ | Broadcasting, Lifecycle | Heartbeat, Dot in Raumliste |
|
||||
| Benachrichtigungen | Local ✓ | Prefs ignoriert, Grouping | Remote Push, iOS, macOS |
|
||||
| Unread-Divider | Konzept ✓ | Position, Auto-Scroll | — |
|
||||
| E2EE | Stärkster Bereich | Incoming-Verif., Placeholder | Device-Trust-Indicator, Warn beim Senden |
|
||||
| Suche | API-Basis ✓ | — | Jump-to-message, Filter |
|
||||
|
||||
**Implementierungsgrad: ~42 %** (inkl. Teilimplementierungen)
|
||||
**Implementierungsgrad gesamt: ~42 %** (inkl. Teilimplementierungen)
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Feature-Prioritäten
|
||||
## Implementierungs-Fortschritt
|
||||
|
||||
1. **Textformatierung** — häufig genutzt, von Nutzern erwartet
|
||||
2. **Push-Benachrichtigungen** — Desktop-Benachrichtigungen für Windows
|
||||
3. **Tipp-Indikatoren** — verbessert Gesprächsgefühl deutlich
|
||||
4. **Nachrichtenbearbeitung** — SDK-Grundlage vorhanden, nur UI fehlt
|
||||
5. **Profilbearbeitung** — Avatar & Anzeigename ändern
|
||||
6. **Raum erstellen** — Basis-Workflow, derzeit nicht möglich
|
||||
7. **Sprachnachricht abspielen** — Struktur vorhanden
|
||||
8. **Suche** — Dialog vorhanden, Backend-Abfrage fehlt
|
||||
9. **Nutzer blockieren** — Sicherheits-Grundfunktion
|
||||
10. **System-Tray** — Desktop-Erlebnis verbessern
|
||||
| ID | Beschreibung | Status |
|
||||
|---|---|---|
|
||||
| P0-1 | Incoming Key-Verification-Request-Handler | ✅ War bereits implementiert (`app_shell.dart`) |
|
||||
| P1-1 | Notification-Prefs auswerten | ✅ Implementiert (`notification_service.dart`) |
|
||||
| P1-2 | Edits rendern (`getDisplayEvent`) | ✅ Implementiert (`message_group.dart`) |
|
||||
| P1-3 | Encrypted-Placeholder + Recovery-Button | ✅ Implementiert (`message_group.dart`) |
|
||||
| P2-1 | Typing: `room.setTyping()` senden | ✅ Implementiert (`chat_composer.dart`) |
|
||||
| P2-2 | Typing: Display-Widget | ✅ Implementiert (`chat_view.dart` — `_TypingIndicator`) |
|
||||
| P2-3 | Unread-Divider: Position + leere fullyRead-Guard | ✅ Neu implementiert (`chat_view.dart`) |
|
||||
| P2-4 | Unread-Divider: Auto-Scroll (Schätzung) | ✅ Implementiert (`_scrollToUnread`) |
|
||||
| P2-5 | Gruppen-Read-Receipts (DM-Guard entfernt) | ✅ Implementiert (`chat_view.dart`) |
|
||||
| P2-6 | Notification-Cancellation beim Lesen | ✅ Implementiert (`chat_view.dart/_markRead`) |
|
||||
| P3-1 | Presence: Auto-Heartbeat (10s) | ✅ Implementiert (`app_shell.dart`) |
|
||||
| P3-2 | Presence: App-Lifecycle-Hook | ✅ Implementiert (`app_shell.dart` — `WidgetsBindingObserver`) |
|
||||
| P3-3 | HTML/Markdown (`_MatrixHtmlText`, `_PlainLinkText`) | ✅ Implementiert (`message_group.dart`) |
|
||||
| P3-4 | `requestFuture()` | ✅ Implementiert (`chat_view.dart/_onScroll`) |
|
||||
| P3-5 | `message_bubble.dart` entfernen | ⏳ Offen |
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# 01 — Authentifizierung & Session
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Login mit Passwort](#login-mit-passwort)
|
||||
- [Login mit Token](#login-mit-token)
|
||||
- [SSO / OAuth](#sso--oauth)
|
||||
- [Register](#registrierung)
|
||||
- [Session prüfen & wiederherstellen](#session-prüfen--wiederherstellen)
|
||||
- [Logout](#logout)
|
||||
- [Multi-Account / Device](#multi-account--device)
|
||||
- [Homeserver ermitteln](#homeserver-ermitteln)
|
||||
- [Flows & Login-Typen abfragen](#flows--login-typen-abfragen)
|
||||
|
||||
---
|
||||
|
||||
## Login mit Passwort
|
||||
|
||||
```dart
|
||||
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
|
||||
|
||||
final loginResponse = await client.login(
|
||||
LoginType.mLoginPassword,
|
||||
password: 'geheimesPasswort',
|
||||
identifier: AuthenticationUserIdentifier(user: '@alice:example.org'),
|
||||
// oder nur Username (ohne Server):
|
||||
// identifier: AuthenticationUserIdentifier(user: 'alice'),
|
||||
initialDeviceDisplayName: 'Pyramid Android',
|
||||
);
|
||||
// loginResponse.accessToken, .deviceId, .userId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Login mit Token
|
||||
|
||||
```dart
|
||||
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
|
||||
|
||||
await client.login(
|
||||
LoginType.mLoginToken,
|
||||
token: 'einmaliger_login_token',
|
||||
initialDeviceDisplayName: 'Pyramid',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSO / OAuth
|
||||
|
||||
```dart
|
||||
// Verfügbare Login-Flows abfragen
|
||||
final flows = await client.getLoginFlows();
|
||||
final hasSso = flows.flows?.any((f) => f.type == AuthenticationTypes.sso) ?? false;
|
||||
|
||||
// SSO-URL erzeugen (öffnet Browser/WebView)
|
||||
final redirectUri = 'io.pyramid.app://login-callback';
|
||||
final ssoUrl = client.homeserver!.toString() +
|
||||
'/_matrix/client/v3/login/sso/redirect?redirectUrl=${Uri.encodeComponent(redirectUri)}';
|
||||
// → Uri im Browser öffnen, nach Redirect den Token extrahieren
|
||||
|
||||
// Nach Redirect mit Token einloggen
|
||||
await client.login(
|
||||
LoginType.mLoginToken,
|
||||
token: tokenAusRedirectUrl,
|
||||
initialDeviceDisplayName: 'Pyramid',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registrierung
|
||||
|
||||
```dart
|
||||
// Homeserver-Registrierung prüfen
|
||||
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
|
||||
|
||||
// UIAA-Flow starten
|
||||
try {
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.register(
|
||||
username: 'neuernutzer',
|
||||
password: 'sicheresPasswort',
|
||||
initialDeviceDisplayName: 'Pyramid',
|
||||
auth: auth,
|
||||
),
|
||||
);
|
||||
} on MatrixException catch (e) {
|
||||
// e.error: M_USER_IN_USE, M_INVALID_USERNAME, etc.
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session prüfen & wiederherstellen
|
||||
|
||||
```dart
|
||||
// Nach client.init() prüfen ob Session noch gültig ist
|
||||
if (client.isLogged()) {
|
||||
print('Eingeloggt als: ${client.userID}');
|
||||
print('Homeserver: ${client.homeserver}');
|
||||
print('Device ID: ${client.deviceID}');
|
||||
print('Access Token: ${client.accessToken}');
|
||||
}
|
||||
|
||||
// Auf Login-State reagieren
|
||||
client.onLoginStateChanged.stream.listen((LoginState state) {
|
||||
switch (state) {
|
||||
case LoginState.loggedIn: // Session aktiv
|
||||
case LoginState.softLoggedOut: // Token abgelaufen, Re-Auth nötig
|
||||
case LoginState.loggedOut: // Ausgeloggt
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logout
|
||||
|
||||
```dart
|
||||
// Nur dieses Device
|
||||
await client.logout();
|
||||
|
||||
// Alle Devices
|
||||
await client.logoutAll();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Account / Device
|
||||
|
||||
```dart
|
||||
// Eigene Devices (Sessions) auflisten
|
||||
final devices = await client.getDevices();
|
||||
for (final device in devices ?? []) {
|
||||
print('${device.deviceId}: ${device.displayName} — ${device.lastSeenIp}');
|
||||
}
|
||||
|
||||
// Device umbenennen
|
||||
await client.updateDevice(
|
||||
deviceId: 'DEVICEID',
|
||||
displayName: 'Pyramid (Wohnzimmer)',
|
||||
);
|
||||
|
||||
// Device löschen (andere Session killen)
|
||||
await client.deleteDevice(
|
||||
'ANDERES_DEVICE_ID',
|
||||
auth: AuthenticationPassword(
|
||||
identifier: AuthenticationUserIdentifier(user: client.userID!),
|
||||
password: 'meinPasswort',
|
||||
),
|
||||
);
|
||||
|
||||
// Mehrere Devices auf einmal löschen
|
||||
await client.deleteDevices(
|
||||
['DEVICE1', 'DEVICE2'],
|
||||
auth: AuthenticationPassword(...),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Homeserver ermitteln
|
||||
|
||||
```dart
|
||||
// .well-known auflösen (Autodiscovery)
|
||||
final discovery = await client.checkHomeserver(
|
||||
Uri.parse('https://example.org'), // Domain reicht, SDK findet Matrix-Server
|
||||
);
|
||||
// client.homeserver ist jetzt gesetzt
|
||||
|
||||
// Direkt mit bekannter HS-URL
|
||||
await client.checkHomeserver(Uri.parse('https://matrix.example.org'));
|
||||
|
||||
// Homeserver-Versioninfo
|
||||
final serverVersion = await client.getVersions();
|
||||
print(serverVersion.versions); // ['v1.1', 'v1.2', ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flows & Login-Typen abfragen
|
||||
|
||||
```dart
|
||||
final response = await client.getLoginFlows();
|
||||
for (final flow in response.flows ?? []) {
|
||||
switch (flow.type) {
|
||||
case AuthenticationTypes.password: // m.login.password
|
||||
case AuthenticationTypes.sso: // m.login.sso
|
||||
case AuthenticationTypes.token: // m.login.token
|
||||
case 'm.login.cas': // Legacy-CAS
|
||||
}
|
||||
}
|
||||
|
||||
// SSO-Provider Details (für Brand-Buttons im UI)
|
||||
final ssoProviders = response.flows
|
||||
?.where((f) => f.type == AuthenticationTypes.sso)
|
||||
.expand((f) => f.identityProviders ?? [])
|
||||
.toList();
|
||||
// ssoProvider.id, .name, .brand, .icon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UIAA (User-Interactive Auth) Flows
|
||||
|
||||
```dart
|
||||
// Generisches UIAA-Handling (z.B. für Account-Löschung)
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.deactivateAccount(auth: auth, eraseData: false),
|
||||
);
|
||||
|
||||
// Passwort ändern (braucht Re-Auth)
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.changePassword(
|
||||
'neuesPasswort',
|
||||
auth: auth,
|
||||
oldPassword: 'altesPasswort', // optional, falls Server es erlaubt
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
```dart
|
||||
try {
|
||||
await client.login(...);
|
||||
} on MatrixException catch (e) {
|
||||
switch (e.error) {
|
||||
case MatrixError.M_FORBIDDEN: // Falsches Passwort
|
||||
case MatrixError.M_USER_IN_USE: // Username vergeben
|
||||
case MatrixError.M_INVALID_USERNAME:
|
||||
case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limiting
|
||||
case MatrixError.M_UNKNOWN:
|
||||
}
|
||||
print(e.errorMessage); // Menschenlesbarer Text vom Server
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,436 @@
|
||||
# 02 — Räume & Spaces
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Raum erstellen](#raum-erstellen)
|
||||
- [Raum beitreten](#raum-beitreten)
|
||||
- [Raum verlassen](#raum-verlassen)
|
||||
- [Raumliste](#raumliste)
|
||||
- [Raum-Metadaten lesen](#raum-metadaten-lesen)
|
||||
- [Raum-Einstellungen ändern](#raum-einstellungen-ändern)
|
||||
- [Mitglieder](#mitglieder)
|
||||
- [Einladen & Kick & Ban](#einladen--kick--ban)
|
||||
- [Öffentliches Raumverzeichnis](#öffentliches-raumverzeichnis)
|
||||
- [Spaces](#spaces)
|
||||
- [Room Upgrades](#room-upgrades)
|
||||
|
||||
---
|
||||
|
||||
## Raum erstellen
|
||||
|
||||
```dart
|
||||
// Einfacher privater Raum
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Mein Raum',
|
||||
topic: 'Willkommen!',
|
||||
visibility: Visibility.private, // oder .public
|
||||
preset: CreateRoomPreset.privateChat, // oder .publicChat, .trustedPrivateChat
|
||||
);
|
||||
|
||||
// Raum mit Einladungen
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Projekt Alpha',
|
||||
invite: ['@bob:example.org', '@charlie:example.org'],
|
||||
enableEncryption: true, // E2EE aktivieren
|
||||
);
|
||||
|
||||
// Raum mit Alias
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Öffentlicher Kanal',
|
||||
roomAliasName: 'kanal', // → #kanal:example.org
|
||||
visibility: Visibility.public,
|
||||
preset: CreateRoomPreset.publicChat,
|
||||
);
|
||||
|
||||
// Raum mit Custom Power Levels
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Moderiert',
|
||||
powerLevelContentOverride: PowerLevelsContent(
|
||||
eventsDefault: 50, // Nur Moderatoren senden
|
||||
usersDefault: 0,
|
||||
users: {'@admin:example.org': 100},
|
||||
),
|
||||
);
|
||||
|
||||
// Raum mit History-Visibility
|
||||
final roomId = await client.createRoom(
|
||||
initialState: [
|
||||
StateEvent(
|
||||
type: EventTypes.HistoryVisibility,
|
||||
content: {'history_visibility': 'invited'}, // shared|invited|joined|world_readable
|
||||
),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum beitreten
|
||||
|
||||
```dart
|
||||
// Per Raum-ID
|
||||
await client.joinRoom('!roomId:server');
|
||||
|
||||
// Per Alias
|
||||
await client.joinRoomOrAlias('#kanal:example.org');
|
||||
|
||||
// Per Invite-Link / Matrix-URI
|
||||
// matrix:r/kanal:example.org
|
||||
// https://matrix.to/#/#kanal:example.org
|
||||
final uri = MatrixUri.parse('https://matrix.to/#/#kanal:example.org');
|
||||
if (uri != null) {
|
||||
await client.joinRoomOrAlias(uri.primaryIdentifier);
|
||||
}
|
||||
|
||||
// Mit Via-Servern (für föderierte Räume)
|
||||
await client.joinRoom(
|
||||
'!roomId:remoteserver.org',
|
||||
serverName: ['remoteserver.org', 'matrix.org'],
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum verlassen
|
||||
|
||||
```dart
|
||||
// Verlassen (bleibt in Left-State)
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
await room.leave();
|
||||
|
||||
// Vergessen (entfernt aus Raumliste)
|
||||
await room.forget();
|
||||
|
||||
// Verlassen + Vergessen
|
||||
await room.leave();
|
||||
await room.forget();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raumliste
|
||||
|
||||
```dart
|
||||
// Alle Räume (joined + invited)
|
||||
final allRooms = client.rooms;
|
||||
|
||||
// Nur beigetretene
|
||||
final joinedRooms = client.rooms
|
||||
.where((r) => r.membership == Membership.join)
|
||||
.toList();
|
||||
|
||||
// Nur Einladungen
|
||||
final invites = client.rooms
|
||||
.where((r) => r.membership == Membership.invite)
|
||||
.toList();
|
||||
|
||||
// Sortiert nach letzter Aktivität
|
||||
final sorted = [...client.rooms]
|
||||
..sort((a, b) => (b.lastEvent?.originServerTs ?? DateTime(0))
|
||||
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
|
||||
|
||||
// Raum per ID
|
||||
final room = client.getRoomById('!roomId:server');
|
||||
|
||||
// Raum per Alias auflösen
|
||||
final resolved = await client.getRoomIdByAlias('#alias:server');
|
||||
// resolved.roomId
|
||||
|
||||
// Ungelesene Nachrichten
|
||||
final unread = client.rooms.fold<int>(
|
||||
0, (sum, r) => sum + r.notificationCount);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Metadaten lesen
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Basis-Info
|
||||
print(room.id); // !roomId:server
|
||||
print(room.name); // Anzeigename (aufgelöst)
|
||||
print(room.topic); // Thema
|
||||
print(room.avatar); // mxc://… URI
|
||||
print(room.canonicalAlias); // #alias:server
|
||||
print(room.membership); // Membership.join/invite/leave/ban
|
||||
print(room.isDirectChat); // DM?
|
||||
print(room.directChatMatrixID); // Gegenüber-MXID bei DM
|
||||
print(room.isEncrypted); // E2EE aktiv?
|
||||
print(room.joinRules); // JoinRules.public/invite/knock/restricted
|
||||
print(room.guestAccess); // GuestAccess.forbidden/canJoin
|
||||
print(room.historyVisibility); // HistoryVisibility.*
|
||||
print(room.notificationCount); // Ungelesene Notifications
|
||||
print(room.highlightCount); // Mentions
|
||||
print(room.summary.mJoinedMemberCount); // Mitgliederzahl
|
||||
|
||||
// Letztes Event
|
||||
final last = room.lastEvent;
|
||||
print(last?.body);
|
||||
print(last?.originServerTs);
|
||||
|
||||
// Display-Name (lokalisiert, wie FluffyChat)
|
||||
final displayName = room.getLocalizedDisplayname();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Einstellungen ändern
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Name
|
||||
await room.setName('Neuer Name');
|
||||
|
||||
// Thema
|
||||
await room.setDescription('Neues Thema');
|
||||
|
||||
// Avatar (mxc:// URI nach Upload)
|
||||
final uri = await client.uploadContent(imageBytes, filename: 'avatar.png');
|
||||
await room.setAvatar(uri);
|
||||
|
||||
// Alias setzen / entfernen
|
||||
await client.setRoomAlias('#neu:example.org', room.id);
|
||||
await client.deleteRoomAlias('#alt:example.org');
|
||||
await room.setCanonicalAlias('#neu:example.org'); // Kanonischen Alias setzen
|
||||
|
||||
// Join-Rules
|
||||
await room.setJoinRules(JoinRules.invite); // private
|
||||
await room.setJoinRules(JoinRules.public); // öffentlich
|
||||
await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen
|
||||
await room.setJoinRules(
|
||||
JoinRules.restricted,
|
||||
allow: [SpaceRoomJoinRulesAllowInner(
|
||||
roomId: '!spaceId:server',
|
||||
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
|
||||
)],
|
||||
);
|
||||
|
||||
// History-Visibility
|
||||
await room.setHistoryVisibility(HistoryVisibility.shared);
|
||||
// .shared | .invited | .joined | .worldReadable
|
||||
|
||||
// Guest Access
|
||||
await room.setGuestAccess(GuestAccess.canJoin);
|
||||
await room.setGuestAccess(GuestAccess.forbidden);
|
||||
|
||||
// E2EE aktivieren (nicht mehr deaktivierbar!)
|
||||
await room.enableEncryption();
|
||||
|
||||
// Generischer State-Event senden
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.room.custom_event',
|
||||
{'key': 'value'},
|
||||
stateKey: '',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mitglieder
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Alle Mitglieder (gecacht)
|
||||
final members = room.getParticipants();
|
||||
|
||||
// Vollständige Mitgliederliste vom Server laden
|
||||
final allMembers = await room.requestParticipants();
|
||||
|
||||
// Einzelnen User holen
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback('@alice:server');
|
||||
print(user.displayName);
|
||||
print(user.avatarUrl);
|
||||
print(user.powerLevel);
|
||||
print(user.membership); // Membership.join/invite/leave/ban/knock
|
||||
|
||||
// Aktuell tippende User
|
||||
final typingUsers = room.typingUsers; // List<User>
|
||||
|
||||
// Power Level eines Users
|
||||
final pl = room.getPowerLevelByUserId('@alice:server');
|
||||
|
||||
// Eigener Power Level
|
||||
final myPl = room.ownPowerLevel;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Einladen, Kick & Ban
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Einladen
|
||||
await room.invite('@alice:server');
|
||||
|
||||
// Kick (entfernt, kann wieder beitreten)
|
||||
await room.kick('@alice:server', reason: 'Regeln verletzt');
|
||||
|
||||
// Ban (dauerhaft gesperrt)
|
||||
await room.ban('@alice:server', reason: 'Spam');
|
||||
|
||||
// Unban
|
||||
await room.unban('@alice:server');
|
||||
|
||||
// Eigene Berechtigungen prüfen
|
||||
room.canBan; // bool
|
||||
room.canKick; // bool
|
||||
room.canInvite; // bool
|
||||
room.canSendEvent(EventTypes.Message); // bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Power Levels
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Power Level eines Users setzen
|
||||
await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin
|
||||
|
||||
// Power Level Content auslesen
|
||||
final plContent = room.getState(EventTypes.RoomPowerLevels)
|
||||
?.content
|
||||
.tryGet<Map>('users');
|
||||
|
||||
// Vollständig setzen
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomPowerLevels,
|
||||
{
|
||||
'ban': 50,
|
||||
'events': {
|
||||
'm.room.name': 50,
|
||||
'm.room.power_levels': 100,
|
||||
},
|
||||
'events_default': 0,
|
||||
'invite': 50,
|
||||
'kick': 50,
|
||||
'redact': 50,
|
||||
'state_default': 50,
|
||||
'users': {
|
||||
'@alice:server': 100,
|
||||
'@mod:server': 50,
|
||||
},
|
||||
'users_default': 0,
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Berechtigungen prüfen
|
||||
room.canSendDefaultMessages; // Nachrichten senden
|
||||
room.canChangeStateEvent(EventTypes.RoomName); // State-Events ändern
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Öffentliches Raumverzeichnis
|
||||
|
||||
```dart
|
||||
// Öffentliche Räume suchen
|
||||
final result = await client.queryPublicRooms(
|
||||
limit: 20,
|
||||
filter: PublicRoomQueryFilter(genericSearchTerm: 'flutter'),
|
||||
server: 'matrix.org', // optionaler anderer Server
|
||||
);
|
||||
|
||||
for (final room in result.chunk) {
|
||||
print(room.name);
|
||||
print(room.roomId);
|
||||
print(room.numJoinedMembers);
|
||||
print(room.worldReadable);
|
||||
}
|
||||
|
||||
// Nächste Seite
|
||||
final next = await client.queryPublicRooms(
|
||||
since: result.nextBatch,
|
||||
limit: 20,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spaces
|
||||
|
||||
```dart
|
||||
// Space erstellen
|
||||
final spaceId = await client.createRoom(
|
||||
name: 'Mein Space',
|
||||
creationContent: {'type': 'm.space'}, // markiert als Space
|
||||
visibility: Visibility.private,
|
||||
);
|
||||
|
||||
// Kind-Raum zu Space hinzufügen
|
||||
final space = client.getRoomById(spaceId)!;
|
||||
await space.client.sendState(
|
||||
spaceId,
|
||||
EventTypes.SpaceChild,
|
||||
{
|
||||
'via': ['example.org'],
|
||||
'suggested': true,
|
||||
'auto_join': false,
|
||||
},
|
||||
stateKey: '!childRoomId:server',
|
||||
);
|
||||
|
||||
// Space-Kinder auflisten (vom Server)
|
||||
final children = await client.getSpaceHierarchy(
|
||||
spaceId,
|
||||
maxDepth: 2,
|
||||
suggestedOnly: false,
|
||||
);
|
||||
for (final room in children.rooms) {
|
||||
print(room.name);
|
||||
print(room.roomType); // 'm.space' oder null
|
||||
print(room.childrenState); // Sub-Spaces
|
||||
}
|
||||
|
||||
// Kind-Raum entfernen
|
||||
await space.client.sendState(
|
||||
spaceId,
|
||||
EventTypes.SpaceChild,
|
||||
{}, // leeres Content → entfernt
|
||||
stateKey: '!childRoomId:server',
|
||||
);
|
||||
|
||||
// Space-Parent setzen (vom Kind-Raum aus)
|
||||
await client.sendState(
|
||||
'!childRoomId:server',
|
||||
EventTypes.SpaceParent,
|
||||
{
|
||||
'via': ['example.org'],
|
||||
'canonical': true,
|
||||
},
|
||||
stateKey: spaceId,
|
||||
);
|
||||
|
||||
// Alle Spaces des Users
|
||||
final spaces = client.rooms.where((r) =>
|
||||
r.membership == Membership.join &&
|
||||
r.getState('m.room.create')?.content['type'] == 'm.space');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Room Upgrades
|
||||
|
||||
```dart
|
||||
// Raum auf neuere Version upgraden (erstellt Nachfolger-Raum)
|
||||
final newRoomId = await client.upgradeRoom(
|
||||
'!alterRaumId:server',
|
||||
newVersion: '11', // Aktuelle empfohlene Version
|
||||
);
|
||||
// Alle Mitglieder werden automatisch in den neuen Raum eingeladen
|
||||
// tombstone-Event wird im alten Raum gesetzt
|
||||
|
||||
// Tombstone lesen
|
||||
final tombstone = room.getState(EventTypes.RoomTombstone);
|
||||
final replacement = tombstone?.content['replacement_room'] as String?;
|
||||
```
|
||||
@@ -0,0 +1,463 @@
|
||||
# 03 — Nachrichten & Events
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Textnachricht senden](#textnachricht-senden)
|
||||
- [HTML / Markdown senden](#html--markdown-senden)
|
||||
- [Antworten (Reply)](#antworten-reply)
|
||||
- [Bearbeiten (Edit)](#bearbeiten-edit)
|
||||
- [Reaktionen](#reaktionen)
|
||||
- [Löschen (Redact)](#löschen-redact)
|
||||
- [Threads](#threads)
|
||||
- [Polls](#polls)
|
||||
- [Standort](#standort)
|
||||
- [Timeline lesen](#timeline-lesen)
|
||||
- [Event-Typen Referenz](#event-typen-referenz)
|
||||
- [Typing-Indikator](#typing-indikator)
|
||||
- [Read Markers / Lesebestätigung](#read-markers--lesebestätigung)
|
||||
- [Pinned Messages](#pinned-messages)
|
||||
- [Fehlgeschlagene Events erneut senden](#fehlgeschlagene-events-erneut-senden)
|
||||
|
||||
---
|
||||
|
||||
## Textnachricht senden
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Einfacher Text
|
||||
final eventId = await room.sendTextEvent('Hallo Welt!');
|
||||
|
||||
// Gibt ein temporäres Event zurück (für optimistische UI)
|
||||
// Das Event bekommt Status EventStatus.sending → .sent → .synced
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTML / Markdown senden
|
||||
|
||||
```dart
|
||||
// Mit HTML-Formatierung
|
||||
await room.sendTextEvent(
|
||||
'Fett und kursiv', // Fallback-Text (Plain)
|
||||
htmlBody: '<b>Fett</b> und <em>kursiv</em>',
|
||||
parseMarkdown: false, // Kein automatisches Markdown-Parsing
|
||||
);
|
||||
|
||||
// Automatisches Markdown-Parsing (SDK parst ** → <b>)
|
||||
await room.sendTextEvent(
|
||||
'**Fett** und *kursiv*',
|
||||
parseMarkdown: true, // Standard: true
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Antworten (Reply)
|
||||
|
||||
```dart
|
||||
// Event, auf das geantwortet wird
|
||||
final replyTo = timeline.events.first; // Event-Objekt
|
||||
|
||||
await room.sendTextEvent(
|
||||
'Das sehe ich auch so!',
|
||||
inReplyTo: replyTo,
|
||||
);
|
||||
|
||||
// Prüfen ob ein Event eine Antwort ist
|
||||
final isReply = event.isReply; // bool
|
||||
final repliedId = event.inReplyTo; // eventId als String?
|
||||
|
||||
// Das referenzierte Event laden
|
||||
final original = await room.getEventById(repliedId!);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bearbeiten (Edit)
|
||||
|
||||
```dart
|
||||
// Nachricht bearbeiten
|
||||
await room.sendTextEvent(
|
||||
'Korrigierter Text',
|
||||
editEventId: altesEvent.eventId, // Referenz auf Original
|
||||
htmlBody: '<s>Alt</s> <b>Korrigiert</b>', // optional
|
||||
);
|
||||
|
||||
// Prüfen ob ein Event bearbeitet wurde
|
||||
final isEdit = event.isEdit; // bool
|
||||
|
||||
// Gerendertes Event (mit letzter Bearbeitung) holen
|
||||
final displayed = event.getDisplayEvent(); // gibt die letzte Version zurück
|
||||
print(displayed.body); // aktuelle Nachricht
|
||||
|
||||
// Alle Bearbeitungen eines Events
|
||||
final edits = timeline.events
|
||||
.where((e) => e.isEdit && e.relationshipEventId == originalEvent.eventId)
|
||||
.toList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reaktionen
|
||||
|
||||
```dart
|
||||
// Reaktion senden (Emoji oder Text)
|
||||
await room.sendReaction(event.eventId, '👍');
|
||||
await room.sendReaction(event.eventId, '❤️');
|
||||
|
||||
// Alle Reaktionen eines Events lesen
|
||||
final reactions = event.aggregatedEvents(
|
||||
timeline,
|
||||
RelationshipTypes.reaction,
|
||||
);
|
||||
// Gruppiert nach Emoji-Key:
|
||||
// Map<String, List<Event>>
|
||||
|
||||
// Eigene Reaktion entfernen (= Redact)
|
||||
final myReaction = reactions['👍']
|
||||
?.firstWhere((e) => e.senderId == client.userID);
|
||||
if (myReaction != null) {
|
||||
await myReaction.redactEvent(reason: null);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Löschen (Redact)
|
||||
|
||||
```dart
|
||||
// Event löschen
|
||||
await room.redactEvent(
|
||||
event.eventId,
|
||||
reason: 'Unpassender Inhalt', // optional
|
||||
);
|
||||
|
||||
// Über das Event-Objekt direkt
|
||||
await event.redactEvent(reason: 'Spam');
|
||||
|
||||
// Prüfen ob redacted
|
||||
final redacted = event.isRedacted; // bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threads
|
||||
|
||||
```dart
|
||||
// Thread-Antwort senden
|
||||
await room.sendEvent(
|
||||
{
|
||||
'm.relates_to': {
|
||||
'rel_type': 'm.thread',
|
||||
'event_id': rootEvent.eventId,
|
||||
'is_falling_back': true, // true wenn kein echter Thread-Client
|
||||
'm.in_reply_to': {
|
||||
'event_id': rootEvent.eventId,
|
||||
},
|
||||
},
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Antwort im Thread',
|
||||
},
|
||||
EventTypes.Message,
|
||||
);
|
||||
|
||||
// Thread-Events eines Root-Events laden
|
||||
final threadEvents = timeline.events
|
||||
.where((e) =>
|
||||
e.relationshipType == RelationshipTypes.thread &&
|
||||
e.relationshipEventId == rootEvent.eventId)
|
||||
.toList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Polls
|
||||
|
||||
```dart
|
||||
// Poll erstellen (MSC3381)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'org.matrix.msc3381.poll.start': {
|
||||
'question': {
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Was ist dein Lieblingsessen?',
|
||||
},
|
||||
'kind': 'org.matrix.msc3381.poll.disclosed', // oder .undisclosed
|
||||
'max_selections': 1,
|
||||
'answers': [
|
||||
{'id': '1', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Pizza'}},
|
||||
{'id': '2', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Pasta'}},
|
||||
{'id': '3', 'org.matrix.msc3381.poll.answer': {'msgtype': 'm.text', 'body': 'Salat'}},
|
||||
],
|
||||
},
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Was ist dein Lieblingsessen? (Poll)',
|
||||
},
|
||||
'org.matrix.msc3381.poll.start',
|
||||
);
|
||||
|
||||
// Abstimmen
|
||||
await room.sendEvent(
|
||||
{
|
||||
'm.relates_to': {
|
||||
'rel_type': 'm.reference',
|
||||
'event_id': pollEvent.eventId,
|
||||
},
|
||||
'org.matrix.msc3381.poll.response': {
|
||||
'answers': ['1'], // Ausgewählte IDs
|
||||
},
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Meine Antwort: Pizza',
|
||||
},
|
||||
'org.matrix.msc3381.poll.response',
|
||||
);
|
||||
|
||||
// Poll beenden
|
||||
await room.sendEvent(
|
||||
{
|
||||
'm.relates_to': {
|
||||
'rel_type': 'm.reference',
|
||||
'event_id': pollEvent.eventId,
|
||||
},
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Poll beendet.',
|
||||
},
|
||||
'org.matrix.msc3381.poll.end',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standort
|
||||
|
||||
```dart
|
||||
// Standort senden
|
||||
await room.sendEvent(
|
||||
{
|
||||
'msgtype': 'm.location',
|
||||
'body': 'Mein Standort: geo:52.5200,13.4050',
|
||||
'geo_uri': 'geo:52.5200,13.4050',
|
||||
'org.matrix.msc3488.location': {
|
||||
'uri': 'geo:52.5200,13.4050',
|
||||
'description': 'Berlin',
|
||||
},
|
||||
},
|
||||
EventTypes.Message,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timeline lesen
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Timeline erstellen (Callbacks werden bei Änderungen gerufen)
|
||||
Timeline? timeline;
|
||||
timeline = await room.getTimeline(
|
||||
onUpdate: () {
|
||||
// Vollständiges UI-Rebuild
|
||||
setState(() {});
|
||||
},
|
||||
onChange: (int index) {
|
||||
// Einzelnes Event geändert (z.B. Reaktion, Edit, Status)
|
||||
setState(() {});
|
||||
},
|
||||
onInsert: (int index) {
|
||||
// Neues Event eingefügt
|
||||
setState(() {});
|
||||
},
|
||||
onRemove: (int index) {
|
||||
// Event entfernt (Redaction)
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
|
||||
// WICHTIG: Immer aufräumen!
|
||||
@override
|
||||
void dispose() {
|
||||
timeline?.cancelSubscriptions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Events abrufen (neueste zuerst, Index 0 = aktuellstes)
|
||||
final events = timeline.events;
|
||||
|
||||
// Ältere Nachrichten nachladen (z.B. beim Scrollen nach oben)
|
||||
await timeline.requestHistory(count: 30);
|
||||
// Gibt true zurück wenn es noch mehr gibt
|
||||
final hasMore = timeline.canRequestHistory;
|
||||
|
||||
// Bestimmtes Event aus Timeline oder Server laden
|
||||
final event = await room.getEventById('$eventId');
|
||||
|
||||
// Neuere Events nachladen (nach Gap)
|
||||
await timeline.requestFuture(count: 20);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event-Typen Referenz
|
||||
|
||||
```dart
|
||||
// Nachrichten-Typen (msgtype)
|
||||
'm.text' // Textnachricht
|
||||
'm.emote' // /me Aktion
|
||||
'm.notice' // Bot-Nachricht (dezenter)
|
||||
'm.image' // Bild
|
||||
'm.video' // Video
|
||||
'm.audio' // Audio/Sprachnachricht
|
||||
'm.file' // Datei
|
||||
'm.location' // Standort
|
||||
'm.sticker' // Sticker (eigener Event-Typ: m.sticker)
|
||||
|
||||
// Event-Typen (type)
|
||||
EventTypes.Message // m.room.message
|
||||
EventTypes.Sticker // m.sticker
|
||||
EventTypes.Reaction // m.reaction
|
||||
EventTypes.RoomRedaction // m.room.redaction
|
||||
EventTypes.Encrypted // m.room.encrypted
|
||||
EventTypes.RoomName // m.room.name
|
||||
EventTypes.RoomTopic // m.room.topic
|
||||
EventTypes.RoomAvatar // m.room.avatar
|
||||
EventTypes.RoomMember // m.room.member
|
||||
EventTypes.RoomPowerLevels // m.room.power_levels
|
||||
EventTypes.RoomCanonicalAlias // m.room.canonical_alias
|
||||
EventTypes.RoomJoinRules // m.room.join_rules
|
||||
EventTypes.RoomGuestAccess // m.room.guest_access
|
||||
EventTypes.HistoryVisibility // m.room.history_visibility
|
||||
EventTypes.RoomCreate // m.room.create
|
||||
EventTypes.RoomTombstone // m.room.tombstone
|
||||
EventTypes.Encryption // m.room.encryption
|
||||
EventTypes.SpaceChild // m.space.child
|
||||
EventTypes.SpaceParent // m.space.parent
|
||||
EventTypes.CallInvite // m.call.invite
|
||||
EventTypes.CallAnswer // m.call.answer
|
||||
EventTypes.CallHangup // m.call.hangup
|
||||
|
||||
// Event-Status prüfen
|
||||
event.status == EventStatus.sending // Wird gesendet (optimistisch)
|
||||
event.status == EventStatus.sent // Vom Server bestätigt
|
||||
event.status == EventStatus.synced // Im eigenen Sync empfangen
|
||||
event.status == EventStatus.error // Fehlgeschlagen
|
||||
event.status == EventStatus.removed // Gelöscht
|
||||
|
||||
// Nützliche Event-Properties
|
||||
event.type // String: Event-Typ
|
||||
event.eventId // String: ID
|
||||
event.senderId // String: MXID des Senders
|
||||
event.originServerTs // DateTime: Sendezeitpunkt
|
||||
event.body // String: Nachrichtentext (entschlüsselt)
|
||||
event.messageType // String?: msgtype
|
||||
event.formattedBody // String?: HTML-Inhalt
|
||||
event.isReply // bool
|
||||
event.isEdit // bool
|
||||
event.isReacted // bool
|
||||
event.isRedacted // bool
|
||||
event.isEncrypted // bool
|
||||
event.isBadEncrypted // bool: Entschlüsselung fehlgeschlagen
|
||||
event.isState // bool: State-Event?
|
||||
event.relationshipEventId // String?: ID des referenzierten Events
|
||||
event.relationshipType // String?: Beziehungstyp (m.replace, m.thread, etc.)
|
||||
|
||||
// Sender-User holen
|
||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
||||
print(sender.displayName);
|
||||
print(sender.avatarUrl);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typing-Indikator
|
||||
|
||||
```dart
|
||||
// Schreiben anzeigen
|
||||
await room.setTyping(true, timeout: 30000); // 30 Sekunden
|
||||
|
||||
// Schreiben beenden
|
||||
await room.setTyping(false);
|
||||
|
||||
// Tippende User im Raum beobachten
|
||||
final typing = room.typingUsers; // List<User>
|
||||
// Automatisch aktuell durch Sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Read Markers / Lesebestätigung
|
||||
|
||||
```dart
|
||||
// Gelesenes Event markieren
|
||||
await room.setReadMarker(
|
||||
event.eventId,
|
||||
mRead: event.eventId, // private receipt (unsichtbar für andere)
|
||||
// public receipt weglassen für private-only
|
||||
);
|
||||
|
||||
// Öffentliche + private Lesebestätigung
|
||||
await room.setReadMarker(
|
||||
event.eventId,
|
||||
mRead: event.eventId,
|
||||
);
|
||||
|
||||
// Letztes gelesenes Event
|
||||
final lastRead = room.lastReadEventId;
|
||||
|
||||
// Ungelesen-Badge
|
||||
final count = room.notificationCount; // Notifications
|
||||
final hl = room.highlightCount; // Mentions/Highlights
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pinned Messages
|
||||
|
||||
```dart
|
||||
// Events anpinnen
|
||||
final currentPinned = (room.getState(EventTypes.RoomPinnedEvents)
|
||||
?.content['pinned'] as List?)
|
||||
?.cast<String>() ?? [];
|
||||
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomPinnedEvents,
|
||||
{'pinned': [...currentPinned, eventId]},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Angepinnte Events lesen
|
||||
final pinned = (room.getState(EventTypes.RoomPinnedEvents)
|
||||
?.content['pinned'] as List?)
|
||||
?.cast<String>() ?? [];
|
||||
|
||||
// Events laden
|
||||
final pinnedEvents = await Future.wait(
|
||||
pinned.map((id) => room.getEventById(id)),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlgeschlagene Events erneut senden
|
||||
|
||||
```dart
|
||||
// Events mit Status error finden
|
||||
final failed = timeline.events
|
||||
.where((e) => e.status == EventStatus.error)
|
||||
.toList();
|
||||
|
||||
// Erneut senden
|
||||
for (final event in failed) {
|
||||
await event.sendAgain();
|
||||
}
|
||||
|
||||
// Oder aus dem Timeline-Controller:
|
||||
await room.sendEvent(
|
||||
event.content,
|
||||
event.type,
|
||||
txnId: event.eventId, // Gleiche txnId → überschreibt fehlgeschlagenes
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,221 @@
|
||||
# 04 — Direct Messages (DMs)
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [DM starten oder finden](#dm-starten-oder-finden)
|
||||
- [DM-Raum identifizieren](#dm-raum-identifizieren)
|
||||
- [DM-Liste](#dm-liste)
|
||||
- [Gruppen-DM](#gruppen-dm)
|
||||
- [DM-Raum schließen / vergessen](#dm-raum-schließen--vergessen)
|
||||
- [User-Profil für DM laden](#user-profil-für-dm-laden)
|
||||
- [Verschlüsselung in DMs](#verschlüsselung-in-dms)
|
||||
|
||||
---
|
||||
|
||||
## DM starten oder finden
|
||||
|
||||
```dart
|
||||
// Existierenden DM finden oder neuen erstellen
|
||||
// Gibt immer einen joined Room zurück
|
||||
final room = await client.startDirectChat('@alice:server');
|
||||
print(room.id); // !roomId:server
|
||||
|
||||
// Mit expliziten Optionen
|
||||
final room = await client.startDirectChat(
|
||||
'@alice:server',
|
||||
// enableEncryption: true, // Standard: true
|
||||
);
|
||||
|
||||
// Navigieren zum DM-Raum
|
||||
// (z.B. in Pyramid:)
|
||||
ref.read(activeRoomIdProvider.notifier).state = room.id;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DM-Raum identifizieren
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Ist es ein DM?
|
||||
final isDm = room.isDirectChat; // bool
|
||||
|
||||
// Mit wem?
|
||||
final partner = room.directChatMatrixID; // '@alice:server' oder null
|
||||
|
||||
// DM zu einem bestimmten User finden
|
||||
final existingDm = client.getDirectChatFromUserId('@alice:server');
|
||||
// Gibt Room? zurück (null wenn kein DM vorhanden)
|
||||
|
||||
// Alle DMs des Users (Account-Data)
|
||||
final dmMap = client.directChats;
|
||||
// Map<String, List<String>>: { '@alice:server': ['!roomId:server'] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DM-Liste
|
||||
|
||||
```dart
|
||||
// Alle DM-Räume (joined)
|
||||
final dms = client.rooms.where((r) =>
|
||||
r.isDirectChat &&
|
||||
r.membership == Membership.join,
|
||||
).toList();
|
||||
|
||||
// Sortiert nach letzter Aktivität
|
||||
dms.sort((a, b) =>
|
||||
(b.lastEvent?.originServerTs ?? DateTime(0))
|
||||
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
|
||||
|
||||
// DMs mit ungelesenen Nachrichten
|
||||
final unreadDms = dms.where((r) => r.notificationCount > 0).toList();
|
||||
|
||||
// Display-Info für DM-Liste
|
||||
for (final dm in dms) {
|
||||
final partner = client.getDirectChatFromUserId(dm.directChatMatrixID!);
|
||||
final user = dm.unsafeGetUserFromMemoryOrFallback(dm.directChatMatrixID!);
|
||||
print(user.displayName ?? dm.directChatMatrixID);
|
||||
print(user.avatarUrl);
|
||||
print(dm.lastEvent?.body); // Vorschautext
|
||||
print(dm.lastEvent?.originServerTs); // Zeitstempel
|
||||
print(dm.notificationCount); // Ungelesen
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gruppen-DM
|
||||
|
||||
```dart
|
||||
// Gruppen-DM mit mehreren Personen erstellen
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Gruppenunterhaltung',
|
||||
invite: ['@bob:server', '@charlie:server'],
|
||||
isDirect: true, // Als DM markieren
|
||||
preset: CreateRoomPreset.trustedPrivateChat,
|
||||
enableEncryption: true,
|
||||
);
|
||||
|
||||
// Oder mit startDirectChat (für Einzel-DMs) und manuell Teilnehmer hinzufügen
|
||||
final room = await client.startDirectChat('@bob:server');
|
||||
await room.invite('@charlie:server');
|
||||
|
||||
// Gruppe als DM im Account-Data kennzeichnen
|
||||
// (passiert automatisch bei isDirect: true)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DM-Raum schließen / vergessen
|
||||
|
||||
```dart
|
||||
final room = client.getRoomById('!roomId:server')!;
|
||||
|
||||
// Verlassen (Gegenüber kann weiter chatten)
|
||||
await room.leave();
|
||||
|
||||
// Aus Raumliste entfernen
|
||||
await room.forget();
|
||||
|
||||
// DM aus Account-Daten entfernen
|
||||
final dmMap = Map<String, dynamic>.from(client.directChats);
|
||||
dmMap.remove('@alice:server');
|
||||
// oder nur den spezifischen Raum entfernen:
|
||||
final rooms = List<String>.from(dmMap['@alice:server'] ?? []);
|
||||
rooms.remove(room.id);
|
||||
if (rooms.isEmpty) {
|
||||
dmMap.remove('@alice:server');
|
||||
} else {
|
||||
dmMap['@alice:server'] = rooms;
|
||||
}
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
'm.direct',
|
||||
dmMap,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User-Profil für DM laden
|
||||
|
||||
```dart
|
||||
// Profil des DM-Partners laden
|
||||
final partnerMxid = room.directChatMatrixID!;
|
||||
|
||||
// Aus Room-Membership (gecacht, schnell)
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback(partnerMxid);
|
||||
print(user.displayName);
|
||||
print(user.avatarUrl);
|
||||
|
||||
// Frisch vom Server (genauer, aber langsamer)
|
||||
final profile = await client.getProfileFromUserId(partnerMxid);
|
||||
print(profile.displayname);
|
||||
print(profile.avatarUrl);
|
||||
|
||||
// Avatar-URL in Widget verwenden
|
||||
// (mxc:// URI → HTTP-URL via SDK)
|
||||
final httpUrl = user.avatarUrl?.getThumbnail(
|
||||
client,
|
||||
width: 56,
|
||||
height: 56,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verschlüsselung in DMs
|
||||
|
||||
```dart
|
||||
// E2EE-Status prüfen
|
||||
final isEncrypted = room.isEncrypted;
|
||||
|
||||
// E2EE aktivieren (einmalig, nicht mehr rückgängig!)
|
||||
if (!room.isEncrypted) {
|
||||
await room.enableEncryption();
|
||||
}
|
||||
|
||||
// Geräte des Partners verifizieren
|
||||
final partnerDevices = await client.getUserDeviceKeys('@alice:server');
|
||||
for (final device in partnerDevices?.deviceKeys.values ?? []) {
|
||||
print('${device.deviceId}: verified=${device.verified}');
|
||||
}
|
||||
|
||||
// Fehlende Schlüssel für Entschlüsselung anfordern
|
||||
await client.requestMissingSessionKeys();
|
||||
|
||||
// Schlechte Verschlüsselung erkennen
|
||||
final badEvents = timeline.events
|
||||
.where((e) => e.isBadEncrypted)
|
||||
.toList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Einladungen (DM-Invite-Ansicht)
|
||||
|
||||
```dart
|
||||
// Eingehende DM-Einladungen
|
||||
final invites = client.rooms.where((r) =>
|
||||
r.membership == Membership.invite &&
|
||||
r.isDirectChat).toList();
|
||||
|
||||
// Einladung anzeigen
|
||||
for (final invite in invites) {
|
||||
final senderId = invite.getState(EventTypes.RoomMember, client.userID!)
|
||||
?.senderId; // Wer hat eingeladen?
|
||||
final sender = await client.getProfileFromUserId(senderId ?? '');
|
||||
print('Einladung von ${sender.displayname}');
|
||||
|
||||
// Annehmen
|
||||
await invite.join();
|
||||
|
||||
// Ablehnen
|
||||
await invite.leave();
|
||||
await invite.forget();
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,355 @@
|
||||
# 05 — Medien & Dateien
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Datei hochladen](#datei-hochladen)
|
||||
- [Bild senden](#bild-senden)
|
||||
- [Video senden](#video-senden)
|
||||
- [Audio / Sprachnachricht senden](#audio--sprachnachricht-senden)
|
||||
- [Datei senden](#datei-senden)
|
||||
- [Sticker senden](#sticker-senden)
|
||||
- [Medien herunterladen & entschlüsseln](#medien-herunterladen--entschlüsseln)
|
||||
- [Thumbnails & Vorschaubilder](#thumbnails--vorschaubilder)
|
||||
- [MXC-URL zu HTTP-URL](#mxc-url-zu-http-url)
|
||||
- [Verschlüsselte Medien](#verschlüsselte-medien)
|
||||
- [Avatar hochladen](#avatar-hochladen)
|
||||
- [MatrixFile-Objekte](#matrixfile-objekte)
|
||||
|
||||
---
|
||||
|
||||
## Datei hochladen
|
||||
|
||||
```dart
|
||||
// Bytes hochladen → mxc:// URI zurück
|
||||
final mxcUri = await client.uploadContent(
|
||||
fileBytes, // Uint8List
|
||||
filename: 'datei.pdf',
|
||||
contentType: 'application/pdf', // optional, SDK erkennt es
|
||||
);
|
||||
print(mxcUri); // mxc://example.org/AbCdEf...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bild senden
|
||||
|
||||
```dart
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
// MatrixImageFile erstellen
|
||||
final imageFile = MatrixImageFile(
|
||||
bytes: imageBytes, // Uint8List
|
||||
name: 'foto.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 1920, // optional, für Info
|
||||
height: 1080,
|
||||
);
|
||||
|
||||
// Mit Thumbnail
|
||||
final thumbnail = MatrixImageFile(
|
||||
bytes: thumbnailBytes,
|
||||
name: 'foto_thumb.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 320,
|
||||
height: 180,
|
||||
);
|
||||
|
||||
// Senden (lädt automatisch hoch + sendet m.image Event)
|
||||
await room.sendFileEvent(
|
||||
imageFile,
|
||||
thumbnail: thumbnail, // optional
|
||||
);
|
||||
|
||||
// Alternativ mit sendImageEvent
|
||||
await room.sendImageEvent(
|
||||
imageFile,
|
||||
thumbnail: thumbnail,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Video senden
|
||||
|
||||
```dart
|
||||
final videoFile = MatrixVideoFile(
|
||||
bytes: videoBytes,
|
||||
name: 'video.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
duration: 45000, // Millisekunden
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
);
|
||||
|
||||
final thumbFile = MatrixImageFile(
|
||||
bytes: thumbnailBytes,
|
||||
name: 'thumb.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
width: 320,
|
||||
height: 180,
|
||||
);
|
||||
|
||||
await room.sendVideoEvent(
|
||||
videoFile,
|
||||
thumbnail: thumbFile,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audio / Sprachnachricht senden
|
||||
|
||||
```dart
|
||||
// Audio-Datei
|
||||
final audioFile = MatrixAudioFile(
|
||||
bytes: audioBytes,
|
||||
name: 'aufnahme.ogg',
|
||||
mimeType: 'audio/ogg',
|
||||
duration: 12000, // Millisekunden
|
||||
);
|
||||
|
||||
await room.sendAudioEvent(audioFile);
|
||||
|
||||
// Sprachnachricht (Voice Message MSC3245)
|
||||
await room.sendFileEvent(
|
||||
audioFile,
|
||||
extraContent: {
|
||||
'org.matrix.msc3245.voice': {}, // markiert als Sprachnachricht
|
||||
'org.matrix.msc1767.audio': {
|
||||
'duration': 12000,
|
||||
'waveform': [0, 50, 100, 75, ...], // Amplituden (0-1024)
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Datei senden
|
||||
|
||||
```dart
|
||||
final file = MatrixFile(
|
||||
bytes: fileBytes,
|
||||
name: 'dokument.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
);
|
||||
|
||||
await room.sendFileEvent(file);
|
||||
|
||||
// Mit Extra-Metadaten
|
||||
await room.sendFileEvent(
|
||||
file,
|
||||
extraContent: {
|
||||
'org.matrix.msc3554.caption': {
|
||||
'msgtype': 'm.text',
|
||||
'body': 'Hier das Dokument',
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sticker senden
|
||||
|
||||
```dart
|
||||
// Sticker sind ein eigener Event-Typ (m.sticker)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'body': 'Ein Sticker',
|
||||
'url': 'mxc://example.org/stickerId',
|
||||
'info': {
|
||||
'mimetype': 'image/png',
|
||||
'w': 256,
|
||||
'h': 256,
|
||||
},
|
||||
},
|
||||
EventTypes.Sticker, // 'm.sticker'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medien herunterladen & entschlüsseln
|
||||
|
||||
```dart
|
||||
// Aus Event-Attachment herunterladen (+ automatisch entschlüsseln bei E2EE)
|
||||
final data = await event.downloadAndDecryptAttachment(
|
||||
getThumbnail: false, // true für Thumbnail statt Original
|
||||
);
|
||||
// data ist MatrixFile mit .bytes: Uint8List
|
||||
|
||||
// Direkte MXC-URL herunterladen
|
||||
final bytes = await client.database.getFile(mxcUri);
|
||||
// oder über HTTP:
|
||||
final httpUri = mxcUri.getDownloadUri(client);
|
||||
// → https://homeserver/_matrix/media/v3/download/server/mediaId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thumbnails & Vorschaubilder
|
||||
|
||||
```dart
|
||||
// Thumbnail-URL für Widget
|
||||
final thumbnailUri = event.thumbnailUrl; // mxc:// URI
|
||||
|
||||
// HTTP-URL für Thumbnail (mit Größe)
|
||||
final httpUrl = thumbnailUri?.getThumbnail(
|
||||
client,
|
||||
width: 800,
|
||||
height: 600,
|
||||
method: ThumbnailMethod.scale, // oder .crop
|
||||
);
|
||||
|
||||
// Avatar-Thumbnail
|
||||
final avatarUrl = user.avatarUrl?.getThumbnail(
|
||||
client,
|
||||
width: 56,
|
||||
height: 56,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
|
||||
// Im Flutter Widget (CachedNetworkImage o.ä.):
|
||||
Image.network(httpUrl.toString());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MXC-URL zu HTTP-URL
|
||||
|
||||
```dart
|
||||
// Download-URL
|
||||
final downloadUrl = Uri.parse(mxcUriString).getDownloadUri(client);
|
||||
// → https://homeserver/_matrix/media/v3/download/server/mediaId
|
||||
|
||||
// Thumbnail-URL
|
||||
final thumbUrl = Uri.parse(mxcUriString).getThumbnail(
|
||||
client,
|
||||
width: 300,
|
||||
height: 300,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
|
||||
// Direkt als String
|
||||
final urlString = downloadUrl.toString();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verschlüsselte Medien
|
||||
|
||||
```dart
|
||||
// In verschlüsselten Räumen werden Medien automatisch verschlüsselt.
|
||||
// Das SDK übernimmt Ver- und Entschlüsselung transparent.
|
||||
|
||||
// Verschlüsselter Download (automatisch):
|
||||
final file = await event.downloadAndDecryptAttachment();
|
||||
// file.bytes enthält die entschlüsselten Rohdaten
|
||||
|
||||
// Manuell ver-/entschlüsseln (selten nötig):
|
||||
// Nutze EncryptedFile aus dem matrix-package
|
||||
final encryptedFile = EncryptedFile(
|
||||
url: 'mxc://...',
|
||||
key: {/* JWK */},
|
||||
iv: 'base64...',
|
||||
hashes: {'sha256': 'base64...'},
|
||||
v: 'v2',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Avatar hochladen
|
||||
|
||||
```dart
|
||||
// Eigener Avatar
|
||||
final avatarBytes = await File('avatar.png').readAsBytes();
|
||||
final mxcUri = await client.uploadContent(
|
||||
avatarBytes,
|
||||
filename: 'avatar.png',
|
||||
contentType: 'image/png',
|
||||
);
|
||||
await client.setAvatarUrl(client.userID!, mxcUri);
|
||||
|
||||
// Raum-Avatar
|
||||
final roomAvatarMxc = await client.uploadContent(imageBytes, filename: 'room.png');
|
||||
await room.setAvatar(roomAvatarMxc);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MatrixFile-Objekte
|
||||
|
||||
```dart
|
||||
// Basis-Klasse
|
||||
MatrixFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType,
|
||||
})
|
||||
|
||||
// Spezialisierte Unterklassen:
|
||||
MatrixImageFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType, // z.B. 'image/jpeg'
|
||||
int? width,
|
||||
int? height,
|
||||
bool? animated, // für GIFs
|
||||
})
|
||||
|
||||
MatrixVideoFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType, // 'video/mp4'
|
||||
int? duration, // ms
|
||||
int? width,
|
||||
int? height,
|
||||
MatrixImageFile? thumbnail,
|
||||
})
|
||||
|
||||
MatrixAudioFile({
|
||||
required Uint8List bytes,
|
||||
required String name,
|
||||
String? mimeType, // 'audio/ogg'
|
||||
int? duration, // ms
|
||||
})
|
||||
|
||||
// Eigenschaften
|
||||
file.bytes // Uint8List
|
||||
file.name // Dateiname
|
||||
file.mimeType // MIME-Typ
|
||||
file.sizeString // '1.4 MB' (formatiert)
|
||||
file.size // Bytes als int
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medien-Events lesen
|
||||
|
||||
```dart
|
||||
// Attachment-Info aus Event
|
||||
if (event.messageType == 'm.image') {
|
||||
final url = event.attachmentMxcUrl; // mxc:// URI
|
||||
final mime = event.attachmentMimetype; // 'image/jpeg'
|
||||
final size = event.attachmentSize; // Bytes
|
||||
final width = event.infoMap['w'] as int?;
|
||||
final height = event.infoMap['h'] as int?;
|
||||
}
|
||||
|
||||
if (event.messageType == 'm.video') {
|
||||
final duration = event.infoMap['duration'] as int?; // ms
|
||||
}
|
||||
|
||||
if (event.messageType == 'm.audio') {
|
||||
final duration = event.infoMap['duration'] as int?;
|
||||
final waveform = event.content
|
||||
.tryGetMap('org.matrix.msc1767.audio')
|
||||
?.tryGetList<int>('waveform');
|
||||
}
|
||||
|
||||
// Datei-Größe als lesbarer String
|
||||
print(event.sizeString); // '2.3 MB'
|
||||
```
|
||||
@@ -0,0 +1,313 @@
|
||||
# 06 — E2EE Verschlüsselung
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Setup & Initialisierung](#setup--initialisierung)
|
||||
- [Verschlüsselung aktivieren](#verschlüsselung-aktivieren)
|
||||
- [Entschlüsselung & Fehlerfälle](#entschlüsselung--fehlerfälle)
|
||||
- [Device-Verifikation (SAS)](#device-verifikation-sas)
|
||||
- [Cross-Signing](#cross-signing)
|
||||
- [Key Backup](#key-backup)
|
||||
- [Fehlende Session Keys anfordern](#fehlende-session-keys-anfordern)
|
||||
- [Geräte-Keys abfragen](#geräte-keys-abfragen)
|
||||
- [Trust-Status](#trust-status)
|
||||
|
||||
---
|
||||
|
||||
## Setup & Initialisierung
|
||||
|
||||
```dart
|
||||
// flutter_vodozemac muss initialisiert sein BEVOR Client erstellt wird
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
await vod.init();
|
||||
|
||||
// Client mit E2EE-Support
|
||||
final client = Client(
|
||||
'Pyramid',
|
||||
database: await MatrixSdkDatabase.init('pyramid', database: db),
|
||||
nativeImplementations: NativeImplementationsIsolate(compute),
|
||||
// Oder für Hintergrund-Isolaten (kein compute):
|
||||
// nativeImplementations: NativeImplementationsDummy(),
|
||||
verificationMethods: {
|
||||
KeyVerificationMethod.numbers,
|
||||
KeyVerificationMethod.emoji,
|
||||
// KeyVerificationMethod.qrCodeScan, // falls QR-Scanner vorhanden
|
||||
// KeyVerificationMethod.qrCodeShow,
|
||||
},
|
||||
);
|
||||
|
||||
await client.init(
|
||||
waitForFirstSync: false,
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
);
|
||||
|
||||
// E2EE verfügbar prüfen
|
||||
print(client.encryptionEnabled); // true wenn Vodozemac/Olm geladen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verschlüsselung aktivieren
|
||||
|
||||
```dart
|
||||
// Raum-Verschlüsselung einschalten (PERMANENT, nicht rückgängig!)
|
||||
if (!room.isEncrypted) {
|
||||
await room.enableEncryption();
|
||||
}
|
||||
|
||||
// Verschlüsselung beim Erstellen aktivieren
|
||||
final roomId = await client.createRoom(
|
||||
name: 'Verschlüsselter Raum',
|
||||
enableEncryption: true,
|
||||
preset: CreateRoomPreset.privateChat,
|
||||
);
|
||||
|
||||
// Status prüfen
|
||||
print(room.isEncrypted); // bool
|
||||
print(room.encryptionAlgorithm); // 'm.megolm.v1.aes-sha2'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entschlüsselung & Fehlerfälle
|
||||
|
||||
```dart
|
||||
// Beim Anzeigen von Events prüfen:
|
||||
if (event.isBadEncrypted) {
|
||||
// Anzeigen: "Nachricht kann nicht entschlüsselt werden"
|
||||
// Mögliche Ursachen:
|
||||
// - Gerät war nicht in der Raumschlüssel-Verteilung
|
||||
// - Session fehlt (Megolm Session nicht vorhanden)
|
||||
// - Falsches Gerät / fehlende Olm-Session
|
||||
}
|
||||
|
||||
// Entschlüsselungs-Fehler-Typ
|
||||
if (event.content['can_not_decrypt'] != null) {
|
||||
// Generischer Fehler
|
||||
}
|
||||
if (event.type == EventTypes.Encrypted && event.plaintextContent == null) {
|
||||
// Noch nicht entschlüsselt
|
||||
}
|
||||
|
||||
// Event-Body bei Fehler prüfen
|
||||
final body = event.body;
|
||||
// Enthält "** Unable to decrypt: ..." bei Fehlern
|
||||
|
||||
// Fehlende Schlüssel anfordern (von anderen Geräten)
|
||||
await client.requestMissingSessionKeys();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Device-Verifikation (SAS)
|
||||
|
||||
```dart
|
||||
// ── Verifikation starten (ich verifiziere ein anderes Gerät) ──────────────
|
||||
|
||||
// Verifikations-Anfrage senden
|
||||
final request = await client.userDeviceKeys[otherUserId]!
|
||||
.startVerification();
|
||||
// request.onUpdate: Stream mit Zustandsänderungen
|
||||
|
||||
// ── Verifikation empfangen (anderes Gerät will mich verifizieren) ─────────
|
||||
|
||||
// Eingehende Anfragen beobachten
|
||||
client.onKeyVerificationRequest.stream.listen((request) async {
|
||||
// Anfrage annehmen
|
||||
await request.acceptVerification();
|
||||
});
|
||||
|
||||
// ── SAS-Flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Auf SAS-Ready warten und Emojis/Zahlen anzeigen
|
||||
request.onUpdate.stream.listen((_) async {
|
||||
if (request.state == KeyVerificationState.showSas) {
|
||||
// Emojis anzeigen
|
||||
final emojis = request.sasEmojis; // List<KeyVerificationEmoji>
|
||||
for (final emoji in emojis) {
|
||||
print('${emoji.emoji} ${emoji.name}');
|
||||
}
|
||||
|
||||
// Oder Dezimalzahlen
|
||||
final decimals = request.sasDecimals; // List<int> (3 Zahlen)
|
||||
print(decimals); // z.B. [1234, 5678, 9012]
|
||||
|
||||
// Wenn User bestätigt dass sie übereinstimmen:
|
||||
await request.confirmSas();
|
||||
|
||||
// Wenn sie nicht übereinstimmen:
|
||||
// await request.rejectSas();
|
||||
}
|
||||
|
||||
if (request.state == KeyVerificationState.done) {
|
||||
print('Verifikation erfolgreich!');
|
||||
}
|
||||
|
||||
if (request.state == KeyVerificationState.error) {
|
||||
print('Verifikation fehlgeschlagen: ${request.cancelCode}');
|
||||
}
|
||||
});
|
||||
|
||||
// ── QR-Code Verifikation ─────────────────────────────────────────────────
|
||||
|
||||
// QR-Code zum Anzeigen generieren
|
||||
final qrData = await request.generateQrCodeData();
|
||||
// qrData als QR-Code rendern (z.B. mit qr_flutter-Package)
|
||||
|
||||
// Gescannten QR-Code verarbeiten
|
||||
await request.scanQrCode(scannedData);
|
||||
|
||||
// Bestätigen dass QR gescannt wurde
|
||||
await request.confirmQrCodeScanned();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Signing
|
||||
|
||||
```dart
|
||||
// Cross-Signing bootstrappen (erstellt Master-, Self- und User-Signing-Keys)
|
||||
// Nur einmal pro Account nötig
|
||||
await client.encryption!.bootstrapCrossSigning(
|
||||
setupMasterKey: true,
|
||||
setupSelfSigningKey: true,
|
||||
setupUserSigningKey: true,
|
||||
);
|
||||
|
||||
// Cross-Signing-Status
|
||||
final crossSigning = client.encryption!.crossSigning;
|
||||
print(crossSigning.enabled); // bool
|
||||
print(crossSigning.selfSigned); // bool: eigenes Gerät ist self-signed
|
||||
|
||||
// Eigenes Gerät ist verifiziert?
|
||||
final myDevice = client.userDeviceKeys[client.userID]
|
||||
?.deviceKeys[client.deviceID];
|
||||
print(myDevice?.verified); // bool
|
||||
|
||||
// Anderen User als vertrauenswürdig markieren
|
||||
await client.encryption!.crossSigning.signUser(otherUserId);
|
||||
|
||||
// Gerät verifizieren (nach SAS/QR)
|
||||
await myDevice?.setVerified(true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Backup
|
||||
|
||||
```dart
|
||||
// ── Key Backup einrichten ─────────────────────────────────────────────────
|
||||
|
||||
// Vollständiges Crypto-Setup (empfohlen):
|
||||
// Erstellt Secret Storage + Cross-Signing + Key Backup
|
||||
await client.bootstrapCryptoIdentity(
|
||||
wipeRecovery: false, // true: bestehende Recovery überschreiben
|
||||
);
|
||||
|
||||
// Nur Key Backup:
|
||||
final backup = client.encryption!.keyManager;
|
||||
|
||||
// Recovery-Key generieren und anzeigen
|
||||
final recoveryKey = await client.encryption!.ssss.generateKey();
|
||||
print(recoveryKey); // Langer alphanumerischer Schlüssel → User aufschreiben lassen!
|
||||
|
||||
// ── Key Backup Status ─────────────────────────────────────────────────────
|
||||
|
||||
// Ist Key Backup aktiv?
|
||||
final keyBackupEnabled = client.encryption!.keyManager.enabled;
|
||||
|
||||
// Alle Sessions hochladen
|
||||
await client.encryption!.keyManager.uploadInboundGroupSessions();
|
||||
|
||||
// ── Backup wiederherstellen (Recovery) ───────────────────────────────────
|
||||
|
||||
// Keys von Server-Backup wiederherstellen
|
||||
await client.encryption!.keyManager.loadFromResponse(
|
||||
await client.getRoomKeysBackup(),
|
||||
);
|
||||
|
||||
// Oder mit Recovery-Key:
|
||||
await client.encryption!.ssss.unlock(recoveryKey: eingabe);
|
||||
await client.encryption!.keyManager.restore();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlende Session Keys anfordern
|
||||
|
||||
```dart
|
||||
// Schlüssel von anderen Geräten desselben Users anfordern
|
||||
await client.requestMissingSessionKeys();
|
||||
|
||||
// Für einen spezifischen Raum
|
||||
await client.requestMissingSessionKeys(
|
||||
rooms: [room],
|
||||
);
|
||||
|
||||
// Einzelne Session anfordern (für ein spezifisches Event)
|
||||
if (event.isBadEncrypted) {
|
||||
final sessionId = event.content
|
||||
.tryGet<String>('session_id');
|
||||
final senderKey = event.content
|
||||
.tryGet<String>('sender_key');
|
||||
if (sessionId != null) {
|
||||
await room.requestKey(sessionId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Geräte-Keys abfragen
|
||||
|
||||
```dart
|
||||
// Alle Geräte eines Users laden
|
||||
final userDevices = await client.getUserDeviceKeys(userId);
|
||||
// → Map<String, DeviceKeysList>
|
||||
|
||||
// Geräte eines Raums (alle Mitglieder)
|
||||
final roomDevices = await room.getUserDeviceKeys();
|
||||
|
||||
// Eigene Geräte
|
||||
final myDevices = client.userDeviceKeys[client.userID]?.deviceKeys;
|
||||
|
||||
// Gerät-Details
|
||||
for (final device in myDevices?.values ?? []) {
|
||||
print(device.deviceId);
|
||||
print(device.displayName);
|
||||
print(device.verified);
|
||||
print(device.blocked);
|
||||
print(device.ed25519Key); // Signatur-Schlüssel
|
||||
print(device.curve25519Key); // Encryption-Schlüssel
|
||||
}
|
||||
|
||||
// Gerät sperren (Nachrichten werden nicht mehr entschlüsselt gesendet)
|
||||
await myDevices?['DEVICEID']?.setBlocked(true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Trust-Status
|
||||
|
||||
```dart
|
||||
// Trust-Level für Raum berechnen
|
||||
final encryption = room.client.encryption;
|
||||
if (encryption != null) {
|
||||
final trust = await room.calcEncryptionHealthState();
|
||||
// trust: .allVerified | .unverifiedDevices | .unknown
|
||||
}
|
||||
|
||||
// User-Trust
|
||||
final userTrust = client.userDeviceKeys[userId]?.verified;
|
||||
// null = unbekannt, true = verifiziert, false = blockiert
|
||||
|
||||
// Nachrichten-Verifikation
|
||||
if (!event.encryptionHealthState.allVerified) {
|
||||
// Anzeigen: "Gesendet von unverifiziertem Gerät"
|
||||
}
|
||||
|
||||
// Eigene Identität
|
||||
final myIdentity = client.encryption?.crossSigning;
|
||||
print(myIdentity?.selfSigned); // Eigenes Gerät cross-signed?
|
||||
```
|
||||
@@ -0,0 +1,291 @@
|
||||
# 07 — Push & Benachrichtigungen
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Pusher registrieren (FCM)](#pusher-registrieren-fcm)
|
||||
- [Pusher abrufen & verwalten](#pusher-abrufen--verwalten)
|
||||
- [Push Rules](#push-rules)
|
||||
- [onNotification Stream](#onnotification-stream)
|
||||
- [Lokale Benachrichtigungen (Android)](#lokale-benachrichtigungen-android)
|
||||
- [Benachrichtigungen: Pyramid-Architektur](#benachrichtigungen-pyramid-architektur)
|
||||
- [Notification Counts](#notification-counts)
|
||||
|
||||
---
|
||||
|
||||
## Pusher registrieren (FCM)
|
||||
|
||||
```dart
|
||||
// FCM-Token holen
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
final fcmToken = await FirebaseMessaging.instance.getToken();
|
||||
|
||||
// Pusher beim Homeserver registrieren
|
||||
await client.postPusher(
|
||||
Pusher(
|
||||
pushkey: fcmToken!,
|
||||
kind: 'http',
|
||||
appId: 'chat.pyramid.pyramid', // Unique App-ID (muss zu Push-Gateway passen)
|
||||
appDisplayName: 'Pyramid',
|
||||
deviceDisplayName: 'Mein Android',
|
||||
lang: 'de',
|
||||
data: PusherData(
|
||||
url: Uri.parse('https://push.example.org/_matrix/push/v1/notify'),
|
||||
format: 'event_id_only', // Empfohlen (nur Event-ID, nicht der Inhalt)
|
||||
),
|
||||
),
|
||||
append: true, // Nicht andere Pusher dieses Geräts löschen
|
||||
);
|
||||
|
||||
// Token-Refresh überwachen
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
|
||||
await client.postPusher(
|
||||
Pusher(pushkey: newToken, /* ... */ ),
|
||||
append: true,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pusher abrufen & verwalten
|
||||
|
||||
```dart
|
||||
// Alle Pusher dieses Users
|
||||
final pushers = await client.getPushers() ?? [];
|
||||
for (final pusher in pushers) {
|
||||
print(pusher.appId);
|
||||
print(pusher.pushkey);
|
||||
print(pusher.data.url);
|
||||
}
|
||||
|
||||
// Pusher entfernen
|
||||
await client.deletePusher(
|
||||
Pusher(
|
||||
pushkey: fcmToken,
|
||||
appId: 'chat.pyramid.pyramid',
|
||||
kind: 'http', // Pflichtfeld beim Löschen
|
||||
// ...
|
||||
),
|
||||
);
|
||||
|
||||
// Beim Logout alle Pusher entfernen
|
||||
final myPushers = (await client.getPushers() ?? [])
|
||||
.where((p) => p.appId == 'chat.pyramid.pyramid');
|
||||
for (final p in myPushers) {
|
||||
await client.deletePusher(p);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Push Rules
|
||||
|
||||
```dart
|
||||
// Alle Push Rules abrufen
|
||||
final ruleset = await client.getPushRules();
|
||||
|
||||
// Globale Regeln
|
||||
for (final rule in ruleset.global.content ?? []) {
|
||||
print(rule.ruleId);
|
||||
print(rule.enabled);
|
||||
print(rule.actions);
|
||||
}
|
||||
|
||||
// Kategorien der Regeln:
|
||||
// .override — höchste Priorität (z.B. DND-Modus)
|
||||
// .content — Inhalt-basiert (z.B. eigener Name erwähnt)
|
||||
// .room — Raum-spezifisch
|
||||
// .sender — Sender-spezifisch
|
||||
// .underride — niedrigste Priorität (Fallback)
|
||||
|
||||
// Push Rule aktivieren/deaktivieren
|
||||
await client.setPushRuleEnabled(
|
||||
'global',
|
||||
PushRuleKind.override,
|
||||
'.m.rule.master', // DND: stumm schalten
|
||||
false, // false = Regel deaktivieren
|
||||
);
|
||||
|
||||
// Eigene Push Rule erstellen (z.B. Raum stumm schalten)
|
||||
await client.setPushRule(
|
||||
'global',
|
||||
PushRuleKind.room,
|
||||
'!roomId:server', // RuleId = RoomId für Raum-Regeln
|
||||
[PushRuleAction.dontNotify],
|
||||
);
|
||||
|
||||
// Push Rule löschen
|
||||
await client.deletePushRule(
|
||||
'global',
|
||||
PushRuleKind.room,
|
||||
'!roomId:server',
|
||||
);
|
||||
|
||||
// Standard-Rules für Erwähnungen
|
||||
// Pyramid prüft lokal ob ein Event eine Mention enthält:
|
||||
final myId = client.userID!;
|
||||
final mentions = event.content
|
||||
.tryGetMap<String, dynamic>('m.mentions')
|
||||
?.tryGetList<String>('user_ids') ?? [];
|
||||
final isMentioned = mentions.contains(myId) ||
|
||||
event.body.contains(myId);
|
||||
|
||||
// Raum stumm schalten
|
||||
await client.setPushRule(
|
||||
'global',
|
||||
PushRuleKind.override,
|
||||
'!roomId:server',
|
||||
[PushRuleAction.dontNotify],
|
||||
conditions: [
|
||||
PushCondition(kind: 'event_match', key: 'room_id', pattern: '!roomId:server'),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## onNotification Stream
|
||||
|
||||
```dart
|
||||
// Matrix-SDK emittiert Events die Benachrichtigungen auslösen sollen
|
||||
// (nach Push-Rule-Matching, entschlüsselt)
|
||||
client.onNotification.stream.listen((Event event) {
|
||||
final roomId = event.room.id;
|
||||
final room = client.getRoomById(roomId)!;
|
||||
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
||||
|
||||
final title = room.isDirectChat
|
||||
? sender.displayName ?? event.senderId
|
||||
: '${sender.displayName} · ${room.getLocalizedDisplayname()}';
|
||||
final body = event.body;
|
||||
|
||||
// Lokale Benachrichtigung anzeigen
|
||||
showLocalNotification(title, body, roomId);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lokale Benachrichtigungen (Android)
|
||||
|
||||
```dart
|
||||
// Siehe auch: lib/core/notification_service.dart und PushService.kt
|
||||
|
||||
// flutter_local_notifications initialisieren
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
await plugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@drawable/ic_notification'),
|
||||
),
|
||||
onDidReceiveNotificationResponse: _handleNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse: handleBackgroundResponse,
|
||||
);
|
||||
|
||||
// Notification-Kanal erstellen (Android 8+)
|
||||
await plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
'pyramid_messages',
|
||||
'Nachrichten',
|
||||
description: 'Neue Nachrichten',
|
||||
importance: Importance.high,
|
||||
),
|
||||
);
|
||||
|
||||
// Benachrichtigung anzeigen (mit Reply-Action)
|
||||
await plugin.show(
|
||||
notifId,
|
||||
'Absender',
|
||||
'Nachrichtentext',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'pyramid_messages',
|
||||
'Nachrichten',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@drawable/ic_notification',
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'reply', 'Antworten',
|
||||
inputs: [AndroidNotificationActionInput(label: 'Antworten…')],
|
||||
allowGeneratedReplies: true,
|
||||
showsUserInterface: false, // BroadcastReceiver-Pfad
|
||||
),
|
||||
AndroidNotificationAction('read', 'Gelesen',
|
||||
showsUserInterface: false),
|
||||
],
|
||||
),
|
||||
),
|
||||
payload: roomId,
|
||||
);
|
||||
|
||||
// Benachrichtigung schließen
|
||||
await plugin.cancel(notifId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen: Pyramid-Architektur
|
||||
|
||||
```
|
||||
FCM-Payload → PushService.kt (Kotlin, kein Dart-Overhead)
|
||||
│
|
||||
├─ App im Vordergrund? → Heartbeat-Check → Skip
|
||||
│
|
||||
├─ HTTP-Fetch: Absender + Raumname (ohne Entschlüsselung möglich)
|
||||
│
|
||||
├─ Native Notification anzeigen: "Neue Nachricht"
|
||||
│ └─ Reply-Action → getActivity(MainActivity) → onNewIntent
|
||||
│
|
||||
└─ Flutter-Engine im Cache? → MethodChannel "decryptAndUpdateNotification"
|
||||
└─ Dart: decryptAndUpdateNotification() → showNativeNotification() [mit echtem Text]
|
||||
|
||||
Reply-Flow:
|
||||
User tippt Antwort → Reply-PendingIntent (getActivity)
|
||||
→ MainActivity.onNewIntent → MethodChannel "replyFromNotification"
|
||||
→ Dart: room.sendTextEvent(text)
|
||||
→ moveTaskToBack(true)
|
||||
```
|
||||
|
||||
```dart
|
||||
// Heartbeat (App aktiv?) setzen:
|
||||
void _pingHeartbeat() {
|
||||
SharedPreferences.getInstance().then(
|
||||
(p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
}
|
||||
|
||||
// Heartbeat löschen (App im Hintergrund):
|
||||
void setAppForeground(bool value) {
|
||||
if (!value) {
|
||||
SharedPreferences.getInstance()
|
||||
.then((p) => p.setInt('notif_app_heartbeat', 0));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notification Counts
|
||||
|
||||
```dart
|
||||
// Pro Raum
|
||||
print(room.notificationCount); // Ungelesene Nachrichten
|
||||
print(room.highlightCount); // Mentions / Highlights
|
||||
|
||||
// Gesamt
|
||||
final totalUnread = client.rooms.fold<int>(
|
||||
0, (sum, r) => sum + r.notificationCount);
|
||||
final totalHighlights = client.rooms.fold<int>(
|
||||
0, (sum, r) => sum + r.highlightCount);
|
||||
|
||||
// Badge für Windows Taskbar
|
||||
import 'package:windows_taskbar/windows_taskbar.dart';
|
||||
if (totalUnread > 0) {
|
||||
WindowsTaskbar.setProgressMode(TaskbarProgressMode.error);
|
||||
} else {
|
||||
WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,422 @@
|
||||
# 08 — VoIP, Calls & LiveKit (MatrixRTC)
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Architektur-Überblick](#architektur-überblick)
|
||||
- [1:1 Call (Legacy m.call)](#11-call-legacy-mcall)
|
||||
- [MatrixRTC (Gruppen-Calls)](#matrixrtc-gruppen-calls)
|
||||
- [LiveKit-Integration](#livekit-integration)
|
||||
- [Klingel-Flow](#klingel-flow)
|
||||
- [WebRTC Setup in Flutter](#webrtc-setup-in-flutter)
|
||||
- [Call-State Management](#call-state-management)
|
||||
- [Pyramid VoIP-Architektur](#pyramid-voip-architektur)
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Überblick
|
||||
|
||||
```
|
||||
Matrix Signaling (to-device / room events)
|
||||
│
|
||||
▼
|
||||
MatrixRTC Layer
|
||||
┌─────────────────────────────────┐
|
||||
│ com.famedly.call.member Events │
|
||||
│ (Membership, Backend, Expiry) │
|
||||
└──────────────┬──────────────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ │
|
||||
Mesh (P2P) LiveKit (SFU)
|
||||
WebRTC direct livekit-server
|
||||
(1:1, max ~4) (skalierbar, n:m)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1:1 Call (Legacy m.call)
|
||||
|
||||
```dart
|
||||
// ── Anruf starten ────────────────────────────────────────────────────────
|
||||
// Wird vom SDK über room events (m.call.invite, m.call.candidates,
|
||||
// m.call.answer, m.call.hangup) signalisiert.
|
||||
|
||||
// Anruf-Einladung senden (nach WebRTC offer)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': 'eindeutige-call-id',
|
||||
'version': 1,
|
||||
'lifetime': 60000, // ms, wie lange das Invite gültig ist
|
||||
'offer': {
|
||||
'type': 'offer',
|
||||
'sdp': sdpString, // WebRTC SDP Offer
|
||||
},
|
||||
},
|
||||
EventTypes.CallInvite, // 'm.call.invite'
|
||||
);
|
||||
|
||||
// ICE-Kandidaten senden
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': callId,
|
||||
'version': 1,
|
||||
'candidates': [
|
||||
{
|
||||
'candidate': 'candidate:...',
|
||||
'sdpMid': '0',
|
||||
'sdpMLineIndex': 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
EventTypes.CallCandidates, // 'm.call.candidates'
|
||||
);
|
||||
|
||||
// Anruf annehmen (SDP Answer)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': callId,
|
||||
'version': 1,
|
||||
'answer': {
|
||||
'type': 'answer',
|
||||
'sdp': sdpAnswerString,
|
||||
},
|
||||
},
|
||||
EventTypes.CallAnswer, // 'm.call.answer'
|
||||
);
|
||||
|
||||
// Auflegen
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': callId,
|
||||
'version': 1,
|
||||
'reason': 'user_hangup', // oder 'ice_failed', 'invite_timeout'
|
||||
},
|
||||
EventTypes.CallHangup, // 'm.call.hangup'
|
||||
);
|
||||
|
||||
// Ablehnen (Reject)
|
||||
await room.sendEvent(
|
||||
{
|
||||
'call_id': callId,
|
||||
'version': 1,
|
||||
'reason': 'user_hangup',
|
||||
},
|
||||
EventTypes.CallReject, // 'm.call.reject'
|
||||
);
|
||||
|
||||
// Eingehende Anrufe überwachen
|
||||
client.onCallInvite.stream.listen((CallInvite invite) {
|
||||
print('Eingehender Anruf von ${invite.roomId}');
|
||||
print('Call-ID: ${invite.callId}');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MatrixRTC (Gruppen-Calls)
|
||||
|
||||
```dart
|
||||
// MatrixRTC verwendet com.famedly.call.member State-Events
|
||||
|
||||
// ── Call beitreten (Membership senden) ───────────────────────────────────
|
||||
|
||||
final myDeviceId = client.deviceID!;
|
||||
final callId = room.id; // Normalerweise = Raum-ID
|
||||
|
||||
// Membership-Event senden (signalisiert "ich bin im Call")
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'org.matrix.msc3401.call.member', // oder 'com.famedly.call.member'
|
||||
{
|
||||
'application': 'm.call',
|
||||
'call_id': callId,
|
||||
'device_id': myDeviceId,
|
||||
'expires_ts': DateTime.now()
|
||||
.add(const Duration(hours: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
'backend': {
|
||||
'type': 'm.sfu', // LiveKit
|
||||
'livekit_service_url': 'https://livekit.example.org',
|
||||
},
|
||||
// Für Mesh:
|
||||
// 'backend': {'type': 'm.mesh'},
|
||||
'membershipID': 'eindeutige-membership-id',
|
||||
},
|
||||
stateKey: '$myDeviceId:$callId',
|
||||
);
|
||||
|
||||
// ── Call verlassen (Membership entfernen) ─────────────────────────────────
|
||||
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'org.matrix.msc3401.call.member',
|
||||
{}, // Leeres Content = Austritt
|
||||
stateKey: '$myDeviceId:$callId',
|
||||
);
|
||||
|
||||
// ── Aktive Call-Mitglieder auflesen ──────────────────────────────────────
|
||||
|
||||
final callStates = room.states['org.matrix.msc3401.call.member'];
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final activeMembers = callStates?.values.where((event) {
|
||||
final expiresTs = event.content['expires_ts'] as int? ?? 0;
|
||||
return expiresTs > now; // Nicht abgelaufen
|
||||
}).toList() ?? [];
|
||||
|
||||
for (final member in activeMembers) {
|
||||
print(member.content['device_id']);
|
||||
print(member.stateKey); // 'deviceId:callId'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LiveKit-Integration
|
||||
|
||||
```dart
|
||||
// LiveKit-Token vom Matrix-Homeserver anfragen (OpenID)
|
||||
// Dieser Flow authentifiziert den User beim LiveKit-Server ohne eigenes Passwort.
|
||||
|
||||
// 1. OpenID-Token vom Homeserver holen
|
||||
final openIdToken = await client.requestOpenIdToken(client.userID!);
|
||||
// openIdToken.accessToken, .matrixServerName
|
||||
|
||||
// 2. LiveKit-Token beim MatrixRTC-Service tauschen
|
||||
// (Pyramid sendet openIdToken an livekit_service_url des Raums)
|
||||
final response = await http.post(
|
||||
Uri.parse('https://livekit-service.example.org/sfu/get'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'room': room.id,
|
||||
'openid_token': {
|
||||
'access_token': openIdToken.accessToken,
|
||||
'token_type': 'Bearer',
|
||||
'matrix_server_name': openIdToken.matrixServerName,
|
||||
},
|
||||
'device_id': client.deviceID,
|
||||
}),
|
||||
);
|
||||
final livekitToken = jsonDecode(response.body)['token'];
|
||||
|
||||
// 3. Mit LiveKit verbinden
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
final room = await LiveKitClient.connect(
|
||||
'wss://livekit.example.org',
|
||||
livekitToken,
|
||||
roomOptions: const RoomOptions(
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
defaultVideoPublishOptions: VideoPublishOptions(
|
||||
simulcast: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 4. Mikrofon aktivieren
|
||||
await room.localParticipant?.setMicrophoneEnabled(true);
|
||||
|
||||
// 5. Kamera aktivieren (Video-Call)
|
||||
await room.localParticipant?.setCameraEnabled(true);
|
||||
|
||||
// 6. Teilnehmer observieren
|
||||
room.addListener(() {
|
||||
for (final participant in room.participants.values) {
|
||||
print(participant.identity); // Matrix User-ID
|
||||
for (final track in participant.videoTracks) {
|
||||
// Widget: VideoTrackRenderer(track.track!)
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Klingel-Flow
|
||||
|
||||
```dart
|
||||
// ── Anruf initiieren ──────────────────────────────────────────────────────
|
||||
|
||||
// 1. Membership-Event senden (MatrixRTC)
|
||||
// 2. To-Device-Event senden für Push-Benachrichtigung beim Empfänger
|
||||
|
||||
// Klingelanruf via to-device-Event
|
||||
await client.sendToDevice(
|
||||
'com.famedly.call', // Event-Typ
|
||||
{
|
||||
otherUserId: {
|
||||
'*': { // Alle Geräte des Users
|
||||
'call_id': callId,
|
||||
'room_id': room.id,
|
||||
'type': 'invite',
|
||||
'sender_session_id': client.deviceID,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ── Eingehenden Anruf empfangen ───────────────────────────────────────────
|
||||
|
||||
client.onToDeviceEvent.stream
|
||||
.where((e) => e.type == 'com.famedly.call')
|
||||
.listen((event) {
|
||||
final callId = event.content['call_id'];
|
||||
final roomId = event.content['room_id'];
|
||||
final type = event.content['type']; // 'invite' | 'hangup'
|
||||
// Klingelton abspielen, UI anzeigen
|
||||
});
|
||||
|
||||
// ── Anruf ablehnen (während Klingeln) ────────────────────────────────────
|
||||
|
||||
await client.sendToDevice(
|
||||
'com.famedly.call',
|
||||
{
|
||||
callerId: {'*': {'call_id': callId, 'type': 'reject'}},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebRTC Setup in Flutter
|
||||
|
||||
```dart
|
||||
// flutter_webrtc Package
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
// Media-Stream holen
|
||||
final stream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': false, // true für Video-Call
|
||||
});
|
||||
|
||||
// PeerConnection erstellen
|
||||
final pc = await createPeerConnection({
|
||||
'iceServers': [
|
||||
{'urls': 'stun:stun.matrix.org:3478'},
|
||||
// TURN-Server (für NAT-Traversal):
|
||||
{
|
||||
'urls': 'turn:turn.example.org:5349',
|
||||
'username': 'user',
|
||||
'credential': 'pass',
|
||||
},
|
||||
],
|
||||
'iceCandidatePoolSize': 10,
|
||||
});
|
||||
|
||||
// Track hinzufügen
|
||||
for (final track in stream.getTracks()) {
|
||||
await pc.addTrack(track, stream);
|
||||
}
|
||||
|
||||
// ICE-Kandidaten sammeln
|
||||
pc.onIceCandidate = (RTCIceCandidate candidate) {
|
||||
// Kandidaten via Matrix senden
|
||||
sendIceCandidates([candidate]);
|
||||
};
|
||||
|
||||
// Angebot erstellen
|
||||
final offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
// offer.sdp → via Matrix senden
|
||||
|
||||
// Angebot annehmen
|
||||
await pc.setRemoteDescription(RTCSessionDescription(sdpOffer, 'offer'));
|
||||
final answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
// answer.sdp → via Matrix senden
|
||||
|
||||
// Eingehende Streams
|
||||
pc.onTrack = (RTCTrackEvent event) {
|
||||
final remoteStream = event.streams.first;
|
||||
// Widget: RTCVideoRenderer() mit remoteStream
|
||||
};
|
||||
|
||||
// Aufräumen
|
||||
await pc.close();
|
||||
await stream.dispose();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Call-State Management
|
||||
|
||||
```dart
|
||||
// Call-Zustand in Pyramid
|
||||
enum CallState {
|
||||
idle,
|
||||
calling, // Anruf geht raus
|
||||
ringing, // Eingehender Anruf
|
||||
connecting, // ICE/SDP in Bearbeitung
|
||||
connected, // Verbunden
|
||||
ended,
|
||||
}
|
||||
|
||||
// Stummschalten
|
||||
final audioTrack = localStream.getAudioTracks().first;
|
||||
audioTrack.enabled = false; // Mute
|
||||
audioTrack.enabled = true; // Unmute
|
||||
|
||||
// Lautsprecher vs. Hörer (Android/iOS)
|
||||
// flutter_webrtc kümmert sich darum via Helper.setSpeakerphoneOn
|
||||
|
||||
// DTMF (Zifferntasten)
|
||||
final sender = pc.getSenders().firstWhere(
|
||||
(s) => s.track?.kind == 'audio');
|
||||
await sender.dtmf?.insertDTMF('1');
|
||||
|
||||
// Screen-Sharing starten
|
||||
final screenStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
'video': true,
|
||||
'audio': false,
|
||||
});
|
||||
final screenTrack = screenStream.getVideoTracks().first;
|
||||
final videoSender = pc.getSenders()
|
||||
.firstWhere((s) => s.track?.kind == 'video');
|
||||
await videoSender.replaceTrack(screenTrack);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pyramid VoIP-Architektur
|
||||
|
||||
```
|
||||
Pyramid Call-Stack:
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Dart (Flutter) │
|
||||
│ lib/features/call/ │
|
||||
│ call_notifier.dart — Zustand, Aktionen │
|
||||
│ call_page.dart — UI (Kamera/Mic Controls) │
|
||||
│ livekit_service.dart — LiveKit-Token-Tausch │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Matrix SDK │
|
||||
│ room.sendState(com.famedly.call.member) │
|
||||
│ client.sendToDevice(com.famedly.call) │
|
||||
│ client.requestOpenIdToken() │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ LiveKit Client (livekit_client package) │
|
||||
│ LiveKitClient.connect(url, token) │
|
||||
│ room.localParticipant.setMicrophoneEnabled() │
|
||||
│ VideoTrackRenderer Widget │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Native (Android/iOS) │
|
||||
│ FlutterForegroundService — hält Call am Leben │
|
||||
│ Klingelton via AudioManager │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
TURN/STUN-Server Konfiguration (from Matrix):
|
||||
await client.getTurnServer() →
|
||||
TurnServerCredentials(
|
||||
username, password, ttl,
|
||||
uris: ['turn:turn.example.org:5349?transport=udp']
|
||||
)
|
||||
```
|
||||
|
||||
```dart
|
||||
// TURN-Credentials vom Homeserver holen
|
||||
final turn = await client.getTurnServer();
|
||||
final iceServers = [
|
||||
{'urls': turn.uris, 'username': turn.username, 'credential': turn.password},
|
||||
{'urls': 'stun:stun.matrix.org:3478'},
|
||||
];
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
# 09 — Profil, Präsenz & Account-Daten
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Eigenes Profil lesen](#eigenes-profil-lesen)
|
||||
- [Profil aktualisieren](#profil-aktualisieren)
|
||||
- [Profil anderer User](#profil-anderer-user)
|
||||
- [Präsenz (Presence)](#präsenz-presence)
|
||||
- [Account-Daten (Account Data)](#account-daten-account-data)
|
||||
- [Ignorierte User](#ignorierte-user)
|
||||
- [Tags / Favoriten](#tags--favoriten)
|
||||
- [Account löschen / deaktivieren](#account-löschen--deaktivieren)
|
||||
|
||||
---
|
||||
|
||||
## Eigenes Profil lesen
|
||||
|
||||
```dart
|
||||
// Aus dem Client (gecacht)
|
||||
print(client.userID); // '@alice:server'
|
||||
print(client.displayName); // 'Alice'
|
||||
print(client.avatarUrl); // mxc://...
|
||||
|
||||
// Frisch vom Server
|
||||
final profile = await client.fetchOwnProfile();
|
||||
print(profile.displayname);
|
||||
print(profile.avatarUrl);
|
||||
|
||||
// Avatar als HTTP-URL
|
||||
final avatarHttp = client.avatarUrl?.getThumbnail(
|
||||
client,
|
||||
width: 128,
|
||||
height: 128,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profil aktualisieren
|
||||
|
||||
```dart
|
||||
// Displayname setzen
|
||||
await client.setDisplayName(client.userID!, 'Neuer Name');
|
||||
|
||||
// Avatar hochladen und setzen
|
||||
final avatarBytes = await File('avatar.png').readAsBytes();
|
||||
final mxcUri = await client.uploadContent(
|
||||
avatarBytes,
|
||||
filename: 'avatar.png',
|
||||
contentType: 'image/png',
|
||||
);
|
||||
await client.setAvatarUrl(client.userID!, mxcUri);
|
||||
|
||||
// Avatar entfernen
|
||||
await client.setAvatarUrl(client.userID!, null);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profil anderer User
|
||||
|
||||
```dart
|
||||
// Profil via API
|
||||
final profile = await client.getProfileFromUserId('@bob:server');
|
||||
print(profile.displayname);
|
||||
print(profile.avatarUrl);
|
||||
|
||||
// Spezifisches Feld (Matrix 1.16+ API)
|
||||
final displayname = await client.getProfileField(
|
||||
'@bob:server',
|
||||
'displayname',
|
||||
);
|
||||
final avatar = await client.getProfileField(
|
||||
'@bob:server',
|
||||
'avatar_url',
|
||||
);
|
||||
|
||||
// User in einem Raum (schneller, gecacht)
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback('@bob:server');
|
||||
print(user.displayName);
|
||||
print(user.avatarUrl);
|
||||
print(user.id);
|
||||
|
||||
// User-Suche
|
||||
final results = await client.searchUserDirectory(
|
||||
'alice',
|
||||
limit: 20,
|
||||
);
|
||||
for (final user in results.results) {
|
||||
print(user.userId);
|
||||
print(user.displayName);
|
||||
print(user.avatarUrl);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Präsenz (Presence)
|
||||
|
||||
```dart
|
||||
// Eigene Präsenz setzen
|
||||
await client.setPresence(
|
||||
client.userID!,
|
||||
PresenceType.online, // .online | .unavailable | .offline
|
||||
statusMsg: 'Im Meeting', // Optionaler Status-Text
|
||||
);
|
||||
|
||||
// Präsenz anderer User abrufen
|
||||
final presence = await client.fetchCurrentPresence('@bob:server');
|
||||
print(presence.presence); // PresenceType
|
||||
print(presence.currentlyActive); // bool?
|
||||
print(presence.statusMsg); // String?
|
||||
print(presence.lastActiveAgo); // int? (Millisekunden seit letzter Aktivität)
|
||||
|
||||
// Präsenz aus Raum-Mitglieder
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback('@bob:server');
|
||||
// Präsenz wird nicht direkt im User gecacht — fetchCurrentPresence nötig
|
||||
|
||||
// Präsenz-Anzeige im UI
|
||||
String getPresenceText(PresenceType? type, int? lastActiveAgo) {
|
||||
switch (type) {
|
||||
case PresenceType.online: return 'Online';
|
||||
case PresenceType.unavailable:
|
||||
return 'Abwesend';
|
||||
case PresenceType.offline:
|
||||
if (lastActiveAgo != null) {
|
||||
final mins = lastActiveAgo ~/ 60000;
|
||||
if (mins < 60) return 'Vor $mins Min. aktiv';
|
||||
final hours = mins ~/ 60;
|
||||
if (hours < 24) return 'Vor $hours Std. aktiv';
|
||||
return 'Vor ${hours ~/ 24} Tagen aktiv';
|
||||
}
|
||||
return 'Offline';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Account-Daten (Account Data)
|
||||
|
||||
```dart
|
||||
// Account-Daten sind private, servergespeicherte Einstellungen.
|
||||
// Bleiben über Geräte hinweg synchron.
|
||||
|
||||
// Lesen
|
||||
final data = client.accountData['com.example.my_setting']?.content;
|
||||
|
||||
// Schreiben
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
'com.example.my_setting',
|
||||
{'key': 'value', 'enabled': true},
|
||||
);
|
||||
|
||||
// Wichtige Standard-Account-Daten:
|
||||
// 'm.direct' → DM-Raumliste
|
||||
// 'm.push_rules' → Push-Regel-Overrides
|
||||
// 'm.ignored_user_list' → Ignorierte User
|
||||
// 'm.identity_server' → Identity-Server-URL
|
||||
// 'im.vector.setting.breadcrumbs' → FluffyChat letzte Räume
|
||||
|
||||
// Raumspezifische Account-Daten
|
||||
await client.setRoomAccountData(
|
||||
client.userID!,
|
||||
'!roomId:server',
|
||||
'm.fully_read',
|
||||
{'event_id': eventId},
|
||||
);
|
||||
|
||||
// Tag für Raum setzen (Favorit, Low-Priority)
|
||||
await client.setRoomTag(
|
||||
client.userID!,
|
||||
'!roomId:server',
|
||||
'm.favourite',
|
||||
TagContent(order: 0.5),
|
||||
);
|
||||
await client.setRoomTag(client.userID!, '!roomId:server', 'm.lowpriority', TagContent());
|
||||
await client.deleteRoomTag(client.userID!, '!roomId:server', 'm.favourite');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ignorierte User
|
||||
|
||||
```dart
|
||||
// User ignorieren
|
||||
final ignoredList = client.ignoredUsers; // List<String>
|
||||
final updated = [...ignoredList, '@spammer:server'];
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
'm.ignored_user_list',
|
||||
{'ignored_users': Map.fromIterable(updated, value: (_) => {})},
|
||||
);
|
||||
|
||||
// User entblockieren
|
||||
final filtered = ignoredList.where((id) => id != '@spammer:server').toList();
|
||||
await client.setAccountData(
|
||||
client.userID!,
|
||||
'm.ignored_user_list',
|
||||
{'ignored_users': Map.fromIterable(filtered, value: (_) => {})},
|
||||
);
|
||||
|
||||
// Ignorierte User abrufen
|
||||
final ignored = client.ignoredUsers; // automatisch aus Account-Data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tags / Favoriten
|
||||
|
||||
```dart
|
||||
// Raum als Favorit markieren
|
||||
await room.addTag('m.favourite', order: 0.1);
|
||||
|
||||
// Raum als Low-Priority markieren
|
||||
await room.addTag('m.lowpriority');
|
||||
|
||||
// Alle Tags eines Raums
|
||||
final tags = room.tags; // Map<String, Tag>
|
||||
final isFavourite = tags.containsKey('m.favourite');
|
||||
final isLowPriority = tags.containsKey('m.lowpriority');
|
||||
|
||||
// Tag entfernen
|
||||
await room.removeTag('m.favourite');
|
||||
|
||||
// Eigene Tags
|
||||
await room.addTag('com.example.myapp.tag', order: 0.5);
|
||||
|
||||
// Räume nach Tag filtern
|
||||
final favourites = client.rooms
|
||||
.where((r) => r.tags.containsKey('m.favourite'))
|
||||
.toList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Account löschen / deaktivieren
|
||||
|
||||
```dart
|
||||
// Account deaktivieren (PERMANENT, nicht rückgängig!)
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.deactivateAccount(
|
||||
auth: auth,
|
||||
eraseData: true, // true: alle Nachrichten löschen (soweit möglich)
|
||||
),
|
||||
);
|
||||
|
||||
// Passwort ändern (braucht Re-Auth via UIAA)
|
||||
await client.uiaRequestBackground(
|
||||
(auth) => client.changePassword(
|
||||
'neuesPasswort',
|
||||
auth: auth,
|
||||
oldPassword: 'altesPasswort',
|
||||
),
|
||||
);
|
||||
|
||||
// E-Mail-Adresse hinzufügen (über Identity-Server)
|
||||
// Dies ist ein mehrstufiger UIAA-Flow und erfordert E-Mail-Bestätigung.
|
||||
// Normalerweise über den Homeserver-spezifischen Registration-Flow.
|
||||
```
|
||||
@@ -0,0 +1,410 @@
|
||||
# 10 — Raum-Administration & Moderation
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Power Levels vollständig](#power-levels-vollständig)
|
||||
- [Moderation: Kick, Ban, Unban](#moderation-kick-ban-unban)
|
||||
- [Server-Side Room Admin](#server-side-room-admin)
|
||||
- [Raum-Einstellungen komplett](#raum-einstellungen-komplett)
|
||||
- [Raum-Alias verwalten](#raum-alias-verwalten)
|
||||
- [Server ACL (IP-Sperren)](#server-acl-ip-sperren)
|
||||
- [Raum-Audit / State inspizieren](#raum-audit--state-inspizieren)
|
||||
- [Widgets / Bots verwalten](#widgets--bots-verwalten)
|
||||
- [Room Tombstone / Upgrade](#room-tombstone--upgrade)
|
||||
- [Reporting (Melden)](#reporting-melden)
|
||||
|
||||
---
|
||||
|
||||
## Power Levels vollständig
|
||||
|
||||
```dart
|
||||
// Power Level eines Users ändern
|
||||
await room.setPower('@alice:server', 50); // 0=User, 50=Mod, 100=Admin
|
||||
|
||||
// Alle Power Levels auf einmal setzen
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomPowerLevels,
|
||||
{
|
||||
// Welche Power-Level für Standard-Aktionen benötigt werden:
|
||||
'ban': 50,
|
||||
'kick': 50,
|
||||
'invite': 0,
|
||||
'redact': 50,
|
||||
'events_default': 0, // Nachrichten senden
|
||||
'state_default': 50, // State-Events ändern
|
||||
|
||||
// Spezifische Event-Typen:
|
||||
'events': {
|
||||
'm.room.name': 50,
|
||||
'm.room.topic': 50,
|
||||
'm.room.avatar': 50,
|
||||
'm.room.power_levels': 100,
|
||||
'm.room.canonical_alias': 50,
|
||||
'm.room.history_visibility': 100,
|
||||
'm.room.encryption': 100,
|
||||
'm.space.child': 50,
|
||||
},
|
||||
|
||||
// User-Overrides:
|
||||
'users': {
|
||||
'@admin:server': 100,
|
||||
'@moderator:server': 50,
|
||||
},
|
||||
'users_default': 0, // Alle anderen User
|
||||
'notifications': {
|
||||
'room': 50, // @room mention power level
|
||||
},
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Power Level lesen
|
||||
final plContent = room.getState(EventTypes.RoomPowerLevels)?.content;
|
||||
final usersMap = plContent?.tryGetMap<String, dynamic>('users') ?? {};
|
||||
final adminPl = usersMap['@alice:server'] as int? ?? 0;
|
||||
|
||||
// Berechtigungen prüfen
|
||||
room.ownPowerLevel; // int: eigener PL
|
||||
room.getPowerLevelByUserId('@alice:server'); // int
|
||||
room.canBan; // bool
|
||||
room.canKick; // bool
|
||||
room.canInvite; // bool
|
||||
room.canRedactEvent(event); // bool
|
||||
room.canSendEvent(EventTypes.Message); // bool
|
||||
room.canSendEvent(EventTypes.RoomName); // bool
|
||||
room.canChangeStateEvent(EventTypes.RoomTopic); // bool
|
||||
room.canChangeNameAndTopic; // bool
|
||||
room.canChangeJoinRules; // bool
|
||||
room.canChangeHistoryVisibility; // bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Moderation: Kick, Ban, Unban
|
||||
|
||||
```dart
|
||||
// Kick (User wird entfernt, kann wieder beitreten)
|
||||
await room.kick('@alice:server', reason: 'Verstoß gegen Regeln');
|
||||
|
||||
// Ban (User kann nicht mehr beitreten)
|
||||
await room.ban('@alice:server', reason: 'Dauerhafter Ausschluss');
|
||||
|
||||
// Unban
|
||||
await room.unban('@alice:server');
|
||||
|
||||
// Alle gebannten User anzeigen
|
||||
final bannedMembers = room.getParticipants(
|
||||
membershipFilter: [Membership.ban],
|
||||
);
|
||||
|
||||
// Eigene Berechtigungen prüfen vor Moderation-Aktionen
|
||||
if (room.canKick) {
|
||||
await room.kick(userId);
|
||||
}
|
||||
if (room.canBan) {
|
||||
await room.ban(userId);
|
||||
}
|
||||
|
||||
// User-PL prüfen (kann man diesen User kicken/bannen?)
|
||||
// Man kann nur User mit niedrigerem PL als dem eigenen moderieren.
|
||||
final theirPl = room.getPowerLevelByUserId(userId);
|
||||
final myPl = room.ownPowerLevel;
|
||||
final canModerate = myPl > theirPl;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Room Admin
|
||||
|
||||
> ⚠️ **Pyramid läuft auf Continuwuity** — Synapse Admin API (`/_synapse/admin/`) **existiert nicht!**
|
||||
> Für vollständige Continuwuity-Admin-Dokumentation: **[→ 13-server-admin-continuwuity.md](13-server-admin-continuwuity.md)**
|
||||
|
||||
```dart
|
||||
// Server-Admin-Status prüfen (Matrix-Standard, funktioniert überall)
|
||||
final isAdmin = await client.getIsAdmin();
|
||||
|
||||
// Für Continuwuity: Admin-Aktionen über Standard Matrix Client API
|
||||
// oder über den Admin-Room (#admins:steggi-matrix.work)
|
||||
|
||||
// Power Level in einem Raum als Admin setzen:
|
||||
await client.setRoomStateWithKey(
|
||||
room.id,
|
||||
EventTypes.RoomPowerLevels,
|
||||
'',
|
||||
{
|
||||
'users': {'@admin:server': 100},
|
||||
'users_default': 0,
|
||||
'state_default': 50,
|
||||
'events_default': 0,
|
||||
'ban': 50, 'kick': 50, 'invite': 0, 'redact': 50,
|
||||
},
|
||||
);
|
||||
|
||||
// Raum beitreten (Admin kann invite-only-Räume per API beitreten):
|
||||
await client.joinRoom(room.id);
|
||||
|
||||
// Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
||||
await client.setRoomStateWithKey(
|
||||
spaceId,
|
||||
EventTypes.SpaceChild,
|
||||
channelRoomId,
|
||||
{'via': [client.homeserver.host], 'suggested': false},
|
||||
);
|
||||
```
|
||||
|
||||
```bash
|
||||
# Continuwuity Admin HTTP API
|
||||
# Basis: https://server/_conduwuit/admin/v1/
|
||||
|
||||
# User zu Server-Admin machen:
|
||||
curl -X POST "https://server/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@admin:server"}'
|
||||
|
||||
# Power Level in Raum setzen (Standard Matrix API, mit Admin-Token):
|
||||
curl -X PUT "https://server/_matrix/client/v3/rooms/!roomId:server/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"users": {"@admin:server": 100}, "users_default": 0, "state_default": 50}'
|
||||
|
||||
# Für Synapse (falls Server-Wechsel zu Synapse):
|
||||
# curl .../server/_synapse/admin/v1/rooms/{roomId} ← dann wieder relevant
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Einstellungen komplett
|
||||
|
||||
```dart
|
||||
// Name
|
||||
await room.setName('Neuer Raumname');
|
||||
|
||||
// Thema
|
||||
await room.setDescription('Neues Thema');
|
||||
|
||||
// Avatar
|
||||
final mxcUri = await client.uploadContent(imageBytes, filename: 'avatar.png');
|
||||
await room.setAvatar(mxcUri);
|
||||
|
||||
// Join-Rules
|
||||
await room.setJoinRules(JoinRules.invite); // Privat
|
||||
await room.setJoinRules(JoinRules.public); // Öffentlich
|
||||
await room.setJoinRules(JoinRules.knock); // Beitrittsanfragen
|
||||
|
||||
// Restricted (Nur Space-Mitglieder können beitreten)
|
||||
await room.setJoinRules(
|
||||
JoinRules.restricted,
|
||||
allow: [
|
||||
SpaceRoomJoinRulesAllowInner(
|
||||
roomId: '!spaceId:server',
|
||||
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// History-Visibility
|
||||
await room.setHistoryVisibility(HistoryVisibility.shared);
|
||||
// .shared = alle sehen vergangene Nachrichten (nach Join)
|
||||
// .invited = sieht Nachrichten ab dem Einladungs-Zeitpunkt
|
||||
// .joined = sieht Nachrichten nur ab dem Beitritts-Zeitpunkt
|
||||
// .worldReadable = öffentlich lesbar (auch ohne Account)
|
||||
|
||||
// Guest Access
|
||||
await room.setGuestAccess(GuestAccess.canJoin); // Gäste erlaubt
|
||||
await room.setGuestAccess(GuestAccess.forbidden); // Keine Gäste
|
||||
|
||||
// E2EE aktivieren (einmalig, nicht deaktivierbar!)
|
||||
await room.enableEncryption();
|
||||
|
||||
// Notifications für @room: Welcher PL nötig?
|
||||
// → über Power Levels 'notifications.room' setzen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Alias verwalten
|
||||
|
||||
```dart
|
||||
// Alias erstellen
|
||||
await client.setRoomAlias('#neuer-alias:server', room.id);
|
||||
|
||||
// Kanonischen Alias setzen (sichtbarer Hauptalias)
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
EventTypes.RoomCanonicalAlias,
|
||||
{
|
||||
'alias': '#hauptalias:server',
|
||||
'alt_aliases': ['#alternativ:server'],
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// Alias auflösen → Raum-ID
|
||||
final resolved = await client.getRoomIdByAlias('#alias:server');
|
||||
print(resolved.roomId); // !roomId:server
|
||||
|
||||
// Alias löschen
|
||||
await client.deleteRoomAlias('#alter-alias:server');
|
||||
|
||||
// Alle Aliases eines Raums
|
||||
final aliases = (room.getState(EventTypes.RoomCanonicalAlias)
|
||||
?.content['alt_aliases'] as List?)
|
||||
?.cast<String>() ?? [];
|
||||
final canonical = room.canonicalAlias;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server ACL (IP-Sperren)
|
||||
|
||||
```dart
|
||||
// Server ACL: Welche Server dürfen am Raum teilnehmen?
|
||||
// Wichtig für öffentliche Räume um Spam-Server zu sperren.
|
||||
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.room.server_acl',
|
||||
{
|
||||
'allow': ['*'], // Alle erlauben
|
||||
'deny': ['spam.example.org', '*.badserver.org'],
|
||||
'allow_ip_literals': false, // IP-Adressen als Server-Namen verboten
|
||||
},
|
||||
stateKey: '',
|
||||
);
|
||||
|
||||
// ACL lesen
|
||||
final acl = room.getState('m.room.server_acl')?.content;
|
||||
final denied = (acl?['deny'] as List?)?.cast<String>() ?? [];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Audit / State inspizieren
|
||||
|
||||
```dart
|
||||
// Alle State-Events lesen
|
||||
final states = room.states;
|
||||
// Map<EventType, Map<StateKey, StrippedStateEvent>>
|
||||
|
||||
// Spezifischer State
|
||||
final nameEvent = room.getState(EventTypes.RoomName);
|
||||
final topicEvent = room.getState(EventTypes.RoomTopic);
|
||||
final memberEvent = room.getState(EventTypes.RoomMember, '@alice:server');
|
||||
final encEvent = room.getState(EventTypes.Encryption);
|
||||
|
||||
// Alle Mitglieder-States
|
||||
final memberStates = states[EventTypes.RoomMember] ?? {};
|
||||
for (final entry in memberStates.entries) {
|
||||
final mxid = entry.key; // stateKey = MXID
|
||||
final membership = entry.value.content['membership'];
|
||||
print('$mxid: $membership');
|
||||
}
|
||||
|
||||
// Raum-History vom Server laden (Admin)
|
||||
final response = await client.request(
|
||||
RequestType.GET,
|
||||
'/rooms/${room.id}/state',
|
||||
);
|
||||
|
||||
// Event-Timeline abrufen (Admin)
|
||||
final timeline = await client.request(
|
||||
RequestType.GET,
|
||||
'/rooms/${room.id}/messages',
|
||||
query: {'limit': '100', 'dir': 'b'},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Widgets / Bots verwalten
|
||||
|
||||
```dart
|
||||
// Widget hinzufügen (z.B. Jitsi, Etherpad)
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.widget',
|
||||
{
|
||||
'type': 'jitsi',
|
||||
'url': 'https://jitsi.example.org/#!roomId',
|
||||
'name': 'Jitsi Call',
|
||||
'data': {'domain': 'jitsi.example.org'},
|
||||
'waitForIframeLoad': true,
|
||||
'creatorUserId': client.userID,
|
||||
'id': 'jitsi-widget',
|
||||
},
|
||||
stateKey: 'jitsi-widget',
|
||||
);
|
||||
|
||||
// Widget entfernen
|
||||
await room.client.sendState(
|
||||
room.id,
|
||||
'm.widget',
|
||||
{}, // Leeres Content = entfernen
|
||||
stateKey: 'jitsi-widget',
|
||||
);
|
||||
|
||||
// Alle Widgets lesen
|
||||
final widgets = room.states['m.widget'] ?? {};
|
||||
for (final widget in widgets.values) {
|
||||
print(widget.content['name']);
|
||||
print(widget.content['url']);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Room Tombstone / Upgrade
|
||||
|
||||
```dart
|
||||
// Raum-Upgrade (Nachfolger-Raum erstellen)
|
||||
final newRoomId = await client.upgradeRoom(
|
||||
room.id,
|
||||
newVersion: '11', // Aktuelle empfohlene Version
|
||||
);
|
||||
// SDK sendet m.room.tombstone in alten Raum
|
||||
// Und m.room.create mit predecessor in neuen Raum
|
||||
// Alle Mitglieder werden in neuen Raum eingeladen
|
||||
|
||||
// Tombstone lesen
|
||||
final tombstone = room.getState(EventTypes.RoomTombstone);
|
||||
if (tombstone != null) {
|
||||
final replacementRoomId = tombstone.content['replacement_room'] as String;
|
||||
final serverMessage = tombstone.content['body'] as String;
|
||||
// → Zum neuen Raum navigieren
|
||||
}
|
||||
|
||||
// Predecessor lesen (im neuen Raum)
|
||||
final create = room.getState(EventTypes.RoomCreate);
|
||||
final predecessor = create?.content['predecessor'] as Map?;
|
||||
if (predecessor != null) {
|
||||
final oldRoomId = predecessor['room_id'] as String;
|
||||
final lastEventId = predecessor['event_id'] as String;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reporting (Melden)
|
||||
|
||||
```dart
|
||||
// Event melden (z.B. Spam, illegaler Inhalt)
|
||||
await client.reportContent(
|
||||
room.id,
|
||||
event.eventId,
|
||||
reason: 'Spam/Belästigung',
|
||||
score: -100, // -100 = sehr schlimm, 0 = nicht schlimm
|
||||
);
|
||||
|
||||
// User melden (via MSC4151, wenn vom Server unterstützt)
|
||||
await client.httpClient.post(
|
||||
Uri.parse('${client.homeserver}/_matrix/client/v3/report/${Uri.encodeComponent("@user:server")}'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer ${client.accessToken}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({'reason': 'Belästigung'}),
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,238 @@
|
||||
# 11 — Suche & Discovery
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Nachrichten in einem Raum suchen](#nachrichten-in-einem-raum-suchen)
|
||||
- [Globale Nachrichten-Suche](#globale-nachrichten-suche)
|
||||
- [User-Suche](#user-suche)
|
||||
- [Öffentliches Raumverzeichnis](#öffentliches-raumverzeichnis)
|
||||
- [Matrix-URI auflösen](#matrix-uri-auflösen)
|
||||
- [Raum per Alias finden](#raum-per-alias-finden)
|
||||
|
||||
---
|
||||
|
||||
## Nachrichten in einem Raum suchen
|
||||
|
||||
```dart
|
||||
// In der lokalen Timeline suchen (gecacht)
|
||||
final found = timeline.events
|
||||
.where((e) =>
|
||||
e.type == EventTypes.Message &&
|
||||
e.body.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
// Auf dem Server suchen (Matrix-Suchendpoint)
|
||||
final result = await client.searchEvents(
|
||||
Categories(
|
||||
roomEvents: RoomEventsCriteria(
|
||||
searchTerm: 'suchbegriff',
|
||||
roomId: room.id, // optional: nur in einem Raum
|
||||
orderBy: SearchOrder.recent, // oder .rank
|
||||
keys: [SearchKey.contentBody], // oder .contentName, .contentTopic
|
||||
filter: RoomEventFilter(
|
||||
limit: 20,
|
||||
rooms: [room.id],
|
||||
notRooms: [],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Ergebnisse
|
||||
for (final result in result.roomEvents?.results ?? []) {
|
||||
print(result.result.body);
|
||||
print(result.result.originServerTs);
|
||||
print(result.result.senderId);
|
||||
}
|
||||
|
||||
// Paginierung
|
||||
final nextToken = result.roomEvents?.nextBatch;
|
||||
if (nextToken != null) {
|
||||
final more = await client.searchEvents(
|
||||
/* gleiche Criteria */,
|
||||
nextBatch: nextToken,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Globale Nachrichten-Suche
|
||||
|
||||
```dart
|
||||
// Über alle Räume suchen
|
||||
final result = await client.searchEvents(
|
||||
Categories(
|
||||
roomEvents: RoomEventsCriteria(
|
||||
searchTerm: 'flutter matrix',
|
||||
orderBy: SearchOrder.recent,
|
||||
includeState: false,
|
||||
groupings: Groupings(
|
||||
groupBy: [Group(key: GroupKey.roomId)], // Gruppiert nach Raum
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Gruppen-Ergebnisse
|
||||
for (final entry in result.roomEvents?.groups?.entries ?? []) {
|
||||
final groupKey = entry.key; // 'room_id'
|
||||
for (final roomGroup in entry.value.entries) {
|
||||
final roomId = roomGroup.key;
|
||||
print('Treffer in $roomId: ${roomGroup.value.count}');
|
||||
// roomGroup.value.results: List<String> (Event-IDs)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User-Suche
|
||||
|
||||
```dart
|
||||
// User-Verzeichnis durchsuchen
|
||||
final response = await client.searchUserDirectory(
|
||||
'alice', // Suchbegriff (Name oder MXID)
|
||||
limit: 20,
|
||||
);
|
||||
|
||||
for (final user in response.results) {
|
||||
print(user.userId); // @alice:server
|
||||
print(user.displayName); // Alice
|
||||
print(user.avatarUrl); // mxc://...
|
||||
|
||||
// Avatar als HTTP-URL
|
||||
final avatarHttp = user.avatarUrl?.getThumbnail(
|
||||
client,
|
||||
width: 56,
|
||||
height: 56,
|
||||
method: ThumbnailMethod.crop,
|
||||
);
|
||||
}
|
||||
|
||||
print(response.limited); // true wenn weitere Ergebnisse vorhanden
|
||||
|
||||
// MXID direkt laden (ohne Suche)
|
||||
final profile = await client.getProfileFromUserId('@alice:server');
|
||||
print(profile.displayname);
|
||||
print(profile.avatarUrl);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Öffentliches Raumverzeichnis
|
||||
|
||||
```dart
|
||||
// Öffentliche Räume (des eigenen Servers)
|
||||
final result = await client.queryPublicRooms(
|
||||
limit: 50,
|
||||
);
|
||||
|
||||
// Mit Suchbegriff
|
||||
final searched = await client.queryPublicRooms(
|
||||
limit: 20,
|
||||
filter: PublicRoomQueryFilter(
|
||||
genericSearchTerm: 'flutter',
|
||||
),
|
||||
);
|
||||
|
||||
// Anderen Server durchsuchen
|
||||
final remote = await client.queryPublicRooms(
|
||||
server: 'matrix.org',
|
||||
limit: 20,
|
||||
filter: PublicRoomQueryFilter(genericSearchTerm: 'linux'),
|
||||
);
|
||||
|
||||
// Paginierung
|
||||
var batch = await client.queryPublicRooms(limit: 50);
|
||||
while (batch.nextBatch != null) {
|
||||
batch = await client.queryPublicRooms(
|
||||
limit: 50,
|
||||
since: batch.nextBatch,
|
||||
);
|
||||
}
|
||||
|
||||
// Einzelnes Ergebnis
|
||||
for (final room in result.chunk) {
|
||||
print(room.roomId);
|
||||
print(room.name);
|
||||
print(room.topic);
|
||||
print(room.numJoinedMembers);
|
||||
print(room.worldReadable);
|
||||
print(room.guestCanJoin);
|
||||
print(room.avatarUrl);
|
||||
print(room.canonicalAlias);
|
||||
print(room.joinRule); // 'public' | 'knock' | etc.
|
||||
print(room.roomType); // 'm.space' | null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Matrix-URI auflösen
|
||||
|
||||
```dart
|
||||
// Matrix-to URI parsen
|
||||
// https://matrix.to/#/@alice:server
|
||||
// https://matrix.to/#/!roomId:server
|
||||
// https://matrix.to/#/#alias:server
|
||||
// matrix:u/alice:server
|
||||
// matrix:r/alias:server
|
||||
// matrix:roomid/roomId:server
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
final uri = MatrixUri.parse('https://matrix.to/#/#kanal:example.org');
|
||||
if (uri != null) {
|
||||
switch (uri.primaryType) {
|
||||
case MatrixUriType.room: // Raum-Alias
|
||||
case MatrixUriType.roomId: // Raum-ID
|
||||
case MatrixUriType.user: // User-MXID
|
||||
case MatrixUriType.event: // Event-Link
|
||||
}
|
||||
|
||||
final identifier = uri.primaryIdentifier;
|
||||
// z.B. '#kanal:example.org' oder '!roomId:server'
|
||||
|
||||
// Beitreten (bei Raum)
|
||||
if (uri.primaryType == MatrixUriType.room ||
|
||||
uri.primaryType == MatrixUriType.roomId) {
|
||||
await client.joinRoomOrAlias(
|
||||
identifier,
|
||||
serverName: uri.viaServers, // föderierte Server
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix-URI generieren
|
||||
final roomUri = MatrixUri(
|
||||
primaryType: MatrixUriType.roomId,
|
||||
primaryIdentifier: room.id,
|
||||
viaServers: ['example.org'],
|
||||
).toUri().toString();
|
||||
// → matrix:roomid/roomId:server?via=example.org
|
||||
|
||||
// Matrix.to Link generieren
|
||||
final matrixToLink = 'https://matrix.to/#/${Uri.encodeComponent(room.canonicalAlias ?? room.id)}';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum per Alias finden
|
||||
|
||||
```dart
|
||||
// Alias → Raum-ID auflösen
|
||||
final result = await client.getRoomIdByAlias('#alias:server');
|
||||
print(result.roomId);
|
||||
print(result.servers); // Via-Server für Beitritt
|
||||
|
||||
// Direkt beitreten via Alias
|
||||
await client.joinRoomOrAlias('#alias:server');
|
||||
|
||||
// Lokal prüfen (gecacht)
|
||||
final localRoom = client.rooms.firstWhere(
|
||||
(r) => r.canonicalAlias == '#alias:server',
|
||||
orElse: () => throw Exception('Nicht gefunden'),
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,366 @@
|
||||
# 12 — Schnellreferenz: Typen, Konstanten & Patterns
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Enum-Werte
|
||||
|
||||
```dart
|
||||
// Membership
|
||||
Membership.join // Mitglied
|
||||
Membership.invite // Eingeladen
|
||||
Membership.leave // Verlassen
|
||||
Membership.ban // Gesperrt
|
||||
Membership.knock // Klopf-Anfrage
|
||||
|
||||
// JoinRules
|
||||
JoinRules.public // Offen
|
||||
JoinRules.invite // Nur per Einladung
|
||||
JoinRules.knock // Beitrittsanfragen erlaubt
|
||||
JoinRules.restricted // Nur Mitglieder bestimmter Spaces/Räume
|
||||
JoinRules.knockRestricted
|
||||
|
||||
// HistoryVisibility
|
||||
HistoryVisibility.worldReadable // Ohne Login lesbar
|
||||
HistoryVisibility.shared // Alle Mitglieder sehen History
|
||||
HistoryVisibility.invited // Ab Einladungszeitpunkt
|
||||
HistoryVisibility.joined // Ab Beitrittszeitpunkt
|
||||
|
||||
// GuestAccess
|
||||
GuestAccess.canJoin // Gäste erlaubt
|
||||
GuestAccess.forbidden // Keine Gäste
|
||||
|
||||
// Visibility
|
||||
Visibility.public // Im Verzeichnis
|
||||
Visibility.private // Nicht im Verzeichnis
|
||||
|
||||
// CreateRoomPreset
|
||||
CreateRoomPreset.privateChat // invite + joined History
|
||||
CreateRoomPreset.publicChat // public + shared History
|
||||
CreateRoomPreset.trustedPrivateChat // wie private + alle Mitglieder sind Admins
|
||||
|
||||
// EventStatus
|
||||
EventStatus.sending // Wird übertragen
|
||||
EventStatus.sent // Server-ACK
|
||||
EventStatus.synced // Im Sync empfangen
|
||||
EventStatus.error // Fehlgeschlagen
|
||||
EventStatus.removed // Gelöscht
|
||||
|
||||
// PresenceType
|
||||
PresenceType.online
|
||||
PresenceType.unavailable
|
||||
PresenceType.offline
|
||||
|
||||
// ThumbnailMethod
|
||||
ThumbnailMethod.crop // Ausschneiden auf exakte Größe
|
||||
ThumbnailMethod.scale // Skalieren ohne Abschneiden
|
||||
|
||||
// LoginType
|
||||
LoginType.mLoginPassword
|
||||
LoginType.mLoginToken
|
||||
LoginType.mLoginSso
|
||||
|
||||
// PushRuleKind
|
||||
PushRuleKind.override
|
||||
PushRuleKind.content
|
||||
PushRuleKind.room
|
||||
PushRuleKind.sender
|
||||
PushRuleKind.underride
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EventTypes Konstanten
|
||||
|
||||
```dart
|
||||
EventTypes.Message // m.room.message
|
||||
EventTypes.Sticker // m.sticker
|
||||
EventTypes.Reaction // m.reaction
|
||||
EventTypes.RoomRedaction // m.room.redaction
|
||||
EventTypes.Encrypted // m.room.encrypted
|
||||
EventTypes.RoomCreate // m.room.create
|
||||
EventTypes.RoomName // m.room.name
|
||||
EventTypes.RoomTopic // m.room.topic
|
||||
EventTypes.RoomAvatar // m.room.avatar
|
||||
EventTypes.RoomMember // m.room.member
|
||||
EventTypes.RoomPowerLevels // m.room.power_levels
|
||||
EventTypes.RoomCanonicalAlias // m.room.canonical_alias
|
||||
EventTypes.RoomJoinRules // m.room.join_rules
|
||||
EventTypes.RoomGuestAccess // m.room.guest_access
|
||||
EventTypes.HistoryVisibility // m.room.history_visibility
|
||||
EventTypes.RoomPinnedEvents // m.room.pinned_events
|
||||
EventTypes.RoomTombstone // m.room.tombstone
|
||||
EventTypes.Encryption // m.room.encryption
|
||||
EventTypes.SpaceChild // m.space.child
|
||||
EventTypes.SpaceParent // m.space.parent
|
||||
EventTypes.CallInvite // m.call.invite
|
||||
EventTypes.CallAnswer // m.call.answer
|
||||
EventTypes.CallHangup // m.call.hangup
|
||||
EventTypes.CallCandidates // m.call.candidates
|
||||
EventTypes.CallReject // m.call.reject
|
||||
EventTypes.CallSelectAnswer // m.call.select_answer
|
||||
EventTypes.CallNegotiate // m.call.negotiate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Häufige Content-Zugriffe
|
||||
|
||||
```dart
|
||||
// Sicher auf Map-Inhalte zugreifen (Matrix SDK Extension)
|
||||
final text = event.content.tryGet<String>('body');
|
||||
final map = event.content.tryGetMap<String, dynamic>('info');
|
||||
final list = event.content.tryGetList<String>('aliases');
|
||||
|
||||
// Event-Inhalt strukturiert
|
||||
final msgtype = event.content['msgtype'] as String?;
|
||||
final body = event.content['body'] as String?;
|
||||
final formatted = event.content['formatted_body'] as String?;
|
||||
final format = event.content['format'] as String?; // 'org.matrix.custom.html'
|
||||
|
||||
// Relation
|
||||
final relatesTo = event.content
|
||||
.tryGetMap<String, dynamic>('m.relates_to');
|
||||
final relType = relatesTo?.tryGet<String>('rel_type');
|
||||
final eventId = relatesTo?.tryGet<String>('event_id');
|
||||
|
||||
// Verschlüsselte Medien
|
||||
final file = event.content.tryGetMap<String, dynamic>('file');
|
||||
final url = file?.tryGet<String>('url'); // mxc://
|
||||
final key = file?.tryGetMap<String, dynamic>('key');
|
||||
|
||||
// Mentions (MSC3952)
|
||||
final mentions = event.content.tryGetMap<String, dynamic>('m.mentions');
|
||||
final userIds = mentions?.tryGetList<String>('user_ids') ?? [];
|
||||
final roomMention = mentions?.tryGet<bool>('room') ?? false;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timeline-Widget Pattern (Flutter)
|
||||
|
||||
```dart
|
||||
class ChatView extends ConsumerStatefulWidget {
|
||||
final String roomId;
|
||||
const ChatView({required this.roomId, super.key});
|
||||
@override
|
||||
ConsumerState<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends ConsumerState<ChatView> {
|
||||
Timeline? _timeline;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTimeline();
|
||||
}
|
||||
|
||||
Future<void> _loadTimeline() async {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final room = client.getRoomById(widget.roomId)!;
|
||||
_timeline = await room.getTimeline(
|
||||
onUpdate: () => setState(() {}),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timeline?.cancelSubscriptions(); // WICHTIG!
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final events = _timeline?.events ?? [];
|
||||
return ListView.builder(
|
||||
reverse: true, // Neueste unten
|
||||
itemCount: events.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final event = events[i];
|
||||
if (event.type != EventTypes.Message) return const SizedBox();
|
||||
return ListTile(
|
||||
title: Text(event.body),
|
||||
subtitle: Text(event.senderId),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Liste Widget Pattern
|
||||
|
||||
```dart
|
||||
// StreamProvider für reaktive Raumliste
|
||||
final roomsProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
yield _sortedRooms(client);
|
||||
await for (final _ in client.onSync.stream) {
|
||||
yield _sortedRooms(client);
|
||||
}
|
||||
});
|
||||
|
||||
List<Room> _sortedRooms(Client client) => client.rooms
|
||||
.where((r) => r.membership == Membership.join)
|
||||
.toList()
|
||||
..sort((a, b) =>
|
||||
(b.lastEvent?.originServerTs ?? DateTime(0))
|
||||
.compareTo(a.lastEvent?.originServerTs ?? DateTime(0)));
|
||||
|
||||
// In Widget:
|
||||
final rooms = ref.watch(roomsProvider);
|
||||
rooms.when(
|
||||
data: (list) => ListView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final room = list[i];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(child: Text(room.name[0])),
|
||||
title: Text(room.getLocalizedDisplayname()),
|
||||
subtitle: Text(room.lastEvent?.body ?? ''),
|
||||
trailing: room.notificationCount > 0
|
||||
? Badge(label: Text('${room.notificationCount}'))
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, s) => Text('Fehler: $e'),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nachrichten senden mit optimistischem UI
|
||||
|
||||
```dart
|
||||
// Das SDK fügt das Event sofort mit Status 'sending' in die Timeline ein.
|
||||
// UI kann es sofort anzeigen, ohne auf Server zu warten.
|
||||
|
||||
class MessageInput extends ConsumerWidget {
|
||||
final Room room;
|
||||
const MessageInput({required this.room, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = TextEditingController();
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: TextField(controller: controller)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () async {
|
||||
final text = controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
controller.clear();
|
||||
try {
|
||||
await room.sendTextEvent(text);
|
||||
} catch (e) {
|
||||
// Event bleibt mit Status 'error' in Timeline
|
||||
// User kann auf "Erneut senden" tippen
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nützliche Client-Extensions & Helpers
|
||||
|
||||
```dart
|
||||
// User-Erwähnung formatieren
|
||||
'@${userId.split(':').first.replaceFirst('@', '')}' // kurzform ohne Server
|
||||
|
||||
// Raum-Display-Name (FluffyChat-Stil)
|
||||
// Für DMs: Name des Gegenübers
|
||||
// Für Räume mit Name: Name
|
||||
// Für Räume ohne Name: kommagetrennte Mitgliedernamen
|
||||
room.getLocalizedDisplayname()
|
||||
|
||||
// Avatar-URL sicher holen
|
||||
user.avatarUrl?.getThumbnail(client, width: 56, height: 56)
|
||||
|
||||
// Event-Zeitstempel formatieren
|
||||
event.originServerTs.toLocal().toString()
|
||||
// oder mit intl:
|
||||
DateFormat.Hm().format(event.originServerTs.toLocal())
|
||||
|
||||
// Stable Notification-ID aus Raum-ID (deterministisch)
|
||||
int stableId(String roomId) {
|
||||
var h = 0;
|
||||
for (final c in roomId.codeUnits) {
|
||||
h = ((h * 31) + c) & 0x7FFFFFFF;
|
||||
}
|
||||
return h == 0 ? 1 : h;
|
||||
}
|
||||
|
||||
// Matrix-Größenangabe
|
||||
// event.sizeString → '2.3 MB', '512 KB'
|
||||
|
||||
// mxc:// URI in HTTP umwandeln
|
||||
mxcUri.getDownloadUri(client)
|
||||
mxcUri.getThumbnail(client, width: 300, height: 300)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehler-Handling
|
||||
|
||||
```dart
|
||||
// MatrixException — Fehler vom Server
|
||||
try {
|
||||
await client.joinRoom('!room:server');
|
||||
} on MatrixException catch (e) {
|
||||
switch (e.error) {
|
||||
case MatrixError.M_FORBIDDEN: // Kein Zugang
|
||||
case MatrixError.M_NOT_FOUND: // Raum existiert nicht
|
||||
case MatrixError.M_LIMIT_EXCEEDED: // Rate-Limit
|
||||
final retryAfter = e.retryAfterMs; // Wann wieder versuchen
|
||||
case MatrixError.M_UNKNOWN:
|
||||
default:
|
||||
print(e.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// TimeoutException — Keine Antwort vom Server
|
||||
// SocketException — Keine Netzwerkverbindung
|
||||
// FormatException — Ungültige Server-Antwort
|
||||
|
||||
// Verbindungsstatus überwachen
|
||||
client.onConnectionStateChanged.stream.listen((state) {
|
||||
switch (state) {
|
||||
case ConnectionState.connected: // Sync läuft
|
||||
case ConnectionState.connecting: // Verbindet
|
||||
case ConnectionState.reconnecting: // Verbindung wiederhergestellt
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Pakete für Pyramid
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
matrix: ^6.x.x # Matrix SDK (Haupt-SDK)
|
||||
flutter_vodozemac: ^x.x.x # E2EE (Rust-basiert, empfohlen)
|
||||
firebase_messaging: ^x.x.x # FCM Push (Android)
|
||||
firebase_core: ^x.x.x
|
||||
flutter_local_notifications: ^x.x.x # Lokale Notifications
|
||||
flutter_riverpod: ^x.x.x # State Management
|
||||
sqflite: ^x.x.x # Datenbank
|
||||
path_provider: ^x.x.x # App-Verzeichnisse
|
||||
shared_preferences: ^x.x.x # Einfache KV-Speicherung
|
||||
livekit_client: ^x.x.x # LiveKit für VoIP
|
||||
flutter_webrtc: ^x.x.x # WebRTC
|
||||
go_router: ^x.x.x # Navigation
|
||||
local_notifier: ^x.x.x # Windows Notifications
|
||||
windows_taskbar: ^x.x.x # Windows Badge
|
||||
```
|
||||
@@ -0,0 +1,437 @@
|
||||
# 13 — Server-Administration: Continuwuity
|
||||
|
||||
> **Gilt für:** Continuwuity 0.5.x (Fork von Conduwuit, welches ein Fork von Conduit ist)
|
||||
> **Homeserver:** `steggi-matrix.work`
|
||||
> **WICHTIG:** Continuwuity hat KEINE Synapse Admin API (`/_synapse/admin/` existiert nicht!)
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Server-Admin vs. Room Power Level](#server-admin-vs-room-power-level)
|
||||
- [Admin Room — Befehle](#admin-room--befehle)
|
||||
- [Continuwuity Admin HTTP API](#continuwuity-admin-http-api)
|
||||
- [Power Level in Räumen erzwingen (als Server-Admin)](#power-level-in-räumen-erzwingen)
|
||||
- [MSC3083: Restricted Join Rules auf Continuwuity](#msc3083-restricted-join-rules-auf-continuwuity)
|
||||
- [Diagnose: Warum M_FORBIDDEN?](#diagnose-warum-m_forbidden)
|
||||
- [User-Admin-Status prüfen/setzen](#user-admin-status-prüfensetzen)
|
||||
- [Raum-Administration via Matrix API](#raum-administration-via-matrix-api)
|
||||
- [Wichtige Unterschiede: Continuwuity vs. Synapse](#wichtige-unterschiede-continuwuity-vs-synapse)
|
||||
- [Continuwuity `homeserver.yaml` — relevante Einstellungen](#continuwuity-homeserveryaml--relevante-einstellungen)
|
||||
|
||||
---
|
||||
|
||||
## Server-Admin vs. Room Power Level
|
||||
|
||||
```
|
||||
Continuwuity Server-Admin
|
||||
├── Ermöglicht: Admin-Room-Befehle, Server-API
|
||||
├── Ermöglicht: User deaktivieren, Räume löschen (Server-Level)
|
||||
└── NICHT: Automatisch PL 100 in jedem Matrix-Raum!
|
||||
|
||||
Matrix Room Power Level 100
|
||||
├── Gilt pro Raum (in m.room.power_levels State-Event gespeichert)
|
||||
├── Ermöglicht: Name/Topic/Avatar setzen, Join-Rules ändern, User kicken/bannen
|
||||
└── Unabhängig vom Server-Admin-Status
|
||||
```
|
||||
|
||||
**Beispiel-Problem:**
|
||||
- Du bist Server-Admin auf `steggi-matrix.work` ✅
|
||||
- Du hast PL 100 im Space `!xyz:steggi-matrix.work` ✅
|
||||
- Du erstellst einen neuen Channel → Server gibt dir PL 100 als Ersteller ✅
|
||||
- Du versuchst `join_rules: restricted` zu setzen → M_FORBIDDEN ❌
|
||||
- **Grund:** Continuwuity 0.5.x unterstützt MSC3083 möglicherweise nicht vollständig
|
||||
|
||||
---
|
||||
|
||||
## Admin Room — Befehle
|
||||
|
||||
Continuwuity hat einen speziellen Admin-Raum. Als Server-Admin kannst du dort Befehle eingeben:
|
||||
|
||||
```
|
||||
# Admin-Raum beitreten (normalerweise automatisch eingeladen)
|
||||
# Raum-Alias: #admins:steggi-matrix.work
|
||||
|
||||
# Befehle im Admin-Raum (als Nachricht senden):
|
||||
|
||||
!admin help # Alle Befehle anzeigen
|
||||
|
||||
!admin make_user_admin @user:server # User zu Server-Admin machen
|
||||
!admin deactivate_user @user:server # User deaktivieren
|
||||
!admin list_users # Alle User auflisten
|
||||
|
||||
!admin get_room_state !roomId:server # Room-State anzeigen
|
||||
!admin delete_room !roomId:server # Raum löschen
|
||||
|
||||
!admin get_mem_usage # Server-Speicherverbrauch
|
||||
!admin server_version # Server-Version
|
||||
```
|
||||
|
||||
```bash
|
||||
# Als curl (Admin-Token erforderlich):
|
||||
# Admin-Room-ID herausfinden:
|
||||
curl -s "https://steggi-matrix.work/_matrix/client/v3/directory/room/%23admins%3Asteggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
# → {"room_id": "!adminRoomId:steggi-matrix.work", ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Continuwuity Admin HTTP API
|
||||
|
||||
```bash
|
||||
# Basis-URL: https://steggi-matrix.work/_conduwuit/admin/v1/
|
||||
# (Nicht /_synapse/admin/ — das ist Synapse-only!)
|
||||
|
||||
# User zu Server-Admin machen
|
||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||
|
||||
# Server-Status abfragen
|
||||
curl "https://steggi-matrix.work/_conduwuit/server/version" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# Alternativ — Standard Matrix-Whoami (prüft ob Token gültig):
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
# → {"user_id": "@todesneutron:steggi-matrix.work", "is_guest": false}
|
||||
|
||||
# Prüfen ob User Server-Admin ist (Conduwuit-spezifisch):
|
||||
curl "https://steggi-matrix.work/_conduwuit/admin/v1/users/@todesneutron:steggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Power Level in Räumen erzwingen (als Server-Admin)
|
||||
|
||||
Als Server-Admin kannst du via Matrix Client API Power Levels in jedem Raum setzen,
|
||||
**auch wenn du kein Mitglied bist** (Continuwuity erlaubt das für Server-Admins):
|
||||
|
||||
```bash
|
||||
# Schritt 1: Aktuelles Power-Level-Event lesen
|
||||
ROOM_ID="!raumId:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
SERVER="https://steggi-matrix.work"
|
||||
|
||||
curl "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
|
||||
# Schritt 2: Power Level setzen (users-Map ändern)
|
||||
curl -X PUT "${SERVER}/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"users": {
|
||||
"@todesneutron:steggi-matrix.work": 100
|
||||
},
|
||||
"users_default": 0,
|
||||
"events_default": 0,
|
||||
"state_default": 50,
|
||||
"ban": 50,
|
||||
"kick": 50,
|
||||
"invite": 0,
|
||||
"redact": 50
|
||||
}'
|
||||
```
|
||||
|
||||
```dart
|
||||
// In Flutter / Dart (mit Admin-Token):
|
||||
// Zuerst aktuellen PL-State lesen:
|
||||
final currentPl = await client.getRoomStateWithKey(
|
||||
roomId,
|
||||
EventTypes.RoomPowerLevels,
|
||||
'',
|
||||
);
|
||||
|
||||
// Users-Map erweitern und zurückschreiben:
|
||||
final users = Map<String, dynamic>.from(
|
||||
(currentPl['users'] as Map?)?.cast<String, dynamic>() ?? {},
|
||||
);
|
||||
users['@todesneutron:steggi-matrix.work'] = 100;
|
||||
|
||||
await client.setRoomStateWithKey(
|
||||
roomId,
|
||||
EventTypes.RoomPowerLevels,
|
||||
'',
|
||||
{
|
||||
...currentPl,
|
||||
'users': users,
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MSC3083: Restricted Join Rules auf Continuwuity
|
||||
|
||||
`join_rules: restricted` (MSC3083) erlaubt Räumen, nur Space-Mitglieder beizutreten.
|
||||
**Continuwuity 0.5.x unterstützt MSC3083 möglicherweise nicht vollständig.**
|
||||
|
||||
```bash
|
||||
# Testen ob restricted join rules funktionieren:
|
||||
ROOM_ID="!testRaum:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
SPACE_ID="!spaceId:steggi-matrix.work"
|
||||
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"join_rule": "restricted",
|
||||
"allow": [
|
||||
{
|
||||
"type": "m.room_membership",
|
||||
"room_id": "'"${SPACE_ID}"'"
|
||||
}
|
||||
]
|
||||
}'
|
||||
# → 200 OK: Unterstützt!
|
||||
# → M_FORBIDDEN oder M_UNRECOGNIZED: Nicht unterstützt oder PL zu niedrig
|
||||
```
|
||||
|
||||
**Workaround wenn restricted nicht funktioniert:**
|
||||
```bash
|
||||
# Fallback: invite-only (explizit jeden User einladen)
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.join_rules/" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"join_rule": "invite"}'
|
||||
```
|
||||
|
||||
```dart
|
||||
// In space_admin_dialog.dart — Fallback wenn restricted nicht supported:
|
||||
try {
|
||||
await room.setJoinRules(
|
||||
JoinRules.restricted,
|
||||
allow: [
|
||||
SpaceRoomJoinRulesAllowInner(
|
||||
roomId: spaceId,
|
||||
type: SpaceRoomJoinRulesAllowInnerType.mRoomMembership,
|
||||
),
|
||||
],
|
||||
);
|
||||
} on MatrixException catch (e) {
|
||||
if (e.error == MatrixError.M_FORBIDDEN || e.error == MatrixError.M_UNKNOWN) {
|
||||
// Continuwuity unterstützt restricted join rules nicht
|
||||
// Fallback zu invite-only:
|
||||
await room.setJoinRules(JoinRules.invite);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagnose: Warum M_FORBIDDEN?
|
||||
|
||||
```bash
|
||||
# 1. Prüfen: Welchen Power Level hat mein User in diesem Raum?
|
||||
ROOM_ID="!raumId:steggi-matrix.work"
|
||||
TOKEN="J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
# Antwort zeigt 'users' Map — prüfen ob '@todesneutron:steggi-matrix.work' drin ist
|
||||
|
||||
# 2. Prüfen: Was ist state_default?
|
||||
# Im Antwort-JSON: "state_default": 50 bedeutet: PL 50 nötig für State-Events
|
||||
# "m.room.join_rules": 100 in events-Map = PL 100 für Join-Rule-Änderung
|
||||
|
||||
# 3. Prüfen: Bin ich Mitglied des Raums?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/${ROOM_ID}/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
# → {"membership": "join"} = Mitglied ✅
|
||||
# → {"errcode": "M_NOT_FOUND"} = Nicht Mitglied ❌ (Server-Admin kann trotzdem State setzen)
|
||||
|
||||
# 4. Prüfen: Unterstützt der Server restricted join rules?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/capabilities" \
|
||||
-H "Authorization: Bearer ${TOKEN}"
|
||||
# Antwort: capabilities-Objekt — MSC3083 ist nicht explizit gelistet (kein Standard-Capability)
|
||||
```
|
||||
|
||||
**Häufige Ursachen und Lösungen:**
|
||||
|
||||
| Fehler | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| M_FORBIDDEN beim State-Event | PL zu niedrig im Raum | Power Level via curl erhöhen (s.o.) |
|
||||
| M_FORBIDDEN bei join_rules: restricted | MSC3083 nicht unterstützt | Fallback zu `invite` nutzen |
|
||||
| M_FORBIDDEN beim Channel erstellen | Bug in space_admin_dialog.dart | Raum manuell erstellen, dann Space-Child setzen |
|
||||
| M_NOT_FOUND | Raum nicht bekannt | Raum beitreten oder Admin-Join |
|
||||
| M_FORBIDDEN bei Power-Level-Änderung | PL darf nur gleich/höher gesetzt werden | Als Ersteller oder via Admin-API |
|
||||
|
||||
---
|
||||
|
||||
## User-Admin-Status prüfen/setzen
|
||||
|
||||
```bash
|
||||
# Prüfen ob User Server-Admin ist:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# Auf Pi (SSH): User-Datenbank direkt abfragen (Conduwuit/Continuwuity nutzt RocksDB)
|
||||
# Alternativ: Über Admin-Room-Befehl
|
||||
# (Im Admin-Room als Nachricht senden:)
|
||||
# !admin make_user_admin @todesneutron:steggi-matrix.work
|
||||
|
||||
# Über HTTP (Conduwuit Admin-Endpoint):
|
||||
curl -X POST "https://steggi-matrix.work/_conduwuit/admin/v1/make_user_admin" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@todesneutron:steggi-matrix.work"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raum-Administration via Matrix API
|
||||
|
||||
Da kein Synapse-Admin-API existiert, nutze die Standard-Matrix-Client-API mit Admin-Token:
|
||||
|
||||
```bash
|
||||
# Raum beitreten (als Admin, auch wenn invite-only):
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/join/!roomId%3Asteggi-matrix.work" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
|
||||
# User in Raum einladen:
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/invite" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"user_id": "@user:steggi-matrix.work"}'
|
||||
|
||||
# Space-Child-Relation setzen (Channel einem Space hinzufügen):
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!spaceId%3Asteggi-matrix.work/state/m.space.child/!channelId%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"via": ["steggi-matrix.work"],
|
||||
"suggested": false
|
||||
}'
|
||||
|
||||
# Space-Parent-Relation setzen (im Channel-Raum):
|
||||
curl -X PUT "https://steggi-matrix.work/_matrix/client/v3/rooms/!channelId%3Asteggi-matrix.work/state/m.space.parent/!spaceId%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"via": ["steggi-matrix.work"],
|
||||
"canonical": true
|
||||
}'
|
||||
|
||||
# Alle Mitglieder eines Raums auflisten:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/members" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# Raum verlassen (als User, nicht löschen):
|
||||
curl -X POST "https://steggi-matrix.work/_matrix/client/v3/rooms/!roomId%3Asteggi-matrix.work/leave" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Unterschiede: Continuwuity vs. Synapse
|
||||
|
||||
| Feature | Synapse | Continuwuity 0.5.x |
|
||||
|---|---|---|
|
||||
| Admin API URL | `/_synapse/admin/v1/` | `/_conduwuit/admin/v1/` |
|
||||
| Admin Room | Optional | Standard |
|
||||
| MSC3083 (restricted join rules) | ✅ Vollständig | ⚠️ Teilweise / experimentell |
|
||||
| Spaces (MSC1772) | ✅ | ✅ Grundfunktionen |
|
||||
| MatrixRTC / MSC3401 | ✅ (Element Call) | ⚠️ Eingeschränkt |
|
||||
| Push-Gateway | ✅ | ✅ |
|
||||
| Server-side search | ✅ | ✅ Grundfunktionen |
|
||||
| E2EE / Cross-Signing | ✅ | ✅ |
|
||||
| Presence | ✅ | ✅ |
|
||||
| Account Deactivation | Admin API | Admin Room / API |
|
||||
| Room Deletion/Purge | Admin API | Admin Room / API |
|
||||
| User Listing | Admin API | Admin Room / API |
|
||||
| Federation | ✅ | ✅ |
|
||||
| Application Services | ✅ | ⚠️ Eingeschränkt |
|
||||
|
||||
---
|
||||
|
||||
## Continuwuity `homeserver.yaml` — relevante Einstellungen
|
||||
|
||||
```yaml
|
||||
# /etc/continuwuity/homeserver.yaml (oder /etc/conduwuit/conduwuit.toml je nach Version)
|
||||
|
||||
# Matrix-RTC / LiveKit
|
||||
# MSC3266: Room Summary
|
||||
# MSC3401: MatrixRTC
|
||||
allow_unstable_room_versions: true
|
||||
|
||||
# Federation (für Multi-Server-Calls nötig)
|
||||
allow_federation: true
|
||||
allow_public_room_directory_without_auth: false
|
||||
|
||||
# Push (für Notifications)
|
||||
# Kein spezieller Config — nutzt Standard-Matrix-Push-API
|
||||
|
||||
# TURN-Server für WebRTC
|
||||
turn_uris:
|
||||
- "turn:steggi-matrix.work:3478?transport=udp"
|
||||
- "turn:steggi-matrix.work:3478?transport=tcp"
|
||||
turn_secret: "dein-turn-secret"
|
||||
turn_username: "" # leer = HMAC-basiert
|
||||
turn_password: ""
|
||||
turn_ttl: 86400
|
||||
|
||||
# Registrierung
|
||||
allow_registration: false # Kein offener Signup
|
||||
registration_token: "dein-token" # Oder token-basiert
|
||||
|
||||
# Log-Level
|
||||
log: "warn,conduwuit=info"
|
||||
|
||||
# Performance
|
||||
rocksdb_parallelism_threads: 4 # Pi 5 hat 4 Cores
|
||||
|
||||
# Unter Continuwuity: Admin-User beim ersten Start setzen
|
||||
# Erster registrierter User wird automatisch Server-Admin
|
||||
```
|
||||
|
||||
```bash
|
||||
# Auf dem Pi: Config-Datei editieren
|
||||
ssh steggi@192.168.178.71
|
||||
sudo nano /etc/continuwuity/homeserver.yaml
|
||||
# oder:
|
||||
sudo nano /etc/conduwuit/conduwuit.toml # Falls TOML-Format
|
||||
|
||||
# Service neu starten:
|
||||
sudo systemctl restart continuwuity
|
||||
# oder:
|
||||
sudo systemctl restart conduwuit
|
||||
|
||||
# Logs prüfen:
|
||||
sudo journalctl -u continuwuity -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schnell-Diagnose Checkliste
|
||||
|
||||
Wenn du M_FORBIDDEN bekommst:
|
||||
|
||||
```bash
|
||||
# 1. Token gültig?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/account/whoami" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# 2. PL im betroffenen Raum prüfen:
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.power_levels/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# 3. Mitglied des Raums?
|
||||
curl "https://steggi-matrix.work/_matrix/client/v3/rooms/!ROOM_ID/state/m.room.member/%40todesneutron%3Asteggi-matrix.work/" \
|
||||
-H "Authorization: Bearer J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI"
|
||||
|
||||
# 4. Was genau schlägt fehl? (Antwort-Body lesen!)
|
||||
# M_FORBIDDEN + "Power level too low" → PL erhöhen
|
||||
# M_FORBIDDEN + "Not a member" → Raum beitreten
|
||||
# M_UNRECOGNIZED → Feature nicht unterstützt
|
||||
# M_FORBIDDEN + "..." → join_rules restricted Problem
|
||||
```
|
||||
@@ -0,0 +1,433 @@
|
||||
# Pyramid — Feature-Checklist & Implementierungsstand
|
||||
|
||||
> Stand: Mai 2026
|
||||
> Legende: ✅ Fertig | ⚠️ Teilweise / Bug | ❌ Fehlt | 🔧 In Arbeit
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- [Authentifizierung](#authentifizierung)
|
||||
- [Raumliste](#raumliste)
|
||||
- [Chat / Timeline](#chat--timeline)
|
||||
- [Nachrichtentypen](#nachrichtentypen)
|
||||
- [Nachrichtenfunktionen](#nachrichtenfunktionen)
|
||||
- [Direct Messages](#direct-messages)
|
||||
- [E2EE Verschlüsselung](#e2ee-verschlüsselung)
|
||||
- [Push Notifications](#push-notifications)
|
||||
- [VoIP & Calls](#voip--calls)
|
||||
- [Settings / Einstellungen](#settings--einstellungen)
|
||||
- [Benutzerprofil](#benutzerprofil)
|
||||
- [Raum-Einstellungen](#raum-einstellungen)
|
||||
- [Suche](#suche)
|
||||
- [Medien](#medien)
|
||||
- [Read Receipts & Typing](#read-receipts--typing)
|
||||
- [Spaces](#spaces)
|
||||
- [Windows / Desktop](#windows--desktop)
|
||||
- [Sonstige Features](#sonstige-features)
|
||||
- [Dokumentations-Vollständigkeit](#dokumentations-vollständigkeit)
|
||||
|
||||
---
|
||||
|
||||
## Authentifizierung
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Login mit Passwort | ✅ | `login_page.dart`, `login_notifier.dart` |
|
||||
| Homeserver-Auswahl | ✅ | `server_page.dart` — HTTPS-Prefix fix, .well-known Autodiscovery |
|
||||
| Session wiederherstellen (init) | ✅ | `matrix_client.dart` — `client.init()` beim App-Start |
|
||||
| Logout | ✅ | Via Matrix SDK `client.logout()` |
|
||||
| Login-Redirect nach Init | ✅ | `router.dart` — StreamProvider-basierter Guard |
|
||||
| SSO / OAuth | ❌ | Nicht implementiert |
|
||||
| Registrierung / Account erstellen | ❌ | Keine UI |
|
||||
| Login mit Token | ❌ | Nur Passwort-Login |
|
||||
| Multi-Account | ❌ | Nur ein Account gleichzeitig |
|
||||
| UIAA Re-Auth (Passwort ändern) | ❌ | Kein UI |
|
||||
|
||||
---
|
||||
|
||||
## Raumliste
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Raumliste anzeigen | ✅ | `rooms_page.dart`, `room_list_item.dart` |
|
||||
| Sortierung nach letzter Aktivität | ✅ | In `rooms_provider.dart` |
|
||||
| Avatar-Anzeige (MXC) | ✅ | `mxc_image.dart` |
|
||||
| Vorschautext (letztes Event) | ✅ | `room_list_item.dart` |
|
||||
| Ungelesen-Badge | ✅ | `notificationCount` |
|
||||
| Ungelesen-Punkt (kein Count) | ✅ | Bei `notificationCount == 0` aber ungelesen |
|
||||
| Zeitstempel-Formatierung | ✅ | Heute: HH:MM, andere: DD.MM |
|
||||
| DM-Erkennung (`isDirectChat`) | ✅ | Typ-Differenzierung in Liste |
|
||||
| Space-Filterung | ✅ | `SpacesRail` filtert nach aktiver Space |
|
||||
| Einladungen anzeigen | ⚠️ | Membership.invite vorhanden, gesonderte Ansicht fehlt |
|
||||
| Raum suchen / filtern | ✅ | Via `search_modal.dart` |
|
||||
| Archivierte Räume | ❌ | Kein UI für left-rooms |
|
||||
| Raum erstellen (Knopf) | ✅ | `create_join_dialog.dart` |
|
||||
| Raum beitreten (Alias) | ✅ | `create_join_dialog.dart` |
|
||||
|
||||
---
|
||||
|
||||
## Chat / Timeline
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Nachrichten anzeigen | ✅ | `message_bubble.dart`, `message_group.dart` |
|
||||
| Gruppen-Bubbles (gleicher Sender) | ✅ | Continuation-Nachrichten (eingerückt, kein Avatar) |
|
||||
| Datums-Trenner | ✅ | Tagstrennlinie |
|
||||
| Ungelesen-Trenner ("Neue Nachrichten") | ✅ | `fullyRead`-Position |
|
||||
| Scroll-to-Unread beim Öffnen | ✅ | `chat_view.dart` |
|
||||
| History-Nachladen beim Scrollen | ✅ | `_onScroll()` → `requestHistory(60)` |
|
||||
| Optimistische Nachrichtenanzeige | ✅ | SDK fügt Event mit Status `sending` ein |
|
||||
| Nachrichtenstatus (senden/sent) | ✅ | Uhren-Icons |
|
||||
| Timeline-Cleanup (`cancelSubscriptions`) | ✅ | In `dispose()` |
|
||||
| Sprung zu bestimmtem Event | ⚠️ | Infrastruktur vorhanden, kein UI |
|
||||
| Event-Suche in Timeline | ❌ | Nur globale Suche |
|
||||
|
||||
---
|
||||
|
||||
## Nachrichtentypen
|
||||
| Typ | Status | Details |
|
||||
|-----|--------|---------|
|
||||
| Text (`m.text`) | ✅ | Vollständig |
|
||||
| Bild (`m.image`) | ✅ | Thumbnail + Vollansicht mit Zoom |
|
||||
| Video (`m.video`) | ✅ | Playback via `media_kit` |
|
||||
| Audio (`m.audio`) | ✅ | Player mit Progress/Dauer |
|
||||
| Datei (`m.file`) | ✅ | Download mit MIME-Icon |
|
||||
| Sticker (`m.sticker`) | ✅ | Anzeige und Senden (Sticker-Picker) |
|
||||
| Sprachnachricht (MSC3245) | ⚠️ | Audio-Aufnahme fehlt, Abspielen funktioniert |
|
||||
| Standort (`m.location`) | ❌ | Nicht implementiert |
|
||||
| Emote (`m.emote`) | ❌ | Kein gesondertes Rendering |
|
||||
| Poll (MSC3381) | ❌ | Nicht implementiert |
|
||||
| Verschlüsselte Nachrichten | ✅ | Automatisch via SDK, Fehler-UI vorhanden |
|
||||
| Redacted-Events | ⚠️ | Wird angezeigt, kein "Nachricht gelöscht"-Platzhalter |
|
||||
|
||||
---
|
||||
|
||||
## Nachrichtenfunktionen
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Antworten (Reply) | ✅ | Swipe-Geste + Quote-Anzeige |
|
||||
| Reaktionen (Emojis) | ✅ | Quick-Picker + vollständiger Emoji-Picker |
|
||||
| Reaktionen entfernen | ⚠️ | UI zum Entfernen fehlt |
|
||||
| Nachrichten bearbeiten (Edit) | ⚠️ | `getDisplayEvent()` vorhanden, Senden nicht im UI |
|
||||
| Nachricht löschen (Redact) | ❌ | Kein Button / Dialog |
|
||||
| Threads | ❌ | Nicht implementiert |
|
||||
| Polls | ❌ | Nicht implementiert |
|
||||
| Weitersenden (Forward) | ❌ | Nicht implementiert |
|
||||
| Kopieren (Text) | ⚠️ | Nicht explizit sichtbar |
|
||||
| Long-Press Auswahl-Modus | ✅ | `message_bubble.dart` — Long-Press aktiviert Selection |
|
||||
| Bulk-Aktionen (Auswahl) | ⚠️ | Modus vorhanden, Aktionen begrenzt |
|
||||
| Pinned Messages Panel | ✅ | `pinned_messages_panel.dart` |
|
||||
| Pins setzen (UI-Aktion) | ❌ | Kein Pin-Button in Nachricht |
|
||||
| Linkvorschau | ❌ | Keine URL-Preview |
|
||||
| Mention-Links klickbar | ✅ | `@user`-Links via `url_launcher` |
|
||||
|
||||
---
|
||||
|
||||
## Direct Messages
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Neuen DM starten | ✅ | `new_dm_dialog.dart` → `startDirectChat()` |
|
||||
| User-Verzeichnis-Suche | ✅ | `searchUserDirectory()` |
|
||||
| DM-Erkennung in Raumliste | ✅ | `room.isDirectChat` |
|
||||
| Partner-Avatar/Name | ✅ | `directChatMatrixID` → Profil laden |
|
||||
| DM-Verschlüsselung | ✅ | Standard E2EE bei DM-Erstellung |
|
||||
| Gruppen-DM | ⚠️ | Erstellbar, kein gesondertes UI |
|
||||
| DM vergessen / verlassen | ⚠️ | Kein UI-Button |
|
||||
| DM-spezifische Notification-Sounds | ❌ | Nicht implementiert |
|
||||
|
||||
---
|
||||
|
||||
## E2EE Verschlüsselung
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Vodozemac Rust-Crypto | ✅ | `flutter_vodozemac` initialisiert in `matrix_client.dart` |
|
||||
| E2EE in verschlüsselten Räumen | ✅ | Automatisch via SDK |
|
||||
| Bootstrap-Flow (SSSS + Cross-Signing) | ✅ | `bootstrap_dialog.dart` — vollständige State-Machine |
|
||||
| Cross-Signing Setup | ✅ | Master-, Self- und User-Signing-Key Erstellung |
|
||||
| Key Backup (online) | ✅ | SSSS-gesichertes Backup |
|
||||
| Schlüssel-Verifikation (SAS/Emoji) | ✅ | Via Bootstrap-Flow |
|
||||
| QR-Code-Verifikation | ⚠️ | SDK-Support vorhanden, kein UI |
|
||||
| Device-Liste anzeigen | ❌ | Kein UI |
|
||||
| Gerät sperren/blockieren | ❌ | Kein UI |
|
||||
| Fehlende Schlüssel anfordern | ✅ | `requestMissingSessionKeys()` bei Bad-Encrypted |
|
||||
| Entschlüsselungs-Fehler-UI | ✅ | "Nachricht konnte nicht entschlüsselt werden" + Schlüssel-Button |
|
||||
| Schlüssel importieren/exportieren | ❌ | Kein UI (Infrastruktur vorhanden) |
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| FCM Token Registrierung | ✅ | `fcm_push_service.dart` |
|
||||
| Pusher auf Homeserver | ✅ | `postPusher()` mit Sygnal-Gateway |
|
||||
| Token-Refresh Handling | ✅ | `onTokenRefresh.listen()` |
|
||||
| PushService.kt (native FCM) | ✅ | Läuft auch bei geschlossener App |
|
||||
| Benachrichtigung bei geschlossener App | 🔧 | APK neu gebaut, noch nicht getestet |
|
||||
| Absender-Name in Notification | ✅ | HTTP-Fetch von Matrix API in PushService.kt |
|
||||
| Verschlüsselte Nachricht entschlüsseln | ⚠️ | "Neue Nachricht" bei geschlossener App; `decryptAndUpdateNotification` bei Hintergrund-App |
|
||||
| Inline Reply in Notification | 🔧 | ReplyReceiver auf getActivity umgestellt — APK gebaut, ungetestet |
|
||||
| Antwort wirklich abschicken | 🔧 | `replyFromNotification` MethodChannel — APK gebaut, ungetestet |
|
||||
| Gelesen-Button in Notification | ✅ | `ACTION_DISMISS` über `ReplyReceiver.kt` |
|
||||
| Notification bei Hintergrund-App | ✅ | Heartbeat-Check verhindert Duplikate |
|
||||
| Notification-Kanal Android | ✅ | `pyramid_messages`, HIGH Importance |
|
||||
| Stabile Notification-ID | ✅ | `stableId(roomId)` — gleicher Algo in Kotlin + Dart |
|
||||
| Windows Notification | ✅ | `local_notifier` (Windows Toast) |
|
||||
| Windows Taskbar Flash | ✅ | `WindowsTaskbar.setProgressMode()` |
|
||||
| Ausstehende Reply beim App-Öffnen senden | ✅ | `_sendPendingReply()` bei Resume + Sync |
|
||||
| Push Rules konfigurieren | ❌ | Kein UI |
|
||||
| Raum stumm schalten | ❌ | Kein UI |
|
||||
| Nur-Erwähnung-Modus | ✅ | `notifMentionOnlyDmProvider` — Preferences |
|
||||
| iOS Push | ❌ | Kein iOS-Build |
|
||||
| Web Push | ❌ | Kein Web-Build |
|
||||
|
||||
---
|
||||
|
||||
## VoIP & Calls
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Matrix WebRTC Legacy-Calls | ✅ | `voip_manager.dart` — CallSession |
|
||||
| LiveKit SFU-Calls | ✅ | `livekit_call_manager.dart` |
|
||||
| Unified Call-UI | ✅ | `voice_channel.dart` |
|
||||
| Audio-Call | ✅ | Mikrofon + Speaker |
|
||||
| Video-Call | ✅ | Kamera + Remote-Video |
|
||||
| Screen-Sharing | ✅ | `screen_share_picker.dart` |
|
||||
| Video-Qualitätsstufen | ✅ | SD/HD/FHD/4K Presets |
|
||||
| Mute-Toggle | ✅ | `setMicrophoneEnabled()` |
|
||||
| Deafen-Toggle | ✅ | Audio komplett stumm |
|
||||
| Kamera-Toggle | ✅ | `setCameraEnabled()` |
|
||||
| Kamera-Wechsel (Front/Rück) | ⚠️ | SDK-Support vorhanden |
|
||||
| Teilnehmer-Grid | ✅ | Mehrere Participants anzeigen |
|
||||
| Lokale Video-Vorschau | ✅ | Overlay-Widget |
|
||||
| Klingel-System (to-device) | ⚠️ | Infrastruktur vorhanden, Klingelton fehlt |
|
||||
| Incoming-Call-UI | ⚠️ | Kein dediziertes Klingel-Screen |
|
||||
| Call-History | ❌ | Nicht implementiert |
|
||||
| TURN-Server via Matrix | ⚠️ | `getTurnServer()` — ob konfiguriert unklar |
|
||||
| Hintergrund-Foreground-Service | ✅ | `FlutterForegroundService` via webrtc |
|
||||
| MatrixRTC Membership-Events | ✅ | `io.pyramid.voice.presence` State-Events |
|
||||
| LiveKit-Token-Exchange | ✅ | `livekit_token.dart` |
|
||||
|
||||
---
|
||||
|
||||
## Settings / Einstellungen
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Settings-Seite (Hauptseite) | ⚠️ | `settings_page.dart` — NUR PLATZHALTER |
|
||||
| Settings Modal | ✅ | `settings_modal.dart` — vollständiger Modal |
|
||||
| Dark/Light Mode | ✅ | `themeModeProvider` + Toggle |
|
||||
| Akzentfarbe (5 Presets) | ✅ | Amber/Coral/Mint/Violet/Cobalt/Lime |
|
||||
| Eckenradius Slider | ✅ | `radiusProvider` |
|
||||
| UI-Motion Toggle | ✅ | `motionProvider` |
|
||||
| UI-Density Toggle | ✅ | `densityProvider` |
|
||||
| Desktop Notifications | ✅ | `notifDesktopProvider` |
|
||||
| Notification Sound | ✅ | `notifSoundProvider` |
|
||||
| Nachrichtenvorschau | ✅ | `notifPreviewProvider` |
|
||||
| Nur-Erwähnung-Modus | ✅ | `notifMentionOnlyDmProvider` |
|
||||
| Presence anzeigen (Online) | ✅ | `privacyPresenceProvider` |
|
||||
| Read Receipts senden | ✅ | `privacyReadReceiptsProvider` |
|
||||
| Typing-Indikator senden | ✅ | `privacyTypingProvider` |
|
||||
| Profil bearbeiten (Name/Avatar) | ❌ | Kein UI |
|
||||
| Geräte-Verwaltung | ❌ | Kein UI |
|
||||
| Session-Liste | ❌ | Kein UI |
|
||||
| Passwort ändern | ❌ | Kein UI |
|
||||
| Account löschen | ❌ | Kein UI |
|
||||
| Raumspezifische Benachrichtigungen | ❌ | Kein UI |
|
||||
| Sprache / Lokalisierung | ❌ | Nur Systemsprache |
|
||||
| Barrierefreiheit | ❌ | Keine Einstellungen |
|
||||
|
||||
---
|
||||
|
||||
## Benutzerprofil
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Avatar anzeigen (MXC) | ✅ | `mxc_image.dart` überall |
|
||||
| Displayname auflösen | ✅ | Fallback auf `@username` |
|
||||
| Online-Präsenz senden | ✅ | `setPresence(online)` alle 10s in `app_shell.dart` |
|
||||
| Präsenz-Anzeige anderer User | ⚠️ | Infrastruktur, kein UI |
|
||||
| Profil-Popover (Hover-Karte) | ✅ | `profile_popover.dart` |
|
||||
| Eigenes Profil bearbeiten | ❌ | Kein UI |
|
||||
| Avatar hochladen | ❌ | Kein UI |
|
||||
| Status-Nachricht | ❌ | Nicht implementiert |
|
||||
| User ignorieren / blockieren | ❌ | Kein UI |
|
||||
| User melden | ❌ | Kein UI |
|
||||
|
||||
---
|
||||
|
||||
## Raum-Einstellungen
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Space-Admin Panel (Name/Avatar/Banner) | ✅ | `space_admin_dialog.dart` — Overview-Tab |
|
||||
| Power-Level-Editor (Mitglieder) | ✅ | `space_admin_dialog.dart` — Members-Tab |
|
||||
| Permissions-Tab (Raum-PLs) | ✅ | `space_admin_dialog.dart` — Permissions-Tab |
|
||||
| Kind-Räume verwalten | ✅ | `space_admin_dialog.dart` — Channels-Tab |
|
||||
| Raum-Name ändern (normaler Raum) | ❌ | Kein UI |
|
||||
| Raum-Thema ändern | ❌ | Kein UI |
|
||||
| Raum-Avatar ändern | ❌ | Kein UI |
|
||||
| Join-Rules ändern | ❌ | Kein UI |
|
||||
| History-Visibility ändern | ❌ | Kein UI |
|
||||
| E2EE aktivieren | ❌ | Kein UI |
|
||||
| Raum-Alias verwalten | ❌ | Kein UI |
|
||||
| Raum verlassen (Button) | ❌ | Kein UI |
|
||||
| Raum vergessen (forget) | ❌ | Kein UI |
|
||||
| User einladen (aus Chat) | ❌ | Kein UI |
|
||||
| Kick / Ban (Moderation) | ❌ | Kein UI |
|
||||
|
||||
---
|
||||
|
||||
## Suche
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Server-seitige Nachrichtensuche | ✅ | `search_modal.dart` → `/search` API |
|
||||
| Lokale Raum-/DM-Suche | ✅ | Filter auf `client.rooms` |
|
||||
| Personen-Suche | ✅ | Lokale DM-Partner-Suche |
|
||||
| Medien-Filter (Bilder/Videos/Audio) | ✅ | `search_modal.dart` |
|
||||
| Paginierung (nextBatch) | ✅ | Infinite scroll |
|
||||
| Suchergebnis-Highlighting | ✅ | Suchbegriff hervorheben |
|
||||
| Debounced Query (400ms) | ✅ | `debounce` in Suchfeld |
|
||||
| Öffentliches Raumverzeichnis | ❌ | Kein UI zum Entdecken |
|
||||
| User-Verzeichnis-Suche (global) | ✅ | In `new_dm_dialog.dart` |
|
||||
|
||||
---
|
||||
|
||||
## Medien
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Bild senden | ✅ | `MatrixImageFile` + Upload |
|
||||
| Bild anzeigen (Thumbnail + Vollbild) | ✅ | `MediaViewer` mit Zoom/Pan |
|
||||
| Video senden | ✅ | |
|
||||
| Video abspielen | ✅ | `media_kit` |
|
||||
| Audio senden | ✅ | |
|
||||
| Audio abspielen | ✅ | Player mit Progress |
|
||||
| Datei senden | ✅ | `MatrixFile` |
|
||||
| Datei herunterladen | ✅ | MIME-Icon + Browser-Öffner |
|
||||
| Clipboard-Bild einfügen (Windows) | ✅ | FFI + `clipboard_image.dart` |
|
||||
| Drag & Drop Upload (Desktop) | ✅ | `desktop_drop` |
|
||||
| GIF-Picker (Giphy) | ✅ | Trending + Suche + Favoriten |
|
||||
| Sticker-Picker | ✅ | Pack-Browser |
|
||||
| Bild-Komprimierung | ✅ | `shrinkImageMaxDimension` |
|
||||
| Verschlüsselte Medien | ✅ | Automatisch bei E2EE-Räumen |
|
||||
| Sprachnachricht aufnehmen | ❌ | Kein Recorder |
|
||||
| Kamera direkt (in App) | ❌ | Nur Gallery-Picker |
|
||||
| Dokument-Vorschau | ❌ | Kein PDF-Viewer |
|
||||
| Media-Galerie (Raum-Mediathek) | ❌ | Kein UI |
|
||||
|
||||
---
|
||||
|
||||
## Read Receipts & Typing
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Read Receipts senden | ✅ | `setReadMarker()` (privacy-gesteuert) |
|
||||
| Eigene ✓✓ Bestätigung | ✅ | Anzeige auf eigenen Nachrichten |
|
||||
| Read-Marker anderer anzeigen | ⚠️ | Position-Marker vorhanden, kein Avatar-Stack |
|
||||
| Typing-Indikator senden | ✅ | 7s Auto-Timeout |
|
||||
| Typing-Indikator empfangen | ✅ | Anzeige-Platzhalter |
|
||||
| Typing-User-Namen anzeigen | ⚠️ | Platzhalter, keine Namen |
|
||||
|
||||
---
|
||||
|
||||
## Spaces
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Space-Erkennung (`isSpace`) | ✅ | |
|
||||
| Spaces-Rail (Sidebar) | ✅ | `spaces_rail.dart` |
|
||||
| Space-Filterung der Raumliste | ✅ | Nur Räume der aktiven Space |
|
||||
| Space-Admin Panel | ✅ | `space_admin_dialog.dart` |
|
||||
| Space erstellen | ✅ | `create_join_dialog.dart` |
|
||||
| Kind-Räume hinzufügen | ✅ | In Space-Admin |
|
||||
| Kind-Räume entfernen | ✅ | In Space-Admin |
|
||||
| Voice-Präsenz in Space | ✅ | `io.pyramid.voice.presence` Events |
|
||||
| Space-Banner | ✅ | Mit Crop-Dialog |
|
||||
| Verschachtelte Spaces | ⚠️ | Anzeige, kein eigener Sub-Space-Rail |
|
||||
| Space beitreten (öffentlich) | ⚠️ | Technisch möglich, kein Discovery-UI |
|
||||
|
||||
---
|
||||
|
||||
## Windows / Desktop
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Windows Build | ✅ | CMake + MSIX |
|
||||
| Windows Notifications | ✅ | `local_notifier` |
|
||||
| Taskbar Flash | ✅ | `WindowsTaskbar` |
|
||||
| Update-Checker (Gitea API) | ✅ | `update_checker.dart` — 4h Intervall |
|
||||
| Update-Banner | ✅ | `update_banner.dart` |
|
||||
| Update-Download | ✅ | `update_download_dialog.dart` |
|
||||
| APK Auto-Install (Android) | ✅ | Kotlin MethodChannel + FileProvider |
|
||||
| PC Update (exe/msix) Download | ⚠️ | Release-Script pusht .zip statt .exe → öffnet Git-Seite |
|
||||
| Drag & Drop | ✅ | `desktop_drop` |
|
||||
| Clipboard Paste (Bild) | ✅ | FFI Windows API |
|
||||
| Resizable Sidebar | ✅ | `panel_layout.dart` — Drag-Divider |
|
||||
| Keyboard Shortcuts | ⚠️ | Enter zum Senden, Ctrl+V — kein vollständiges System |
|
||||
| System Tray | ❌ | Nicht implementiert |
|
||||
| Fenster-Badge (ungelesen) | ⚠️ | Nur Taskbar-Flash, kein Zähler |
|
||||
| macOS / Linux | ❌ | Nicht gebaut/getestet |
|
||||
|
||||
---
|
||||
|
||||
## Sonstige Features
|
||||
| Feature | Status | Details / Datei |
|
||||
|---------|--------|-----------------|
|
||||
| Emoji-Picker | ✅ | Kategorisiert + Suche |
|
||||
| Quick-Reaktionen (6 Emojis) | ✅ | Hover-Aktionsleiste |
|
||||
| Mitglieder-Panel (Raum) | ✅ | `members/` — Teilnehmerliste |
|
||||
| Profil-Popover (Hover) | ✅ | `profile_popover.dart` |
|
||||
| Neuer-DM Dialog | ✅ | `new_dm_dialog.dart` |
|
||||
| Raum-erstellen Dialog | ✅ | `create_join_dialog.dart` |
|
||||
| Nachricht-Auswahl-Modus | ✅ | Long-Press |
|
||||
| Reply-Swipe-Geste | ✅ | Rechts-wischen zum Antworten |
|
||||
| Immersive-Mode (Android) | ✅ | Status-Bar ausgeblendet |
|
||||
| PyramidLoader (Ladeanimation) | ✅ | `pyramid_loader.dart` |
|
||||
| Rich-Text-Editor | ❌ | Kein Markdown/Formatting-UI |
|
||||
| HTML-Nachrichtenrendering | ❌ | Kein `flutter_html` |
|
||||
| Message Forwarding | ❌ | |
|
||||
| Nachrichtenübersetzung | ❌ | |
|
||||
| Bot-Kommandos (`/`) | ❌ | |
|
||||
| Matrix-URI Handler | ❌ | Kein Deep-Link-Handler |
|
||||
| Barcode/QR-Scanner | ⚠️ | Nur in Verifikation |
|
||||
|
||||
---
|
||||
|
||||
## Dokumentations-Vollständigkeit
|
||||
|
||||
| Doku-Abschnitt | Vollständig | Anmerkungen |
|
||||
|----------------|-------------|-------------|
|
||||
| 01 Auth | ✅ | SSO, UIAA, alle Login-Flows |
|
||||
| 02 Räume | ✅ | Create, Join, Spaces, Power Levels |
|
||||
| 03 Nachrichten | ✅ | Text, Replies, Edits, Threads, Polls |
|
||||
| 04 DMs | ✅ | Alle DM-Flows |
|
||||
| 05 Medien | ✅ | Upload, Download, alle Dateitypen |
|
||||
| 06 E2EE | ✅ | Setup, SAS, Cross-Signing, Key Backup |
|
||||
| 07 Push | ✅ | FCM, Push Rules, Pyramid-Architektur |
|
||||
| 08 VoIP | ✅ | Legacy m.call, MatrixRTC |
|
||||
| 08b LiveKit | ✅ | **KOMPLETT** — Server-Setup bis Flutter-UI |
|
||||
| 09 Profil | ✅ | Presence, Account-Data, Tags |
|
||||
| 10 Room-Admin | ✅ | Power Levels, Moderation, ACL, Synapse Admin |
|
||||
| 11 Suche | ✅ | Event-Suche, User-Suche, Raumverzeichnis |
|
||||
| 12 Schnellreferenz | ✅ | Alle Enums, Widget-Patterns, Fehlerhandling |
|
||||
|
||||
---
|
||||
|
||||
## Prioritäts-Roadmap
|
||||
|
||||
### 🔴 Kritisch (bald testen/fixen)
|
||||
1. **Notification-Reply testen** — Neue APK (getActivity-Fix) installieren und Reply-Flow verifizieren
|
||||
2. **Benachrichtigung bei geschlossener App** — Mit neuer APK testen (PushService.kt Ansatz)
|
||||
3. **PC Update-Script** — `.zip` durch `.exe`-Installer ersetzen in `scripts/release.ps1`
|
||||
|
||||
### 🟠 Wichtig (nächste Features)
|
||||
4. **Nachricht löschen (Redact)** — Long-Press-Menü → `event.redactEvent()`
|
||||
5. **Nachricht bearbeiten** — Edit-Modus im Composer → `sendTextEvent(editEventId:...)`
|
||||
6. **Raum-Einstellungen** — Name/Thema/Avatar für normale Räume (`room.setName()` etc.)
|
||||
7. **Profil bearbeiten** — Displayname + Avatar in Settings
|
||||
8. **Registrierung** — Account-Erstellung im Onboarding
|
||||
|
||||
### 🟡 Sinnvoll (mittelfristig)
|
||||
9. **Einladungen-Screen** — Pending invites sichtbar darstellen
|
||||
10. **Kick/Ban UI** — Moderation aus der Mitgliederliste
|
||||
11. **Device-Verwaltung** — Eigene Sessions anzeigen + löschen
|
||||
12. **Rich-Text-Editor** — Markdown/Formatierung in Nachrichten
|
||||
13. **Klingelton bei eingehendem Call** — Audio + dedizierter Klingel-Screen
|
||||
14. **Lautlos/Stumm per Raum** — Push-Rules im UI
|
||||
|
||||
### 🟢 Nice-to-have
|
||||
15. Sprachnachrichten aufnehmen
|
||||
16. HTML-Nachrichtenrendering (`flutter_html`)
|
||||
17. Matrix-URI Deep-Links
|
||||
18. Media-Galerie pro Raum
|
||||
19. System Tray (Windows)
|
||||
20. Polls / Abstimmungen
|
||||
@@ -0,0 +1,122 @@
|
||||
# Matrix SDK — Pyramid Entwicklungsreferenz
|
||||
|
||||
> **Basis:** [`package:matrix`](https://pub.dev/packages/matrix) (Dart/Flutter Matrix SDK)
|
||||
> **Ziel:** Vollständige Landkarte aller Matrix-Funktionen mit Code-Snippets für den Pyramid-App-Bau.
|
||||
> **Referenz-Implementierung:** FluffyChat (https://gitlab.com/famedly/fluffychat)
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
| # | Thema | Beschreibung |
|
||||
|---|-------|--------------|
|
||||
| [01](01-auth.md) | **Authentifizierung** | Login, Logout, Register, SSO, Token-Management |
|
||||
| [02](02-rooms.md) | **Räume & Spaces** | Erstellen, Beitreten, Verlassen, Spaces, Suche |
|
||||
| [03](03-messages.md) | **Nachrichten & Events** | Senden, Timeline, Reaktionen, Edits, Threads, Polls |
|
||||
| [04](04-dm.md) | **Direct Messages** | DM-Räume finden, starten, verwalten |
|
||||
| [05](05-media.md) | **Medien & Dateien** | Upload, Download, Thumbnails, verschlüsselte Medien |
|
||||
| [06](06-encryption.md) | **E2EE Verschlüsselung** | Keys, Verifizierung, Cross-Signing, Key-Backup |
|
||||
| [07](07-push.md) | **Push & Notifications** | Pusher, Push Rules, FCM, lokale Benachrichtigungen |
|
||||
| [08](08-voip.md) | **VoIP & Calls** | MatrixRTC, Legacy m.call, Klingel-Flow |
|
||||
| [08b](08b-livekit-streaming.md) | **LiveKit Streaming** ⭐ | **Vollständige Server-Anleitung + Flutter-Implementierung** |
|
||||
| [09](09-profile.md) | **Profil & Präsenz** | Avatar, Displayname, Presence, Account-Daten |
|
||||
| [10](10-room-admin.md) | **Raum-Administration** | Power Levels, Moderierung, Einstellungen, Bans |
|
||||
| [11](11-search.md) | **Suche & Discovery** | Nachrichten-Suche, User-Suche, Raum-Verzeichnis |
|
||||
| [12](12-quick-reference.md) | **Schnellreferenz** | Alle Enums, Widget-Patterns, Fehlerhandling |
|
||||
| [13](13-server-admin-continuwuity.md) | **Server-Admin Continuwuity** ⭐ | Admin API, Power Levels erzwingen, MSC3083, Diagnose |
|
||||
| [CHECKLIST](CHECKLIST.md) | **Feature-Checklist** ⭐ | Implementierungsstand + Roadmap |
|
||||
|
||||
---
|
||||
|
||||
## Schnellreferenz: Client-Initialisierung
|
||||
|
||||
```dart
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
final client = Client(
|
||||
'MeinApp', // App-Name (User-Agent)
|
||||
database: MatrixSdkDatabase(...), // Persistenz (sqflite)
|
||||
nativeImplementations: NativeImplementationsIsolate(compute), // E2EE
|
||||
logLevel: Level.warning,
|
||||
);
|
||||
|
||||
// Initialisieren (lädt gespeicherten Session-State)
|
||||
await client.init(
|
||||
waitForFirstSync: false, // UI nicht blockieren
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Überblick
|
||||
|
||||
```
|
||||
Client
|
||||
├── rooms: List<Room> // alle bekannten Räume
|
||||
├── userID: String? // eigene MXID (@user:server)
|
||||
├── accessToken: String? // Bearer-Token
|
||||
├── homeserver: Uri // https://matrix.example.org
|
||||
│
|
||||
├── Room
|
||||
│ ├── id: String // !roomId:server
|
||||
│ ├── getTimeline() // Event-Stream
|
||||
│ ├── sendTextEvent()
|
||||
│ ├── sendEvent()
|
||||
│ ├── members // Map<String, User>
|
||||
│ └── states // Map<String, Map<String, StrippedStateEvent>>
|
||||
│
|
||||
├── Event
|
||||
│ ├── type: String // m.room.message, m.reaction, …
|
||||
│ ├── content: Map<String, dynamic>
|
||||
│ ├── body: String // text body (decrypted)
|
||||
│ └── messageType: String? // m.text, m.image, m.file, …
|
||||
│
|
||||
└── User
|
||||
├── id: String // MXID
|
||||
├── displayName: String?
|
||||
└── avatarUrl: Uri?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Streams / Provider-Hooks
|
||||
|
||||
```dart
|
||||
// Auf Sync-Events reagieren
|
||||
client.onSync.stream.listen((SyncUpdate update) { ... });
|
||||
|
||||
// Neue/geänderte Räume
|
||||
client.onRoomUpdate.stream.listen((String roomId) { ... });
|
||||
|
||||
// Verbindungsstatus
|
||||
client.onConnectionStateChanged.stream.listen((ConnectionState s) { ... });
|
||||
|
||||
// Eigener User geändert
|
||||
client.onUserEvent.stream.listen((EventUpdate update) { ... });
|
||||
|
||||
// Benachrichtigungs-Events (gefiltert nach Push-Rules)
|
||||
client.onNotification.stream.listen((Event event) { ... });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tipps für Riverpod-Integration
|
||||
|
||||
```dart
|
||||
// Matrix-Client als Provider
|
||||
final matrixClientProvider = FutureProvider<Client>((ref) async {
|
||||
final client = Client('Pyramid', ...);
|
||||
await client.init(waitForFirstSync: false);
|
||||
return client;
|
||||
});
|
||||
|
||||
// Raumliste reaktiv
|
||||
final roomsProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
yield client.rooms;
|
||||
await for (final _ in client.onSync.stream) {
|
||||
yield client.rooms;
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# Server-Incident: Messaging-Ausfall — 2026-05-31
|
||||
|
||||
## Betroffene Nutzer
|
||||
`@uta:steggi-matrix.work`, `@circuitslave:steggi-matrix.work`
|
||||
|
||||
## Symptome
|
||||
- Uta und Circuitslave konnten in verschlüsselten DM-Räumen keine Nachrichten senden
|
||||
- Nachrichten von `@todesneutron` kamen nicht an bzw. konnten nicht entschlüsselt werden
|
||||
- `@jochen` (Test-Account, FluffyChat) war nicht betroffen
|
||||
- Später: Element zeigt „Verbindung unterbrochen / Server offline"
|
||||
- Nach Behebung: alle Chats zunächst leer (History nicht sichtbar)
|
||||
|
||||
---
|
||||
|
||||
## Ursachen (in Reihenfolge des Auftretens)
|
||||
|
||||
### 1. Continuwuity 0.5.8 — `room_keys/version` 500-Fehler
|
||||
**Endpunkt:** `GET /_matrix/client/unstable/room_keys/version`
|
||||
|
||||
Continuwuity 0.5.8 crashte bei dieser Anfrage mit:
|
||||
```
|
||||
response error: JSON serialization failed: missing field `algorithm` at line 1 column 2
|
||||
500 Internal Server Error
|
||||
```
|
||||
|
||||
Element prüft diesen Endpunkt beim Start, bevor es verschlüsselte Nachrichten sendet.
|
||||
Der 500-Fehler verhinderte den korrekten Aufbau des E2E-Zustands → Senden blockiert.
|
||||
|
||||
FluffyChat/Pyramid implementieren diesen Check nicht → waren nicht betroffen.
|
||||
|
||||
### 2. Zu viele unverifizierte Geräte bei `@todesneutron`
|
||||
Durch die Pyramid-Entwicklung wurden 14 Geräte angesammelt (alte FluffyChat-,
|
||||
Commet- und Pyramid-Debug-Sessions). Element von Uta/Circuitslave sah all diese
|
||||
Geräte als unbekannt und konnte nicht für alle gleichzeitig verschlüsseln.
|
||||
|
||||
### 3. Continuwuity 0.5.9-latest — Axum Routing Panic (Folgeschaden)
|
||||
Beim Update auf `latest` (0.5.9, Commit `0514491`) trat ein Panic auf:
|
||||
```
|
||||
thread 'conduwuit:worker' panicked at axum-0.8.9/src/routing/path_router.rs:406:22:
|
||||
no route for id. This is a bug in axum.
|
||||
```
|
||||
|
||||
Ausgelöst durch: `GET /_matrix/client/v3/user/@circuitslave:steggi-matrix.work/filter/ieOT`
|
||||
|
||||
Circuitslaves Element hatte nach dem ersten Neustart seinen Filter verloren und
|
||||
sendete diese Anfrage in einer Schleife → Server-Worker crashte wiederholt →
|
||||
Cloudflare bekam EOF → alle Clients offline.
|
||||
|
||||
### 4. Leere Chat-History nach Behebung
|
||||
Continuwuity 0.5.9 hat die interne RocksDB-Struktur von 98 auf 100 Column Families
|
||||
erweitert. Das invalidierte alle gespeicherten Sync-Token der Clients → Fresh Initial
|
||||
Sync → Server schickt nur aktuelle Room-States ohne vollständige Timeline.
|
||||
|
||||
**Die Daten waren nicht verloren** — Pagination via `/messages` funktionierte korrekt.
|
||||
Durch Hochscrollen in den Räumen wurde die History nachgeladen.
|
||||
|
||||
---
|
||||
|
||||
## Maßnahmen
|
||||
|
||||
| Schritt | Aktion |
|
||||
|---|---|
|
||||
| Diagnose | Geräteliste, Raumzustand, Server-Logs via SSH geprüft |
|
||||
| Geräte aufräumen | 14 → 5 aktive Geräte bei `@todesneutron` |
|
||||
| Server-Update | Wechsel auf `continuwuity:main` (Commit `1faa09b`) |
|
||||
| Ergebnis | `room_keys/version` gibt gültige Antwort, Filter-Panic behoben |
|
||||
| History | Nutzer scrollten in Räumen nach oben → Pagination lud History nach |
|
||||
|
||||
---
|
||||
|
||||
## Server-Infrastruktur
|
||||
|
||||
- **Homeserver:** Continuwuity (Conduit-Fork, Rust) via Docker
|
||||
- **Compose-Datei:** `/home/steggi/matrix/docker-compose.yml`
|
||||
- **Image (vorher):** `registry.gitlab.com/continuwuity/continuwuity:latest` (0.5.8 → 0.5.9)
|
||||
- **Image (jetzt):** `registry.gitlab.com/continuwuity/continuwuity:main` (0.5.9, Commit `1faa09b`)
|
||||
- **Tunnel:** Cloudflare (`cloudflared`)
|
||||
- **Datenbank:** RocksDB unter `/home/steggi/matrix/db/`
|
||||
|
||||
---
|
||||
|
||||
## Lektionen
|
||||
|
||||
- `latest`-Tag vor produktivem Update testen (0.5.9-latest hatte Routing-Bug)
|
||||
- Alte Geräte regelmäßig aus Element/Pyramid abmelden (verhindert E2E-Probleme)
|
||||
- `continuwuity:main` ist stabiler als `continuwuity:latest`
|
||||
- FluffyChat/Pyramid senden auch ohne Key-Backup-Check → können als Fallback dienen
|
||||
Reference in New Issue
Block a user