feat: import Chinese-localized Buzz source snapshot
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
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>
This commit is contained in:
@@ -0,0 +1,787 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user