part of '../channel_detail_page.dart'; class _MessageBubble extends ConsumerWidget { final TimelineMessage message; final bool showAuthor; final Map channelNames; final String currentChannelId; final String? currentPubkey; final List? allMessages; final bool isMember; final bool isArchived; const _MessageBubble({ required this.message, required this.showAuthor, required this.channelNames, required this.currentChannelId, required this.currentPubkey, this.allMessages, this.isMember = false, this.isArchived = false, }); @override Widget build(BuildContext context, WidgetRef ref) { // Watch only this user's profile to avoid rebuilding on unrelated cache changes. 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()); // Build mention names map from event p-tags. final userCache = ref.watch(userCacheProvider); final knownAgentPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: ref.watch( agentMentionPubkeysProvider(currentChannelId), ), 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: currentChannelId, canManageMessage: canManageMessage, allMessages: allMessages, currentPubkey: currentPubkey, isMember: isMember, isArchived: isArchived, anchorRect: anchorRect, ); } return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), 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('message-row-${message.id}'), onLongPress: openMessageActions, borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), // Tap opens the thread; long-press still opens the action sheet. // MessageContent handles mention, channel-link, and media taps. onTap: allMessages == null ? null : () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => ThreadDetailPage( threadHead: message, allMessages: allMessages!, channelId: currentChannelId, currentPubkey: currentPubkey, isMember: isMember, isArchived: isArchived, ), ), ), 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: _UserAvatar( 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, l10n: context.l10n, ), nameColor: context.colors.onSurface, metadataColor: context.colors.onSurfaceVariant, onAuthorTap: () => showUserProfileSheet( context, message.pubkey, ), displayNameKey: ValueKey( 'message-author-${message.id}', ), usernameKey: ValueKey( 'message-username-${message.id}', ), timestampKey: ValueKey( 'message-timestamp-${message.id}', ), ), ), if (message.edited) ...[ const SizedBox(width: Grid.half), Text( context.l10n.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: currentChannelId, currentPubkey: currentPubkey, isMember: isMember, isArchived: isArchived, ), ), ); }, onMediaMore: (viewerContext, imageUrl) => showImageActions( context: viewerContext, ref: ref, message: message, channelId: currentChannelId, imageUrl: imageUrl, canManageMessage: canManageMessage, onDeleted: () { if (viewerContext.mounted) { Navigator.of(viewerContext).maybePop(); } }, ), onChannelTap: (channelId) { openChannelLink( context: context, ref: ref, channelId: channelId, currentChannelId: currentChannelId, ); }, onMentionTap: (pubkey) => showUserProfileSheet(context, pubkey), ), if (message.reactions.isNotEmpty) ReactionRow( messageId: message.id, reactions: message.reactions, onToggle: (emoji) => toggleReaction(ref, message, emoji), showAddButton: isMember && !isArchived, onAddReaction: () => showAddReactionPicker( context: context, ref: ref, message: message, ), ), ], ), ), ), ], ), ), ), ), ); } } Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: Grid.xxl), child: Text( key: key, formatMessageTime(createdAt, l10n: context.l10n), maxLines: 1, overflow: TextOverflow.ellipsis, style: messageTimestampTextStyle.copyWith( color: context.colors.onSurfaceVariant, ), ), ); } class _UserAvatar extends StatelessWidget { final UserProfile? profile; final String pubkey; final double size; const _UserAvatar({ required this.profile, required this.pubkey, this.size = messageAvatarSize, }); @override Widget build(BuildContext context) { final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); final avatarUrl = profile?.avatarUrl; return AvatarImage( imageUrl: avatarUrl, radius: size / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, style: (size > 28 ? context.textTheme.labelMedium : context.textTheme.labelSmall) ?.copyWith( color: context.colors.onPrimaryContainer, fontWeight: FontWeight.w600, ), ), ); } } IconData channelIcon(Channel channel) { if (channel.isDm) return LucideIcons.messagesSquare; if (channel.isPrivate) return LucideIcons.lock; if (channel.isForum) return LucideIcons.messageSquareText; return LucideIcons.hash; }