fix(mobile): keep thread references consistent across push and Inbox (#8332)

Follow-up to [#8294](https://github.com/block/buzz/pull/8294). Malformed
trailing thread tags could make a notification open the right thread
while Flutter indexed its reply elsewhere; uppercase references could
similarly miss lowercase event IDs.

Share one validated thread-reference parser between the mobile timeline,
Inbox, and feed helper. It ignores malformed markers, keeps the last
valid root/reply, and normalizes lookup IDs to lowercase. Native push
parsing now uses the same casing. Existing thread fixtures use valid
event IDs so they continue exercising real thread relationships.

Add RunnerTests coverage for the production native-to-Flutter argument
conversion, including buffered cold-start navigation and omitted
optional thread IDs, and run it in the native CI lane.

### Validation

- New parser and Inbox regressions cover malformed markers and uppercase
references. Widget regressions open and highlight the exact reply for
valid, uppercase, and trailing-malformed tags.
- After integrating [#8106](https://github.com/block/buzz/pull/8106),
Flutter analysis and the complete mobile suite pass: 3,337 tests, four
existing skips. Integration preserves per-event read predicates and
migrates incoming thread fixtures to valid IDs. An unchanged
keyboard-history test also failed with main's original parser; recording
reads before the scroll transition fixes its timing assumption while
retaining visibility assertions.
- Native package tests passed (127 tests), and both Runner bridge tests
passed on an iOS simulator with Xcode 26.5. Removing the production
`threadRootId` assignment made the bridge regression fail; restoring it
passed. Native code is unchanged by the main integration.
- [Current-head CI
passed](https://github.com/block/buzz/actions/runs/38659307247),
including Mobile and Mobile Swift. [Codex
review](https://github.com/block/buzz/pull/8332#issuecomment-6181458528)
and [security
review](https://github.com/block/buzz/pull/8332#issuecomment-6181449235)
found no issues on the integrated commit. [Owner
approval](https://github.com/block/buzz/pull/8332#pullrequestreview-5526812957)
covers the exact integrated commit.
- Standards and spec reviews found no blockers, including the merge
resolutions and test adjustments. Validation is scoped to mobile/native
per VISION_MOBILE; unrelated repository-wide checks were stopped, not
counted as passing.

The bridge test covers serialization and buffering, not end-to-end APNs
delivery or app launch. No new physical-device acceptance is claimed;
routine post-merge dogfood remains human-owned.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-14 18:42:39 -07:00
committed by GitHub
parent 349f566e93
commit 8222e99915
29 changed files with 1807 additions and 1075 deletions

View File

@@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart';
import '../../shared/relay/thread_reference.dart';
@immutable
class FeedItem {
final String id;
@@ -43,14 +45,7 @@ class FeedItem {
/// The message whose thread directly contains this item, when this event is
/// a reply. For nested replies this is the direct parent, not the outer root.
String? get threadHeadId {
for (final tag in tags) {
if (tag.length >= 4 && tag[0] == 'e' && tag[3] == 'reply') {
return tag[1];
}
}
return null;
}
String? get threadHeadId => parseThreadReference(tags).parentId;
/// Human-readable headline based on event kind and category.
String get headline {

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import '../../shared/relay/thread_reference.dart';
import 'feed_item.dart';
/// Inbox filters, mirroring desktop's `InboxFilter` in
@@ -26,22 +27,10 @@ int categoryPriority(String category) {
};
}
/// Thread reference from NIP-10 tags — desktop's `getThreadReference`.
/// Validated, lowercase thread IDs shared with timeline navigation.
({String? parentId, String? rootId}) threadReferenceOf(
List<List<String>> tags,
) {
List<String>? rootTag;
List<String>? replyTag;
for (final tag in tags) {
if (tag.length >= 4 && tag[0] == 'e') {
if (tag[3] == 'root') rootTag = tag;
if (tag[3] == 'reply') replyTag = tag;
}
}
if (replyTag == null) return (parentId: null, rootId: null);
final parentId = replyTag[1];
return (parentId: parentId, rootId: rootTag?[1] ?? parentId);
}
) => parseThreadReference(tags);
/// Broadcast replies surface at the channel top level — desktop's
/// `isBroadcastReply`.

View File

@@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'thread_reference.dart';
/// Nostr event kind constants.
///
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
@@ -156,36 +158,9 @@ class NostrEvent {
/// The channel/group ID from the `h` tag (NIP-29).
String? get channelId => getTagValue('h');
/// Extract thread parent and root IDs from `e` tags.
///
/// Matches the desktop's `getThreadReference` logic:
/// - Tags with marker `"reply"` identify the direct parent.
/// - Tags with marker `"root"` identify the thread root.
/// - If no markers are present, falls back to null (top-level message).
({String? parentId, String? rootId}) get threadReference {
final eTags = [
for (final tag in tags)
if (tag.length >= 2 && tag[0] == 'e') tag,
];
if (eTags.isEmpty) return (parentId: null, rootId: null);
// Find tagged root and reply markers (desktop convention).
List<String>? rootTag;
List<String>? replyTag;
for (final tag in eTags) {
if (tag.length >= 4) {
if (tag[3] == 'root') rootTag = tag;
if (tag[3] == 'reply') replyTag = tag;
}
}
if (replyTag == null) return (parentId: null, rootId: null);
final parentId = replyTag[1];
final rootId = rootTag?[1] ?? parentId;
return (parentId: parentId, rootId: rootId);
}
/// Validated, lowercase thread IDs shared by timeline and Inbox navigation.
({String? parentId, String? rootId}) get threadReference =>
parseThreadReference(tags);
/// The parent event ID from the `e` tag.
String? get parentEventId => threadReference.parentId;

View File

@@ -0,0 +1,22 @@
/// Resolve NIP-10 markers using relay ingest validity and ordering rules.
///
/// The last valid 64-character ASCII-hex marker wins. IDs are lowercased for
/// client lookup. Root-only events remain top-level; a reply without a valid
/// root uses its parent as root.
({String? parentId, String? rootId}) parseThreadReference(
List<List<String>> tags,
) {
String? parentId;
String? rootId;
for (final tag in tags) {
if (tag.length < 4 || tag[0] != 'e') continue;
final id = tag[1];
if (id.length != 64 || !_threadEventId.hasMatch(id)) continue;
if (tag[3] == 'reply') parentId = id.toLowerCase();
if (tag[3] == 'root') rootId = id.toLowerCase();
}
if (parentId == null) return (parentId: null, rootId: null);
return (parentId: parentId, rootId: rootId ?? parentId);
}
final _threadEventId = RegExp(r'^[0-9a-fA-F]{64}$');