From eb2308da6b4e03aa0a9cb652247d4fb92e934acf Mon Sep 17 00:00:00 2001 From: NuCl34R Date: Sat, 8 Aug 2026 20:38:59 -0400 Subject: [PATCH 1/2] fix(mobile): open threads scrolled to the newest reply Opening a thread on mobile landed on the thread head, forcing users to scroll through potentially hundreds of replies to reach the newest message. Desktop's thread panel already opens at the bottom. Once the authoritative thread query hydrates, jump to the last reply and align its trailing edge with the viewport bottom, then keep following the tail. Deep links to a specific reply keep their existing behavior and take precedence over the tail jump; short threads whose tail is already visible are left alone. Fixes #4354 Signed-off-by: NuCl34R --- .../features/channels/thread_detail_page.dart | 39 ++++++ .../channels/channel_detail_page_test.dart | 124 +++++++++++++++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 2ce862ea71..dbc50c680a 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -117,6 +117,7 @@ class ThreadDetailPage extends HookConsumerWidget { final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); final didJumpToInitialMessage = useRef(false); + final didJumpToThreadTail = useRef(false); final followsThreadTail = useRef(false); final pendingTailAlignment = useRef(null); final tailRealignmentQueued = useRef(false); @@ -173,6 +174,44 @@ class ThreadDetailPage extends HookConsumerWidget { return null; }, [initialMessageId, fetchedReplies, replies.length]); + // Without a deep-link target, opening a thread lands on the newest reply + // (matching desktop's thread panel) instead of the head. Wait for the + // authoritative thread query so the jump measures the full reply list, + // and leave short threads alone when the tail is already on screen. + useEffect(() { + if (initialMessageId != null || + fetchedReplies == null || + didJumpToThreadTail.value) { + return null; + } + didJumpToThreadTail.value = true; + if (replies.isEmpty) return null; + final lastIndex = indexForReply(replies.length - 1); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || !itemScrollController.isAttached) return; + followsThreadTail.value = true; + if (threadTailIsVisible()) return; + // Render the tail first, then align its trailing edge with the + // viewport bottom once its extent is measurable. + itemScrollController.jumpTo(index: lastIndex); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || !itemScrollController.isAttached) return; + final lastPosition = itemPositionsListener.itemPositions.value + .where((position) => position.index == lastIndex) + .firstOrNull; + if (lastPosition == null) return; + final targetAlignment = + 1.0 - + (lastPosition.itemTrailingEdge - lastPosition.itemLeadingEdge); + itemScrollController.jumpTo( + index: lastIndex, + alignment: targetAlignment, + ); + }); + }); + return null; + }, [initialMessageId, fetchedReplies == null, replies.length]); + // A top-anchored list doesn't stick to the newest item the way the old // reversed one did, so follow the tail explicitly: when a reply arrives // while the last item is on screen, scroll it into view. If the user has diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 15cf79a313..35b70c2dd8 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3995,6 +3995,124 @@ void main() { expect(oldestReplyY, lessThan(newestReplyY)); }); + testWidgets('thread opens scrolled to the newest reply', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply number $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + // The newest reply is on screen; the head and oldest replies are not. + expect(find.text('Reply number 39'), findsOneWidget); + expect(find.text('Thread root'), findsNothing); + expect(find.text('Reply number 0'), findsNothing); + }); + + testWidgets('thread deep link still lands on the linked reply', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply number $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-2', + ), + ), + ); + await tester.pumpAndSettle(); + + // The deep-link target wins over the tail jump. + expect(find.text('Reply number 2'), findsOneWidget); + expect(find.text('Reply number 39'), findsNothing); + }); + testWidgets('thread keeps its tail above a growing composer dock', ( tester, ) async { @@ -4086,7 +4204,7 @@ void main() { }); testWidgets( - 'initial thread hydration keeps the head visible instead of following the tail', + 'initial thread hydration lands on the newest reply', (tester) async { final rootEvent = _textMsg( id: 'thread-root', @@ -4143,11 +4261,11 @@ void main() { await tester.pumpAndSettle(); expect( - find.byKey(const ValueKey('thread-message-group-thread-root')), + find.byKey(const ValueKey('thread-message-group-reply-29')), findsOneWidget, ); expect( - find.byKey(const ValueKey('thread-message-group-reply-29')), + find.byKey(const ValueKey('thread-message-group-thread-root')), findsNothing, ); }, From 8e84005e674e0b4519f35165bc109815d19fd3b6 Mon Sep 17 00:00:00 2001 From: NuCl34R Date: Sat, 8 Aug 2026 21:09:51 -0400 Subject: [PATCH 2/2] feat(mobile): jump-to-latest pill in thread view Scrolling up in a long thread left no quick way back to the newest reply. Reuse the channel timeline's frosted Latest pill in the thread detail page: it appears when the newest reply scrolls out of view and animates back to the tail on tap. The pill widget is extracted from the channel message list into a shared public JumpToLatestButton used by both surfaces. _ThreadMessage and _Avatar move to a part file to keep thread_detail_page.dart under the 1000-line ceiling. Signed-off-by: NuCl34R --- .../channels/channel_detail_page.dart | 2 +- .../channel_detail_page/message_list.dart | 64 +--- .../channels/jump_to_latest_button.dart | 80 ++++ .../features/channels/thread_detail_page.dart | 345 +++--------------- .../thread_detail_page/thread_message.dart | 284 ++++++++++++++ .../channels/channel_detail_page_test.dart | 198 ++++++---- 6 files changed, 553 insertions(+), 420 deletions(-) create mode 100644 mobile/lib/features/channels/jump_to_latest_button.dart create mode 100644 mobile/lib/features/channels/thread_detail_page/thread_message.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index c0b64f3e40..f8c5e2b911 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:math' show min; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; @@ -42,6 +41,7 @@ import 'date_formatters.dart'; import 'day_divider.dart'; import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; +import 'jump_to_latest_button.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index b05126f314..e4335f5907 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -550,8 +550,9 @@ class _MessageList extends HookConsumerWidget { right: 0, bottom: composerBottomInset + Grid.xs, child: Center( - child: _JumpToLatestButton( + child: JumpToLatestButton( key: const ValueKey('channel-jump-to-latest'), + surfaceKey: const ValueKey('channel-jump-to-latest-surface'), onPressed: scrollToLatest, ), ), @@ -561,67 +562,6 @@ class _MessageList extends HookConsumerWidget { } } -class _JumpToLatestButton extends StatelessWidget { - final VoidCallback onPressed; - - const _JumpToLatestButton({required this.onPressed, super.key}); - - @override - Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(Radii.full); - return Semantics( - button: true, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - key: const ValueKey('channel-jump-to-latest-surface'), - decoration: BoxDecoration( - color: context.colors.surface.withValues(alpha: 0.5), - borderRadius: borderRadius, - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: onPressed, - borderRadius: borderRadius, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.arrowDown, - size: 16, - color: context.colors.onSurface, - ), - const SizedBox(width: Grid.half), - Text( - 'Latest', - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.onSurface, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - } -} - class _ChannelLatestMetricsObserver with WidgetsBindingObserver { final VoidCallback onMetricsChanged; diff --git a/mobile/lib/features/channels/jump_to_latest_button.dart b/mobile/lib/features/channels/jump_to_latest_button.dart new file mode 100644 index 0000000000..fde8939f59 --- /dev/null +++ b/mobile/lib/features/channels/jump_to_latest_button.dart @@ -0,0 +1,80 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; + +/// Frosted pill that jumps a message timeline back to its newest entry. +/// +/// Shared by the channel timeline and the thread detail page. Callers show +/// it only while the newest message is scrolled out of view and hide it once +/// the tail is visible again. +class JumpToLatestButton extends StatelessWidget { + final VoidCallback onPressed; + + /// Optional key for the frosted surface container, kept separate from the + /// widget [key] so tests can target the surface decoration directly. + final Key? surfaceKey; + + const JumpToLatestButton({ + required this.onPressed, + this.surfaceKey, + super.key, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(Radii.full); + return Semantics( + button: true, + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + key: surfaceKey, + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.5), + borderRadius: borderRadius, + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onPressed, + borderRadius: borderRadius, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.xxs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.arrowDown, + size: 16, + color: context.colors.onSurface, + ), + const SizedBox(width: Grid.half), + Text( + 'Latest', + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index dbc50c680a..7ebaa8b60d 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -17,6 +17,7 @@ import 'channel_link_navigation.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; +import 'jump_to_latest_button.dart'; import 'thread_replies_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; @@ -34,6 +35,8 @@ import 'send_message_provider.dart'; import 'small_avatar.dart'; import 'timeline_message.dart'; +part 'thread_detail_page/thread_message.dart'; + /// Full-screen thread detail page. /// /// Shows the thread head message, direct replies, typing indicators scoped to @@ -116,6 +119,7 @@ class ThreadDetailPage extends HookConsumerWidget { final replies = childrenByParent[threadHead.id] ?? const []; final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); + final isAtTail = useState(true); final didJumpToInitialMessage = useRef(false); final didJumpToThreadTail = useRef(false); final followsThreadTail = useRef(false); @@ -138,7 +142,9 @@ class ThreadDetailPage extends HookConsumerWidget { useEffect(() { void onPositionsChanged() { - if (threadTailIsVisible()) followsThreadTail.value = true; + final atTail = threadTailIsVisible(); + if (atTail) followsThreadTail.value = true; + if (isAtTail.value != atTail) isAtTail.value = atTail; } itemPositionsListener.itemPositions.addListener(onPositionsChanged); @@ -147,6 +153,18 @@ class ThreadDetailPage extends HookConsumerWidget { ); }, [itemPositionsListener, replies.length]); + // Second pass of a tail jump: once the tail item has rendered and its + // extent is measurable, align its trailing edge with the viewport bottom. + void alignTailWithViewportBottom(int lastIndex) { + final lastPosition = itemPositionsListener.itemPositions.value + .where((position) => position.index == lastIndex) + .firstOrNull; + if (lastPosition == null) return; + final targetAlignment = + 1.0 - (lastPosition.itemTrailingEdge - lastPosition.itemLeadingEdge); + itemScrollController.jumpTo(index: lastIndex, alignment: targetAlignment); + } + useEffect(() { final messageId = initialMessageId; // Wait for the authoritative thread query before consuming the one-shot @@ -196,22 +214,30 @@ class ThreadDetailPage extends HookConsumerWidget { itemScrollController.jumpTo(index: lastIndex); WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted || !itemScrollController.isAttached) return; - final lastPosition = itemPositionsListener.itemPositions.value - .where((position) => position.index == lastIndex) - .firstOrNull; - if (lastPosition == null) return; - final targetAlignment = - 1.0 - - (lastPosition.itemTrailingEdge - lastPosition.itemLeadingEdge); - itemScrollController.jumpTo( - index: lastIndex, - alignment: targetAlignment, - ); + alignTailWithViewportBottom(lastIndex); }); }); return null; }, [initialMessageId, fetchedReplies == null, replies.length]); + // Animated return to the newest reply, used by the jump-to-latest pill. + Future scrollToTail() async { + if (!itemScrollController.isAttached) return; + followsThreadTail.value = true; + final lastIndex = replies.isEmpty + ? headIndex + : indexForReply(replies.length - 1); + await itemScrollController.scrollTo( + index: lastIndex, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || !itemScrollController.isAttached) return; + alignTailWithViewportBottom(lastIndex); + }); + } + // A top-anchored list doesn't stick to the newest item the way the old // reversed one did, so follow the tail explicitly: when a reply arrives // while the last item is on screen, scroll it into view. If the user has @@ -587,6 +613,18 @@ class ThreadDetailPage extends HookConsumerWidget { ), ), ), + if (!isAtTail.value) + Positioned( + left: 0, + right: 0, + bottom: composerDockHeight.value + Grid.xs, + child: Center( + child: JumpToLatestButton( + key: const ValueKey('thread-jump-to-latest'), + onPressed: scrollToTail, + ), + ), + ), ], ), ); @@ -749,286 +787,3 @@ class _ThreadTailMetricsObserver with WidgetsBindingObserver { @override void didChangeMetrics() => onMetricsChanged(); } - -class _ThreadMessage extends ConsumerWidget { - final TimelineMessage message; - final Map channelNames; - final String channelId; - final String? currentPubkey; - final bool showAuthor; - final bool isHighlighted; - final List? allMessages; - final bool isMember; - final bool isArchived; - - /// Whether this is the message the thread hangs off, which keeps a standing - /// "+" where replies only get one once they carry a reaction. - final bool isThreadHead; - - const _ThreadMessage({ - required this.message, - required this.channelNames, - required this.channelId, - required this.currentPubkey, - required this.showAuthor, - this.isHighlighted = false, - this.allMessages, - this.isMember = false, - this.isArchived = false, - this.isThreadHead = false, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pk = message.pubkey.toLowerCase(); - final profile = - ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? - ref.read(userCacheProvider.notifier).get(pk); - final displayName = profile?.label ?? shortPubkey(message.pubkey); - final canManageMessage = - currentPubkey?.toLowerCase() == pk || - (profile?.ownerPubkey != null && - profile?.ownerPubkey == currentPubkey?.toLowerCase()); - - final userCache = ref.watch(userCacheProvider); - final knownAgentPubkeys = agentPubkeysWithProfileOwners( - knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), - profileOwnedAgentPubkeys: [ - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey, - ], - ); - final mentionNames = {}; - final agentMentionPubkeys = {}; - for (final mpk in message.mentionPubkeys) { - final normalizedPubkey = mpk.toLowerCase(); - final p = userCache[normalizedPubkey]; - if (p?.displayName != null) { - mentionNames[normalizedPubkey] = p!.displayName!; - } - if (knownAgentPubkeys.contains(normalizedPubkey)) { - agentMentionPubkeys.add(normalizedPubkey); - } - } - final resolvedMentionNames = mentionNamesWithDirectoryLabels( - mentionPubkeys: message.mentionPubkeys, - profileMentionNames: mentionNames, - directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), - agentMentionPubkeys: agentMentionPubkeys, - ); - - void openMessageActions(Rect anchorRect) { - showMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - ); - } - - return Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), - child: DecoratedBox( - key: ValueKey('thread-message-${message.id}'), - decoration: BoxDecoration( - color: isHighlighted - ? context.colors.primary.withValues(alpha: 0.12) - : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's - // trailing gutter. InkWell still clips its ink to [borderRadius], - // while leaving overflowing message content visible. - clipBehavior: Clip.none, - child: MessageLongPressInkWell( - key: ValueKey('thread-message-row-${message.id}'), - onLongPress: openMessageActions, - borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? 0 : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => - showUserProfileSheet(context, message.pubkey), - child: _Avatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', - ), - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: - context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, - ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - ReactionRow( - messageId: message.id, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: - isMember && - !isArchived && - (isThreadHead || message.reactions.isNotEmpty), - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _Avatar extends StatelessWidget { - final UserProfile? profile; - final String pubkey; - - const _Avatar({required this.profile, required this.pubkey}); - - @override - Widget build(BuildContext context) { - final initial = - profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); - final avatarUrl = profile?.avatarUrl; - - return AvatarImage( - imageUrl: avatarUrl, - radius: messageAvatarSize / 2, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - initial, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart new file mode 100644 index 0000000000..248ccc9681 --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -0,0 +1,284 @@ +part of '../thread_detail_page.dart'; + +class _ThreadMessage extends ConsumerWidget { + final TimelineMessage message; + final Map channelNames; + final String channelId; + final String? currentPubkey; + final bool showAuthor; + final bool isHighlighted; + final List? allMessages; + final bool isMember; + final bool isArchived; + + /// Whether this is the message the thread hangs off, which keeps a standing + /// "+" where replies only get one once they carry a reaction. + final bool isThreadHead; + + const _ThreadMessage({ + required this.message, + required this.channelNames, + required this.channelId, + required this.currentPubkey, + required this.showAuthor, + this.isHighlighted = false, + this.allMessages, + this.isMember = false, + this.isArchived = false, + this.isThreadHead = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pk = message.pubkey.toLowerCase(); + final profile = + ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? + ref.read(userCacheProvider.notifier).get(pk); + final displayName = profile?.label ?? shortPubkey(message.pubkey); + final canManageMessage = + currentPubkey?.toLowerCase() == pk || + (profile?.ownerPubkey != null && + profile?.ownerPubkey == currentPubkey?.toLowerCase()); + + final userCache = ref.watch(userCacheProvider); + final knownAgentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = {}; + final agentMentionPubkeys = {}; + for (final mpk in message.mentionPubkeys) { + final normalizedPubkey = mpk.toLowerCase(); + final p = userCache[normalizedPubkey]; + if (p?.displayName != null) { + mentionNames[normalizedPubkey] = p!.displayName!; + } + if (knownAgentPubkeys.contains(normalizedPubkey)) { + agentMentionPubkeys.add(normalizedPubkey); + } + } + final resolvedMentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: message.mentionPubkeys, + profileMentionNames: mentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); + + void openMessageActions(Rect anchorRect) { + showMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + ); + } + + return Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + child: DecoratedBox( + key: ValueKey('thread-message-${message.id}'), + decoration: BoxDecoration( + color: isHighlighted + ? context.colors.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + // The media carousel intentionally continues through the list's + // trailing gutter. InkWell still clips its ink to [borderRadius], + // while leaving overflowing message content visible. + clipBehavior: Clip.none, + child: MessageLongPressInkWell( + key: ValueKey('thread-message-row-${message.id}'), + onLongPress: openMessageActions, + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? 0 : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: _Avatar(profile: profile, pubkey: message.pubkey), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel(profile), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), + ), + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + MessageContent( + content: message.content, + mentionNames: resolvedMentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), + ), + ReactionRow( + messageId: message.id, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: + isMember && + !isArchived && + (isThreadHead || message.reactions.isNotEmpty), + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _Avatar extends StatelessWidget { + final UserProfile? profile; + final String pubkey; + + const _Avatar({required this.profile, required this.pubkey}); + + @override + Widget build(BuildContext context) { + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final avatarUrl = profile?.avatarUrl; + + return AvatarImage( + imageUrl: avatarUrl, + radius: messageAvatarSize / 2, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + initial, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 35b70c2dd8..f9c9a91c1f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4053,6 +4053,81 @@ void main() { expect(find.text('Reply number 0'), findsNothing); }); + testWidgets('thread shows a Latest pill when scrolled up that jumps back ' + 'to the newest reply', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply number $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + // At the tail after opening: no pill. + expect(find.byKey(const ValueKey('thread-jump-to-latest')), findsNothing); + + // Scroll up to read older replies: the pill appears. + await tester.drag( + find.byKey(const ValueKey('thread-message-list')), + const Offset(0, 600), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsOneWidget, + ); + expect(find.text('Reply number 39'), findsNothing); + + // Tapping the pill returns to the newest reply and hides the pill. + await tester.tap(find.byKey(const ValueKey('thread-jump-to-latest'))); + await tester.pumpAndSettle(); + expect(find.text('Reply number 39'), findsOneWidget); + expect(find.byKey(const ValueKey('thread-jump-to-latest')), findsNothing); + }); + testWidgets('thread deep link still lands on the linked reply', ( tester, ) async { @@ -4203,73 +4278,72 @@ void main() { ); }); - testWidgets( - 'initial thread hydration lands on the newest reply', - (tester) async { - final rootEvent = _textMsg( - id: 'thread-root', - pubkey: 'alice', - content: 'Thread root', - createdAt: 1000, - ); - final replies = [ - for (var i = 0; i < 30; i++) - _textMsg( - id: 'reply-$i', - pubkey: 'bob', - content: 'Reply $i', - createdAt: 1100 + i, - extraTags: const [ - ['e', 'thread-root', '', 'reply'], - ], - ), - ]; - final completer = Completer>(); - - await tester.pumpWidget( - _buildTestable( - messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), - }, + testWidgets('initial thread hydration lands on the newest reply', ( + tester, + ) async { + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], ), - ); - await tester.pumpAndSettle(); + ]; + final completer = Completer>(); - final threadHead = formatTimeline([rootEvent]).single; - Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: threadHead, - allMessages: [threadHead], - channelId: _channelId, - currentPubkey: 'self', - isMember: true, - isArchived: false, - ), + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + pendingThreadReplies: {'thread-root': completer.future}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, ), - ); - await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('thread-message-group-thread-root')), - findsOneWidget, - ); + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-message-group-thread-root')), + findsOneWidget, + ); - completer.complete(replies); - await tester.pumpAndSettle(); + completer.complete(replies); + await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('thread-message-group-reply-29')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('thread-message-group-thread-root')), - findsNothing, - ); - }, - ); + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('thread-message-group-thread-root')), + findsNothing, + ); + }); testWidgets( 'deep-linking an older reply does not resume tail following on keyboard resize',