mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
291 lines
12 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|