From 8d820e4f6230219afcfb25f4d0039adf7541d799 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 27 Oct 2026 21:41:14 -0700 Subject: [PATCH] fix(mobile): read channel metadata and roster separately findJoinedChannel asked for kinds 39000 and 39002 under one limit of 2. A channel with lingering pre-backfill metadata revisions could fill that limit with metadata and drop the roster, so reopening an older DM would fail after the relay had already opened it. Query each kind with its own limit of 1, in parallel. Signed-off-by: Tom Brow --- .../channels/channel_management_actions.dart | 39 +++++++---- .../channels/dm_open_latency_test.dart | 69 ++++++++++--------- .../channels/me/solo_creation_tags_test.dart | 2 +- .../channels/solo_creation_latency_test.dart | 10 +-- 4 files changed, 67 insertions(+), 53 deletions(-) diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index dd89bf7954..aba86dfb8d 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -70,22 +70,31 @@ class ChannelActions { /// and every channel's recent messages. Missing membership fails closed. Future findJoinedChannel(String channelId) async { _ensureCommunityValid(); - final events = await _session.fetchHistory( - NostrFilter( - kinds: const [39000, 39002], - tags: { - '#d': [channelId], - }, - limit: 2, - ), - ); + // One query per kind: stale kind:39000 revisions from before the relay's + // d_tag backfill can linger and would crowd the roster out of a shared + // limit. + Future latest(int kind) async { + final events = await _session.fetchHistory( + NostrFilter( + kinds: [kind], + tags: { + '#d': [channelId], + }, + limit: 1, + ), + ); + return events + .where((e) => e.kind == kind && e.getTagValue('d') == channelId) + .firstOrNull; + } + + final metadataRequest = latest(39000); + final rosterRequest = latest(39002); + // Future.wait rethrows the first failure unchanged. + await Future.wait(eagerError: true, [metadataRequest, rosterRequest]); + final metadata = await metadataRequest; + final roster = await rosterRequest; _ensureCommunityValid(); - final metadata = events - .where((e) => e.kind == 39000 && e.getTagValue('d') == channelId) - .firstOrNull; - final roster = events - .where((e) => e.kind == 39002 && e.getTagValue('d') == channelId) - .firstOrNull; if (metadata == null || roster == null) return null; final members = membersFromEvent(roster); if (!members.any((m) => m.pubkey == _currentPubkey)) return null; diff --git a/mobile/test/features/channels/dm_open_latency_test.dart b/mobile/test/features/channels/dm_open_latency_test.dart index 03f0285cda..fd0348f8c9 100644 --- a/mobile/test/features/channels/dm_open_latency_test.dart +++ b/mobile/test/features/channels/dm_open_latency_test.dart @@ -140,42 +140,45 @@ class _DmSession extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async { - expect(filter.kinds, [39000, 39002]); expect(filter.tags, { '#d': [_dmId], }); onRead?.call(); - return [ - NostrEvent( - id: 'metadata', - pubkey: 'relay', - createdAt: 1, - kind: 39000, - tags: [ - ['d', _dmId], - ['name', 'DM'], - ['t', 'dm'], - ['private'], - ['hidden'], - ['p', self], - ['p', _peer], - ], - content: '', - sig: '', - ), - NostrEvent( - id: 'roster', - pubkey: 'relay', - createdAt: 1, - kind: 39002, - tags: [ - ['d', _dmId], - if (member) ['p', self, '', 'member'], - ['p', _peer, '', 'member'], - ], - content: '', - sig: '', - ), - ]; + // Serve like a relay: newest first, one limit across every kind. A stale + // metadata revision from before the d_tag backfill still lingers. + final events = [ + _event(39000, 3, [ + ['name', 'DM'], + ['t', 'dm'], + ['private'], + ['hidden'], + ['p', self], + ['p', _peer], + ]), + _event(39000, 2, [ + ['name', 'DM'], + ['t', 'dm'], + ['private'], + ]), + _event(39002, 1, [ + if (member) ['p', self, '', 'member'], + ['p', _peer, '', 'member'], + ]), + ].where((e) => filter.kinds.contains(e.kind)).toList(); + return events.take(filter.limit).toList(); } } + +NostrEvent _event(int kind, int createdAt, List> tags) => + NostrEvent( + id: '$kind-$createdAt', + pubkey: 'relay', + createdAt: createdAt, + kind: kind, + tags: [ + ['d', _dmId], + ...tags, + ], + content: '', + sig: '', + ); diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart index 1e30cbb48e..e9dad011bc 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -46,7 +46,7 @@ class _Relay extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => filter.kinds.contains(39000) ? existing : []; + }) async => existing.where((e) => filter.kinds.contains(e.kind)).toList(); @override Future publish( diff --git a/mobile/test/features/channels/solo_creation_latency_test.dart b/mobile/test/features/channels/solo_creation_latency_test.dart index fda46d9b3f..679c93a369 100644 --- a/mobile/test/features/channels/solo_creation_latency_test.dart +++ b/mobile/test/features/channels/solo_creation_latency_test.dart @@ -144,12 +144,14 @@ class _SoloSession extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async { - reads++; - expect(filter.kinds, [39000, 39002]); + // Each lookup reads metadata and roster separately. + final kind = filter.kinds.single; + if (kind == 39000) reads++; + expect(kind, anyOf(39000, 39002)); expect(filter.tags, { '#d': ['solo-id'], }); - expect(filter.limit, 2); + expect(filter.limit, 1); onRead?.call(); if (failure != null) throw failure!; if (!exists) return []; @@ -182,6 +184,6 @@ class _SoloSession extends RelaySessionNotifier { content: '', sig: '', ), - ]; + ].where((e) => e.kind == kind).toList(); } }