mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
## Summary Bring desktop Solo chats and project organization to mobile, with synced collapsible sections, shared unread highlights and tab badges, agent selection, shared search, native iOS creation sheets, and inline typing bubbles that transition into replies. Resolve Solo placement for every chat entry route, retain encrypted organization snapshots for offline reading, and let users resume or discard unfinished chat and project setup without deleting existing messages. ### Related issue Related: #6565; expandable agent transcripts remain separate work. ### Testing - Fresh Solo read state seeds each active chat from its captured initial last-message timestamp before exposing row/section/tab unread state. Baselines survive intervening channel updates and reset across identity/community boundaries while surviving read-state readiness changes. Regressions cover multiple chats, events before read-state readiness and during/after seeding, and identity changes; removing either the seed or the display gate fails them. - Chat completion reconciles a retained Solo-save journal even when placement is already confirmed. False/throwing cleanup regressions ensure the outer intent remains until reconciliation and only one placement is published. Typing terminal-close loops remain bounded; the retry budget resets after an uninterrupted eight-second health interval or transport reconnect. - Typing retry budgets reset after sustained recovery; invalid Solo updates retain cached rows with a retry warning that survives failed refreshes. Project names use the editors’ 120-grapheme limit, including emoji and combining characters. Regression coverage exercises repeated recovery, invalid live/refresh heads, and character-limit boundaries. - Rebased onto current main, preserving the newer Search controls, Huddle timeline filtering, and message-media layout. - Failed project journal writes reload storage before claiming recovery and reopen the entered name for retry. Typing subscription rejections are caught; terminal closures clear stale indicators and retry with a bounded backoff, canceled on disposal. Regression tests cover both false/throwing storage writes and initial/late subscription rejection. - Explicit ordinary destinations skip optional Solo lookup waits. Generic destinations perform one bounded lookup in the destination (search no longer duplicates it); an unavailable classification falls back to ordinary sending per the product owner's explicit choice. Positively identified Solo destinations use Solo providers. - Terminal Solo subscription closure preserves cached rows and exposes live-update Retry. Page coverage verifies warning visibility, usable rows during recovery, and a subsequent terminal close. Removing the warning or the immediate ordinary-route guard fails its regression. - Generic channel entries share an ordinary default when Solo classification is unavailable, per the requested product behavior. Known Solo placement takes precedence; callers can explicitly require unresolved placement to remain blocked. Bounded initial lookup preserves known Solo deep-link routing. - Dirty newest snapshots and offline-save warnings survive transport reconnects; Retry persists that same event even if the relay returns an older head. Community changes replace the service, and agent-directory reconnect refresh remains intact. - Pending-save discard reloads failed removals. Repeated reconnect testing confirms typing subscriptions are released on provider rebuild and disposal; removing the existing cleanup fails the regression. - Ordinary Home/create routes explicitly preserve private-channel sending during Solo failures; identified Solo sends use the Solo sender and explicit unresolved routes remain fail-closed. Solo unread replies beyond the initial 200-event page are loaded through the oldest-unread boundary. - Chat/project/agent cleanup reloads failed removals, retains same-process recovery, and protects replaced records. False/throw regressions verify retries do not duplicate creation or membership. Route, unread, and cleanup guard-removal mutations each fail their regression. - Ordinary channels, DMs, and forums remain sendable when optional Solo sync fails; explicit unresolved placement stays blocked. Controlled cache races retain the newest verified snapshot, keep usable chats visible, and retry offline persistence without stale writes. Both protection-removal regressions fail as expected. - Confirmed journal cleanup checks false/throwing removal and exact-record identity. Interrupted agent choices are visible immediately, identify the saved agent, and cover Resume/Later/Discard with accessible name/key-fallback controls. - Notification deep links preserve the contiguous history cursor; same-second messages infer the newest agent consistently. Failed offline-cache writes surface Retry, with false/throwing storage failures and offline restart covered. - Composite history cursors cover 350 same-second events plus older history; removing the ID cursor makes the regression fail. Agent changes now journal before granting access, with restart/failure and Resume/Later/Discard widget coverage. Signed malformed profiles remain isolated. - Native iOS avatar tests passed on the simulator: visible-row loading, three-request concurrency, streamed byte limits, response validation, downsampling/pixel limits, cancellation, and visible avatar updates. - Solo and Home share observed-event/read-marker logic. Widget coverage verifies unread rows, collapsed-section indicators, the tab badge before first Solo visit, message/thread reads, forced unread, and filtering archived/nonmember/unplaced chats. - Chat intent is saved before ownership and organization preflight; tests cover interrupted ownership lookup and a disconnect while the native creation form is open. - Mobile analysis, formatting, file-size checks, and full mobile tests passed at `1d4c41354`. - Project creation persists its encrypted name/UUID before connectivity checks or relay reads. Restart tests cover Resume/Later/Discard, scope isolation, stale-intent rejection, and lost acknowledgements. Removing the pre-network save makes all four production-flow regressions fail. - Regression coverage includes notification/Activity routing, inline replies, unknown-placement send protection, restart recovery/discard, and encrypted offline cache isolation/refresh. - Earlier feature build exercised on a physical iPhone with existing account/community data; review follow-ups have not been reinstalled on the device. UIKit agent-row accessibility checks passed on an iOS simulator. - Earlier cross-platform `just ci` stopped on the existing `buzz-acp` test `signals_cancel_startup_turn_and_pending_stdin_without_hanging` (process exit-code assertion); no Rust production changes are included in this PR. ### Remaining review limitation Agent discovery is bounded to 10 pages / 1,000 profiles with automatic retries disabled. Owner-scoped discovery remains open because the current relay ignores `#auth` filters; supporting that query requires a relay change. ### Snapshots Sample data, shared Flutter/Android surface. Native iOS uses platform sheets and controls. | Solo: projects and chats | New chat | Choose an agent | | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png" width="240" alt="Solo with collapsible projects and chats" /> | <img src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png" width="240" alt="New chat form" /> | <img src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png" width="240" alt="Agent selection list" /> | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
819 lines
26 KiB
Dart
819 lines
26 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/custom_emoji/custom_emoji.dart';
|
|
import '../../shared/mentions/mention_tags.dart';
|
|
import 'channel_window.dart';
|
|
import 'message_media.dart';
|
|
import 'message_content/bubble_segments.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;
|
|
final String? ephemeralChannelId;
|
|
|
|
const SystemEvent({
|
|
required this.type,
|
|
this.actorPubkey,
|
|
this.targetPubkey,
|
|
this.topic,
|
|
this.purpose,
|
|
this.ephemeralChannelId,
|
|
});
|
|
|
|
/// 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;
|
|
|
|
String? ephemeralChannelId;
|
|
try {
|
|
final content = jsonDecode(event.content);
|
|
if (content is Map) {
|
|
ephemeralChannelId = _canonicalHuddleRoomId(
|
|
content['ephemeral_channel_id'],
|
|
);
|
|
}
|
|
} catch (_) {
|
|
// The row still renders, but joining requires the validated room ID.
|
|
}
|
|
|
|
return SystemEvent(
|
|
type: type,
|
|
actorPubkey: event.pubkey,
|
|
ephemeralChannelId: ephemeralChannelId,
|
|
);
|
|
}
|
|
|
|
/// 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 '$actor added $target to the channel';
|
|
}(),
|
|
SystemEventType.memberLeft => '$actor left the channel',
|
|
SystemEventType.memberRemoved => () {
|
|
final target = resolveLabel(targetPubkey);
|
|
return '$actor removed $target from the channel';
|
|
}(),
|
|
SystemEventType.topicChanged =>
|
|
'$actor ${describeChannelTextFieldChange('topic', topic)}',
|
|
SystemEventType.purposeChanged =>
|
|
'$actor ${describeChannelTextFieldChange('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',
|
|
};
|
|
}
|
|
}
|
|
|
|
/// 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 describeChannelTextFieldChange(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') ||
|
|
messageBubbleSegments(
|
|
content,
|
|
parseImetaTags(tags),
|
|
).any((segment) => segment.attachment);
|
|
}
|
|
|
|
@immutable
|
|
class ThreadSummary {
|
|
final String threadHeadId;
|
|
final int replyCount;
|
|
|
|
/// Whether this count is a minimum pending a complete recount.
|
|
final bool isLowerBound;
|
|
|
|
/// The last known total is awaiting reconciliation and should not be displayed.
|
|
final bool isCountPending;
|
|
|
|
/// Up to 3 most recent unique participant pubkeys.
|
|
final List<String> participantPubkeys;
|
|
final int? lastReplyAt;
|
|
|
|
const ThreadSummary({
|
|
required this.threadHeadId,
|
|
required this.replyCount,
|
|
this.isLowerBound = false,
|
|
this.isCountPending = false,
|
|
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;
|
|
final SystemEventType type;
|
|
|
|
const _MembershipChange({
|
|
required this.actor,
|
|
required this.isSelfJoin,
|
|
required this.type,
|
|
});
|
|
}
|
|
|
|
_MembershipChange? _membershipChange(MainTimelineEntry entry) {
|
|
final event = entry.message.systemEvent;
|
|
if (!entry.message.isSystem ||
|
|
(event?.type != SystemEventType.memberJoined &&
|
|
event?.type != SystemEventType.memberRemoved)) {
|
|
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 =
|
|
event!.type == SystemEventType.memberJoined && actor == target;
|
|
return _MembershipChange(
|
|
type: event.type,
|
|
actor: isSelfJoin ? null : actor,
|
|
isSelfJoin: isSelfJoin,
|
|
);
|
|
}
|
|
|
|
bool _membershipChangesCanGroup(
|
|
_MembershipChange first,
|
|
_MembershipChange second,
|
|
) {
|
|
return first.type == second.type &&
|
|
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 matching additions, removals, or self-joins within a
|
|
/// five-minute window into 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;
|
|
}
|
|
|
|
/// Debug-only instrumentation for actual timeline formatting invocations.
|
|
@visibleForTesting
|
|
VoidCallback? debugOnFormatTimeline;
|
|
|
|
/// 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,
|
|
}) {
|
|
assert(() {
|
|
debugOnFormatTimeline?.call();
|
|
return true;
|
|
}());
|
|
// 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);
|
|
// Purpose is legacy metadata with no mobile surface. Omit its activity
|
|
// before grouping so it cannot leave blank rows or split sender groups.
|
|
if (systemEvent?.type == SystemEventType.purposeChanged) continue;
|
|
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 = mentionedPubkeysFromTags(effectiveTags).toList();
|
|
|
|
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,
|
|
bool flat = false,
|
|
}) {
|
|
if (flat) {
|
|
return [
|
|
for (final message in messages) MainTimelineEntry(message: message),
|
|
];
|
|
}
|
|
// 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 represent descendants. The relay only increments descendant
|
|
/// counts on the outer root, so a broadcast reply's direct-reply count is also
|
|
/// positive evidence, and a lower bound on its descendants. Locally assembled
|
|
/// [_buildDescendantStats] includes nested replies under every ancestor.
|
|
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,
|
|
isLowerBound: relay.isLowerBound,
|
|
isCountPending: relay.isCountPending,
|
|
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) return null;
|
|
// The relay counts direct children on every parent, but descendants only on
|
|
// the outer root. Broadcast replies can therefore have valid 1/0 counters.
|
|
final useDirectCount = relaySummary.replyCount > relaySummary.descendantCount;
|
|
final count = useDirectCount
|
|
? relaySummary.replyCount
|
|
: relaySummary.descendantCount;
|
|
if (count <= 0) return null;
|
|
return ThreadSummary(
|
|
threadHeadId: messageId,
|
|
replyCount: count,
|
|
isLowerBound: relaySummary.isLowerBound || useDirectCount,
|
|
isCountPending: relaySummary.isCountPending,
|
|
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;
|
|
}
|
|
|
|
// Match Rust Uuid::parse_str: simple, hyphenated, URN and braced forms.
|
|
String? _canonicalHuddleRoomId(Object? value) {
|
|
if (value is! String) return null;
|
|
var text = value;
|
|
if (text.length == 45 && text.startsWith('urn:uuid:')) {
|
|
text = text.substring(9);
|
|
} else if (text.length == 38 && text.startsWith('{') && text.endsWith('}')) {
|
|
text = text.substring(1, 37);
|
|
}
|
|
if (RegExp(r'^[0-9a-fA-F]{32}$').hasMatch(text)) {
|
|
text =
|
|
'${text.substring(0, 8)}-${text.substring(8, 12)}-${text.substring(12, 16)}-${text.substring(16, 20)}-${text.substring(20)}';
|
|
}
|
|
if (!RegExp(
|
|
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
|
|
).hasMatch(text)) {
|
|
return null;
|
|
}
|
|
return text.toLowerCase();
|
|
}
|