Files
buzz/mobile/lib/features/channels/timeline_message.dart
Tom Brow 9f88eb9c45 mobile: hide self-joins and leaves, collapse membership runs like desktop (#8962)
Busy channels fill the mobile timeline with page after page of small "X
joined the channel along with Y and N others" and "X added Y to the
channel" rows. Desktop now hides most of that
([buzz-app#2258](https://github.com/block/buzz-app/pull/2258)). This
brings mobile's membership notices in line with desktop's current rules
(`membershipRows` / `membershipDescription` in buzz-app
`src/features/messages/membership-rows.ts`).

### Behavior

- **Only explicit changes show.** Self-joins and voluntary leaves are
hidden. That covers both relay leave shapes: a normal Leave (kind 9022,
actor only) and removing yourself (kind 9001, with a target). Additions
by someone else and removals still show.
- **One row per run.** Each uninterrupted run of additions and removals
becomes one row, whatever the elapsed time, local day or actor. Messages
and other channel events (topic, archive, huddles) still split runs.
- **Each person appears once**, under their latest change in the run.
- **Wording:**
- One adder: "Alice added Bob, Carol and N others to the channel. See
all" (unchanged)
  - One remover: "Alice removed Bob ... from the channel" (unchanged)
- Several adders: "Bob, Carol and N others were added to the channel".
It no longer says they "joined".
- Additions and removals together, or several removers: "Bob and 4
others were added · Dan was removed". Each "N others" opens that list.

Mobile keeps its "See all" sheet in place of desktop's hover popover.

### Validation

- Added tests:
  - unit tests for hiding self-joins and both leave shapes
  - grouping across hours, days and actors
  - runs split by messages and other channel events
- a channel page journey with a self-join, a 9022 leave, a 9001 leave,
additions by two people hours apart and a removal: it renders one mixed
row, and the "1 other" sheet works
  - a several-adders wording test
- Updated tests that previously relied on visible self-joins or leaves.
- Full mobile suite (`just mobile-test`) and `flutter analyze` pass.
- Automated full-app run on an iPhone 17 Pro simulator, signed in to a
hosted community:
- The home list and a 33-member channel with membership history load.
Self-joins are gone, messages flow without them, and a two-adder run
reads "X and Y were added to the channel".
- Scrolling back through history and opening and closing a thread work.
  - Captures are not attached because they show internal names.
- Limitation: the internal community now rejects the simulator's
websocket without hosted sign-in, so the 1,500-member channel wasn't
re-checked on this head.
- Human testing is waived per the owner's standing policy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-03 15:50:33 -08:00

779 lines
25 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});
}
/// Whether [message] is a self-join or a voluntary leave. Like desktop, the
/// timeline leaves these out: only additions by someone else and removals
/// show as membership notices.
bool isHiddenMembershipNotice(TimelineMessage message) {
final event = message.systemEvent;
if (!message.isSystem || event == null) return false;
if (event.type == SystemEventType.memberLeft) return true;
if (event.type != SystemEventType.memberJoined) return false;
final actor = event.actorPubkey?.trim().toLowerCase();
return actor != null &&
actor.isNotEmpty &&
actor == event.targetPubkey?.trim().toLowerCase();
}
bool _isGroupableMembership(MainTimelineEntry entry) {
final event = entry.message.systemEvent;
if (!entry.message.isSystem ||
(event?.type != SystemEventType.memberJoined &&
event?.type != SystemEventType.memberRemoved)) {
return false;
}
final actor = event?.actorPubkey?.trim();
final target = event?.targetPubkey?.trim();
return actor != null &&
actor.isNotEmpty &&
target != null &&
target.isNotEmpty;
}
/// Groups each uninterrupted run of additions and removals into one render
/// item, matching the desktop timeline: any other entry breaks a run, but
/// elapsed time, local day boundaries and different actors do not. Callers
/// leave out [isHiddenMembershipNotice] entries first.
///
/// 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;) {
if (!_isGroupableMembership(entries[end])) {
end -= 1;
continue;
}
var start = end;
while (start > 0 && _isGroupableMembership(entries[start - 1])) {
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();
}