Files
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

547 lines
20 KiB
Dart

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