diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index ddfbc77ba9..a7ac5dad44 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -231,16 +231,54 @@ class ChannelsNotifier extends AsyncNotifier> { .toSet(); _cacheMemberSnapshots(memberships, replaceAll: true); - // Step 2: pull metadata for joined channels. A user with no memberships - // must still continue to directory discovery below. - final memberMetas = memberChannelIds.isEmpty - ? const [] - : await _fenced( - fence, - session.fetchHistory( - NostrFilters.channelMetadata(memberChannelIds.toList()), + // Step 2: everything below the membership list depends only on it, so + // send those reads together instead of one round trip after another. A + // user with no memberships must still continue to directory discovery. + final memberChannelIdList = memberChannelIds.toList(); + final memberMetasRequest = memberChannelIds.isEmpty + ? Future.value(const []) + : session.fetchHistory( + NostrFilters.channelMetadata(memberChannelIdList), + ); + final hiddenDmIdsRequest = _fetchHiddenDmIds(session, myPk); + // The authoritative membership snapshots are needed to filter Huddle + // backing channels. The relay-signed kind:39000 metadata identifies the + // relay, not the channel creator; the owner role in kind:39002 is the + // canonical creator identity used to reject forged Huddle links. + final memberEventsRequest = memberChannelIds.isEmpty + ? Future.value(const []) + : session.fetchHistory( + NostrFilter( + kinds: const [39002], + tags: {'#d': memberChannelIdList}, + limit: memberChannelIdList.length, ), ); + final huddleStartsRequest = _fetchHuddleStarts( + session, + memberChannelIdList, + ); + final ( + memberMetas, + hiddenDmIds, + memberEvents, + huddleStarts, + ) = await _fenced(fence, () async { + // Future.wait handles every request's failure and rethrows the first + // one unchanged, as soon as it happens. + await Future.wait(eagerError: true, [ + memberMetasRequest, + hiddenDmIdsRequest, + memberEventsRequest, + huddleStartsRequest, + ]); + return ( + await memberMetasRequest, + await hiddenDmIdsRequest, + await memberEventsRequest, + await huddleStartsRequest, + ); + }()); // Step 3: fetch the open-channel directory. The relay filters this global // kind:39000 query by the caller's access, but the client still rejects @@ -278,31 +316,9 @@ class ChannelsNotifier extends AsyncNotifier> { myPk, ); - final hiddenDmIds = await _fenced(fence, _fetchHiddenDmIds(session, myPk)); + // Name resolution may return without a fenced read; check before writing. + fence.ensureCurrent(); _hiddenDmIds = Set.unmodifiable(hiddenDmIds); - // Fetch the authoritative membership snapshots before filtering Huddle - // backing channels. The relay-signed kind:39000 metadata identifies the - // relay, not the channel creator; the owner role in kind:39002 is the - // canonical creator identity used to reject forged Huddle links. - final memberCountChannelIds = memberChannelIds.toList(); - final memberEvents = memberCountChannelIds.isEmpty - ? const [] - : await _fenced( - fence, - session.fetchHistory( - NostrFilter( - kinds: const [39002], - tags: {'#d': memberCountChannelIds}, - limit: memberCountChannelIds.length, - ), - ), - ); - final huddleStarts = memberCountChannelIds.isEmpty - ? const [] - : await _fenced( - fence, - _fetchHuddleStarts(session, memberCountChannelIds), - ); final huddleBackingIds = huddleBackingChannelIds( huddleStarts, memberEvents, diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 943e9a3cde..e873306d57 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -619,7 +619,7 @@ void main() { ); test( - 'community switch after directory success discards a late failure', + 'community switch during a directory refresh discards a late failure', () async { final session = _FakeRelaySession( memberships: const [], @@ -630,8 +630,8 @@ void main() { expect(await container.read(channelsProvider.future), isEmpty); - // The directory query succeeds, then the refresh parks on the member-count - // query and fails there after the switch. A retired failure must not + // The directory refresh parks on the member-count query and fails there + // after the switch. A retired failure must not // overwrite the new scope's list or push it into an error state. session.memberships = [_membership(_channelA, myPk)]; session.pauseNextMemberCountQuery(); @@ -671,7 +671,7 @@ void main() { ); test( - 'identity switch after directory success discards the late refresh', + 'identity switch during a directory refresh discards the late refresh', () async { final session = _FakeRelaySession( memberships: const [], @@ -682,7 +682,7 @@ void main() { expect(await container.read(channelsProvider.future), isEmpty); - // Directory success, then the refresh parks on the hidden-DM query while + // The directory refresh parks on the hidden-DM query while // the signing identity changes and the new identity finishes its rebuild. session.pauseNextHiddenDmQuery(); final staleRefresh = container @@ -710,7 +710,7 @@ void main() { ); test( - 'identity switch after directory success discards a late failure', + 'identity switch during a directory refresh discards a late failure', () async { final session = _FakeRelaySession( memberships: const [], @@ -746,6 +746,46 @@ void main() { }, ); + test('reads after the membership list share one round trip', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + // Park all four reads. Any of them waiting on another would leave that one + // unsent. + final metadataGate = session.metadataGate = Completer(); + session.pauseNextHiddenDmQuery(); + session.pauseNextMemberCountQuery(); + session.pauseNextHuddleStartQuery(); + final load = container.read(channelsProvider.future); + await _settle(); + + // The membership list's own kind:39002 read has no `#d` tag. + final sentKinds = { + for (final filter in session.historyFilters) + if (!filter.kinds.contains(39002) || filter.tags['#d'] != null) + ...filter.kinds, + }; + expect( + sentKinds, + containsAll([ + 39000, + 39002, + EventKind.dmVisibility, + EventKind.huddleStarted, + ]), + ); + + metadataGate.complete(); + session.resumePausedHiddenDmQuery(); + session.resumePausedMemberCountQuery(); + session.resumePausedHuddleStartQuery(); + expect((await load).map((channel) => channel.id), [_channelA]); + }); + test('a newer ordinary refresh owns the installed membership list', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -2666,6 +2706,9 @@ class _FakeRelaySession extends RelaySessionNotifier { int metadataPageRequestCount = 0; final List historyFilters = []; + + /// While set, member metadata reads wait for it to complete. + Completer? metadataGate; final List> queryBatches = []; Object? messageBatchError; Object? latestBatchError; @@ -2950,6 +2993,7 @@ class _FakeRelaySession extends RelaySessionNotifier { if (ids == null) { throw StateError('Directory queries must use the HTTP query bridge'); } + await metadataGate?.future; // Member metadata query — return only matching `d` tags. return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); }