25ed765a03
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
464 lines
11 KiB
Markdown
464 lines
11 KiB
Markdown
# 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
|
|
);
|
|
```
|