From 1d0ccfde8b6891a833246ade1a8b85adab133257 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Wed, 7 Oct 2026 21:32:36 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Lesebest=C3=A4tigung=20nur=20im=20Vorder?= =?UTF-8?q?grund,=20ohne=20Dauerfeuer,=20privat=20statt=20gar=20nicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Offener Chat meldete bei jedem Neuaufbau erneut "gelesen" – auch mit App im Hintergrund; die gerade gezeigte Benachrichtigung verschwand dann sofort wieder. Jetzt nur bei Fokus, je Event nur einmal. - Zurück in der App: "Neue Nachrichten"-Trenner hinter dem zuletzt gelesenen Stand, danach gelesen melden. - "Lesebestätigungen senden" aus: nur noch private Bestätigung (m.read.private) statt gar keiner – Ungelesen-Zähler stimmen wieder. Gilt auch für "Gelesen" aus der Benachrichtigung. Co-Authored-By: Claude Opus 5.5 --- lib/core/background_push.dart | 9 ++- lib/core/notification_service.dart | 12 +++- lib/core/read_markers.dart | 25 +++++++++ lib/core/settings_prefs.dart | 3 +- lib/features/chat/chat_view.dart | 1 + lib/features/chat/view/chat_view_core.dart | 65 +++++++++++++++++++--- 6 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 lib/core/read_markers.dart diff --git a/lib/core/background_push.dart b/lib/core/background_push.dart index 6119e3c..d4c4bab 100644 --- a/lib/core/background_push.dart +++ b/lib/core/background_push.dart @@ -9,6 +9,7 @@ import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/app_database.dart'; import 'package:pyramid/core/notification_content.dart'; +import 'package:pyramid/core/read_markers.dart'; import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -167,9 +168,11 @@ Future _bgMarkRead(String? roomId, String? eventId) async { if (client == null) return false; try { final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client); - await room - .setReadMarker(eventId, mRead: eventId) - .timeout(const Duration(seconds: 15)); + await markRoomReadUpTo( + room, + eventId, + publicReceipt: await loadReadReceiptsPublic(), + ).timeout(const Duration(seconds: 15)); debugPrint('[NOTIF-BGENGINE] read marker set for $roomId'); return true; } catch (e) { diff --git a/lib/core/notification_service.dart b/lib/core/notification_service.dart index d7bca3d..86e1fb0 100644 --- a/lib/core/notification_service.dart +++ b/lib/core/notification_service.dart @@ -13,6 +13,7 @@ import 'package:pyramid/core/background_push.dart'; import 'package:pyramid/core/fcm_push_service.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/notification_content.dart'; +import 'package:pyramid/core/read_markers.dart'; import 'package:pyramid/core/settings_prefs.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:windows_taskbar/windows_taskbar.dart'; @@ -205,9 +206,14 @@ Future _initAndroid(WidgetRef ref) async { if (roomId != null && eventId != null) { try { final client = await ref.read(matrixClientProvider.future); - await client - .getRoomById(roomId) - ?.setReadMarker(eventId, mRead: eventId); + final room = client.getRoomById(roomId); + if (room != null) { + await markRoomReadUpTo( + room, + eventId, + publicReceipt: ref.read(privacyReadReceiptsProvider), + ); + } debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId'); } catch (e) { debugPrint('[NOTIF-FG] markReadFromNotification error: $e'); diff --git a/lib/core/read_markers.dart b/lib/core/read_markers.dart new file mode 100644 index 0000000..6fde2d5 --- /dev/null +++ b/lib/core/read_markers.dart @@ -0,0 +1,25 @@ +import 'package:matrix/matrix.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// SharedPreferences-Schlüssel der Einstellung „Lesebestätigungen senden“ +/// (siehe privacyReadReceiptsProvider). Auch der Hintergrund-Isolate der +/// Benachrichtigungen liest ihn, dort gibt es keine Provider. +const readReceiptsPrefKey = 'privacy_read_receipts'; + +Future loadReadReceiptsPublic() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(readReceiptsPrefKey) ?? true; +} + +/// Setzt Lesemarker und Lesebestätigung bis [eventId]. +/// +/// Mit [publicReceipt] = false (Einstellung „Lesebestätigungen senden“ aus) +/// geht nur die private Bestätigung (m.read.private) raus: Der Server zählt +/// Ungelesenes trotzdem herunter und andere eigene Geräte kennen den Stand, +/// Chatpartner sehen aber kein „gelesen“. Früher wurde in diesem Fall gar +/// nichts gesendet – Ungelesen-Zähler blieben dann stehen. +Future markRoomReadUpTo( + Room room, + String eventId, { + required bool publicReceipt, +}) => room.setReadMarker(eventId, mRead: eventId, public: publicReceipt); diff --git a/lib/core/settings_prefs.dart b/lib/core/settings_prefs.dart index 7f8e590..bf5ebdc 100644 --- a/lib/core/settings_prefs.dart +++ b/lib/core/settings_prefs.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:pyramid/core/appearance.dart'; +import 'package:pyramid/core/read_markers.dart'; import 'package:pyramid/core/theme.dart'; // ── Generic SharedPreferences-backed bool notifier ────────────────────────── @@ -87,7 +88,7 @@ final privacyPresenceProvider = StateNotifierProvider((ref) => BoolPref('privacy_presence', true)); final privacyReadReceiptsProvider = - StateNotifierProvider((ref) => BoolPref('privacy_read_receipts', true)); + StateNotifierProvider((ref) => BoolPref(readReceiptsPrefKey, true)); final privacyTypingProvider = StateNotifierProvider((ref) => BoolPref('privacy_typing', true)); diff --git a/lib/features/chat/chat_view.dart b/lib/features/chat/chat_view.dart index 8b75088..ebbec62 100644 --- a/lib/features/chat/chat_view.dart +++ b/lib/features/chat/chat_view.dart @@ -20,6 +20,7 @@ import 'package:pyramid/features/chat/message_group.dart'; import 'package:pyramid/features/chat/pinned_messages_panel.dart'; import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/measure_size.dart'; +import 'package:pyramid/core/read_markers.dart'; import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/core/notification_service.dart'; diff --git a/lib/features/chat/view/chat_view_core.dart b/lib/features/chat/view/chat_view_core.dart index fd504d0..ef48cde 100644 --- a/lib/features/chat/view/chat_view_core.dart +++ b/lib/features/chat/view/chat_view_core.dart @@ -28,6 +28,10 @@ class _ChatViewState extends ConsumerState { // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom. bool _unreadFading = false; Timer? _unreadFadeTimer; + // Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder + // Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem + // Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen. + String? _lastMarkedEventId; // Jump-to-message (from search): scroll to + briefly highlight a target event. final _jumpKey = GlobalKey(); @@ -127,6 +131,11 @@ class _ChatViewState extends ConsumerState { void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); + // App/Fenster wieder im Vordergrund: was inzwischen kam, jetzt als + // gelesen melden – mit „Neue Nachrichten“-Trenner davor. + ref.listenManual(windowFocusedProvider, (prev, focused) { + if (focused && prev == false) _onFocusRegained(); + }); } @override @@ -142,6 +151,7 @@ class _ChatViewState extends ConsumerState { _historyExhausted = false; _unreadFading = false; _unreadFadeTimer?.cancel(); + _lastMarkedEventId = null; _jumpEventId = null; _highlightEventId = null; _highlightOn = false; @@ -242,21 +252,60 @@ class _ChatViewState extends ConsumerState { } void _markRead([Timeline? tl]) { + // Nur als gelesen melden, was wirklich zu sehen ist: Läuft die App im + // Hintergrund (oder hat das Fenster keinen Fokus), bleibt alles ungelesen + // und die Benachrichtigung stehen – vorher wurde sie beim nächsten + // Neuaufbau sofort wieder entfernt und die Nachricht als gelesen gemeldet. + if (!ref.read(windowFocusedProvider)) return; + // Cancel any pending notification for this room (user is now reading it). cancelNotificationForRoom(widget.roomId); - final sendReceipts = ref.read(privacyReadReceiptsProvider); - if (!sendReceipts) return; final room = ref.read(roomProvider(widget.roomId)); if (room == null) return; + if (room.markedUnread) room.markUnread(false).catchError((_) {}); final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull; - final latest = timeline?.events.firstOrNull; - if (latest != null && latest.status.isSynced) { - room.setReadMarker(latest.eventId, mRead: latest.eventId) - .catchError((_) {}); - } else { - room.markUnread(false).catchError((_) {}); + final latest = timeline?.events + .where((e) => e.status.isSynced) + .firstOrNull; + if (latest == null || latest.eventId == _lastMarkedEventId) return; + _lastMarkedEventId = latest.eventId; + markRoomReadUpTo( + room, + latest.eventId, + publicReceipt: ref.read(privacyReadReceiptsProvider), + ).catchError((_) { + // Beim nächsten Anlass erneut versuchen. + if (_lastMarkedEventId == latest.eventId) _lastMarkedEventId = null; + }); + } + + /// Zurück in der App mit diesem Chat offen: Kamen inzwischen Nachrichten + /// von anderen, erscheint der „Neue Nachrichten“-Trenner hinter dem zuletzt + /// gelesenen Stand; danach wird bis zur neuesten Nachricht gelesen gemeldet. + void _onFocusRegained() { + if (!mounted || !_sessionCaptured) return; + final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; + if (timeline == null) return; + final since = _lastMarkedEventId; + if (since != null && !_sessionHasUnread) { + final me = timeline.room.client.userID; + final events = timeline.events; // newest-first + final idx = events.indexWhere((e) => e.eventId == since); + final hasNew = idx > 0 && + events + .take(idx) + .any((e) => _MessageList._isVisible(e) && e.senderId != me); + if (hasNew) { + _unreadFadeTimer?.cancel(); + setState(() { + _sessionFullyReadId = since; + _sessionHasUnread = true; + _unreadFading = false; + }); + } } + _markRead(timeline); } Future _onScroll() async {