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

788 lines
25 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/relay/relay.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import 'channel_window.dart';
enum SystemEventType {
memberJoined,
memberLeft,
memberRemoved,
topicChanged,
purposeChanged,
channelCreated,
channelArchived,
channelUnarchived,
huddleStarted,
huddleEnded,
}
@immutable
class SystemEvent {
final SystemEventType type;
final String? actorPubkey;
final String? targetPubkey;
final String? topic;
final String? purpose;
const SystemEvent({
required this.type,
this.actorPubkey,
this.targetPubkey,
this.topic,
this.purpose,
});
/// Parse a system event from the JSON content of a kind-40099 event.
/// Returns null if the payload is unrecognised.
static SystemEvent? fromContent(String content) {
final Map<dynamic, dynamic> json;
try {
final decoded = jsonDecode(content);
if (decoded is! Map) {
return null;
}
json = decoded;
} catch (_) {
return null;
}
final type = switch (_readString(json, 'type')) {
'member_joined' => SystemEventType.memberJoined,
'member_left' => SystemEventType.memberLeft,
'member_removed' => SystemEventType.memberRemoved,
'topic_changed' => SystemEventType.topicChanged,
'purpose_changed' => SystemEventType.purposeChanged,
'channel_created' => SystemEventType.channelCreated,
'channel_archived' => SystemEventType.channelArchived,
'channel_unarchived' => SystemEventType.channelUnarchived,
_ => null,
};
if (type == null) return null;
return SystemEvent(
type: type,
actorPubkey: _readString(json, 'actor'),
targetPubkey: _readString(json, 'target'),
topic: _readString(json, 'topic'),
purpose: _readString(json, 'purpose'),
);
}
static SystemEvent? fromHuddleEvent(NostrEvent event) {
final type = switch (event.kind) {
EventKind.huddleStarted => SystemEventType.huddleStarted,
EventKind.huddleEnded => SystemEventType.huddleEnded,
_ => null,
};
if (type == null) return null;
return SystemEvent(type: type, actorPubkey: event.pubkey);
}
/// Human-readable description. [resolveLabel] maps a pubkey to a display
/// name — the caller provides it so this class stays free of provider deps.
String describe(String Function(String? pubkey) resolveLabel) {
final actor = resolveLabel(actorPubkey);
return switch (type) {
SystemEventType.memberJoined => () {
if (actorPubkey != null && actorPubkey == targetPubkey) {
return '$actor joined the channel';
}
final target = resolveLabel(targetPubkey);
return '$target was added by $actor';
}(),
SystemEventType.memberLeft => '$actor left the channel',
SystemEventType.memberRemoved => () {
final target = resolveLabel(targetPubkey);
return '$actor removed $target from the channel';
}(),
SystemEventType.topicChanged =>
'$actor ${_describeTextFieldChange('topic', topic)}',
SystemEventType.purposeChanged =>
'$actor ${_describeTextFieldChange('purpose', purpose)}',
SystemEventType.channelCreated => '$actor created this channel',
SystemEventType.channelArchived => '$actor archived this channel',
SystemEventType.channelUnarchived => '$actor unarchived this channel',
SystemEventType.huddleStarted => '$actor started a huddle',
SystemEventType.huddleEnded => '$actor ended the huddle',
};
}
/// Localized description for the rendered channel timeline. The legacy
/// [describe] method remains unchanged for protocol-facing callers/tests.
String describeLocalized(
AppLocalizations l10n,
String Function(String? pubkey) resolveLabel,
) {
final actor = resolveLabel(actorPubkey);
return switch (type) {
SystemEventType.memberJoined => () {
if (actorPubkey != null && actorPubkey == targetPubkey) {
return l10n.systemJoinedChannel(actor);
}
return l10n.systemMemberAdded(resolveLabel(targetPubkey), actor);
}(),
SystemEventType.memberLeft => l10n.systemMemberLeft(actor),
SystemEventType.memberRemoved => l10n.systemMemberRemoved(
actor,
resolveLabel(targetPubkey),
),
SystemEventType.topicChanged => _describeLocalizedTextFieldChange(
l10n,
actor,
topic,
isTopic: true,
),
SystemEventType.purposeChanged => _describeLocalizedTextFieldChange(
l10n,
actor,
purpose,
isTopic: false,
),
SystemEventType.channelCreated => l10n.systemChannelCreated(actor),
SystemEventType.channelArchived => l10n.systemChannelArchived(actor),
SystemEventType.channelUnarchived => l10n.systemChannelUnarchived(actor),
SystemEventType.huddleStarted => l10n.systemHuddleStarted(actor),
SystemEventType.huddleEnded => l10n.systemHuddleEnded(actor),
};
}
}
String _describeLocalizedTextFieldChange(
AppLocalizations l10n,
String actor,
String? value, {
required bool isTopic,
}) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
return isTopic
? l10n.systemTopicCleared(actor)
: l10n.systemPurposeCleared(actor);
}
return isTopic
? l10n.systemTopicChanged(actor, trimmed)
: l10n.systemPurposeChanged(actor, trimmed);
}
/// Caption fragment for a channel topic or purpose change, e.g.
/// `changed the topic to "Release planning"` or `cleared the topic`.
///
/// A blank value means the field was cleared: the relay reports a clear as a
/// `topic_changed` / `purpose_changed` event carrying an empty string, not as a
/// separate event type. Without this branch the timeline renders
/// `changed the topic to ""`, which reads as if the topic were set to two quote
/// marks. Whitespace-only values are treated as cleared for the same reason.
///
/// Mirrors `describeChannelTextFieldChange` in
/// `desktop/src/features/messages/lib/systemEventCopy.ts`.
String _describeTextFieldChange(String field, String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
return 'cleared the $field';
}
return 'changed the $field to "$trimmed"';
}
@immutable
class TimelineReaction {
final String emoji;
final int count;
final bool reactedByCurrentUser;
final List<String> userPubkeys;
final String? emojiUrl;
/// The event ID of the current user's reaction, for deletion.
final String? currentUserReactionId;
const TimelineReaction({
required this.emoji,
required this.count,
required this.reactedByCurrentUser,
required this.userPubkeys,
this.emojiUrl,
this.currentUserReactionId,
});
}
@immutable
class TimelineMessage {
final String id;
final String pubkey;
final int createdAt;
final String content;
final List<List<String>> tags;
final bool isSystem;
final bool edited;
final SystemEvent? systemEvent;
/// Pubkeys mentioned in this message (from p-tags).
final List<String> mentionPubkeys;
/// Aggregated reactions on this message.
final List<TimelineReaction> reactions;
/// Direct parent event ID (null for top-level messages).
final String? parentId;
/// Root event ID of the thread (null for top-level messages).
final String? rootId;
const TimelineMessage({
required this.id,
required this.pubkey,
required this.createdAt,
required this.content,
this.tags = const [],
this.isSystem = false,
this.edited = false,
this.systemEvent,
this.mentionPubkeys = const [],
this.reactions = const [],
this.parentId,
this.rootId,
});
/// Attachment messages stay visually distinct from surrounding messages,
/// even when several are sent by the same author in quick succession.
bool get hasAttachments =>
tags.any((tag) => tag.isNotEmpty && tag.first == 'imeta');
}
@immutable
class ThreadSummary {
final String threadHeadId;
final int replyCount;
/// Up to 3 most recent unique participant pubkeys.
final List<String> participantPubkeys;
final int? lastReplyAt;
const ThreadSummary({
required this.threadHeadId,
required this.replyCount,
required this.participantPubkeys,
this.lastReplyAt,
});
}
/// A main-timeline entry: a root message with an optional thread summary.
@immutable
class MainTimelineEntry {
final TimelineMessage message;
final ThreadSummary? summary;
const MainTimelineEntry({required this.message, this.summary});
}
const _membershipGroupWindowSeconds = 5 * 60;
@immutable
class _MembershipChange {
final String? actor;
final bool isSelfJoin;
const _MembershipChange({required this.actor, required this.isSelfJoin});
}
_MembershipChange? _membershipChange(MainTimelineEntry entry) {
final event = entry.message.systemEvent;
if (!entry.message.isSystem || event?.type != SystemEventType.memberJoined) {
return null;
}
final actor = event?.actorPubkey?.trim().toLowerCase();
final target = event?.targetPubkey?.trim().toLowerCase();
if (actor == null || actor.isEmpty || target == null || target.isEmpty) {
return null;
}
final isSelfJoin = actor == target;
return _MembershipChange(
actor: isSelfJoin ? null : actor,
isSelfJoin: isSelfJoin,
);
}
bool _membershipChangesCanGroup(
_MembershipChange first,
_MembershipChange second,
) {
return first.isSelfJoin == second.isSelfJoin &&
(first.isSelfJoin || first.actor == second.actor);
}
bool _isSameLocalDay(int firstTimestamp, int secondTimestamp) {
final first = DateTime.fromMillisecondsSinceEpoch(firstTimestamp * 1000);
final second = DateTime.fromMillisecondsSinceEpoch(secondTimestamp * 1000);
return first.year == second.year &&
first.month == second.month &&
first.day == second.day;
}
/// Groups consecutive membership arrivals using the same display rule as
/// desktop: matching additions (or self-joins) within a fixed five-minute
/// window become one render item. Other events and local day boundaries break
/// the group.
///
/// Each inner list is one renderable timeline item. Non-grouped entries are
/// returned as single-item lists.
List<List<MainTimelineEntry>> groupMembershipTimelineEntries(
List<MainTimelineEntry> entries,
) {
final groupsByStart = <int, int>{};
for (var end = entries.length - 1; end >= 0;) {
final newestEntry = entries[end];
final newestChange = _membershipChange(newestEntry);
if (newestChange == null) {
end -= 1;
continue;
}
var start = end;
while (start > 0) {
final candidate = entries[start - 1];
final candidateChange = _membershipChange(candidate);
if (candidateChange == null ||
!_membershipChangesCanGroup(candidateChange, newestChange) ||
!_isSameLocalDay(
candidate.message.createdAt,
newestEntry.message.createdAt,
) ||
newestEntry.message.createdAt < candidate.message.createdAt ||
newestEntry.message.createdAt - candidate.message.createdAt >
_membershipGroupWindowSeconds) {
break;
}
start -= 1;
}
if (start < end) groupsByStart[start] = end;
end = start - 1;
}
final result = <List<MainTimelineEntry>>[];
for (var index = 0; index < entries.length;) {
final groupEnd = groupsByStart[index];
if (groupEnd == null) {
result.add([entries[index]]);
index += 1;
continue;
}
result.add(entries.sublist(index, groupEnd + 1));
index = groupEnd + 1;
}
return result;
}
/// Process a chronologically-sorted list of [NostrEvent]s into a list of
/// [TimelineMessage]s, applying deletions, edits, reactions, and system event
/// parsing.
///
/// Mirrors the desktop's `formatTimelineMessages` logic.
/// [currentPubkey] is used to determine if the current user has reacted.
List<TimelineMessage> formatTimeline(
List<NostrEvent> events, {
String? currentPubkey,
}) {
// 1. Collect deletion targets. Both kind:5 (NIP-09) and kind:9005
// (Buzz-native) are deletion markers; mirror desktop's behavior.
final deletedIds = <String>{};
for (final event in events) {
if (event.kind != EventKind.deletion &&
event.kind != EventKind.nip29DeleteEvent) {
continue;
}
for (final tag in event.tags) {
if (tag.length >= 2 && tag[0] == 'e') {
deletedIds.add(tag[1]);
}
}
}
// 2. Build edit map: targetId → latest edit content.
final edits = <String, _Edit>{};
for (final event in events) {
if (event.kind != EventKind.streamMessageEdit) continue;
if (deletedIds.contains(event.id)) continue;
final targetId = _lastETag(event.tags);
if (targetId == null || deletedIds.contains(targetId)) continue;
final existing = edits[targetId];
if (existing == null || event.createdAt > existing.createdAt) {
edits[targetId] = _Edit(
content: event.content,
createdAt: event.createdAt,
tags: event.tags,
);
}
}
// 3. Aggregate reactions: targetId → { emoji → { pubkey → eventId } }.
final reactionMap = <String, Map<String, Map<String, String>>>{};
final reactionEmojiUrls = <String, Map<String, String>>{};
for (final event in events) {
if (event.kind != EventKind.reaction) continue;
if (deletedIds.contains(event.id)) continue;
final targetId = _lastETag(event.tags);
if (targetId == null || deletedIds.contains(targetId)) continue;
final emoji = event.content.trim();
if (emoji.isEmpty) continue;
reactionMap
.putIfAbsent(targetId, () => {})
.putIfAbsent(emoji, () => {})[event.pubkey.toLowerCase()] =
event.id;
final shortcode = normalizeShortcode(emoji);
if (shortcode != null) {
for (final tag in event.tags) {
if (tag.length < 3 || tag[0] != 'emoji') continue;
if (normalizeShortcode(tag[1]) == shortcode) {
reactionEmojiUrls.putIfAbsent(targetId, () => {})[emoji] = tag[2];
break;
}
}
}
}
final normalizedCurrentPubkey = currentPubkey?.toLowerCase();
List<TimelineReaction> reactionsFor(String eventId) {
final emojiMap = reactionMap[eventId];
if (emojiMap == null) return const [];
return [
for (final entry in emojiMap.entries)
TimelineReaction(
emoji: entry.key,
count: entry.value.length,
reactedByCurrentUser:
normalizedCurrentPubkey != null &&
entry.value.containsKey(normalizedCurrentPubkey),
userPubkeys: entry.value.keys.toList(),
emojiUrl: reactionEmojiUrls[eventId]?[entry.key],
currentUserReactionId: normalizedCurrentPubkey != null
? entry.value[normalizedCurrentPubkey]
: null,
),
];
}
// 4. Filter to visible content events and build TimelineMessages.
final result = <TimelineMessage>[];
for (final event in events) {
if (deletedIds.contains(event.id)) continue;
if (event.kind == EventKind.systemMessage) {
final systemEvent = SystemEvent.fromContent(event.content);
if (systemEvent != null) {
result.add(
TimelineMessage(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
content: event.content,
tags: event.tags,
isSystem: true,
systemEvent: systemEvent,
reactions: reactionsFor(event.id),
),
);
}
continue;
}
if (event.kind == EventKind.huddleStarted ||
event.kind == EventKind.huddleEnded) {
final systemEvent = SystemEvent.fromHuddleEvent(event);
if (systemEvent != null) {
result.add(
TimelineMessage(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
content: event.content,
tags: event.tags,
isSystem: true,
systemEvent: systemEvent,
reactions: reactionsFor(event.id),
),
);
}
continue;
}
if (event.kind == EventKind.streamMessage ||
event.kind == EventKind.streamMessageV2 ||
event.kind == EventKind.streamMessageDiff) {
final edit = edits[event.id];
final effectiveTags = edit?.tags ?? event.tags;
// Include both notify (`p`) and reference-only (`mention`) tags —
// mirrors desktop's resolveMentionNames, so names in messages sent
// "without inviting" still render as mentions.
final mentions = <String>[
for (final tag in effectiveTags)
if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention')) tag[1],
];
final threadRef = event.threadReference;
result.add(
TimelineMessage(
id: event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
content: edit?.content ?? event.content,
tags: effectiveTags,
edited: edit != null,
mentionPubkeys: mentions,
reactions: reactionsFor(event.id),
parentId: threadRef.parentId,
rootId: threadRef.rootId,
),
);
}
}
return result;
}
/// Build main-timeline entries: only root messages (parentId == null),
/// each with an optional [ThreadSummary] when replies exist.
///
/// Mirrors the desktop's `buildMainTimelineEntries`.
List<MainTimelineEntry> buildMainTimelineEntries(
List<TimelineMessage> messages, {
Map<String, ChannelWindowThreadSummary>? relaySummaries,
}) {
// Index descendant stats by ancestor, so a nested reply updates every summary
// above it and not only the summary of its direct parent.
final descendantStats = _buildDescendantStats(messages);
return [
for (final msg in messages)
if (msg.parentId == null || _isBroadcastReply(msg))
MainTimelineEntry(
message: msg,
summary: _buildSummary(
msg.id,
descendantStats,
relaySummaries?[msg.id],
),
),
];
}
bool _isBroadcastReply(TimelineMessage message) {
return message.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
);
}
/// Combine what the relay counted with what this client has actually seen.
///
/// The count and last-reply time follow the desktop's `mergeThreadSummaries`
/// (`desktop/src/features/messages/lib/threadPanel.ts`): the relay recount is
/// authoritative for replies this client never loaded, and the locally observed
/// replies are authoritative for anything that landed after (or alongside) the
/// last recount. Neither source alone is complete, so take the larger count and
/// the later reply time rather than letting one shadow the other. The facepile
/// order is mobile's own (see [_mergeParticipants]) because this file already
/// renders relay participants in the order the relay sent them.
///
/// Both halves count descendants, not direct replies: the relay's
/// `descendant_count` and the locally assembled [_buildDescendantStats]. A badge
/// on the main timeline stands for the whole thread under that message, so a
/// reply to a reply has to raise it.
ThreadSummary? _buildSummary(
String messageId,
Map<String, _DescendantStats> descendantStats,
ChannelWindowThreadSummary? relaySummary,
) {
final local = _buildLocalSummary(messageId, descendantStats);
final relay = _buildRelaySummary(messageId, relaySummary);
if (relay == null) return local;
if (local == null) return relay;
return ThreadSummary(
threadHeadId: messageId,
replyCount: local.replyCount > relay.replyCount
? local.replyCount
: relay.replyCount,
// Relay participants first: they describe the whole thread, including
// replies this client never loaded, so a recount's facepile keeps rendering
// as it does today. Locally seen repliers (newest first, matching the relay
// order this file already renders) only fill the remaining slots.
participantPubkeys: _mergeParticipants(
relay.participantPubkeys,
local.participantPubkeys.reversed,
),
lastReplyAt: _laterOf(local.lastReplyAt, relay.lastReplyAt),
);
}
/// Summary assembled from the replies present in the loaded timeline.
///
/// Counts every loaded descendant, not only direct children, because the root
/// badge in the main timeline stands for the whole thread. This mirrors the
/// desktop's `buildSummaryForDirectReplies`, which reads the same descendant
/// stats and reverses the newest-first participants to oldest-first.
ThreadSummary? _buildLocalSummary(
String messageId,
Map<String, _DescendantStats> descendantStats,
) {
final stats = descendantStats[messageId];
if (stats == null || stats.descendantCount == 0) return null;
return ThreadSummary(
threadHeadId: messageId,
replyCount: stats.descendantCount,
participantPubkeys: stats.recentParticipantsNewestFirst.reversed.toList(),
lastReplyAt: stats.lastReplyAt,
);
}
/// Descendant count, last reply time, and recent participants for every message
/// that has at least one loaded descendant.
///
/// Mirrors the desktop's `buildDescendantStatsByMessageId`
/// (`desktop/src/features/messages/lib/threadPanel.ts`): each message is
/// attributed to every ancestor on its parent chain, so a reply nested under a
/// reply still counts towards the root it belongs to. Messages are visited
/// newest first so the capped participant list keeps the most recent repliers.
Map<String, _DescendantStats> _buildDescendantStats(
List<TimelineMessage> messages,
) {
final messageById = <String, TimelineMessage>{
for (final msg in messages) msg.id: msg,
};
final statsByMessageId = <String, _DescendantStats>{
for (final msg in messages) msg.id: _DescendantStats(),
};
// Oldest first, keeping the original order for messages sharing a timestamp,
// then walked in reverse so participants are collected newest first.
final ordered = List<int>.generate(messages.length, (index) => index)
..sort((left, right) {
final byCreatedAt = messages[left].createdAt.compareTo(
messages[right].createdAt,
);
return byCreatedAt != 0 ? byCreatedAt : left.compareTo(right);
});
for (var i = ordered.length - 1; i >= 0; i--) {
final message = messages[ordered[i]];
final participant = message.pubkey.toLowerCase();
// Cap the walk so a malformed parent chain (a cycle, for instance) cannot
// spin forever.
var ancestorId = message.parentId;
var hops = 0;
final maxHops = messages.length + 1;
while (ancestorId != null && hops < maxHops) {
final ancestorStats = statsByMessageId[ancestorId];
if (ancestorStats == null) break;
ancestorStats.descendantCount += 1;
ancestorStats.lastReplyAt = _laterOf(
ancestorStats.lastReplyAt,
message.createdAt,
);
if (ancestorStats.recentParticipantsNewestFirst.length < 3 &&
!ancestorStats.recentParticipantsNewestFirst.contains(participant)) {
ancestorStats.recentParticipantsNewestFirst.add(participant);
}
ancestorId = messageById[ancestorId]?.parentId;
hops += 1;
}
}
return statsByMessageId;
}
/// Mutable accumulator for [_buildDescendantStats].
class _DescendantStats {
int descendantCount = 0;
int? lastReplyAt;
final List<String> recentParticipantsNewestFirst = [];
}
/// Summary from the relay's recount, or null when it reports no replies.
ThreadSummary? _buildRelaySummary(
String messageId,
ChannelWindowThreadSummary? relaySummary,
) {
if (relaySummary == null || relaySummary.descendantCount <= 0) return null;
return ThreadSummary(
threadHeadId: messageId,
replyCount: relaySummary.descendantCount,
participantPubkeys: relaySummary.participantPubkeys.take(3).toList(),
lastReplyAt: relaySummary.lastReplyAt,
);
}
int? _laterOf(int? left, int? right) {
if (left == null) return right;
if (right == null) return left;
return left > right ? left : right;
}
/// Up to 3 unique pubkeys, [primary] first.
///
/// [secondary] is expected newest-first so that a capped facepile keeps the
/// most recent participants rather than the oldest ones. Uniqueness is
/// case-insensitive because the locally assembled half lowercases pubkeys while
/// the relay half is passed through as received.
List<String> _mergeParticipants(
Iterable<String> primary,
Iterable<String> secondary,
) {
final seen = <String>{};
final merged = <String>[];
for (final pubkey in [...primary, ...secondary]) {
if (!seen.add(pubkey.toLowerCase())) continue;
merged.add(pubkey);
if (merged.length == 3) break;
}
return merged;
}
class _Edit {
final String content;
final int createdAt;
final List<List<String>> tags;
const _Edit({
required this.content,
required this.createdAt,
required this.tags,
});
}
/// Get the last `e` tag value (reaction/edit target convention).
String? _lastETag(List<List<String>> tags) {
for (var i = tags.length - 1; i >= 0; i--) {
final tag = tags[i];
if (tag.length >= 2 && tag[0] == 'e') return tag[1];
}
return null;
}
String? _readString(Map<dynamic, dynamic> json, String key) {
final value = json[key];
return value is String ? value : null;
}