Files
buzz/mobile/lib/features/activity/activity_page/inbox_row.dart
klopez4212 69107dc3bf Polish mobile message threads and composer (#5645)
## Summary

- refine mobile message metadata, search spacing, and Activity filter
semantics
- add channel-parity Latest navigation and stable tail following to
threads
- synchronize Android composer/keyboard geometry and keep Latest spacing
stable across IME transitions

## Validation

- `bin/just mobile-check`
- `bin/just mobile-test` (1,276 tests)
- Pixel 10 install/launch and channel/thread keyboard, Latest, tail, and
back-navigation review
- signed iPhone install/launch workflow

## Snapshots

See the review snapshots below.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-15 09:14:14 +01:00

478 lines
18 KiB
Dart

part of '../activity_page.dart';
const _inboxSwipeActionInset = Grid.half;
const _inboxSwipeActionLabelMinWidth = Grid.xxl;
const _inboxSwipeLabelRevealWidth =
_inboxSwipeActionLabelMinWidth + (_inboxSwipeActionInset * 2);
/// "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(
'New',
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 senderPubkey = item.item.pubkey.toLowerCase();
final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags);
final relevantPubkeys = {senderPubkey, ...mentionPubkeys};
final profiles = <String, UserProfile?>{
for (final pubkey in relevantPubkeys)
pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])),
};
final profile = profiles[senderPubkey];
final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey);
final profileMentionNames = {
for (final pubkey in mentionPubkeys)
if (profiles[pubkey]?.displayName?.trim().isNotEmpty == true)
pubkey: profiles[pubkey]!.displayName!.trim(),
};
final knownAgentPubkeys = channel == null
? ref.watch(knownAgentPubkeysProvider)
: ref.watch(agentMentionPubkeysProvider(channel!.id));
final agentMentionPubkeys = agentPubkeysWithProfileOwners(
knownAgentPubkeys: knownAgentPubkeys,
profileOwnedAgentPubkeys: [
for (final pubkey in mentionPubkeys)
if (profiles[pubkey]?.ownerPubkey != null) 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 = inboxTypeLabel(
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 ? 'Mark unread' : 'Mark as read',
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(
item.latestActivityAt,
),
nameColor: context.colors.onSurface,
metadataColor: mutedColor,
nameStyle: activityUsernameTextStyle,
timestampStyle:
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: item.item.displayContent,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: item.item.tags,
maxLines: 2,
baseStyle: activityPreviewTextStyle
.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
],
),
),
),
),
),
),
],
),
);
},
);
}
void _showRowActions(BuildContext context) {
showBuzzModalBottomSheet<void>(
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 ? 'Mark unread' : 'Mark as read'),
onTap: () {
Navigator.of(sheetContext).pop();
isDone ? onMarkUnread() : onMarkRead();
},
),
ListTile(
leading: const Icon(LucideIcons.externalLink),
title: const Text('Open conversation'),
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,
),
),
);
}
}