diff --git a/mobile/lib/features/channels/huddle_agents_provider.dart b/mobile/lib/features/channels/huddle_agents_provider.dart index f86d41aa8d..3c0c2870ab 100644 --- a/mobile/lib/features/channels/huddle_agents_provider.dart +++ b/mobile/lib/features/channels/huddle_agents_provider.dart @@ -30,16 +30,21 @@ const managedAgentRecordKind = 30177; /// /// The result holds only current members. Until the records first load, /// only the `bot` role counts. While the records reload after a membership -/// change, or if that reload fails, the last loaded records still apply to -/// the members who remain; a failed read never removes a `bot` agent. +/// change, if that reload fails, or while the relay is disconnected, the last +/// records loaded for the same relay and account still apply to the members +/// who remain; a failed read never removes a `bot` agent. final huddleAgentPubkeysProvider = Provider.autoDispose .family, String>((ref, channelId) { final members = ref.watch(channelMembersProvider(channelId)).value ?? const []; - final recordAgents = - ref.watch(huddleManagedAgentRecordPubkeysProvider(channelId)).value ?? - const {}; + final scope = ref.watch(huddleManagedAgentRecordScopeProvider); + final loaded = ref + .watch(huddleManagedAgentRecordPubkeysProvider(channelId)) + .value; + final recordAgents = loaded != null && loaded.scope == scope + ? loaded.agents + : const {}; return Set.unmodifiable({ for (final member in members) if (member.isBot || @@ -48,25 +53,50 @@ final huddleAgentPubkeysProvider = Provider.autoDispose }); }); +/// The relay and account that a managed-agent record read belongs to. +typedef HuddleManagedAgentRecordScope = ({String relay, String? viewer}); + +/// Agents named by trusted managed-agent records, with the relay and account +/// that read them. +typedef HuddleManagedAgentRecords = ({ + HuddleManagedAgentRecordScope scope, + Set agents, +}); + +final huddleManagedAgentRecordScopeProvider = + Provider( + (ref) => ( + relay: ref.watch( + relayConfigProvider.select((config) => config.baseUrl), + ), + viewer: ref.watch(myPubkeyProvider), + ), + ); + +/// Fails, rather than returning no records, while the relay is disconnected: +/// "not read" must not replace the last records read. final huddleManagedAgentRecordPubkeysProvider = FutureProvider.autoDispose - .family, String>((ref, channelId) async { + .family((ref, channelId) async { final connected = ref.watch( relaySessionProvider.select( (state) => state.status == SessionStatus.connected, ), ); - final viewer = ref.watch(myPubkeyProvider); + final scope = ref.watch(huddleManagedAgentRecordScopeProvider); final members = await ref.watch(channelMembersProvider(channelId).future); - if (!connected) return const {}; + if (!connected) { + throw StateError('relay disconnected; managed-agent records not read'); + } try { - return await fetchTrustedManagedAgentPubkeys( + final agents = await fetchTrustedManagedAgentPubkeys( ref.read(relaySessionProvider.notifier), members: [ for (final member in members) MemberEntry(pubkey: member.pubkey, role: member.role), ], - viewerPubkey: viewer, + viewerPubkey: scope.viewer, ); + return (scope: scope, agents: agents); } catch (error) { debugPrint('[HuddleAgents] managed-agent query failed: $error'); rethrow; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index e760a1def0..761c277033 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -395,9 +395,12 @@ Widget _buildTestable({ if (huddleMembersNotifier != null) _mutableHuddleMembersProvider.overrideWith(() => huddleMembersNotifier), if (huddleRecordAgentPubkeys != null) - huddleManagedAgentRecordPubkeysProvider( - _huddleChannelId, - ).overrideWith((ref) async => huddleRecordAgentPubkeys), + huddleManagedAgentRecordPubkeysProvider(_huddleChannelId).overrideWith( + (ref) async => ( + scope: ref.watch(huddleManagedAgentRecordScopeProvider), + agents: huddleRecordAgentPubkeys, + ), + ), if (!watchChannelMembershipUpdates) channelBotPubkeysProvider(_channelId).overrideWith( (ref) async => loadChannelBotPubkeys?.call() ?? const {}, diff --git a/mobile/test/features/channels/huddle_agents_provider_test.dart b/mobile/test/features/channels/huddle_agents_provider_test.dart index 513a9bb38c..e8a0f0d7ce 100644 --- a/mobile/test/features/channels/huddle_agents_provider_test.dart +++ b/mobile/test/features/channels/huddle_agents_provider_test.dart @@ -95,6 +95,64 @@ void main() { expect(await _settledAgents(container), {_botAgent}); }); + test( + 'a relay disconnect keeps record agents until a connected read', + () async { + final relay = _HuddleRelaySession( + members: { + _owner.public: 'owner', + _botAgent: 'bot', + _ownerAgent: 'member', + _viewerAgent: 'member', + }, + records: [_record(_owner, _ownerAgent), _record(_owner, _viewerAgent)], + ); + final container = _container(relay); + expect(await _settledAgents(container), { + _botAgent, + _ownerAgent, + _viewerAgent, + }); + + // While disconnected, the last records still apply. + relay.setStatus(SessionStatus.reconnecting); + expect(await _settledAgents(container), { + _botAgent, + _ownerAgent, + _viewerAgent, + }); + expect(relay.recordFilters, hasLength(1)); + + // After reconnecting, a successful read is authoritative again. + relay.records = [_record(_owner, _ownerAgent)]; + relay.setStatus(SessionStatus.connected); + expect(await _settledAgents(container), {_botAgent, _ownerAgent}); + expect(relay.recordFilters, hasLength(2)); + }, + ); + + test('records read for another account do not apply', () async { + final relay = _HuddleRelaySession( + members: {_owner.public: 'owner', _viewerAgent: 'member'}, + // Only the viewer vouches for this agent. + records: [_record(_viewer, _viewerAgent)], + ); + final container = _container(relay); + final viewer = container.read(_viewerOverride.notifier); + // Load the Huddle once as the other account, so its member snapshot is + // cached, then as the viewer. + viewer.set(_stranger.public); + expect(await _settledAgents(container), isEmpty); + viewer.set(_viewer.public); + expect(await _settledAgents(container), {_viewerAgent}); + + // Switching account while disconnected must not carry the viewer's + // records over to the other account's cached members. + relay.setStatus(SessionStatus.reconnecting); + viewer.set(_stranger.public); + expect(await _settledAgents(container), isEmpty); + }); + test('last-human count leaves out agents known by their record', () async { final relay = _HuddleRelaySession( members: { @@ -174,12 +232,21 @@ void main() { }); } +final _viewerOverride = NotifierProvider<_Viewer, String?>(_Viewer.new); + +class _Viewer extends Notifier { + @override + String? build() => _viewer.public; + + void set(String? pubkey) => state = pubkey; +} + ProviderContainer _container(_HuddleRelaySession relay) { final container = ProviderContainer( retry: (_, _) => null, overrides: [ relaySessionProvider.overrideWith(() => relay), - myPubkeyProvider.overrideWithValue(_viewer.public), + myPubkeyProvider.overrideWith((ref) => ref.watch(_viewerOverride)), ], ); addTearDown(container.dispose); @@ -220,7 +287,7 @@ MemberEntry _member(String pubkey, String role) => class _HuddleRelaySession extends RelaySessionNotifier { Map members; - final List records; + List records; Object? recordError; Completer? recordGate; final List recordFilters = []; @@ -234,6 +301,8 @@ class _HuddleRelaySession extends RelaySessionNotifier { @override SessionState build() => const SessionState(status: SessionStatus.connected); + void setStatus(SessionStatus status) => state = SessionState(status: status); + @override Future> fetchHistory( NostrFilter filter, {