From 13a6aa19f42b3c11c3db4e3b19e9bb5905787042 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 10 Nov 2026 07:18:36 -0800 Subject: [PATCH] fix(mobile): list known people in recipient pickers before a name is typed (#9311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Add members (and New DM, which shares the picker) showed an empty list until the first character was typed. An empty query runs no directory search, so the only candidates were the viewer's own agents and agents shared with them. Once those are members, or while the owned-agent list is still loading, nothing is left. `@` completion shows the channel's members before a name is typed. A recipient picker has no channel to draw from, so this lists the people the viewer already knows instead. ## Change - With an empty query, `recipientCandidatesProvider` adds people from the viewer's open channels and DMs (from the member lists the channel list already loaded). Add members skips the target channel's own members before choosing. The 50 people (`mentionSuggestionLimit`) are gathered from DMs first, then channels, and shown in the usual label order. - Empty-query rows list agents ahead of known people, so people never push an agent off the 50-row list. When an agent the opening hasn't offered yet arrives after rows were shown (the viewer's own agents loading late, a picker opened mid-reload, or a reconnect that brings a new agent), the people rows start over so it takes its place ahead of them; agent rows already shown keep their places, including one whose list is still reloading. A reload that brings no new agent moves nothing, and typed rows never restart. Known people don't wait for the agent lists, which never load offline. - Member lists carry no profiles, so the unnamed people among the 50 are loaded through the user cache's batched preload, each person once per picker opening regardless of rebuilds. - The roster scan stops at 50 people or 2,000 roster entries, whichever comes first, and doesn't copy rosters. - Typed queries and `@` completion are unchanged: same ranking, directory search only, no known-people rows mixed in. ## Why nothing gets worse - Network: at most 50 profile reads per opening, batched, only for uncached people, and never repeated on rebuilds (new messages, presence, profile updates). - CPU: at most 2,000 roster entries per rebuild, only while a picker shows an empty query. - Agents: every agent the picker listed before still lists, ahead of the new rows. - Ranking, selection, typed search, the viewer and unshared-agent filters, and "Add members hides existing members" are unchanged. ## Validation - Added tests: `recipient_known_people_test.dart` (empty query lists people from open channels and DMs, excludes archived and non-member channels, loads a missing name; agents stay ahead of people and people don't wait for an unloaded agent list; the real user cache reads each unnamed person once across rebuilds; Add members skips the channel's own members; at most 50 people are gathered; typed query adds nothing) and `recipient_late_agents_test.dart` (Add members sheet with a controllable own-agent list and 60 known people: agents arriving on first load, in a picker opened mid-reload, and on a reconnect that adds a new agent all take rows ahead of the people; reload, land, reload, land moves nothing; typed rows keep their places; 55 agents fill the list and stay put across reloads and a rename that changes their order; both agent lists reloading after a reconnect and landing in either order, with a new agent in the first, keep every earlier agent's row). Confirmed the late-agent tests fail without the restart. Confirmed the ordering and read-once tests fail without their fixes. Confirmed the first test fails without the fix. - Focused existing validation: `flutter test test/features/channels` (2,308 passed), `flutter analyze` on the changed directory. - Automated app run (on an earlier head; iOS 27 simulator, iPhone 17 Pro, a Builderlab test account): opened a private channel, Channel details, Add members. Before: only the account's own agents. After: agents, then people from its channels and DMs with names and avatars; typing `ar` still narrows to directory matches. Captures show real community members, so they were shared internally rather than embedded here. Native iOS sheet only; Android and New DM share the provider but were not run in the app. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../features/channels/add_members_sheet.dart | 4 + .../channels/channels_page/sheets.dart | 4 + .../mentions/mention_candidates_provider.dart | 99 +++++- .../mentions/stable_mention_rows.dart | 39 ++- .../mentions/recipient_known_people_test.dart | 270 +++++++++++++++ .../channels/recipient_late_agents_test.dart | 321 ++++++++++++++++++ 6 files changed, 728 insertions(+), 9 deletions(-) create mode 100644 mobile/test/features/channels/mentions/recipient_known_people_test.dart create mode 100644 mobile/test/features/channels/recipient_late_agents_test.dart diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index 6c3c7106b3..3268c03e84 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -99,6 +99,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { query: search, historyKey: addMembersMentionHistoryKey, unsharedAgents: true, + channelId: channelId, )), ); final searching = ref.watch(recipientSearchPendingProvider(search)); @@ -124,6 +125,9 @@ class AddChannelMembersSheet extends HookConsumerWidget { query: search, opening: opening.value, pending: searching, + // Agents rank ahead of the known people an empty query lists, even + // when they load after them. Typed rows keep their places. + restartForNewAgents: search.isEmpty, ); final availableUsers = shown.rows; final userCache = ref.watch(userCacheProvider); diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 0025e89ce3..2e949b447a 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -324,6 +324,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget { query: search, historyKey: newDmMentionHistoryKey, unsharedAgents: false, + channelId: null, )), ); final searching = ref.watch(recipientSearchPendingProvider(search)); @@ -339,6 +340,9 @@ class _NewDirectMessageSheet extends HookConsumerWidget { query: search, opening: opening.value, pending: searching, + // Agents rank ahead of the known people an empty query lists, even + // when they load after them. Typed rows keep their places. + restartForNewAgents: search.isEmpty, ); final availableResults = shown.rows; // Shown and chosen recipients are named together on every build, so a diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 521b155ea2..9826b3785c 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -201,23 +201,28 @@ const newDmMentionHistoryKey = 'new-dm'; const addMembersMentionHistoryKey = 'add-members'; /// One recipient picker search: its [query], the [historyKey] its choices -/// are remembered under, and whether agents that do not answer the viewer -/// are offered too ([unsharedAgents]), as a channel can hold them. +/// are remembered under, whether agents that do not answer the viewer +/// are offered too ([unsharedAgents]), as a channel can hold them, and the +/// channel whose members the picker hides ([channelId]), if any. typedef RecipientSearch = ({ String query, String historyKey, bool unsharedAgents, + String? channelId, }); /// Ranked recipients for a picker (New DM, Add members), chosen exactly like /// mention autocomplete in a stream: no channel members yet, the viewer's /// own agents offered unprompted, agents shared with the viewer, and people /// from the community directory search, with the same debounce and search -/// cache. +/// cache. Before a name is typed, where `@` lists the channel's members, a +/// picker lists its agents and then the people the viewer shares a channel +/// or DM with, so known people never push an agent off the list. final recipientCandidatesProvider = Provider.family .autoDispose, RecipientSearch>((ref, search) { final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); - return [ + final empty = search.query.trim().isEmpty; + final candidates = [ for (final candidate in _chooserCandidates( ref, members: const [], @@ -226,11 +231,27 @@ final recipientCandidatesProvider = Provider.family offerOwnedAgents: true, offerUnsharedAgents: search.unsharedAgents, historyKey: search.historyKey, + knownPeople: empty + ? _knownPeople(ref, excludedChannelId: search.channelId) + : const [], )) if (candidate.pubkey != currentPubkey) candidate, ]; + if (!empty) return candidates; + return [ + for (final candidate in candidates) + if (candidate.isAgent) candidate, + for (final candidate in candidates) + if (!candidate.isAgent) candidate, + ]; }); +/// Unnamed known people whose profiles a picker opening already requested, +/// so a person with no profile is asked for once, not on every rebuild. +final _requestedKnownPeopleProvider = Provider.autoDispose>( + (ref) => {}, +); + /// Whether a recipient picker's directory search for [query] still runs. final recipientSearchPendingProvider = Provider.family .autoDispose( @@ -247,6 +268,73 @@ final recipientSearchFailedProvider = Provider.family.autoDispose( }, ); +/// Most roster entries one known-people scan reads. +const _knownPeopleScanLimit = 2000; + +/// Named people in the viewer's open channels and DMs, as directory search +/// results, from the member lists the channel list already loaded. DM +/// partners come first, and members of [excludedChannelId] are skipped. At +/// most [mentionSuggestionLimit] people are gathered, a full picker list; +/// profiles are loaded for the unnamed ones among them. +List _knownPeople(Ref ref, {String? excludedChannelId}) { + final listed = ref.watch(channelsProvider).asData?.value ?? const []; + final channels = [ + for (final channel in listed) + if (channel.isDm) channel, + for (final channel in listed) + if (!channel.isDm) channel, + ]; + final notifier = ref.read(channelsProvider.notifier); + final userCache = ref.watch(userCacheProvider); + final verified = ref.watch(verifiedProfilesProvider); + final viewer = ref.watch(currentPubkeyProvider)?.toLowerCase(); + final seen = { + ?viewer, + if (excludedChannelId != null) + for (final member in notifier.cachedMembersForChannel(excludedChannelId)) + member.pubkey.toLowerCase(), + }; + final people = []; + final unnamed = []; + // Roster entries read, bounded even when most are already excluded. + var visited = 0; + gather: + for (final channel in channels) { + if (!channel.isMember || channel.isArchived) continue; + if (channel.id == excludedChannelId) continue; + final pubkeys = channel.participantPubkeys.followedBy( + notifier.cachedMembersForChannel(channel.id).map((m) => m.pubkey), + ); + for (final pubkey in pubkeys) { + if (people.length + unnamed.length >= mentionSuggestionLimit || + ++visited > _knownPeopleScanLimit) { + break gather; + } + final key = pubkey.toLowerCase(); + if (!seen.add(key)) continue; + final profile = userCache[key]; + if (profile?.displayName?.trim().isNotEmpty == true) { + people.add(profile!); + } else if (verified[key] case final name?) { + people.add(UserProfile(pubkey: key, displayName: name.displayName)); + } else { + unnamed.add(key); + } + } + } + final requested = ref.watch(_requestedKnownPeopleProvider); + final load = [ + for (final pubkey in unnamed) + if (requested.add(pubkey)) pubkey, + ]; + if (load.isNotEmpty) { + Future.microtask(() { + if (ref.mounted) ref.read(userCacheProvider.notifier).preload(load); + }); + } + return people; +} + List _chooserCandidates( Ref ref, { required List members, @@ -255,6 +343,7 @@ List _chooserCandidates( required bool offerOwnedAgents, bool offerUnsharedAgents = false, required String historyKey, + List knownPeople = const [], }) { final channels = ref.watch(channelsProvider).asData?.value ?? const []; @@ -289,7 +378,7 @@ List _chooserCandidates( sharedChannelIds: sharedChannelIds, userCache: userCache, ownerByAgentPubkey: owners, - searchResults: [...searchResults, ...verifiedMatches], + searchResults: [...searchResults, ...verifiedMatches, ...knownPeople], currentPubkey: currentPubkey, ownedAgents: ownedAgents, offerOwnedAgents: offerOwnedAgents, diff --git a/mobile/lib/features/channels/mentions/stable_mention_rows.dart b/mobile/lib/features/channels/mentions/stable_mention_rows.dart index c451504822..ad3e5bdb43 100644 --- a/mobile/lib/features/channels/mentions/stable_mention_rows.dart +++ b/mobile/lib/features/channels/mentions/stable_mention_rows.dart @@ -10,19 +10,50 @@ import 'mention_ranking.dart'; /// ([pending]), `reserveHeight` holds until the query or the opening /// changes, so a chooser can keep its height and a late row fills empty /// space instead of moving the row under a finger. +/// +/// With [restartForNewAgents], the shown rows start over when [ranked] +/// offers an agent this query and opening have not offered, so an agent that +/// loads after people filled the list still takes its place ahead of them. +/// Agent rows already shown keep their places across a restart, and one +/// that drops out during a reload stays, marked unavailable, until it +/// returns. ({List rows, Set unavailable, bool reserveHeight}) useStableMentionRows( List ranked, { required String query, required int opening, required bool pending, + bool restartForNewAgents = false, }) { - final shown = useRef<(String, int, List, bool)?>(null); + final shown = + useRef<(String, int, List, bool, Set)?>(null); final last = shown.value; - final same = last != null && last.$1 == query && last.$2 == opening; - final result = stableMentionRows(same ? last.$3 : const [], ranked); + final kept = last != null && last.$1 == query && last.$2 == opening; + final seenAgents = kept ? last.$5 : {}; + final same = + kept && + !(restartForNewAgents && + ranked + .take(mentionSuggestionLimit) + .any((c) => c.isAgent && !seenAgents.contains(c.pubkey))); + // A restart for a new agent keeps the agent rows already shown, so one + // whose list is still reloading keeps its place, marked unavailable. + final result = stableMentionRows(switch ((same, kept)) { + (true, _) => last!.$3, + (false, true) => [ + for (final row in last!.$3) + if (row.isAgent) row, + ], + _ => const [], + }, ranked); + // Every agent offered this opening counts as seen, shown or past the cap, + // so a reload that only reorders them or brings one back moves nothing. + seenAgents.addAll([ + for (final candidate in ranked) + if (candidate.isAgent) candidate.pubkey, + ]); final reserveHeight = pending || (same && last.$4); - shown.value = (query, opening, result.rows, reserveHeight); + shown.value = (query, opening, result.rows, reserveHeight, seenAgents); return ( rows: result.rows, unavailable: result.unavailable, diff --git a/mobile/test/features/channels/mentions/recipient_known_people_test.dart b/mobile/test/features/channels/mentions/recipient_known_people_test.dart new file mode 100644 index 0000000000..88253af1b1 --- /dev/null +++ b/mobile/test/features/channels/mentions/recipient_known_people_test.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; +import 'package:buzz/features/channels/mentions/mention_ranking.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +// Before a name is typed, `@` lists the channel's members. A recipient +// picker has no channel members to list, so it lists the named people the +// viewer shares an open channel or DM with. + +final _me = 'f' * 64; +final _ada = 'a' * 64; +final _nameless = 'b' * 64; +final _carol = 'c' * 64; +final _dan = 'd' * 64; +final _eve = 'e' * 64; +final _ghost = '9' * 64; +final _zed = '8' * 64; + +/// An agent shared with the viewer in `general`, sorting after every person. +final _zedAgent = AgentDirectoryEntry( + pubkey: _zed, + displayName: 'Zed', + respondTo: 'anyone', + channelIds: const ['general'], +); + +/// Profile requests per person. +final _requests = {}; + +Channel _channel( + String id, { + String type = 'stream', + bool member = true, + DateTime? archivedAt, + List participants = const [], +}) => Channel( + id: id, + name: id, + channelType: type, + visibility: 'open', + description: '', + createdBy: _me, + createdAt: DateTime.utc(2026), + memberCount: 2, + isMember: member, + archivedAt: archivedAt, + participantPubkeys: participants, +); + +ChannelMember _member(String pubkey) => + ChannelMember(pubkey: pubkey, role: 'member', joinedAt: DateTime.utc(2026)); + +class _Session extends RelaySessionNotifier { + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> searchProfiles( + String query, { + int limit = 50, + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filter.kinds.contains(0)) { + for (final author in filter.authors ?? const []) { + _profileReads[author] = (_profileReads[author] ?? 0) + 1; + } + } + return const []; + } +} + +/// Relay profile reads per person, through the real user cache. +final _profileReads = {}; + +/// Named people in the `crowd` channel; none unless a test sets it. +var _crowd = []; + +class _Channels extends ChannelsNotifier { + @override + Future> build() async => [ + _channel('general'), + _channel('crowd'), + _channel('old', archivedAt: DateTime.utc(2026)), + _channel('elsewhere', member: false), + _channel('dm', type: 'dm', participants: [_me, _eve]), + ]; + + @override + List cachedMembersForChannel(String channelId) => + switch (channelId) { + 'general' => [ + _member(_me), + _member(_ada), + _member(_nameless), + _member(_ghost), + ], + 'old' => [_member(_carol)], + 'elsewhere' => [_member(_dan)], + 'crowd' => [for (final pubkey in _crowd) _member(pubkey)], + _ => const [], + }; + + /// Republishes the same list, as a new message or presence change does. + void touch() => state = AsyncData([...state.requireValue]); +} + +class _Cache extends UserCacheNotifier { + @override + Map build() => { + for (final (pubkey, name) in [ + (_me, 'Me'), + (_ada, 'Ada'), + (_carol, 'Carol'), + (_dan, 'Dan'), + (_eve, 'Eve'), + ]) + pubkey: UserProfile(pubkey: pubkey, displayName: name), + for (final (i, pubkey) in _crowd.indexed) + pubkey: UserProfile( + pubkey: pubkey, + displayName: 'Crowd ${i.toString().padLeft(2, '0')}', + ), + }; + + /// Loads a name for an unnamed person when the picker asks for it. + @override + Future preload(List pubkeys) async { + for (final pubkey in pubkeys) { + _requests[pubkey] = (_requests[pubkey] ?? 0) + 1; + } + if (pubkeys.contains(_nameless)) { + state = { + ...state, + _nameless: UserProfile(pubkey: _nameless, displayName: 'Bo'), + }; + } + return true; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + ProviderContainer container0({ + List agents = const [], + bool agentsLoading = false, + bool realCache = false, + }) => ProviderContainer( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(_Session.new), + currentPubkeyProvider.overrideWith((ref) => _me), + channelsProvider.overrideWith(_Channels.new), + if (!realCache) userCacheProvider.overrideWith(_Cache.new), + agentDirectoryProvider.overrideWith( + (ref) => agentsLoading + ? Completer>().future + : Future.value(agents), + ), + agentOwnersProvider.overrideWith((ref) async => const {}), + ], + ); + + Future> pick( + String query, { + String? channelId, + List agents = const [], + bool agentsLoading = false, + }) async { + _requests.clear(); + final container = container0(agents: agents, agentsLoading: agentsLoading); + addTearDown(container.dispose); + final search = ( + query: query, + historyKey: addMembersMentionHistoryKey, + unsharedAgents: true, + channelId: channelId, + ); + final sub = container.listen( + recipientCandidatesProvider(search), + (_, _) {}, + ); + addTearDown(sub.close); + await container.read(channelsProvider.future); + await Future.delayed(mentionSearchDebounce * 2); + await container.pump(); + return [ + for (final candidate in container.read( + recipientCandidatesProvider(search), + )) + candidate.label, + ]; + } + + test( + 'an empty query lists people from open channels and DMs, loading names', + () async { + expect(await pick(''), ['Ada', 'Bo', 'Eve']); + // Someone with no profile is asked for once, not on each rebuild. + expect(_requests[_ghost], 1); + }, + ); + + test('agents keep their rows ahead of known people', () async { + expect(await pick('', agents: [_zedAgent]), ['Zed', 'Ada', 'Bo', 'Eve']); + // Known people do not wait for an agent list that has not loaded, as + // the viewer's own agents never do offline. + expect(await pick('', agentsLoading: true), ['Ada', 'Bo', 'Eve']); + }); + + test('the real cache reads each unnamed person once per opening', () async { + _profileReads.clear(); + final container = container0(realCache: true); + addTearDown(container.dispose); + final search = ( + query: '', + historyKey: addMembersMentionHistoryKey, + unsharedAgents: true, + channelId: null, + ); + final sub = container.listen( + recipientCandidatesProvider(search), + (_, _) {}, + ); + addTearDown(sub.close); + await container.read(channelsProvider.future); + for (var i = 0; i < 3; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + await container.pump(); + (container.read(channelsProvider.notifier) as _Channels).touch(); + } + await Future.delayed(const Duration(milliseconds: 100)); + expect(_profileReads.keys, containsAll([_ada, _nameless, _ghost, _eve])); + expect(_profileReads.values.toSet(), {1}); + }); + + test('Add members skips the channel\'s own members', () async { + expect(await pick('', channelId: 'general'), ['Eve']); + }); + + test('at most one picker list of people is gathered', () async { + _crowd = [ + for (var i = 0; i < 80; i++) i.toRadixString(16).padLeft(64, '1'), + ]; + addTearDown(() => _crowd = []); + final people = await pick(''); + // One gathered slot is the person with no profile. + expect(people, hasLength(mentionSuggestionLimit - 1)); + expect(people, containsAll(['Ada', 'Bo', 'Eve'])); + }); + + test('a typed query finds people by the directory search', () async { + expect(await pick('Ad'), isEmpty); + }); +} diff --git a/mobile/test/features/channels/recipient_late_agents_test.dart b/mobile/test/features/channels/recipient_late_agents_test.dart new file mode 100644 index 0000000000..718e6a7b08 --- /dev/null +++ b/mobile/test/features/channels/recipient_late_agents_test.dart @@ -0,0 +1,321 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/add_members_sheet.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/agents/owned_agent_directory.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../helpers/recipient_directory_session.dart'; + +// The viewer's own agents can load after the known people an empty query +// lists, as on first load or a reconnect. They still take their rows ahead +// of the people, and a reload that brings no new agent moves nothing. + +final _me = 'f' * 64; +final _people = [ + for (var i = 0; i < 60; i++) '$i'.padLeft(4, '0').padLeft(64, '2'), +]; +final _ann = OwnedAgentDirectoryEntry(pubkey: 'a' * 64, displayName: 'Ann'); +final _bea = OwnedAgentDirectoryEntry(pubkey: 'b' * 64, displayName: 'Bea'); + +class _Channels extends ChannelsNotifier { + @override + Future> build() async => [ + Channel( + id: 'crowd', + name: 'crowd', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: _me, + createdAt: DateTime.utc(2026), + memberCount: _people.length, + isMember: true, + ), + ]; + + @override + List cachedMembersForChannel(String channelId) => [ + if (channelId == 'crowd') + for (final pubkey in _people) + ChannelMember( + pubkey: pubkey, + role: 'member', + joinedAt: DateTime.utc(2026), + ), + ]; +} + +class _Cache extends UserCacheNotifier { + @override + Map build() => { + for (final (i, pubkey) in _people.indexed) + pubkey: UserProfile( + pubkey: pubkey, + displayName: 'Person ${i.toString().padLeft(2, '0')}', + ), + }; + + @override + Future preload(List pubkeys) async => true; +} + +/// An agent list. Each [reload] starts a load that keeps the earlier list, +/// as a session status change does, until [land]. +class _Loads extends Notifier { + final loads = [Completer>()]; + + @override + int build() => 0; + + void reload() { + loads.add(Completer()); + state++; + } + + void land(List agents) => loads.last.complete(agents); +} + +/// The viewer's own agents. +final _owned = NotifierProvider<_Loads, int>( + _Loads.new, +); + +/// Relay directory agents that answer anyone. +final _shared = NotifierProvider<_Loads, int>(_Loads.new); + +AgentDirectoryEntry _sharedAgent(String pubkey, String name) => + AgentDirectoryEntry( + pubkey: pubkey, + displayName: name, + respondTo: 'anyone', + channelIds: const ['crowd'], + ); + +void main() { + late ProviderContainer container; + + Future open( + WidgetTester tester, { + bool loadFirst = false, + bool sharedLater = false, + }) async { + container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + currentPubkeyProvider.overrideWith((ref) => _me), + channelsProvider.overrideWith(_Channels.new), + userCacheProvider.overrideWith(_Cache.new), + channelMembersProvider('channel').overrideWith((ref) async => []), + agentDirectoryProvider.overrideWith((ref) { + final load = ref.watch(_shared); + return ref.read(_shared.notifier).loads[load].future; + }), + agentOwnersProvider.overrideWith((ref) async => const {}), + ownedAgentDirectoryProvider.overrideWith((ref) { + final load = ref.watch(_owned); + return ref.read(_owned.notifier).loads[load].future; + }), + relaySessionProvider.overrideWith( + () => RecipientDirectorySession({_people.first: 'Person 00'}), + ), + ], + ); + if (!sharedLater) container.read(_shared.notifier).land(const []); + if (loadFirst) { + container.listen(ownedAgentDirectoryProvider, (_, _) {}); + container.read(_owned.notifier).land(const []); + await container.read(ownedAgentDirectoryProvider.future); + container.read(_owned.notifier).reload(); + } + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: AddChannelMembersSheet( + channelId: 'channel', + existingPubkeys: {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + _Loads owned() => container.read(_owned.notifier); + _Loads shared() => container.read(_shared.notifier); + + /// Closes the sheet and lets its pending timers run. + Future close(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + container.dispose(); + await tester.pump(const Duration(seconds: 30)); + } + + /// The labels of the rows built, top first. + List rows(WidgetTester tester) => [ + for (final tile in tester.widgetList( + find.descendant( + of: find.byKey(const ValueKey('add-channel-members-results')), + matching: find.byType(ListTile), + ), + )) + (tile.title! as Text).data!, + ]; + + testWidgets('agents that load after known people show ahead of them', ( + tester, + ) async { + await open(tester); + expect(rows(tester).first, 'Person 00'); + + // First load, with 50 people already shown. + owned().land([_ann]); + await tester.pumpAndSettle(); + expect(rows(tester).take(2), ['Ann', 'Person 00']); + + // A flapping link that brings no new agent moves nothing. + final shown = rows(tester); + for (var i = 0; i < 2; i++) { + owned().reload(); + await tester.pumpAndSettle(); + expect(rows(tester), shown); + owned().land([_ann]); + await tester.pumpAndSettle(); + expect(rows(tester), shown); + } + + // A reload that brings a new agent gives it a row ahead of the people. + owned().reload(); + await tester.pumpAndSettle(); + owned().land([_ann, _bea]); + await tester.pumpAndSettle(); + expect(rows(tester).take(3), ['Ann', 'Bea', 'Person 00']); + await close(tester); + }); + + testWidgets('a picker opened during a reload shows the agents it brings', ( + tester, + ) async { + await open(tester, loadFirst: true); + expect(rows(tester).first, 'Person 00'); + owned().land([_ann]); + await tester.pumpAndSettle(); + expect(rows(tester).take(2), ['Ann', 'Person 00']); + await close(tester); + }); + + testWidgets('typed rows keep their places when an agent loads', ( + tester, + ) async { + await open(tester); + await tester.enterText( + find.byKey(const ValueKey('add-channel-members-search')), + 'Person', + ); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(rows(tester), ['Person 00']); + owned().land([ + OwnedAgentDirectoryEntry(pubkey: 'c' * 64, displayName: 'Person 00 Bot'), + ]); + await tester.pumpAndSettle(); + expect(rows(tester), ['Person 00', 'Person 00 Bot']); + await close(tester); + }); + + testWidgets('more agents than rows fill the list and stay put', ( + tester, + ) async { + final many = [ + for (var i = 0; i < 55; i++) + OwnedAgentDirectoryEntry( + pubkey: '$i'.padLeft(4, '0').padLeft(64, '3'), + displayName: 'Agent ${i.toString().padLeft(2, '0')}', + ), + ]; + await open(tester); + owned().land(many); + await tester.pumpAndSettle(); + final shown = rows(tester); + expect(shown.first, 'Agent 00'); + for (var i = 0; i < 2; i++) { + owned().reload(); + await tester.pumpAndSettle(); + owned().land(many); + await tester.pumpAndSettle(); + expect(rows(tester), shown); + } + // A reload that only reorders the agents moves nothing either. + owned().reload(); + await tester.pumpAndSettle(); + owned().land([ + ...many.take(54), + OwnedAgentDirectoryEntry(pubkey: many.last.pubkey, displayName: 'Aa'), + ]); + await tester.pumpAndSettle(); + expect(rows(tester), shown); + await tester.drag( + find.byKey(const ValueKey('add-channel-members-results')), + const Offset(0, -10000), + ); + await tester.pumpAndSettle(); + expect(rows(tester).last, 'Agent 49'); + await close(tester); + }); + + testWidgets('agents keep their rows while the other list reloads', ( + tester, + ) async { + final sam = _sharedAgent('5' * 64, 'Sam'); + final zed = _sharedAgent('6' * 64, 'Zed'); + final bea = _bea; + for (final ownedFirst in [true, false]) { + await open(tester, sharedLater: true); + owned().land([_ann]); + shared().land([sam]); + await tester.pumpAndSettle(); + expect(rows(tester).take(3), ['Ann', 'Sam', 'Person 00']); + + // Both lists reload after a reconnect. The first to land brings a new + // agent; the other's agent keeps its row until its list lands, and + // that landing moves nothing. + owned().reload(); + shared().reload(); + await tester.pumpAndSettle(); + if (ownedFirst) { + owned().land([_ann, bea]); + } else { + shared().land([sam, zed]); + } + await tester.pumpAndSettle(); + final restarted = rows(tester); + expect(restarted.take(4), [ + 'Ann', + 'Sam', + ownedFirst ? 'Bea' : 'Zed', + 'Person 00', + ]); + if (ownedFirst) { + shared().land([sam]); + } else { + owned().land([_ann]); + } + await tester.pumpAndSettle(); + expect(rows(tester), restarted); + await close(tester); + } + }); +}