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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
547 lines
20 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|