Files
buzz/mobile/lib/features/channels/thread_message.dart
T
2026-08-03 16:46:09 +01:00

291 lines
12 KiB
Dart

part of 'thread_detail_page.dart';
class _ThreadMessage extends ConsumerWidget {
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;
/// 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 isTabletLayout = MediaQuery.sizeOf(context).width >= 840;
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,
);
return Padding(
// In the tablet's wider thread pane, the first author header follows a
// day divider. Halve that handoff gap so the timestamp sits with the
// message rather than floating below the divider.
padding: EdgeInsets.only(
top: showAuthor ? (isTabletLayout ? Grid.half : 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: InkWell(
key: ValueKey('thread-message-row-${message.id}'),
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
onLongPress: () => showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
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: _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),
),
ReactionRow(
messageId: message.id,
reactions: message.reactions,
onToggle: (emoji) => toggleReaction(
ref,
message,
emoji,
channelId: channelId,
),
showAddButton:
isMember &&
!isArchived &&
(isThreadHead || message.reactions.isNotEmpty),
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
channelId: channelId,
),
),
],
),
),
),
],
),
),
),
),
),
);
}
}
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,
),
),
);
}
}