Files
buzz/mobile/lib/features/activity/inbox_item.dart
T
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

285 lines
8.2 KiB
Dart

import 'package:flutter/foundation.dart';
import 'feed_item.dart';
/// Inbox filters, mirroring desktop's `InboxFilter` in
/// `desktop/src/features/home/lib/inbox.ts`.
enum InboxFilter {
all,
mention,
thread,
needsAction,
activity,
agentActivity,
reminders,
drafts,
}
/// Category ordering used to pick the label for a grouped conversation.
/// Mirrors desktop's `categoryPriority`.
int categoryPriority(String category) {
return switch (category) {
'needs_action' => 0,
'mention' => 1,
'agent_activity' => 2,
_ => 3,
};
}
/// Thread reference from NIP-10 tags — desktop's `getThreadReference`.
({String? parentId, String? rootId}) threadReferenceOf(
List<List<String>> tags,
) {
List<String>? rootTag;
List<String>? replyTag;
for (final tag in tags) {
if (tag.length >= 4 && tag[0] == 'e') {
if (tag[3] == 'root') rootTag = tag;
if (tag[3] == 'reply') replyTag = tag;
}
}
if (replyTag == null) return (parentId: null, rootId: null);
final parentId = replyTag[1];
return (parentId: parentId, rootId: rootTag?[1] ?? parentId);
}
/// Broadcast replies surface at the channel top level — desktop's
/// `isBroadcastReply`.
bool isBroadcastReply(List<List<String>> tags) {
return tags.any(
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
);
}
/// A thread reply that is not broadcast — desktop's `isThreadReply`.
bool isThreadReply(List<List<String>> tags) {
return threadReferenceOf(tags).parentId != null && !isBroadcastReply(tags);
}
/// Stable conversation identity: `rootId ?? parentId ?? event.id`, except
/// ordinary (non-thread) DM messages, which group by DM channel identity so
/// one DM conversation renders as one row. Mirrors desktop's
/// `getInboxConversationId`.
String inboxConversationId(
List<List<String>> tags,
String eventId, {
String? dmChannelId,
}) {
final thread = threadReferenceOf(tags);
final threadId = thread.rootId ?? thread.parentId;
if (threadId != null) return threadId;
if (dmChannelId != null) return 'dm:$dmChannelId';
return eventId;
}
/// One conversation-grouped inbox row. Mirrors desktop's `InboxItem`.
@immutable
class InboxItem {
/// Stable conversation identity for the thread group.
final String conversationId;
/// Representative (latest) event id.
final String id;
/// Representative (latest) feed item.
final FeedItem item;
/// All events grouped into this conversation, unordered.
final List<FeedItem> groupItems;
/// Distinct categories present in the group, priority-sorted.
final List<String> categories;
final bool isActionRequired;
final int latestActivityAt;
const InboxItem({
required this.conversationId,
required this.id,
required this.item,
required this.groupItems,
required this.categories,
required this.isActionRequired,
required this.latestActivityAt,
});
/// Root id if any grouped event carries thread-reply tags.
String? get threadRootId {
for (final candidate in [item, ...groupItems]) {
if (isThreadReply(candidate.tags)) {
return threadReferenceOf(candidate.tags).rootId;
}
}
return null;
}
/// The event the row should deep-link to: the oldest event in the group
/// newer than [readAt] (oldest unread), falling back to the latest event.
FeedItem deepLinkTarget(int? readAt) {
if (readAt != null) {
FeedItem? oldestUnread;
for (final candidate in groupItems) {
if (candidate.createdAt <= readAt) continue;
if (oldestUnread == null ||
candidate.createdAt < oldestUnread.createdAt) {
oldestUnread = candidate;
}
}
if (oldestUnread != null) return oldestUnread;
}
return item;
}
}
/// Contextual row label — desktop's `getInboxTypeLabel`:
/// "DM from X" / "Mentioned in" / "Needs action in" / "Thread in" / "... in".
({String text, String? channelLabel}) inboxTypeLabel(
InboxItem item, {
required String? channelName,
required bool isDm,
required String senderLabel,
}) {
if (isDm) {
return (
text: senderLabel.isNotEmpty ? 'DM from $senderLabel' : 'DM',
channelLabel: null,
);
}
final category = item.categories.firstOrNull ?? item.item.category;
if (category == 'mention') {
return (
text: channelName != null ? 'Mentioned in' : 'Mentioned',
channelLabel: channelName,
);
}
if (category == 'needs_action') {
return (
text: channelName != null ? 'Needs action in' : 'Needs action',
channelLabel: channelName,
);
}
if (item.threadRootId != null) {
return (
text: channelName != null ? 'Thread in' : 'Thread',
channelLabel: channelName,
);
}
final headline = item.item.headline;
return (
text: channelName != null ? '$headline in' : headline,
channelLabel: channelName,
);
}
/// Whether [item] matches [filter] — desktop's `matchesInboxFilter`.
/// `reminders` and `drafts` are separate surfaces and never match here.
bool matchesInboxFilter(InboxItem item, InboxFilter filter) {
return switch (filter) {
InboxFilter.all => true,
InboxFilter.thread => [
item.item,
...item.groupItems,
].any((i) => isThreadReply(i.tags)),
InboxFilter.mention => item.categories.contains('mention'),
InboxFilter.needsAction => item.categories.contains('needs_action'),
InboxFilter.activity => item.categories.contains('activity'),
InboxFilter.agentActivity => item.categories.contains('agent_activity'),
InboxFilter.reminders || InboxFilter.drafts => false,
};
}
/// Group raw feed items into conversation rows sorted by latest activity.
/// [isDmChannel] identifies DM channels so their ordinary top-level messages
/// collapse into one row per DM conversation. Mirrors desktop's
/// `buildInboxItems`.
List<InboxItem> buildInboxItems(
Iterable<FeedItem> feedItems, {
bool Function(String channelId)? isDmChannel,
}) {
final groups = <String, List<FeedItem>>{};
for (final item in feedItems) {
final channelId = item.channelId;
final dmChannelId =
channelId != null && (isDmChannel?.call(channelId) ?? false)
? channelId
: null;
final key = inboxConversationId(
item.tags,
item.id,
dmChannelId: dmChannelId,
);
groups.putIfAbsent(key, () => []).add(item);
}
final rows = <InboxItem>[];
for (final entry in groups.entries) {
final items = entry.value;
var latest = items.first;
var latestActivityAt = 0;
for (final item in items) {
if (item.createdAt > latest.createdAt) latest = item;
if (item.createdAt > latestActivityAt) latestActivityAt = item.createdAt;
}
final categories = items.map((i) => i.category).toSet().toList()
..sort((a, b) => categoryPriority(a).compareTo(categoryPriority(b)));
rows.add(
InboxItem(
conversationId: entry.key,
id: latest.id,
item: latest,
groupItems: List.unmodifiable(items),
categories: List.unmodifiable(categories),
isActionRequired: categories.contains('needs_action'),
latestActivityAt: latestActivityAt,
),
);
}
rows.sort((a, b) => b.latestActivityAt.compareTo(a.latestActivityAt));
return rows;
}
/// Date-bucket label for section headers — desktop's `groupInboxItems`:
/// Today / Yesterday / weekday (within 7 days) / short date.
String inboxDayLabel(int unixSeconds, {DateTime? now}) {
final current = now ?? DateTime.now();
final date = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
final today = DateTime(current.year, current.month, current.day);
final day = DateTime(date.year, date.month, date.day);
final dayDiff = today.difference(day).inDays;
if (dayDiff == 0) return 'Today';
if (dayDiff == 1) return 'Yesterday';
if (dayDiff < 7 && dayDiff > 0) {
const weekdays = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
return weekdays[date.weekday - 1];
}
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final label = '${months[date.month - 1]} ${date.day}';
return date.year == current.year ? label : '$label, ${date.year}';
}