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); + } + }); +}