mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
First slice of #2216, scoped to the system/status lines in the chat timeline. ## Why Two problems on the same surface. **Clearing a channel topic renders as empty quotes.** The relay reports a clear as a `topic_changed` event carrying an empty string — there's no separate "cleared" event type. So the timeline printed: > Alice > changed the topic to “” which reads as if the topic were *set to* two quote marks. Same for purpose. **The membership caption reads like a headline, not a metadata line.** `title` and `action` render on separate lines — the member's name sits in the header row with the avatar and timestamp, and the caption sits beneath it. So the caption was "was added by Alice Chen" standing alone under a name, while its siblings on that same line are "joined the channel" and "left the channel". ## What - Blank, missing, or whitespace-only topic/purpose now reads **"cleared the channel topic"** / **"cleared the channel purpose"**. - Membership captions drop "was": **"added by Alice Chen"**, matching "joined the channel" and "left the channel". - The wording moves to `lib/systemEventCopy.ts` as a pure function, so it's assertable in a unit test instead of only reachable through the DOM. That also removes two JSX fragments from `SystemMessageRow.tsx`, taking it 911 → 900 lines. ## Two E2E assertions this exposed Both were measuring something other than what they claimed, and the copy change tipped them over. Neither is a product bug, but both would have failed the next person too. 1. **`mentions.spec.ts:1245`** asserted a button was un-underlined while the mouse was still parked from a previous `hover()`. Any reflow — new rows, scroll-to-bottom, a different text wrap — can slide that button under the stationary pointer, so the assertion measured *where the mouse happened to be* rather than the resting style. Dropping four characters changed the text wrap, changed the row height, changed the scroll offset, and the pointer landed on it. Now parks the pointer off-target first. 2. **`mentions.spec.ts:1253`** used a bare `role=tooltip` lookup. Once the first tooltip animates out while the second opens, two elements match and strict mode trips. Now scopes to the open tooltip via `:not([data-state="closed"])`. ## Deliberately out of scope - **Timestamps.** The day divider, per-message clock times, the Inbox thread pane, and the inbox list have three divergent date implementations and none fully match the writing standard's Today/Yesterday/weekday/date progression. That's its own slice of #2216. - **Whose avatar shows.** An addition puts the *added* member in the header; a removal puts the *remover* there. Possibly intentional, but it's a design question, not copy. - **`the channel` vs `this channel`.** joined/left/removed say "the channel"; created/archived/unarchived say "this channel". Worth normalizing, but it touches lines this PR otherwise leaves alone. ## Validation - `pnpm check`, `pnpm typecheck` — clean - Unit: **3781/3781**, including 6 new tests in `systemEventCopy.test.mjs` covering set/blank/undefined/null/whitespace for both fields, plus a guard that no variant can emit empty quotes - Smoke E2E `mentions` + `messaging`: **85/85** - The previously fragile test run with `--repeat-each=5`: **5/5** Signed-off-by: Clay Delk <clay.delk@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
598 lines
18 KiB
Dart
598 lines
18 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.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',
|
|
};
|
|
}
|
|
}
|
|
|
|
/// 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 direct children by parentId.
|
|
final childrenByParent = <String, List<TimelineMessage>>{};
|
|
for (final msg in messages) {
|
|
final pid = msg.parentId;
|
|
if (pid == null) continue;
|
|
childrenByParent.putIfAbsent(pid, () => []).add(msg);
|
|
}
|
|
|
|
return [
|
|
for (final msg in messages)
|
|
if (msg.parentId == null || _isBroadcastReply(msg))
|
|
MainTimelineEntry(
|
|
message: msg,
|
|
summary: _buildSummary(
|
|
msg.id,
|
|
childrenByParent,
|
|
relaySummaries?[msg.id],
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
bool _isBroadcastReply(TimelineMessage message) {
|
|
return message.tags.any(
|
|
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
|
|
);
|
|
}
|
|
|
|
ThreadSummary? _buildSummary(
|
|
String messageId,
|
|
Map<String, List<TimelineMessage>> childrenByParent,
|
|
ChannelWindowThreadSummary? relaySummary,
|
|
) {
|
|
if (relaySummary != null && relaySummary.replyCount > 0) {
|
|
return ThreadSummary(
|
|
threadHeadId: messageId,
|
|
replyCount: relaySummary.replyCount,
|
|
participantPubkeys: relaySummary.participantPubkeys.take(3).toList(),
|
|
lastReplyAt: relaySummary.lastReplyAt,
|
|
);
|
|
}
|
|
|
|
final replies = childrenByParent[messageId];
|
|
if (replies == null || replies.isEmpty) return null;
|
|
|
|
// Up to 3 most recent unique participants (walk backwards).
|
|
final seen = <String>{};
|
|
final participants = <String>[];
|
|
for (var i = replies.length - 1; i >= 0 && participants.length < 3; i--) {
|
|
final pk = replies[i].pubkey.toLowerCase();
|
|
if (seen.add(pk)) participants.add(pk);
|
|
}
|
|
|
|
return ThreadSummary(
|
|
threadHeadId: messageId,
|
|
replyCount: replies.length,
|
|
participantPubkeys: participants.reversed.toList(),
|
|
lastReplyAt: replies.last.createdAt,
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|