mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
fix(mobile): list known people in recipient pickers before a name is typed (#9311)
## 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 <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<MentionCandidate>, 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<Set<String>>(
|
||||
(ref) => <String>{},
|
||||
);
|
||||
|
||||
/// Whether a recipient picker's directory search for [query] still runs.
|
||||
final recipientSearchPendingProvider = Provider.family
|
||||
.autoDispose<bool, String>(
|
||||
@@ -247,6 +268,73 @@ final recipientSearchFailedProvider = Provider.family.autoDispose<bool, String>(
|
||||
},
|
||||
);
|
||||
|
||||
/// 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<UserProfile> _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 = <String>{
|
||||
?viewer,
|
||||
if (excludedChannelId != null)
|
||||
for (final member in notifier.cachedMembersForChannel(excludedChannelId))
|
||||
member.pubkey.toLowerCase(),
|
||||
};
|
||||
final people = <UserProfile>[];
|
||||
final unnamed = <String>[];
|
||||
// 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<MentionCandidate> _chooserCandidates(
|
||||
Ref ref, {
|
||||
required List<ChannelMember> members,
|
||||
@@ -255,6 +343,7 @@ List<MentionCandidate> _chooserCandidates(
|
||||
required bool offerOwnedAgents,
|
||||
bool offerUnsharedAgents = false,
|
||||
required String historyKey,
|
||||
List<UserProfile> knownPeople = const [],
|
||||
}) {
|
||||
final channels =
|
||||
ref.watch(channelsProvider).asData?.value ?? const <Channel>[];
|
||||
@@ -289,7 +378,7 @@ List<MentionCandidate> _chooserCandidates(
|
||||
sharedChannelIds: sharedChannelIds,
|
||||
userCache: userCache,
|
||||
ownerByAgentPubkey: owners,
|
||||
searchResults: [...searchResults, ...verifiedMatches],
|
||||
searchResults: [...searchResults, ...verifiedMatches, ...knownPeople],
|
||||
currentPubkey: currentPubkey,
|
||||
ownedAgents: ownedAgents,
|
||||
offerOwnedAgents: offerOwnedAgents,
|
||||
|
||||
@@ -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<MentionCandidate> rows, Set<String> unavailable, bool reserveHeight})
|
||||
useStableMentionRows(
|
||||
List<MentionCandidate> ranked, {
|
||||
required String query,
|
||||
required int opening,
|
||||
required bool pending,
|
||||
bool restartForNewAgents = false,
|
||||
}) {
|
||||
final shown = useRef<(String, int, List<MentionCandidate>, bool)?>(null);
|
||||
final shown =
|
||||
useRef<(String, int, List<MentionCandidate>, bool, Set<String>)?>(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 : <String>{};
|
||||
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,
|
||||
|
||||
@@ -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 = <String, int>{};
|
||||
|
||||
Channel _channel(
|
||||
String id, {
|
||||
String type = 'stream',
|
||||
bool member = true,
|
||||
DateTime? archivedAt,
|
||||
List<String> 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<List<NostrEvent>> searchProfiles(
|
||||
String query, {
|
||||
int limit = 50,
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => const [];
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
if (filter.kinds.contains(0)) {
|
||||
for (final author in filter.authors ?? const <String>[]) {
|
||||
_profileReads[author] = (_profileReads[author] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay profile reads per person, through the real user cache.
|
||||
final _profileReads = <String, int>{};
|
||||
|
||||
/// Named people in the `crowd` channel; none unless a test sets it.
|
||||
var _crowd = <String>[];
|
||||
|
||||
class _Channels extends ChannelsNotifier {
|
||||
@override
|
||||
Future<List<Channel>> 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<ChannelMember> 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<String, UserProfile> 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<bool> preload(List<String> 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<AgentDirectoryEntry> 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<List<AgentDirectoryEntry>>().future
|
||||
: Future.value(agents),
|
||||
),
|
||||
agentOwnersProvider.overrideWith((ref) async => const {}),
|
||||
],
|
||||
);
|
||||
|
||||
Future<List<String>> pick(
|
||||
String query, {
|
||||
String? channelId,
|
||||
List<AgentDirectoryEntry> 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<void>.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<void>.delayed(const Duration(milliseconds: 100));
|
||||
await container.pump();
|
||||
(container.read(channelsProvider.notifier) as _Channels).touch();
|
||||
}
|
||||
await Future<void>.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);
|
||||
});
|
||||
}
|
||||
321
mobile/test/features/channels/recipient_late_agents_test.dart
Normal file
321
mobile/test/features/channels/recipient_late_agents_test.dart
Normal file
@@ -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<List<Channel>> 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<ChannelMember> 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<String, UserProfile> build() => {
|
||||
for (final (i, pubkey) in _people.indexed)
|
||||
pubkey: UserProfile(
|
||||
pubkey: pubkey,
|
||||
displayName: 'Person ${i.toString().padLeft(2, '0')}',
|
||||
),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<bool> preload(List<String> 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<T> extends Notifier<int> {
|
||||
final loads = [Completer<List<T>>()];
|
||||
|
||||
@override
|
||||
int build() => 0;
|
||||
|
||||
void reload() {
|
||||
loads.add(Completer());
|
||||
state++;
|
||||
}
|
||||
|
||||
void land(List<T> agents) => loads.last.complete(agents);
|
||||
}
|
||||
|
||||
/// The viewer's own agents.
|
||||
final _owned = NotifierProvider<_Loads<OwnedAgentDirectoryEntry>, int>(
|
||||
_Loads.new,
|
||||
);
|
||||
|
||||
/// Relay directory agents that answer anyone.
|
||||
final _shared = NotifierProvider<_Loads<AgentDirectoryEntry>, 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<void> 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<OwnedAgentDirectoryEntry> owned() => container.read(_owned.notifier);
|
||||
_Loads<AgentDirectoryEntry> shared() => container.read(_shared.notifier);
|
||||
|
||||
/// Closes the sheet and lets its pending timers run.
|
||||
Future<void> 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<String> rows(WidgetTester tester) => [
|
||||
for (final tile in tester.widgetList<ListTile>(
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user