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 &&