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,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
|
||||
);
|
||||
```
|
||||
Reference in New Issue
Block a user