From 7063ebfcebd2e7670d6a674249feb7ede782c812 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Wed, 7 Oct 2026 18:17:35 +0200 Subject: [PATCH] feat: Antwort-Zitat antippen springt zur zitierten Nachricht (inkl. Aufleuchten) _ReplyPreview nutzt den Sprung-Mechanismus der Suche (pendingJumpEventProvider). Der Sprung scrollt jetzt schrittweise Richtung aeltere Nachrichten, wenn das Ziel zwar geladen, aber in der lazy ListView noch nicht gezeichnet ist (vorher gab er dann nach 40 Versuchen auf); dasselbe Zitat darf erneut angetippt werden. UI-Durchlauf prueft den Sprung per Screenshot. Co-Authored-By: Claude Opus 5.5 --- integration_test/mobile_ui_screens_test.dart | 18 +++++++++++++++ .../chat/message/message_link_preview.dart | 16 +++++++++++++- lib/features/chat/message_group.dart | 4 +++- lib/features/chat/view/chat_view_core.dart | 22 ++++++++++++++++--- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/integration_test/mobile_ui_screens_test.dart b/integration_test/mobile_ui_screens_test.dart index 8c6297d..01fb1bd 100644 --- a/integration_test/mobile_ui_screens_test.dart +++ b/integration_test/mobile_ui_screens_test.dart @@ -10,6 +10,7 @@ import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/appearance.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/main.dart' as app; +import 'package:pyramid/widgets/spring_press.dart'; /// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem /// TEST-Profil – erzeugt Screenshots unter build/ui_screens/. @@ -70,6 +71,23 @@ void main() { await tester.tap(find.text(_otherUser).first); await _pumpFor(tester, const Duration(seconds: 1)); + // Antwort-Zitat antippen → springt zur zitierten Nachricht. + final quote = find.byWidgetPredicate( + (w) => w is SpringPress && w.pressedScale == 0.97, + ); + await tester.scrollUntilVisible( + quote.first, + 300, + scrollable: find.byType(Scrollable).first, + maxScrolls: 40, + ); + await _pumpFor(tester, const Duration(milliseconds: 600)); + await _shot(tester, '02e_zitat_vor_dem_tippen'); + await tester.tap(quote.first, warnIfMissed: false); + await _pumpFor(tester, const Duration(milliseconds: 900)); + await _shot(tester, '02f_sprung_zum_zitat'); + await _scrollToNewest(tester); + // ⋮-Menü mit Suchen/Mitglieder. await tester.tap(find.byIcon(Icons.more_vert_rounded).first); await _pumpFor(tester, const Duration(seconds: 1)); diff --git a/lib/features/chat/message/message_link_preview.dart b/lib/features/chat/message/message_link_preview.dart index 316a436..20a9897 100644 --- a/lib/features/chat/message/message_link_preview.dart +++ b/lib/features/chat/message/message_link_preview.dart @@ -159,7 +159,18 @@ class _ReplyPreview extends StatelessWidget { // matching WhatsApp/Discord. Falls back to stripped text for m.text. final (IconData? icon, String body) = _previewFor(replyEvent); - return Padding( + // Antippen springt zur zitierten Nachricht und lässt sie kurz aufleuchten + // (gleicher Mechanismus wie der Sprung aus der Suche). + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => ProviderScope.containerOf(context, listen: false) + .read(pendingJumpEventProvider.notifier) + .state = replyEvent.eventId, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: SpringPress( + pressedScale: 0.97, + child: Padding( padding: const EdgeInsets.only(bottom: 4), child: IntrinsicHeight( child: ClipRRect( @@ -204,6 +215,9 @@ class _ReplyPreview extends StatelessWidget { ), ), ), + ), + ), + ), ), ); } diff --git a/lib/features/chat/message_group.dart b/lib/features/chat/message_group.dart index db6c211..799f707 100644 --- a/lib/features/chat/message_group.dart +++ b/lib/features/chat/message_group.dart @@ -9,7 +9,8 @@ import 'package:pyramid/features/chat/media_player.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; -import 'package:pyramid/core/app_state.dart' show messageStyleProvider; +import 'package:pyramid/core/app_state.dart' + show messageStyleProvider, pendingJumpEventProvider; import 'package:pyramid/core/appearance.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/media_cache.dart'; @@ -21,6 +22,7 @@ import 'package:pyramid/features/chat/media_viewer.dart'; import 'package:pyramid/features/chat/reaction_service.dart'; import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/widgets/mxc_image.dart'; +import 'package:pyramid/widgets/spring_press.dart'; import 'package:url_launcher/url_launcher.dart'; part 'message/message_group_core.dart'; diff --git a/lib/features/chat/view/chat_view_core.dart b/lib/features/chat/view/chat_view_core.dart index b9a85fa..2f88beb 100644 --- a/lib/features/chat/view/chat_view_core.dart +++ b/lib/features/chat/view/chat_view_core.dart @@ -77,11 +77,25 @@ class _ChatViewState extends ConsumerState { setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; }); return; } - // Not built yet — load older history toward the target every few tries. + // Not built yet. Die Liste baut nur sichtbare Einträge (lazy, reverse): + // Ist das Ziel schon geladen, Richtung ältere Nachrichten scrollen, bis + // es gezeichnet ist (Antwort-Zitate zeigen fast immer nach oben); sonst + // zusätzlich ältere Historie nachladen. final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; - if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) { + final loaded = + timeline?.events.any((e) => e.eventId == _jumpEventId) ?? false; + if (!loaded && + timeline != null && + timeline.canRequestHistory && + attempt % 3 == 0) { timeline.requestHistory(historyCount: 100).catchError((_) {}); } + if (_scrollCtrl.hasClients && (loaded || attempt % 3 == 1)) { + final pos = _scrollCtrl.position; + final step = pos.viewportDimension * 0.8; + final target = (pos.pixels + step).clamp(0.0, pos.maxScrollExtent); + if (target > pos.pixels) _scrollCtrl.jumpTo(target); + } WidgetsBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1)); }); @@ -313,7 +327,9 @@ class _ChatViewState extends ConsumerState { // listen) so it also fires when this ChatView is freshly mounted for a // DIFFERENT room — listen would miss a value set before registration. final pendingJump = ref.watch(pendingJumpEventProvider); - if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) { + // (Kein Vergleich mit _jumpEventId mehr: dasselbe Zitat darf zweimal + // hintereinander angetippt werden – _startJump verbraucht die Anfrage.) + if (pendingJump != null && !_jumpInProgress) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_jumpInProgress &&