part of '../activity_page.dart'; const _inboxSwipeActionInset = Grid.half; const _inboxSwipeActionLabelMinWidth = Grid.xxl; const _inboxSwipeLabelRevealWidth = _inboxSwipeActionLabelMinWidth + (_inboxSwipeActionInset * 2); String _localizedInboxHeadline(BuildContext context, InboxItem item) { final l10n = context.l10n; return switch (item.item.kind) { 45001 => l10n.forumPost, 45003 => l10n.forumReply, 46010 => l10n.approvalRequested, 43001 => l10n.jobRequested, 43002 => l10n.jobAccepted, 43003 => l10n.progressUpdate, 43004 => l10n.jobResult, 43005 => l10n.jobCancelled, 43006 => l10n.jobFailed, _ => switch (item.item.category) { 'mention' => l10n.mention, 'agent_activity' => l10n.agentUpdate, _ => l10n.channelUpdate, }, }; } ({String text, String? channelLabel}) _localizedInboxTypeLabel( BuildContext context, InboxItem item, { required String? channelName, required bool isDm, required String senderLabel, }) { final l10n = context.l10n; if (isDm) { return ( text: senderLabel.isNotEmpty ? l10n.dmFrom(senderLabel) : l10n.directMessage, channelLabel: null, ); } final category = item.categories.firstOrNull ?? item.item.category; if (category == 'mention') { return ( text: channelName != null ? l10n.mentionedIn : l10n.mentioned, channelLabel: channelName, ); } if (category == 'needs_action') { return ( text: channelName != null ? l10n.needsActionIn : l10n.needsAction, channelLabel: channelName, ); } if (item.threadRootId != null) { return ( text: channelName != null ? l10n.threadInLabel : l10n.threadTitle, channelLabel: channelName, ); } final headline = _localizedInboxHeadline(context, item); return ( text: channelName != null ? l10n.headlineIn(headline) : headline, channelLabel: channelName, ); } String _localizedInboxPreview(BuildContext context, InboxItem item) { final content = item.item.content.trim(); if (content.isNotEmpty) return content; return item.item.kind == 46010 ? context.l10n.workflowWaitingApproval : context.l10n.noAdditionalDetails; } /// "New" boundary between unread and previously read rows. class _NewBoundaryDivider extends StatelessWidget { const _NewBoundaryDivider(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.gutter, vertical: Grid.half, ), child: Row( children: [ Expanded( child: Divider(color: context.colors.error.withValues(alpha: 0.5)), ), Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Text( context.l10n.newActivity, style: context.textTheme.labelSmall?.copyWith( color: context.colors.error, fontWeight: FontWeight.w600, ), ), ), Expanded( child: Divider(color: context.colors.error.withValues(alpha: 0.5)), ), ], ), ); } } /// One conversation row, matching desktop's inbox item hierarchy: /// avatar | sender + unread dot + time | contextual label | preview. /// /// Swiping left reveals the row's read-state action. Opening remains a tap or /// long-press action, so the swipe has one clear, easily recoverable outcome. class _InboxRow extends HookConsumerWidget { final InboxItem item; final Channel? channel; final String? currentPubkey; final bool isDone; final VoidCallback onTap; final VoidCallback onMarkRead; final VoidCallback onMarkUnread; const _InboxRow({ super.key, required this.item, required this.channel, required this.currentPubkey, required this.isDone, required this.onTap, required this.onMarkRead, required this.onMarkUnread, }); @override Widget build(BuildContext context, WidgetRef ref) { const restingRevealWidth = 132.0; const commitThreshold = 0.58; final revealAmount = useState(0.0); final isDragging = useState(false); final labelHapticFired = useRef(false); final userCache = ref.watch(userCacheProvider); final profile = userCache[item.item.pubkey.toLowerCase()]; final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey); final profileMentionNames = { for (final pubkey in mentionedPubkeysFromTags(item.item.tags)) if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) pubkey: userCache[pubkey]!.displayName!.trim(), }; final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags); final knownAgentPubkeys = channel == null ? ref.watch(knownAgentPubkeysProvider) : ref.watch(agentMentionPubkeysProvider(channel!.id)); final agentMentionPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: knownAgentPubkeys, profileOwnedAgentPubkeys: [ for (final profile in userCache.values) if (profile.ownerPubkey != null) profile.pubkey, ], ); final mentionNames = mentionNamesWithDirectoryLabels( mentionPubkeys: mentionPubkeys, profileMentionNames: profileMentionNames, directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), agentMentionPubkeys: agentMentionPubkeys, ); final isDm = channel?.isDm ?? false; final channelName = channel != null && !isDm ? (channel!.name.isNotEmpty ? channel!.name : null) : null; final label = _localizedInboxTypeLabel( context, item, channelName: channelName, isDm: isDm, senderLabel: senderLabel, ); final mutedColor = context.colors.onSurfaceVariant; final labelColor = item.isActionRequired && !isDone ? context.colors.tertiary : mutedColor; final reducedMotion = MediaQuery.of(context).disableAnimations; final swipeDirection = Directionality.of(context) == TextDirection.ltr ? 1.0 : -1.0; void closeActions() => revealAmount.value = 0; void toggleReadState() { closeActions(); isDone ? onMarkUnread() : onMarkRead(); } return LayoutBuilder( builder: (context, constraints) { final actionExtent = constraints.maxWidth; final revealedWidth = revealAmount.value .clamp(0, actionExtent) .toDouble(); final actionColor = isDone ? context.colors.primary : context.appColors.success; return ClipRect( child: Stack( children: [ if (revealedWidth > 0) PositionedDirectional( top: 0, end: 0, bottom: 0, width: revealedWidth, child: Padding( key: ValueKey('inbox-swipe-background-${item.id}'), padding: const EdgeInsets.all(_inboxSwipeActionInset), child: _InboxSwipeAction( key: ValueKey('inbox-swipe-read-${item.id}'), color: actionColor, foregroundColor: contrastForeground(actionColor), icon: isDone ? LucideIcons.mail : LucideIcons.mailOpen, label: isDone ? context.l10n.markUnread : context.l10n.markRead, onTap: toggleReadState, ), ), ), AnimatedSlide( offset: Offset( -swipeDirection * revealAmount.value / constraints.maxWidth, 0, ), duration: reducedMotion || isDragging.value ? Duration.zero : const Duration(milliseconds: 160), curve: Curves.easeOutCubic, child: GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragStart: (_) { isDragging.value = true; labelHapticFired.value = false; }, onHorizontalDragUpdate: (details) { isDragging.value = true; final previous = revealAmount.value; final next = (previous - (details.delta.dx * swipeDirection)) .clamp(0, actionExtent) .toDouble(); if (!labelHapticFired.value && previous < _inboxSwipeLabelRevealWidth && next >= _inboxSwipeLabelRevealWidth) { labelHapticFired.value = true; unawaited(HapticFeedback.selectionClick()); } revealAmount.value = next; }, onHorizontalDragEnd: (details) { isDragging.value = false; final velocity = details.primaryVelocity ?? 0; final shouldCommit = (velocity * swipeDirection) < -900 || revealAmount.value >= actionExtent * commitThreshold; if (shouldCommit) { toggleReadState(); } else { revealAmount.value = (velocity * swipeDirection) < -200 || revealAmount.value >= restingRevealWidth / 2 ? restingRevealWidth : 0; } }, child: Material( color: context.colors.surface, child: InkWell( key: ValueKey('inbox-row-${item.id}'), onTap: onTap, onLongPress: () => _showRowActions(context), child: Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.gutter, vertical: Grid.twelve, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _RowAvatar( pubkey: item.item.pubkey, profile: profile, ), const SizedBox(width: messageAvatarContentGap), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Sender + unread dot + timestamp. Row( children: [ Expanded( child: MessageAuthorMeta( displayName: senderLabel, username: messageUsernameLabel( profile, ), timestamp: _inboxTimestamp( context, item.latestActivityAt, ), nameColor: context.colors.onSurface, metadataColor: mutedColor, nameStyle: activityUsernameTextStyle, metadataStyle: activityTimestampTextStyle, displayNameKey: ValueKey( 'activity-author-${item.id}', ), usernameKey: ValueKey( 'activity-username-${item.id}', ), timestampKey: ValueKey( 'activity-timestamp-${item.id}', ), ), ), if (!isDone) ...[ const SizedBox(width: Grid.xxs), Container( key: ValueKey( 'inbox-unread-dot-${item.id}', ), width: 6, height: 6, decoration: BoxDecoration( shape: BoxShape.circle, color: context.colors.primary, ), ), ], ], ), const SizedBox(height: Grid.quarter), // Contextual label: "Mentioned in #channel" etc. Row( children: [ Flexible( child: Text( label.text, style: activityContextTextStyle .copyWith(color: labelColor), overflow: TextOverflow.ellipsis, ), ), if (label.channelLabel != null) ...[ const SizedBox(width: Grid.half), Flexible( child: Container( padding: const EdgeInsets.symmetric( horizontal: Grid.half + Grid.quarter, vertical: Grid.quarter / 2, ), decoration: BoxDecoration( color: context .colors .surfaceContainerHighest, borderRadius: BorderRadius.circular( Grid.half, ), ), child: Text( '#${label.channelLabel}', style: activityContextTextStyle .copyWith(color: mutedColor), overflow: TextOverflow.ellipsis, ), ), ), ], ], ), const SizedBox(height: Grid.half), // Message preview. MessageContent( content: _localizedInboxPreview(context, item), mentionNames: mentionNames, agentMentionPubkeys: agentMentionPubkeys, tags: item.item.tags, maxLines: 2, baseStyle: activityPreviewTextStyle .copyWith( color: context.colors.onSurface, ), ), ], ), ), ], ), ), ), ), ), ), ], ), ); }, ); } void _showRowActions(BuildContext context) { showBuzzModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, 0, Grid.gutter, Grid.xs, ), child: IconTheme.merge( data: const IconThemeData(size: 18), child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: Icon( isDone ? LucideIcons.mail : LucideIcons.mailOpen, ), title: Text( isDone ? context.l10n.markUnread : context.l10n.markRead, ), onTap: () { Navigator.of(sheetContext).pop(); isDone ? onMarkUnread() : onMarkRead(); }, ), ListTile( leading: const Icon(LucideIcons.externalLink), title: Text(context.l10n.openConversation), onTap: () { Navigator.of(sheetContext).pop(); onTap(); }, ), ], ), ), ), ), ); } } class _InboxSwipeAction extends StatelessWidget { final Color color; final Color foregroundColor; final IconData icon; final String label; final VoidCallback onTap; const _InboxSwipeAction({ super.key, required this.color, required this.foregroundColor, required this.icon, required this.label, required this.onTap, }); @override Widget build(BuildContext context) => Material( color: color, borderRadius: BorderRadius.circular(Radii.full), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(Radii.full), child: Semantics( button: true, label: label, child: LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth < Grid.lg) { return const SizedBox.shrink(); } final showLabel = constraints.maxWidth >= _inboxSwipeActionLabelMinWidth; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, size: 18, color: foregroundColor), if (showLabel) ...[ const SizedBox(height: Grid.quarter), Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Text( label, maxLines: 2, softWrap: true, textAlign: TextAlign.center, style: context.textTheme.labelSmall?.copyWith( color: foregroundColor, fontWeight: FontWeight.w600, ), ), ), ], ], ); }, ), ), ), ); } class _RowAvatar extends StatelessWidget { final String pubkey; final UserProfile? profile; const _RowAvatar({required this.pubkey, required this.profile}); @override Widget build(BuildContext context) { final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); return AvatarImage( imageUrl: profile?.avatarUrl, radius: activityAvatarSize / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: context.colors.onPrimaryContainer, ), ), ); } }