Files
pyramid/lib/features/chat/chat_provider.dart
T
Bernd SteckmeisterandClaude Opus 5.5 c817de924a fix: Haken live, Einladung im Chat annehmen, Trenner verschwindet zuverlässig
Bernds Test mit der PC-Testinstanz:
- ✓✓ erschien erst nach erneutem Öffnen: eine Lesebestätigung ohne neue
  Nachricht zeichnete nichts neu (Raum-Update kommt im SDK vor der
  Bestätigung). Jetzt nach jedem Sync Fingerabdruck der Lesepositionen
  prüfen und bei Änderung neu zeichnen.
- Einladung: Chat zeigte leeren Verlauf, Annehmen ging nur in der Liste, und
  danach blieb der Chat leer bis zum App-Neustart. Jetzt Annehmen/Ablehnen
  direkt im Chat; Annehmen wartet auf den Beitritts-Sync, der Verlauf baut
  sich bei invite→join selbst neu auf.
- Trenner blieb stehen, wenn nach dem Öffnen/Zurückkommen nicht gescrollt
  wurde. Jetzt: neueste Nachrichten sichtbar (≤ halbe Bildschirmhöhe
  hochgescrollt) → nach 6 s weich ausblenden, auch ohne Scrollen.
- Live-Test mit beiden Testkonten (Gegenprobe: ohne Fix rot) in
  scripts/test.ps1 -Ui; Testlauf-Sperre erkennt nur noch eigene Testläufe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:33:59 +02:00

204 lines
6.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/e2ee_diagnostics.dart';
import 'package:pyramid/core/matrix_client.dart';
// AsyncNotifier so onUpdate can refresh state in-place without recreating the
// timeline (which would reset the loaded event window back to the default 30).
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
Timeline? _timeline;
StreamSubscription<String>? _roomUpdateSub;
StreamSubscription<SyncUpdate>? _syncSub;
Timer? _redecryptTimer;
String _receiptSig = '';
@override
Future<Timeline> build(String arg) async {
final roomId = arg;
final client = await ref.watch(matrixClientProvider.future);
final room = client.getRoomById(roomId);
if (room == null) throw Exception('Raum nicht gefunden: $roomId');
_timeline?.cancelSubscriptions();
_roomUpdateSub?.cancel();
_syncSub?.cancel();
_redecryptTimer?.cancel();
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
final timeline = await room.getTimeline(
onUpdate: _onUpdate,
);
_timeline = timeline;
if (timeline.events.length < 20) {
await timeline.requestHistory(historyCount: 60);
}
timeline.requestKeys(onlineKeyBackupOnly: false);
unawaited(_decryptLegacyEvents(client, timeline, roomId, diag));
// Re-decrypt whenever the room updates (e.g. after new keys arrive from
// key requests). Debounced to avoid redundant passes on rapid updates.
_roomUpdateSub = room.onUpdate.stream.listen((_) {
_redecryptTimer?.cancel();
_redecryptTimer = Timer(const Duration(milliseconds: 800), () {
final t = _timeline;
if (t != null) {
unawaited(_decryptLegacyEvents(client, t, roomId, diag));
}
});
});
// Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das
// Raum-Update kommt im SDK VORHER):
// • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim
// Einladen gebaute blieb sonst leer, bis man die App neu startete.
// • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit
// ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus,
// eine Bestätigung ohne neue Nachricht bisher nicht.
final membershipAtBuild = room.membership;
_receiptSig = _receiptSignature(room);
_syncSub = client.onSync.stream.listen((_) {
if (membershipAtBuild != Membership.join &&
room.membership == Membership.join) {
ref.invalidateSelf();
return;
}
final sig = _receiptSignature(room);
if (sig != _receiptSig) {
_receiptSig = sig;
_onUpdate();
}
});
// Ensure device keys are downloaded for all room members so outbound
// Megolm session key sharing works on the first send.
if (room.encrypted && client.encryptionEnabled) {
unawaited(_preloadDeviceKeys(client, room));
}
ref.onDispose(() {
_roomUpdateSub?.cancel();
_syncSub?.cancel();
_redecryptTimer?.cancel();
_timeline?.cancelSubscriptions();
_timeline = null;
});
return timeline;
}
void _onUpdate() {
final t = _timeline;
if (t != null) state = AsyncData(t);
}
}
/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder.
String _receiptSignature(Room room) {
final b = StringBuffer();
for (final users in [
room.receiptState.global.otherUsers,
room.receiptState.mainThread?.otherUsers ?? const {},
]) {
users.forEach((userId, data) => b
..write(userId)
..write('=')
..write(data.eventId)
..write(';'));
}
return b.toString();
}
/// Einladung annehmen und warten, bis der Raum als beigetreten
/// synchronisiert ist – erst dann hat der Verlauf Inhalt.
Future<void> acceptInvite(WidgetRef ref, Room room) async {
final client = room.client;
// Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen.
final synced = client
.waitForRoomInSync(room.id, join: true)
.timeout(const Duration(seconds: 20))
.then<void>((_) {}, onError: (_) {});
await room.join();
await synced;
ref.invalidate(timelineProvider(room.id));
}
final timelineProvider =
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
TimelineNotifier.new,
);
Future<void> _decryptLegacyEvents(
Client client,
Timeline timeline,
String roomId,
E2eeDiagnosticsNotifier diag,
) async {
final enc = client.encryption;
if (enc == null) return;
try {
await enc.keyManager.loadAllKeysFromRoom(roomId);
} catch (e) {
Logs().e('[ChatProvider] loadAllKeysFromRoom failed', e);
}
if (!enc.enabled) return;
var changed = false;
for (var i = 0; i < timeline.events.length; i++) {
final event = timeline.events[i];
if (event.type != EventTypes.Encrypted) continue;
try {
final decrypted = await enc.decryptRoomEvent(
event,
store: true,
updateType: EventUpdateType.history,
);
if (decrypted.type != EventTypes.Encrypted) {
timeline.events[i] = decrypted;
changed = true;
} else {
// Still encrypted — log for diagnostics
final sessionId = event.content.tryGet<String>('session_id') ?? '';
diag.add(E2eeDiagEntry(
timestamp: DateTime.now(),
roomId: roomId,
eventId: event.eventId,
sessionId: sessionId,
error: decrypted.content.tryGet<String>('body') ?? 'unknown',
));
}
} catch (e) {
final sessionId = event.content.tryGet<String>('session_id') ?? '';
diag.add(E2eeDiagEntry(
timestamp: DateTime.now(),
roomId: roomId,
eventId: event.eventId,
sessionId: sessionId,
error: e.toString(),
));
}
}
// Notify UI of in-place changes only when something actually decrypted
if (changed) timeline.onUpdate?.call();
}
Future<void> _preloadDeviceKeys(Client client, Room room) async {
try {
final members = await room.requestParticipants([Membership.join, Membership.invite]);
final userIds = members.map((m) => m.id).toSet();
await client.updateUserDeviceKeys(additionalUsers: userIds);
} catch (e) {
Logs().w('[ChatProvider] Device key preload failed: $e');
}
}
final roomProvider = Provider.family<Room?, String>((ref, roomId) {
final client = ref.watch(matrixClientProvider).valueOrNull;
return client?.getRoomById(roomId);
});