part of '../channel_detail_page.dart'; class _SystemMessageRow extends HookConsumerWidget { final TimelineMessage message; final List? groupedMessages; final String channelId; final String? currentPubkey; final List? 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 targetPubkeys; final bool isSelfJoin; const _MembershipDisplayEvent({ required this.actorPubkey, required this.targetPubkeys, required this.isSelfJoin, }); } _MembershipDisplayEvent? _membershipDisplayEvent( List 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 = []; 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 _aggregateSystemMessageReactions( List messages, ) { final byEmoji = >{}; 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( 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 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 = [ 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 userCache; final List 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 _memberNameSpans( BuildContext context, { required List visibleTargets, required List hiddenTargets, required String Function(String? pubkey) resolveLabel, required TextStyle? style, }) { final spans = []; 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 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 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( 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, ), ), ], ), ), ); } }