Files
buzz/mobile/lib/features/channels/channel_detail_page/system_rows.dart
T
cls 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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

650 lines
21 KiB
Dart

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,
),
),
],
),
),
);
}
}