feat: import Chinese-localized Buzz source snapshot
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>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,200 @@
part of '../channel_detail_page.dart';
double _scaledTextHeight(BuildContext context, TextStyle style) {
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 0);
return scaledFontSize * (style.height ?? 1);
}
double _dmAppBarTitleContentHeight(BuildContext context) {
const titleStyle = channelTitleTextStyle;
final presenceStyle = context.textTheme.bodySmall;
if (presenceStyle == null) {
return 30;
}
final textHeight =
_scaledTextHeight(context, titleStyle) +
_scaledTextHeight(context, presenceStyle);
return textHeight > 30 ? textHeight : 30;
}
class _MembersButton extends ConsumerWidget {
final String channelId;
final Channel channel;
final String? currentPubkey;
const _MembersButton({
required this.channelId,
required this.channel,
required this.currentPubkey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasWorkingBot = ref
.watch(workingBotPubkeysProvider(channelId))
.isNotEmpty;
return IconButton(
color: context.colors.primary,
onPressed: () {
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
MembersSheet(channel: channel, currentPubkey: currentPubkey),
);
},
tooltip: context.l10n.viewMembers,
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(LucideIcons.users, size: 22),
if (hasWorkingBot)
Positioned(
top: -2,
right: -2,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: context.appColors.success,
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
),
),
],
),
);
}
}
class _DmAppBarTitle extends ConsumerWidget {
final Channel channel;
final String? currentPubkey;
const _DmAppBarTitle({required this.channel, required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profiles = ref.watch(userCacheProvider);
final presenceMap = ref.watch(presenceCacheProvider);
final normalizedCurrent = currentPubkey?.toLowerCase();
String? otherPubkey;
for (final pk in channel.participantPubkeys) {
if (pk.toLowerCase() != normalizedCurrent) {
otherPubkey = pk.toLowerCase();
break;
}
}
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
if (otherPubkey != null) {
if (profile == null) {
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
}
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
}
final avatarUrl = profile?.avatarUrl;
final initial =
profile?.initial ??
(channel.participants.isNotEmpty
? channel.participants.first[0].toUpperCase()
: '?');
final presence = otherPubkey != null
? (presenceMap[otherPubkey] ?? 'offline')
: 'offline';
final presenceLabel = switch (presence) {
'online' => context.l10n.online,
'away' => context.l10n.away,
_ => context.l10n.offline,
};
return Row(
children: [
SizedBox(
width: 30,
height: 30,
child: Stack(
clipBehavior: Clip.none,
children: [
AvatarImage(
imageUrl: avatarUrl,
radius: 14,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
Positioned(
right: -1,
bottom: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
},
shape: BoxShape.circle,
border: Border.all(
color: context.colors.surface,
width: 1.5,
),
),
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
channel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: channelTitleTextStyle,
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: channel),
],
],
),
Text(
presenceLabel,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
],
);
}
}
@@ -0,0 +1,220 @@
part of '../channel_detail_page.dart';
class _ReadOnlyNotice extends StatelessWidget {
final Channel channel;
const _ReadOnlyNotice({required this.channel});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: Grid.xxs,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: context.colors.outlineVariant)),
color: context.colors.surface,
),
child: Text(
channel.isArchived
? context.l10n.archivedReadOnly(
channel.isForum ? context.l10n.forum : context.l10n.channel,
)
: context.l10n.joinFromManage(
channel.isForum ? context.l10n.forum : context.l10n.channel,
),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
);
}
}
class _HeaderEphemeralBadge extends StatelessWidget {
final Channel channel;
const _HeaderEphemeralBadge({required this.channel});
@override
Widget build(BuildContext context) {
final display = ephemeralChannelDisplay(channel, l10n: context.l10n);
if (display == null) return const SizedBox.shrink();
return Tooltip(
message: display.tooltipLabel,
child: Icon(
LucideIcons.clockFading,
key: const Key('chat-ephemeral-badge'),
size: 16,
color: context.colors.onSurfaceVariant,
),
);
}
}
class _MessageTimelineSkeleton extends StatelessWidget {
final double appBarTitleContentHeight;
final SessionStatus status;
const _MessageTimelineSkeleton({
required this.appBarTitleContentHeight,
required this.status,
});
@override
Widget build(BuildContext context) {
final semanticsLabel = switch (status) {
SessionStatus.connecting => context.l10n.connecting,
SessionStatus.reconnecting => context.l10n.reconnecting,
SessionStatus.connected || SessionStatus.disconnected =>
context.l10n.loading,
};
return Semantics(
key: const Key('channel-detail-connection-skeleton'),
liveRegion: true,
label: semanticsLabel,
child: ExcludeSemantics(
child: ListView.separated(
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.fromLTRB(
Grid.gutter,
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) +
Grid.xs,
Grid.gutter,
Grid.xs,
),
itemCount: 4,
separatorBuilder: (_, _) => const SizedBox(height: Grid.xs),
itemBuilder: (_, index) => _MessageSkeletonRow(index: index),
),
),
);
}
}
class _MessageSkeletonRow extends StatelessWidget {
final int index;
const _MessageSkeletonRow({required this.index});
@override
Widget build(BuildContext context) {
const authorWidths = <double>[112, 96, 128, 80];
const lineWidths = <List<double>>[
[280, 224],
[272, 184],
[232],
[288, 216],
];
final availableWidth = MediaQuery.sizeOf(context).width - 88;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBar(
width: 36,
height: 36,
borderRadius: BorderRadius.circular(Radii.full),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SkeletonBar(width: authorWidths[index], height: 15),
const SizedBox(width: Grid.xxs),
const SkeletonBar(width: 40, height: 12),
],
),
const SizedBox(height: Grid.half),
for (final width in lineWidths[index]) ...[
SkeletonBar(width: min(width, availableWidth), height: 16),
const SizedBox(height: Grid.half),
],
const Row(
children: [
SkeletonBar(width: 32, height: 16),
SizedBox(width: Grid.xs),
SkeletonBar(width: 32, height: 16),
SizedBox(width: Grid.xs),
SkeletonBar(width: 32, height: 16),
],
),
],
),
),
],
);
}
}
class _ForumConnectionSkeleton extends StatelessWidget {
final SessionStatus status;
const _ForumConnectionSkeleton({required this.status});
@override
Widget build(BuildContext context) {
final semanticsLabel = switch (status) {
SessionStatus.connecting => context.l10n.connecting,
SessionStatus.reconnecting => context.l10n.reconnecting,
SessionStatus.connected || SessionStatus.disconnected =>
context.l10n.loading,
};
return Semantics(
key: const Key('forum-connection-skeleton'),
liveRegion: true,
label: semanticsLabel,
child: ExcludeSemantics(
child: IgnorePointer(
child: ExcludeFocus(
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.lg),
border: Border.all(color: context.colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(Grid.twelve),
child: SkeletonShimmer(
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
SkeletonBar(
width: 28,
height: 28,
borderRadius: BorderRadius.all(
Radius.circular(Radii.full),
),
),
SizedBox(width: Grid.xxs),
SkeletonBar(width: 112, height: 14),
],
),
SizedBox(height: Grid.xxs),
SkeletonBar(width: 240, height: 14),
],
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,320 @@
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;
}
@@ -0,0 +1,637 @@
part of '../channel_detail_page.dart';
class _MessageList extends HookConsumerWidget {
final List<MainTimelineEntry> entries;
final List<TimelineMessage> allMessages;
final String? initialMessageId;
final String? initialThreadRootId;
final Set<String> initialOrdinaryUnreadMessageIds;
final String? initialOldestOrdinaryUnreadMessageId;
final Set<String> initialForcedUnreadMessageIds;
final bool hasInitialUnread;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
final double appBarTitleContentHeight;
final double composerBottomInset;
const _MessageList({
required this.entries,
required this.allMessages,
required this.initialMessageId,
required this.initialThreadRootId,
required this.initialOrdinaryUnreadMessageIds,
required this.initialOldestOrdinaryUnreadMessageId,
required this.initialForcedUnreadMessageIds,
required this.hasInitialUnread,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
required this.appBarTitleContentHeight,
required this.composerBottomInset,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayEntries = groupMembershipTimelineEntries(entries);
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
final hasUserScrolled = useState(false);
final followsLatest = useRef(
initialMessageId == null && initialThreadRootId == null,
);
final isAutoScrolling = useRef(false);
final latestRealignmentQueued = useRef(false);
final latestEntryId = entries.isEmpty ? null : entries.last.message.id;
final previousLatestEntryId = useRef<String?>(null);
final didOpenInitialThread = useRef(false);
final didJumpToInitialMessage = useRef(false);
final isUnreadNavigationDismissed = useState(false);
final detachedWhileUnreadShown = useRef(false);
final oldestUnreadMessageId = useState<String?>(null);
final unreadBoundaryLoadFailed = useState(false);
final unreadBoundaryFetchCount = useRef(0);
final hasUnreadDeepLink =
initialMessageId != null || initialThreadRootId != null;
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
useEffect(
() {
if (!hasInitialUnread ||
hasUnreadDeepLink ||
oldestUnreadMessageId.value != null ||
unreadBoundaryLoadFailed.value ||
entries.isEmpty) {
return null;
}
final hasLoadedOrdinaryTarget =
initialOldestOrdinaryUnreadMessageId != null &&
entries.any(
(entry) =>
entry.message.id == initialOldestOrdinaryUnreadMessageId,
);
final hasLoadedForcedTarget = entries.any(
(entry) => initialForcedUnreadMessageIds.contains(entry.message.id),
);
final hasKnownTarget =
initialOldestOrdinaryUnreadMessageId != null ||
initialForcedUnreadMessageIds.isNotEmpty;
final hasLoadedFetchTarget =
initialOldestOrdinaryUnreadMessageId != null
? hasLoadedOrdinaryTarget
: hasLoadedForcedTarget;
final canFetchTarget =
hasKnownTarget &&
!hasLoadedFetchTarget &&
!notifier.reachedOldest &&
unreadBoundaryFetchCount.value < 4;
if (canFetchTarget) {
unreadBoundaryFetchCount.value += 1;
var cancelled = false;
unawaited(
Future<void>(() async {
final loaded = await notifier.fetchOlder();
if (!cancelled && !loaded && !notifier.reachedOldest) {
unreadBoundaryLoadFailed.value = true;
}
}),
);
return () => cancelled = true;
}
if (hasKnownTarget &&
!hasLoadedFetchTarget &&
!notifier.reachedOldest) {
unreadBoundaryLoadFailed.value = true;
}
final ordinaryUnread = entries
.where(
(entry) =>
initialOrdinaryUnreadMessageIds.contains(entry.message.id),
)
.map((entry) => entry.message)
.firstOrNull;
final forcedUnread = entries
.where(
(entry) =>
initialForcedUnreadMessageIds.contains(entry.message.id),
)
.map((entry) => entry.message)
.firstOrNull;
final candidates = [ordinaryUnread, forcedUnread].nonNulls.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
oldestUnreadMessageId.value = candidates.firstOrNull?.id;
return null;
},
[
hasInitialUnread,
hasUnreadDeepLink,
initialOrdinaryUnreadMessageIds,
initialOldestOrdinaryUnreadMessageId,
initialForcedUnreadMessageIds,
entries.length,
notifier.reachedOldest,
unreadBoundaryLoadFailed.value,
],
);
final showUnreadNavigation =
!isUnreadNavigationDismissed.value &&
oldestUnreadMessageId.value != null;
int? reversedIndexOf(String? messageId) {
if (messageId == null) return null;
final chronologicalIndex = displayEntries.indexWhere(
(group) => group.any((entry) => entry.message.id == messageId),
);
return chronologicalIndex < 0
? null
: displayEntries.length - 1 - chronologicalIndex;
}
double latestAlignment() {
final viewportHeight = context.size?.height ?? 0;
return viewportHeight > 0
? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
: 0.0;
}
Future<void> scrollToLatest() async {
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
followsLatest.value = true;
hasUserScrolled.value = false;
isAutoScrolling.value = true;
try {
await itemScrollController.scrollTo(
index: 0,
alignment: latestAlignment(),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
if (context.mounted && !hasUserScrolled.value) {
isAtLatest.value = true;
}
} finally {
isAutoScrolling.value = false;
}
}
Future<void> scrollToOldestUnread() async {
final targetIndex = reversedIndexOf(oldestUnreadMessageId.value);
if (targetIndex == null ||
!itemScrollController.isAttached ||
isAutoScrolling.value) {
return;
}
isUnreadNavigationDismissed.value = true;
followsLatest.value = false;
hasUserScrolled.value = false;
isAtLatest.value = false;
isAutoScrolling.value = true;
try {
await itemScrollController.scrollTo(
index: targetIndex,
alignment: 0.35,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
} finally {
isAutoScrolling.value = false;
}
}
bool latestIsAtBoundary() {
// In this reversed list, item 0's leading edge is the visible bottom
// boundary above the composer. Being merely visible is not enough: a
// user who has pulled a tall newest row away from that boundary must not
// snap back on live updates.
final boundary = latestAlignment();
return itemPositionsListener.itemPositions.value.any(
(position) =>
position.index == 0 &&
(position.itemLeadingEdge - boundary).abs() < 0.01,
);
}
void realignLatestAfterLayoutChange() {
if (latestRealignmentQueued.value ||
!followsLatest.value ||
hasUserScrolled.value) {
return;
}
latestRealignmentQueued.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
latestRealignmentQueued.value = false;
if (!context.mounted ||
!itemScrollController.isAttached ||
!followsLatest.value ||
hasUserScrolled.value ||
latestIsAtBoundary()) {
return;
}
// A dock or keyboard resize is a layout correction, not a navigation
// action. Keeping it instant avoids restarting a smooth scroll for
// every position report while the viewport settles.
itemScrollController.jumpTo(index: 0);
});
}
useEffect(() {
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
final nextIsAtLatest = latestIsAtBoundary();
if (showUnreadNavigation &&
nextIsAtLatest &&
detachedWhileUnreadShown.value) {
isUnreadNavigationDismissed.value = true;
}
if (nextIsAtLatest) {
if (!isAtLatest.value) isAtLatest.value = true;
} else if (!followsLatest.value && isAtLatest.value) {
isAtLatest.value = false;
}
final oldestVisible = positions
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
if (!hasUserScrolled.value ||
oldestVisible < displayEntries.length - 3 ||
isLoadingOlder.value) {
return;
}
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
if (notifier.reachedOldest) return;
isLoadingOlder.value = true;
notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false);
}
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
return () => itemPositionsListener.itemPositions.removeListener(
onPositionsChanged,
);
}, [channelId, entries.length, itemPositionsListener]);
// Composer size changes and keyboard metrics changes arrive in separate
// layout passes. Preserve the latest-message anchor for both, but only
// while the user has not deliberately left the tail.
useEffect(() {
realignLatestAfterLayoutChange();
return null;
}, [composerBottomInset]);
useEffect(() {
final observer = _ChannelLatestMetricsObserver(
onMetricsChanged: realignLatestAfterLayoutChange,
);
WidgetsBinding.instance.addObserver(observer);
return () => WidgetsBinding.instance.removeObserver(observer);
}, [itemScrollController]);
useEffect(() {
if (initialThreadRootId == null || didOpenInitialThread.value) {
return null;
}
final threadHead = allMessages
.where((message) => message.id == initialThreadRootId)
.firstOrNull;
if (threadHead == null) return null;
didOpenInitialThread.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
initialMessageId: initialMessageId,
),
),
);
});
return null;
}, [initialThreadRootId, allMessages]);
useEffect(() {
final targetIndex = reversedIndexOf(initialMessageId);
if (initialThreadRootId != null ||
targetIndex == null ||
didJumpToInitialMessage.value) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !itemScrollController.isAttached) return;
didJumpToInitialMessage.value = true;
followsLatest.value = false;
hasUserScrolled.value = false;
isAtLatest.value = false;
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
});
return null;
}, [initialMessageId, initialThreadRootId, entries.length]);
useEffect(() {
final previous = previousLatestEntryId.value;
previousLatestEntryId.value = latestEntryId;
if (previous == null ||
latestEntryId == null ||
previous == latestEntryId ||
!isAtLatest.value) {
return null;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) scrollToLatest();
});
return null;
}, [latestEntryId]);
if (entries.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.messageSquare,
size: Grid.xl,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xxs),
Text(
context.l10n.noMessages,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.half),
Text(
context.l10n.beFirstMessage,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
// Build channel names map once for all message bubbles.
final channelsAsync = ref.watch(channelsProvider);
final channelNamesMap = <String, String>{};
channelsAsync.whenData((channels) {
for (final ch in channels) {
channelNamesMap[ch.name.toLowerCase()] = ch.id;
}
});
return Stack(
children: [
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is UserScrollNotification &&
notification.direction != ScrollDirection.idle) {
hasUserScrolled.value = true;
followsLatest.value = false;
if (showUnreadNavigation) {
detachedWhileUnreadShown.value = true;
}
} else if (notification is ScrollEndNotification &&
hasUserScrolled.value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted || !latestIsAtBoundary()) return;
hasUserScrolled.value = false;
followsLatest.value = true;
if (!isAtLatest.value) isAtLatest.value = true;
});
}
return false;
},
child: KeyboardDismissOnDrag(
child: ScrollablePositionedList.builder(
key: const ValueKey('channel-message-list'),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
reverse: true,
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
bottom: composerBottomInset,
),
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),
itemBuilder: (context, index) {
// Loading indicator at the top (last index in reversed list).
if (index >= displayEntries.length) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
child: Center(
child: BuzzLoadingIndicator(
size: 24,
semanticLabel: context.l10n.loadingOlderMessages,
),
),
);
}
// Reversed list: index 0 = newest (bottom of screen).
final chronIdx = displayEntries.length - 1 - index;
final entryGroup = displayEntries[chronIdx];
final entry = entryGroup.first;
final message = entry.message;
// Day boundary check — applies to all messages including system.
final prevEntry = chronIdx > 0
? displayEntries[chronIdx - 1].last
: null;
final prevMessage = prevEntry?.message;
final showDayDivider =
prevMessage == null ||
!isSameDay(prevMessage.createdAt, message.createdAt);
final showAuthor =
!message.isSystem &&
(message.hasAttachments ||
prevMessage == null ||
prevMessage.isSystem ||
showDayDivider ||
prevMessage.pubkey.toLowerCase() !=
message.pubkey.toLowerCase() ||
(message.createdAt - prevMessage.createdAt) > 300);
return Padding(
key: ValueKey('channel-message-group-${message.id}'),
padding: EdgeInsets.only(bottom: index == 0 ? Grid.xs : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(
label: formatDayHeading(
message.createdAt,
l10n: context.l10n,
),
),
if (message.isSystem)
_SystemMessageRow(
message: message,
groupedMessages: entryGroup.length > 1
? entryGroup
.map((entry) => entry.message)
.toList()
: null,
channelId: channelId,
currentPubkey: currentPubkey,
allMessages: null,
isMember: isMember,
isArchived: isArchived,
)
else ...[
_MessageBubble(
message: message,
showAuthor: showAuthor,
channelNames: channelNamesMap,
currentChannelId: channelId,
currentPubkey: currentPubkey,
allMessages: allMessages,
isMember: isMember,
isArchived: isArchived,
),
if (entry.summary != null)
_ThreadSummaryRow(
summary: entry.summary!,
message: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
],
],
),
);
},
),
),
),
if (showUnreadNavigation)
Positioned(
left: 0,
right: 0,
top:
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) +
Grid.xs,
child: Center(
child: IconButton.filled(
key: const ValueKey('channel-jump-to-oldest-unread'),
onPressed: scrollToOldestUnread,
tooltip: context.l10n.jumpOldestUnread,
style: IconButton.styleFrom(
backgroundColor: context.colors.primaryContainer,
foregroundColor: context.colors.onPrimaryContainer,
),
icon: const Icon(LucideIcons.chevronUp, size: 20),
),
),
)
else if (!isAtLatest.value)
Positioned(
left: 0,
right: 0,
bottom: composerBottomInset + Grid.xs,
child: Center(
child: _JumpToLatestButton(
key: const ValueKey('channel-jump-to-latest'),
onPressed: scrollToLatest,
),
),
),
],
);
}
}
class _JumpToLatestButton extends StatelessWidget {
final VoidCallback onPressed;
const _JumpToLatestButton({required this.onPressed, super.key});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(Radii.full);
return Semantics(
button: true,
child: ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
key: const ValueKey('channel-jump-to-latest-surface'),
decoration: BoxDecoration(
color: context.colors.surface.withValues(alpha: 0.5),
borderRadius: borderRadius,
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onPressed,
borderRadius: borderRadius,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: context.colors.onSurface,
),
const SizedBox(width: Grid.half),
Text(
context.l10n.jumpLatest,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
),
),
),
),
),
);
}
}
class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;
_ChannelLatestMetricsObserver({required this.onMetricsChanged});
@override
void didChangeMetrics() => onMetricsChanged();
}
@@ -0,0 +1,649 @@
part of '../channel_detail_page.dart';
class _SystemMessageRow extends HookConsumerWidget {
final TimelineMessage message;
final List<TimelineMessage>? groupedMessages;
final String channelId;
final String? currentPubkey;
final List<TimelineMessage>? allMessages;
final bool isMember;
final bool isArchived;
const _SystemMessageRow({
required this.message,
this.groupedMessages,
required this.channelId,
this.currentPubkey,
this.allMessages,
this.isMember = false,
this.isArchived = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final spotlightKey = useMemoized(() => GlobalKey());
final systemEvent = message.systemEvent;
if (systemEvent == null) return const SizedBox.shrink();
final userCache = ref.watch(userCacheProvider);
final sourceMessages = groupedMessages ?? [message];
final groupedMembership = _membershipDisplayEvent(sourceMessages);
final messageStyleAction = switch (systemEvent.type) {
SystemEventType.channelCreated => context.l10n.systemCreatedThisChannel,
SystemEventType.huddleStarted => context.l10n.systemStartedHuddle,
SystemEventType.huddleEnded => context.l10n.systemEndedHuddle,
_ => null,
};
final messageStyleActor = messageStyleAction == null
? null
: systemEvent.actorPubkey?.trim();
final usesMessageStyleLayout =
groupedMembership != null ||
(messageStyleActor != null && messageStyleActor.isNotEmpty);
String resolveLabel(String? pubkey) {
if (pubkey == null) return context.l10n.systemSomeone;
final profile =
userCache[pubkey.toLowerCase()] ??
ref.read(userCacheProvider.notifier).get(pubkey.toLowerCase());
return profile?.label ?? shortPubkey(pubkey);
}
final reactions = groupedMessages == null
? message.reactions
: _aggregateSystemMessageReactions(sourceMessages);
void toggleGroupedReaction(String emoji) {
final actions = ref.read(channelActionsProvider);
final reactedMessages = sourceMessages.where(
(source) => source.reactions.any(
(reaction) =>
reaction.emoji == emoji &&
reaction.reactedByCurrentUser &&
reaction.currentUserReactionId != null,
),
);
if (reactedMessages.isEmpty) {
actions.addReaction(message.id, emoji);
return;
}
for (final source in reactedMessages) {
final reaction = source.reactions.firstWhere(
(candidate) =>
candidate.emoji == emoji &&
candidate.reactedByCurrentUser &&
candidate.currentUserReactionId != null,
);
actions.removeReaction(reaction.currentUserReactionId!, emoji);
}
}
void openReactionPopover(Rect anchorRect) {
final spotlightRenderObject = spotlightKey.currentContext
?.findRenderObject();
final spotlightRect =
spotlightRenderObject is RenderBox && spotlightRenderObject.hasSize
? spotlightRenderObject.localToGlobal(Offset.zero) &
spotlightRenderObject.size
: anchorRect;
showMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: false,
allMessages: null,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: spotlightRect,
popoverSpotlightPadding: EdgeInsets.fromLTRB(
Grid.xxs,
Grid.xxs,
Grid.xxs,
reactions.isEmpty ? Grid.xxs : Grid.quarter,
),
);
}
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
child: MessageLongPressInkWell(
key: ValueKey('system-message-row-${message.id}'),
onLongPress: openReactionPopover,
borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1),
child: Padding(
padding: EdgeInsets.only(
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
bottom: usesMessageStyleLayout ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
KeyedSubtree(
key: spotlightKey,
child: groupedMembership != null
? _MembershipSystemMessageContent(
event: groupedMembership,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
)
: messageStyleActor != null &&
messageStyleActor.isNotEmpty &&
messageStyleAction != null
? _MessageStyleSystemMessageContent(
displayPubkey: messageStyleActor,
createdAt: message.createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: [TextSpan(text: messageStyleAction)],
)
: Row(
children: [
_systemEventAvatar(context, systemEvent, userCache),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
systemEvent.describeLocalized(
context.l10n,
resolveLabel,
),
style: systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
_messageTimestamp(
context,
message.createdAt,
key: ValueKey(
'system-message-timestamp-${message.id}',
),
),
],
),
),
if (reactions.isNotEmpty)
Padding(
padding: EdgeInsets.only(
left:
(usesMessageStyleLayout ? messageAvatarSize : 36) +
(usesMessageStyleLayout
? messageAvatarContentGap
: Grid.xxs),
),
child: ReactionRow(
messageId: message.id,
reactions: reactions,
onToggle: groupedMessages == null
? (emoji) => toggleReaction(ref, message, emoji)
: toggleGroupedReaction,
),
),
],
),
),
),
);
}
}
const _maxVisibleAdditionalMemberNames = 3;
class _MembershipDisplayEvent {
final String? actorPubkey;
final List<String> targetPubkeys;
final bool isSelfJoin;
const _MembershipDisplayEvent({
required this.actorPubkey,
required this.targetPubkeys,
required this.isSelfJoin,
});
}
_MembershipDisplayEvent? _membershipDisplayEvent(
List<TimelineMessage> messages,
) {
if (messages.isEmpty) return null;
final first = messages.first.systemEvent;
final firstActor = first?.actorPubkey?.trim().toLowerCase();
final firstTarget = first?.targetPubkey?.trim().toLowerCase();
if (first?.type != SystemEventType.memberJoined ||
firstActor == null ||
firstActor.isEmpty ||
firstTarget == null ||
firstTarget.isEmpty) {
return null;
}
final isSelfJoin = firstActor == firstTarget;
final targets = <String>[];
for (final message in messages) {
final event = message.systemEvent;
final actor = event?.actorPubkey?.trim().toLowerCase();
final target = event?.targetPubkey?.trim().toLowerCase();
if (event?.type != SystemEventType.memberJoined ||
actor == null ||
actor.isEmpty ||
target == null ||
target.isEmpty ||
(isSelfJoin
? actor != target
: actor != firstActor || actor == target)) {
return null;
}
targets.add(target);
}
return _MembershipDisplayEvent(
actorPubkey: isSelfJoin ? null : firstActor,
targetPubkeys: targets,
isSelfJoin: isSelfJoin,
);
}
List<TimelineReaction> _aggregateSystemMessageReactions(
List<TimelineMessage> messages,
) {
final byEmoji = <String, List<TimelineReaction>>{};
for (final message in messages) {
for (final reaction in message.reactions) {
byEmoji.putIfAbsent(reaction.emoji, () => []).add(reaction);
}
}
return [
for (final entry in byEmoji.entries)
() {
final reactions = entry.value;
final userPubkeys = {
for (final reaction in reactions) ...reaction.userPubkeys,
}.toList();
final reactedByCurrentUser = reactions.any(
(reaction) => reaction.reactedByCurrentUser,
);
final currentUserReactionId = reactions
.where((reaction) => reaction.currentUserReactionId != null)
.firstOrNull
?.currentUserReactionId;
final fallbackCount = reactions.fold<int>(
0,
(total, reaction) => total + reaction.count,
);
return TimelineReaction(
emoji: entry.key,
count: userPubkeys.isEmpty ? fallbackCount : userPubkeys.length,
reactedByCurrentUser: reactedByCurrentUser,
userPubkeys: userPubkeys,
emojiUrl: reactions.first.emojiUrl,
currentUserReactionId: currentUserReactionId,
);
}(),
];
}
class _MembershipSystemMessageContent extends StatelessWidget {
final _MembershipDisplayEvent event;
final int createdAt;
final String Function(String? pubkey) resolveLabel;
final Map<String, UserProfile> userCache;
const _MembershipSystemMessageContent({
required this.event,
required this.createdAt,
required this.resolveLabel,
required this.userCache,
});
@override
Widget build(BuildContext context) {
final firstTarget = event.targetPubkeys.first;
final additionalTargets = event.targetPubkeys.skip(1).toList();
final visibleTargets = additionalTargets
.take(_maxVisibleAdditionalMemberNames)
.toList();
final hiddenTargets = additionalTargets
.skip(_maxVisibleAdditionalMemberNames)
.toList();
final actionStyle = _systemActionTextStyle(context);
final actionSpans = <InlineSpan>[
TextSpan(
text: event.isSelfJoin
? context.l10n.systemJoinedChannelAction
// No "was": the name renders on the line above via
// MessageAuthorMeta, so this reads as a status line rather than a
// sentence continuing across the metadata row. Matches desktop's
// SystemMessageRow. `SystemEvent.describe` keeps "was added by"
// because it builds subject and predicate into one string.
: context.l10n.systemAddedBy(resolveLabel(event.actorPubkey)),
),
if (additionalTargets.isNotEmpty)
TextSpan(
text: event.isSelfJoin
? context.l10n.systemAlongWith
: context.l10n.systemCommaAlongWith,
),
..._memberNameSpans(
context,
visibleTargets: visibleTargets,
hiddenTargets: hiddenTargets,
resolveLabel: resolveLabel,
style: actionStyle,
),
];
return _MessageStyleSystemMessageContent(
displayPubkey: firstTarget,
createdAt: createdAt,
resolveLabel: resolveLabel,
userCache: userCache,
actionSpans: actionSpans,
);
}
}
TextStyle? _systemActionTextStyle(BuildContext context) {
return systemMessageBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
);
}
class _MessageStyleSystemMessageContent extends StatelessWidget {
final String displayPubkey;
final int createdAt;
final String Function(String? pubkey) resolveLabel;
final Map<String, UserProfile> userCache;
final List<InlineSpan> actionSpans;
const _MessageStyleSystemMessageContent({
required this.displayPubkey,
required this.createdAt,
required this.resolveLabel,
required this.userCache,
required this.actionSpans,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, displayPubkey),
child: _UserAvatar(
profile: userCache[displayPubkey.toLowerCase()],
pubkey: displayPubkey,
size: messageAvatarSize,
),
),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Transform.translate(
offset: const Offset(0, -Grid.quarter),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MessageAuthorMeta(
displayName: resolveLabel(displayPubkey),
username: messageUsernameLabel(
userCache[displayPubkey.toLowerCase()],
),
timestamp: formatMessageTime(createdAt, l10n: context.l10n),
nameColor: context.colors.onSurface,
metadataColor: context.colors.onSurfaceVariant,
nameStyle: systemMessageHeadingTextStyle,
displayNameKey: ValueKey(
'system-message-author-$displayPubkey',
),
usernameKey: ValueKey(
'system-message-username-$displayPubkey',
),
timestampKey: ValueKey(
'system-message-timestamp-$displayPubkey',
),
),
Text.rich(
TextSpan(
style: _systemActionTextStyle(context),
children: actionSpans,
),
),
],
),
),
),
],
);
}
}
List<InlineSpan> _memberNameSpans(
BuildContext context, {
required List<String> visibleTargets,
required List<String> hiddenTargets,
required String Function(String? pubkey) resolveLabel,
required TextStyle? style,
}) {
final spans = <InlineSpan>[];
for (var index = 0; index < visibleTargets.length; index++) {
final isLast = index == visibleTargets.length - 1;
final separator = index == 0
? ''
: isLast && hiddenTargets.isEmpty
? (visibleTargets.length == 2
? context.l10n.systemAnd
: context.l10n.systemCommaAnd)
: ', ';
spans.add(
TextSpan(text: '$separator${resolveLabel(visibleTargets[index])}'),
);
}
if (hiddenTargets.isNotEmpty) {
final hiddenLabels = hiddenTargets.map(resolveLabel).toList();
spans
..add(TextSpan(text: context.l10n.systemCommaAnd))
..add(
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: Tooltip(
message: hiddenLabels.join('\n'),
triggerMode: TooltipTriggerMode.tap,
child: Text(
context.l10n.othersCount(hiddenTargets.length),
key: const Key('membership-overflow'),
style: style?.copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
),
),
),
),
);
}
return spans;
}
Widget _systemEventAvatar(
BuildContext context,
SystemEvent event,
Map<String, UserProfile> userCache,
) {
final hasTarget =
event.targetPubkey != null && event.targetPubkey != event.actorPubkey;
if (event.actorPubkey != null && hasTarget) {
// Two-avatar stack: actor + target (e.g. "Alice added Bob").
return SizedBox(
width: 32,
height: 20,
child: Stack(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
child: SmallAvatar(
pubkey: event.actorPubkey!,
userCache: userCache,
),
),
Positioned(
left: 12,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.targetPubkey!),
child: SmallAvatar(
pubkey: event.targetPubkey!,
userCache: userCache,
),
),
),
],
),
);
}
if (event.actorPubkey != null) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
child: SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache),
);
}
// Fallback: generic icon when no actor is available.
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.arrowLeftRight,
size: 12,
color: context.colors.onSurfaceVariant,
),
);
}
class _ThreadSummaryRow extends ConsumerWidget {
final ThreadSummary summary;
final TimelineMessage message;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
const _ThreadSummaryRow({
required this.summary,
required this.message,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userCache = ref.watch(userCacheProvider);
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
child: Padding(
key: ValueKey('thread-summary-${message.id}'),
padding: const EdgeInsets.only(
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.half,
bottom: Grid.xs,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Stacked participant avatars.
SizedBox(
width: 32.0 + (summary.participantPubkeys.length - 1) * 20.0,
height: 32,
child: Stack(
children: [
for (var i = 0; i < summary.participantPubkeys.length; i++)
Positioned(
left: i * 20.0,
child: SmallAvatar(
pubkey: summary.participantPubkeys[i],
userCache: userCache,
size: 32,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
context.l10n.replyCount(summary.replyCount),
style: replyPreviewTextStyle.copyWith(
color: context.colors.primary,
),
),
if (summary.lastReplyAt case final lastReplyAt?) ...[
TextSpan(
text: ' · ',
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant.withValues(
alpha: 0.5,
),
),
),
TextSpan(
text: context.l10n.lastReply(
formatThreadSummaryLastReplyTime(
lastReplyAt,
l10n: context.l10n,
),
),
style: replyPreviewTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}