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 2ce862ea71..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,7 +119,9 @@ 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); final pendingTailAlignment = useRef(null); final tailRealignmentQueued = useRef(false); @@ -137,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); @@ -146,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 @@ -173,6 +192,52 @@ 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; + 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 @@ -548,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, + ), + ), + ), ], ), ); @@ -710,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 15cf79a313..f9c9a91c1f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3995,6 +3995,199 @@ 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 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 { + 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 { @@ -4085,73 +4278,72 @@ void main() { ); }); - testWidgets( - 'initial thread hydration keeps the head visible instead of following the tail', - (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-thread-root')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('thread-message-group-reply-29')), - 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',