Files
buzz/mobile/lib/features/channels/thread_detail_page/thread_message.dart
klopez4212 93114c9c65 Fix mobile Activity thread navigation (#5850)
## Summary

- Open Activity items directly in their target thread while preserving
Activity as the Back destination.
- Fade in a muted target-message highlight after navigation settles,
hold it for three seconds, then fade it away.

## Why

Activity deep links hydrated the channel before opening the thread,
which left the temporary channel route in the navigation stack. The
target highlight also appeared before the route settled and remained
indefinitely.

## Validation

- `just mobile-check`
- Full Flutter test suite (1,357 tests)
- Signed Profile build installed and launched on a physical iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
2026-08-18 16:01:15 -07:00

314 lines
14 KiB
Dart

part of '../thread_detail_page.dart';
class _ThreadMessage extends HookConsumerWidget {
final TimelineMessage message;
final Map<String, String> channelNames;
final String channelId;
final String? currentPubkey;
final bool showAuthor;
final bool isHighlighted;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
final FocusNode? composerFocusNode;
final VoidCallback? restoreComposerFocus;
/// 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,
this.composerFocusNode,
this.restoreComposerFocus,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
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 = <String, String>{};
final agentMentionPubkeys = <String>{};
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(MessageLongPressDetails details) {
showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: details.anchorRect,
captureAnchorSnapshot: details.captureSnapshot,
onPopoverPreviewVisibilityChanged: details.setSourceHidden,
onPopoverDismissed: () => details.setSourceHidden(false),
composerFocusNode: composerFocusNode,
restoreComposerFocus: restoreComposerFocus,
);
}
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final highlightController = useAnimationController(
duration: _landingHighlightTransitionDuration,
);
final highlightProgress = useAnimation(highlightController);
useEffect(() {
if (reducedMotion) {
highlightController.value = isHighlighted ? 1 : 0;
} else {
unawaited(
highlightController.animateTo(
isHighlighted ? 1 : 0,
duration: _landingHighlightTransitionDuration,
curve: Curves.easeOutCubic,
),
);
}
return null;
}, [highlightController, isHighlighted, reducedMotion]);
final highlightColor = highlightProgress == 0
? Colors.transparent
: context.colors.primary.withValues(
alpha: _landingHighlightOpacity * highlightProgress,
);
return Padding(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
child: DecoratedBox(
key: ValueKey('thread-message-${message.id}'),
decoration: BoxDecoration(
color: highlightColor,
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}'),
onLongPressDetails: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
snapshotKey: messageSnapshotKey,
child: Padding(
padding: EdgeInsets.only(
top: showAuthor ? 0 : Grid.xxs,
bottom: showAuthor ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RepaintBoundary(
key: messageSnapshotKey,
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<void>(
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),
),
],
),
),
),
],
),
),
if (isThreadHead || message.reactions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
),
child: ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
),
),
),
],
),
),
),
),
),
);
}
}