9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
321 lines
13 KiB
Dart
321 lines
13 KiB
Dart
part of '../channel_detail_page.dart';
|
|
|
|
class _MessageBubble extends ConsumerWidget {
|
|
final TimelineMessage message;
|
|
final bool showAuthor;
|
|
final Map<String, String> channelNames;
|
|
final String currentChannelId;
|
|
final String? currentPubkey;
|
|
final List<TimelineMessage>? 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 = <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(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<void>(
|
|
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<void>(
|
|
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;
|
|
}
|