From 1b59a54afc5ddc09f7116b136f04b640852b7206 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 10:32:56 -0500 Subject: [PATCH 1/7] fix(mobile): hide archived identities from pickers, search and Agents Mobile offered known-archived people and agents wherever a person is picked: Add members (also adding an agent to a Solo chat), New DM, the mention chooser, Search > People and the Agents page. An archived agent with a live agent's name also forced a qualifier onto the live name, because labels were scoped over both. This follows desktop's archiveHides rule. The NIP-IA archive reader moves from shared/me to shared/identity_archives, and a new hiddenArchivedIdentitiesProvider names the identities to leave out: the known-archived ones, never the viewer, and none while the archive list loads or cannot be read. History keeps showing them. - The shared chooser drops them before labels are scoped, which covers Add members, New DM and mentions. - Search > People asks for 24 people and shows the first 8 that are not archived, so archived people do not push live people out. - The Agents page leaves them out. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/features/agents/agents_page.dart | 23 +++++-- .../channels/channel_detail_page.dart | 2 +- .../huddle_agent_connection.dart | 4 +- .../channels/dm_voice_agent_provider.dart | 8 +-- .../mentions/mention_candidates_provider.dart | 8 ++- mobile/lib/features/search/search_page.dart | 14 +++- .../lib/features/search/search_provider.dart | 9 ++- .../identity_archives.dart} | 23 +++++-- mobile/lib/shared/me/me_assistants.dart | 4 +- .../features/agents/agent_ordering_test.dart | 26 ++++++++ .../add_members_sheet_names_test.dart | 64 +++++++++++++++++++ .../search/search_page_lazy_test.dart | 3 +- .../features/search/search_page_test.dart | 24 ++++++- .../shared/agents/remote_agents_test.dart | 4 +- .../identity_archives_test.dart} | 22 +++---- mobile/test/shared/me/me_assistants_test.dart | 4 +- mobile/test/shared/me/me_sync_test.dart | 4 +- 17 files changed, 200 insertions(+), 46 deletions(-) rename mobile/lib/shared/{me/me_archived_agents.dart => identity_archives/identity_archives.dart} (73%) rename mobile/test/shared/{me/me_archived_agents_test.dart => identity_archives/identity_archives_test.dart} (86%) diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index a1077c8a4a7..84ab731463b 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -14,6 +14,7 @@ import '../../shared/community/community_provider.dart'; import '../../shared/me/default_agent.dart'; import '../../shared/hosted/hosted_flow.dart'; import '../../shared/hosted/hosted_flow_provider.dart'; +import '../../shared/identity_archives/identity_archives.dart'; import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/profile/user_cache_provider.dart'; @@ -317,12 +318,22 @@ class AgentsPage extends HookConsumerWidget { final owned = registered.data ?? (lastOwned.value?.$1 == scope ? lastOwned.value?.$2 : null); - final keys = { - ...?owned, - for (final entry - in ownedDirectory.asData?.value ?? []) - entry.pubkey, - }.where((key) => !controller.deletedAgents.contains(key)).toList(); + // Archived agents are not offered, as on desktop, where archiving an + // agent takes it out of the library. + final archived = ref.watch(hiddenArchivedIdentitiesProvider); + final keys = + { + ...?owned, + for (final entry + in ownedDirectory.asData?.value ?? []) + entry.pubkey, + } + .where( + (key) => + !controller.deletedAgents.contains(key) && + !archived.contains(key), + ) + .toList(); final names = watchIdentityNames( ref, keys, diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 0790f1e4219..02b2383269f 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -27,7 +27,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/agents/owned_agent_directory.dart'; import '../../shared/emoji/emoji_burst.dart'; -import '../../shared/me/me_archived_agents.dart'; +import '../../shared/identity_archives/identity_archives.dart'; import '../../shared/me/me_assistants.dart'; import '../../shared/me/me_sync.dart'; import '../../shared/me/me_search_placement.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_agent_connection.dart b/mobile/lib/features/channels/channel_detail_page/huddle_agent_connection.dart index 40aeca288f8..3ba3b937a92 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_agent_connection.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_agent_connection.dart @@ -26,7 +26,7 @@ Future _openMobileHuddle({ final channel = ref.read(channelByIdProvider(invite.parentChannelId)); if (ref.read(dmVoiceCandidateProvider(invite.parentChannelId)) != null) { ref.invalidate(ownedAgentDirectoryProvider); - ref.invalidate(meArchivedAgentsProvider); + ref.invalidate(identityArchivesProvider); final agents = await ref.read(ownedCodexVoiceAgentsProvider.future); if (!context.mounted) return; final agent = dmVoiceAgent( @@ -52,7 +52,7 @@ Future _openMobileHuddle({ ); if (!confirmed || !context.mounted) return; ref.invalidate(ownedAgentDirectoryProvider); - ref.invalidate(meArchivedAgentsProvider); + ref.invalidate(identityArchivesProvider); final currentAgents = await ref.read( ownedCodexVoiceAgentsProvider.future, ); diff --git a/mobile/lib/features/channels/dm_voice_agent_provider.dart b/mobile/lib/features/channels/dm_voice_agent_provider.dart index bb4b25cf316..48b08bf64ea 100644 --- a/mobile/lib/features/channels/dm_voice_agent_provider.dart +++ b/mobile/lib/features/channels/dm_voice_agent_provider.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/agents/owned_agent_directory.dart'; -import '../../shared/me/me_archived_agents.dart'; +import '../../shared/identity_archives/identity_archives.dart'; import '../../shared/me/me_sync.dart'; import '../../shared/relay/relay.dart'; import '../../shared/mentions/agent_identity_provider.dart'; @@ -19,7 +19,7 @@ import 'channels_provider.dart'; /// adds no inventory read. final ownedCodexVoiceAgentsProvider = FutureProvider>((ref) async { ref.watch(_voiceInventoryChangesProvider); - final archived = ref.watch(meArchivedAgentsProvider.future); + final archived = ref.watch(identityArchivesProvider.future); final owned = await ref.watch(ownedAgentDirectoryProvider.future); final hidden = await archived; return { @@ -57,7 +57,7 @@ final _voiceInventoryChangesProvider = Provider.autoDispose((ref) { NostrFilter(kinds: const [30177], authors: [sync.pubkey], limit: 0), (_) { if (!disposed) ref.invalidate(ownedAgentDirectoryProvider); - ref.invalidate(meArchivedAgentsProvider); + ref.invalidate(identityArchivesProvider); }, ); if (disposed) unsubscribe?.call(); @@ -103,7 +103,7 @@ Future resolveDmVoiceAgent(Ref ref, String parentChannelId) async { return null; } ref.invalidate(ownedAgentDirectoryProvider); - ref.invalidate(meArchivedAgentsProvider); + ref.invalidate(identityArchivesProvider); final agents = await ref.read(ownedCodexVoiceAgentsProvider.future); final agent = dmVoiceAgent(channel, ref.read(currentPubkeyProvider), agents); if (agent == null) { diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 9826b3785cd..fa0015690a3 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/agents/owned_agent_directory.dart'; import '../../../shared/crypto/nip_oa.dart'; +import '../../../shared/identity_archives/identity_archives.dart'; import '../../../shared/identity_names/identity_names.dart'; import '../../../shared/identity_names/identity_names_provider.dart'; import '../../../shared/mentions/agent_identity_provider.dart'; @@ -356,6 +357,9 @@ List _chooserCandidates( final userCache = ref.watch(userCacheProvider); final currentPubkey = ref.watch(currentPubkeyProvider); final searches = ref.watch(mentionSearchCacheProvider); + // Removed before labels are scoped, so an archived namesake never adds a + // qualifier to a live name. + final archived = ref.watch(hiddenArchivedIdentitiesProvider); final sharedChannelIds = { for (final channel in channels) @@ -384,7 +388,9 @@ List _chooserCandidates( offerOwnedAgents: offerOwnedAgents, offerUnsharedAgents: offerUnsharedAgents, )) - if (isMentionKey(candidate.pubkey)) candidate, + if (isMentionKey(candidate.pubkey) && + !archived.contains(candidate.pubkey)) + candidate, ]; // Admission does not gate the search: `@Mary J` can name a person diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index cc10d469582..cd852c94cfc 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; +import '../../shared/identity_archives/identity_archives.dart'; import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/mentions/mention_tags.dart'; @@ -327,12 +328,19 @@ class _SearchBody extends ConsumerWidget { filter == _SearchFilter.all || filter == _SearchFilter.people; final showMessages = filter == _SearchFilter.all || filter == _SearchFilter.messages; + // Known-archived people leave the results. The search asks for more + // people than are shown, so they do not push live people out. + final archived = ref.watch(hiddenArchivedIdentitiesProvider); + final people = state.userResults + .where((user) => !archived.contains(user.pubkey.toLowerCase())) + .take(searchPeopleShown) + .toList(); // Only the active tab's results count, so a tab with none of its own // says so rather than showing a blank list. final hasAnyResults = (showChannels && state.channelResults.isNotEmpty) || - (showPeople && state.userResults.isNotEmpty) || + (showPeople && people.isNotEmpty) || (showMessages && state.messageResults.isNotEmpty); void recordResultSelection() => ref.read(recentSearchesProvider.notifier).record(state.query); @@ -388,9 +396,9 @@ class _SearchBody extends ConsumerWidget { SliverToBoxAdapter( child: _ChannelSearchFailed(onRetry: retryChannels), ), - if (showPeople && state.userResults.isNotEmpty) + if (showPeople && people.isNotEmpty) _PeopleSection( - users: state.userResults, + users: people, onResultSelected: recordResultSelection, ), if (showMessages && state.messageResults.isNotEmpty) diff --git a/mobile/lib/features/search/search_provider.dart b/mobile/lib/features/search/search_provider.dart index 3b920f47ab1..0f44c93cc8f 100644 --- a/mobile/lib/features/search/search_provider.dart +++ b/mobile/lib/features/search/search_provider.dart @@ -112,6 +112,9 @@ bool _searchesOpenChannels(String query) => _channelNeedle(query).isNotEmpty; /// Most channel rows one query shows, ranked best first. const searchChannelResultLimit = 8; +/// Most people rows one query shows. +const searchPeopleShown = 8; + /// How long a loaded open-channel directory serves searches before the next /// search reads it again, as desktop's `PUBLIC_CHANNEL_PAGE_TTL` does. const searchDirectoryMaxAge = Duration(seconds: 30); @@ -380,12 +383,16 @@ class SearchNotifier extends Notifier { } } + /// People asked of the directory search: more than [searchPeopleShown], + /// so archived people the page leaves out do not leave it short. + static const _peopleSearchLimit = 3 * searchPeopleShown; + Future _searchUsers(String query, int generation) async { List? users; try { users = await ref .read(channelActionsProvider) - .searchUsers(query, limit: 8); + .searchUsers(query, limit: _peopleSearchLimit); } catch (_) { // User search failure is non-critical — keep existing results. } diff --git a/mobile/lib/shared/me/me_archived_agents.dart b/mobile/lib/shared/identity_archives/identity_archives.dart similarity index 73% rename from mobile/lib/shared/me/me_archived_agents.dart rename to mobile/lib/shared/identity_archives/identity_archives.dart index 66b48944660..c7f66a9f72b 100644 --- a/mobile/lib/shared/me/me_archived_agents.dart +++ b/mobile/lib/shared/identity_archives/identity_archives.dart @@ -7,19 +7,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import '../relay/relay.dart'; -import 'me_sync.dart'; +import '../me/me_sync.dart'; -final meRelayInformationClientProvider = Provider((ref) { +final relayInformationClientProvider = Provider((ref) { final client = http.Client(); ref.onDispose(client.close); return client; }); -/// Matches desktop's known-archived filtering. Unknown archive evidence does -/// not hide identities; this is presentation, never ownership or admission. -final meArchivedAgentsProvider = FutureProvider>((ref) async { +/// The identities this relay's NIP-IA archive list (kind 13535) names, never +/// the viewer: an archive stays visible to its subject. Empty when the list +/// cannot be read. This is presentation, never ownership or admission. +final identityArchivesProvider = FutureProvider>((ref) async { final sync = ref.watch(meSyncProvider); - final client = ref.watch(meRelayInformationClientProvider); + final client = ref.watch(relayInformationClientProvider); try { final uri = Uri.parse(sync.community); final response = await client @@ -61,12 +62,20 @@ final meArchivedAgentsProvider = FutureProvider>((ref) async { timeout: const Duration(seconds: 3), ); sync.check(); - return await _parseArchives(events, authority); + return (await _parseArchives(events, authority))..remove(sync.pubkey); } catch (_) { return {}; } }); +/// Identities to leave out of forward-looking choices (pickers, search and +/// the Agents page), as desktop's `archiveHides`: the known-archived ones. +/// None while the archive list loads or cannot be read. History (messages, +/// activity, profiles) keeps showing archived identities. +final hiddenArchivedIdentitiesProvider = Provider>( + (ref) => ref.watch(identityArchivesProvider).value ?? const {}, +); + Future> _parseArchives(List events, String authority) => Isolate.run(() => _archived(events, authority)); diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index 1f3913cc96a..077a054ab57 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -10,7 +10,7 @@ import '../agents/remote_agents.dart'; import '../mentions/agent_identity_provider.dart'; import '../relay/relay.dart'; import 'me_agent_inventory.dart'; -import 'me_archived_agents.dart'; +import '../identity_archives/identity_archives.dart'; import 'me_sync.dart'; /// The owner's current inventory, with verified ownership and desktop's @@ -26,7 +26,7 @@ final meAssistantsProvider = FutureProvider>(( ref.watch(remoteAgentRegistrationRevisionProvider); // Refresh archive evidence with the inventory, including explicit retries. // Ordinary sheet openings reuse this provider's completed directory. - final archivedRequest = ref.refresh(meArchivedAgentsProvider.future); + final archivedRequest = ref.refresh(identityArchivesProvider.future); final inventory = await loadMeAgentInventory(sync); sync.check(); final profiles = await _loadProfiles(sync, inventory.keys.toList()); diff --git a/mobile/test/features/agents/agent_ordering_test.dart b/mobile/test/features/agents/agent_ordering_test.dart index 3fff34201aa..df158beb365 100644 --- a/mobile/test/features/agents/agent_ordering_test.dart +++ b/mobile/test/features/agents/agent_ordering_test.dart @@ -1,5 +1,6 @@ import 'package:buzz/features/agents/agent_card.dart'; import 'package:buzz/features/agents/agents_page.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -37,4 +38,29 @@ void main() { ['aardvark', 'alpha'], ); }); + + testWidgets('leaves archived agents out of the directory', (tester) async { + final h = await AgentsHarness.create(); + final existing = existingDesktopKeys.public; + await h.open( + tester, + directory: [existing, fakeAgent], + overrides: [ + identityArchivesProvider.overrideWith((ref) async => {fakeAgent}), + ], + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(AgentsPage)), + ); + final profiles = container.read(userCacheProvider.notifier); + profiles.put(UserProfile(pubkey: existing, displayName: 'Zulu')); + profiles.put(UserProfile(pubkey: fakeAgent, displayName: 'alpha')); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(AgentCard)) + .map((card) => card.name), + ['Zulu'], + ); + }); } diff --git a/mobile/test/features/channels/add_members_sheet_names_test.dart b/mobile/test/features/channels/add_members_sheet_names_test.dart index 1a7e90e6830..5b357a5b123 100644 --- a/mobile/test/features/channels/add_members_sheet_names_test.dart +++ b/mobile/test/features/channels/add_members_sheet_names_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:buzz/features/channels/add_members_sheet.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.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'; @@ -190,4 +191,67 @@ void main() { ]); }, ); + + testWidgets( + 'leaves out an archived namesake, and shows both while archives are unknown', + (tester) async { + final live = 'a' * 64; + final archived = 'b' * 64; + Future> shownScouts( + Future> Function() archives, + ) async { + await tester.pumpWidget( + ProviderScope( + // A new scope per case, so no state is carried between them. + key: UniqueKey(), + overrides: [ + userCacheProvider.overrideWith(_RecordingUserCache.new), + channelMembersProvider('channel').overrideWith((ref) async => []), + relaySessionProvider.overrideWith( + () => RecipientDirectorySession( + {live: 'Scout', archived: 'Scout'}, + agents: {live, archived}, + ), + ), + identityArchivesProvider.overrideWith((ref) => archives()), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: AddChannelMembersSheet( + channelId: 'channel', + existingPubkeys: {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('add-channel-members-search')), + 'Scout', + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + return [ + for (final text in tester.widgetList(find.byType(Text))) + if (text.data?.startsWith('Scout') == true) text.data!, + ]; + } + + // The archived agent is not offered, and the live one keeps its + // plain name: a hidden namesake adds no qualifier. + expect(await shownScouts(() async => {archived}), ['Scout']); + + // A failed archive read hides no one: both agents are offered, told + // apart from each other. + final unknown = await shownScouts( + () => Future.error(StateError('relay information unavailable')), + ); + expect(unknown, hasLength(2)); + expect(unknown.toSet(), hasLength(2)); + expect(unknown, isNot(contains('Scout'))); + }, + ); } diff --git a/mobile/test/features/search/search_page_lazy_test.dart b/mobile/test/features/search/search_page_lazy_test.dart index 56f88b74664..db087e0daf7 100644 --- a/mobile/test/features/search/search_page_lazy_test.dart +++ b/mobile/test/features/search/search_page_lazy_test.dart @@ -174,7 +174,8 @@ void main() { await tester.pumpAndSettle(); final key = category == 'Channels' ? 'search-channel-row-channel-29' - : 'search-person-row-person-29'; + // The page shows at most [searchPeopleShown] people. + : 'search-person-row-person-${searchPeopleShown - 1}'; final last = find.byKey(ValueKey(key)); expect(last, findsNothing); await tester.scrollUntilVisible( diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 4fa4b6c964c..291142ceec2 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/widgets/buzz_agent_icon.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; @@ -568,13 +569,20 @@ void main() { }); testWidgets('same-name people results get distinct labels', (tester) async { - final first = 'a' * 64, second = 'b' * 64; + final first = 'a' * 64, second = 'b' * 64, archived = 'c' * 64; + // Tall enough that every row is built. + tester.view.physicalSize = const Size(800, 4000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); final state = SearchState( query: 'scout', channelResults: const [], userResults: [ + DirectoryUser(pubkey: archived, displayName: 'Scout'), DirectoryUser(pubkey: first, displayName: 'Scout'), DirectoryUser(pubkey: second, displayName: 'Scout'), + for (var i = 0; i < 8; i++) + DirectoryUser(pubkey: '$i' * 64, displayName: 'Scout fan $i'), ], messageResults: const [], ); @@ -592,6 +600,7 @@ void main() { UserProfile(pubkey: first, displayName: 'Scout'), ), ), + identityArchivesProvider.overrideWith((ref) async => {archived}), ], child: const SearchPage(), ), @@ -603,6 +612,19 @@ void main() { .data!; expect(title(first), isNot(title(second))); expect(title(first), isNot('Scout')); + // An archived person is left out, and does not take a live person's + // place among the eight rows shown. + final rows = find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'search-person-row-', + ), + ); + expect(find.byKey(ValueKey('search-person-row-$archived')), findsNothing); + expect(rows, findsNWidgets(searchPeopleShown)); + expect(find.byKey(ValueKey('search-person-row-${'5' * 64}')), findsOne); + expect(find.byKey(ValueKey('search-person-row-${'6' * 64}')), findsNothing); }); testWidgets('uses compact content styles and keeps message time by author', ( diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart index 96ea0919cd2..68e35eb6b7c 100644 --- a/mobile/test/shared/agents/remote_agents_test.dart +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -11,7 +11,7 @@ import 'package:buzz/shared/hosted/account_identity_store.dart'; import 'package:buzz/shared/hosted/builderlab_account.dart'; import 'package:buzz/shared/hosted/hosted_environment.dart'; import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; -import 'package:buzz/shared/me/me_archived_agents.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/me/me_assistants.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -1020,7 +1020,7 @@ void main() { RelayConfig(baseUrl: 'https://relay.test', nsec: fakeOwner.nsec), ), ), - meArchivedAgentsProvider.overrideWith((ref) async => {}), + identityArchivesProvider.overrideWith((ref) async => {}), remoteAgentControllerProvider.overrideWithValue(controller), ], ); diff --git a/mobile/test/shared/me/me_archived_agents_test.dart b/mobile/test/shared/identity_archives/identity_archives_test.dart similarity index 86% rename from mobile/test/shared/me/me_archived_agents_test.dart rename to mobile/test/shared/identity_archives/identity_archives_test.dart index 99a53534ba7..cf9552077f5 100644 --- a/mobile/test/shared/me/me_archived_agents_test.dart +++ b/mobile/test/shared/identity_archives/identity_archives_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:buzz/shared/me/me_archived_agents.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -47,12 +47,12 @@ void main() { RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), ), ), - meRelayInformationClientProvider.overrideWithValue(client), + relayInformationClientProvider.overrideWithValue(client), ], ); addTearDown(container.dispose); addTearDown(client.close); - expect(await container.read(meArchivedAgentsProvider.future), {target}); + expect(await container.read(identityArchivesProvider.future), {target}); expect(relay.author, authority.public); for (final invalid in [ snapshot(impostor), @@ -60,12 +60,12 @@ void main() { NostrEvent.fromJson(snapshot(authority).toJson()..['sig'] = '0' * 128), ]) { relay.event = invalid; - container.invalidate(meArchivedAgentsProvider); - expect(await container.read(meArchivedAgentsProvider.future), isEmpty); + container.invalidate(identityArchivesProvider); + expect(await container.read(identityArchivesProvider.future), isEmpty); } relay.event = snapshot(authority); - container.invalidate(meArchivedAgentsProvider); - expect(await container.read(meArchivedAgentsProvider.future), {target}); + container.invalidate(identityArchivesProvider); + expect(await container.read(identityArchivesProvider.future), {target}); }, ); @@ -85,7 +85,7 @@ void main() { RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), ), ), - meRelayInformationClientProvider.overrideWithValue(client), + relayInformationClientProvider.overrideWithValue(client), ], ); addTearDown(container.dispose); @@ -96,7 +96,7 @@ void main() { }); addTearDown(trickle.cancel); final result = await container - .read(meArchivedAgentsProvider.future) + .read(identityArchivesProvider.future) .timeout(const Duration(seconds: 5)); expect(result, isEmpty); expect(cancelled, isTrue); @@ -119,12 +119,12 @@ void main() { RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), ), ), - meRelayInformationClientProvider.overrideWithValue(client), + relayInformationClientProvider.overrideWithValue(client), ], ); addTearDown(container.dispose); addTearDown(client.close); - expect(await container.read(meArchivedAgentsProvider.future), isEmpty); + expect(await container.read(identityArchivesProvider.future), isEmpty); expect(relay.author, isNull); }, ); diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index b370edc86ac..90c805823a3 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -3,7 +3,7 @@ import 'package:buzz/features/channels/dm_voice_agent_provider.dart'; import 'dart:typed_data'; import 'package:buzz/shared/agents/owned_agent_directory.dart'; import 'package:buzz/shared/me/me_assistants.dart'; -import 'package:buzz/shared/me/me_archived_agents.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/me/me_agent_inventory.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -139,7 +139,7 @@ void main() { overrides: [ relaySessionProvider.overrideWith(() => relay), meSyncProvider.overrideWithValue(sync), - meArchivedAgentsProvider.overrideWith((ref) async => archived), + identityArchivesProvider.overrideWith((ref) async => archived), if (viewer != null) myPubkeyProvider.overrideWithValue(viewer), ], ); diff --git a/mobile/test/shared/me/me_sync_test.dart b/mobile/test/shared/me/me_sync_test.dart index 718a8666af2..8cde0fed64d 100644 --- a/mobile/test/shared/me/me_sync_test.dart +++ b/mobile/test/shared/me/me_sync_test.dart @@ -9,7 +9,7 @@ import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; import 'package:buzz/shared/me/me_assistants.dart'; -import 'package:buzz/shared/me/me_archived_agents.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/me/me_recovery.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -204,7 +204,7 @@ void main() { final config = _Config(RelayConfig(baseUrl: origin, nsec: nsec)); final container = ProviderContainer( overrides: [ - meArchivedAgentsProvider.overrideWith((ref) async => {}), + identityArchivesProvider.overrideWith((ref) async => {}), relayConfigProvider.overrideWith(() => config), relaySessionProvider.overrideWith(() => relay), savedPrefsProvider.overrideWithValue(prefs), From 50daa5e121ba02d251c98e9c91bebe1ca4d4d626 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 10:46:55 -0500 Subject: [PATCH 2/7] fix(mobile): drop the previous community's archive list on a switch A community or account switch reloads the archive list, and while it reloaded the hidden set was still the previous community's, so people and agents archived there disappeared from the new community's pickers for a few seconds. The hidden set now records the relay configuration it was read for and hides nobody until the new community's list arrives. A refresh in the same community still keeps the known set. Tests now also cover the viewer never being hidden and Search > People asking for more people than it shows. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../identity_archives/identity_archives.dart | 22 +++++- .../search/search_channels_journey_test.dart | 54 +++++++++++++- .../identity_archives_test.dart | 74 +++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/mobile/lib/shared/identity_archives/identity_archives.dart b/mobile/lib/shared/identity_archives/identity_archives.dart index c7f66a9f72b..f1428f6b82d 100644 --- a/mobile/lib/shared/identity_archives/identity_archives.dart +++ b/mobile/lib/shared/identity_archives/identity_archives.dart @@ -68,13 +68,29 @@ final identityArchivesProvider = FutureProvider>((ref) async { } }); +/// [identityArchivesProvider] with the relay configuration (community and +/// account) it was read for. +final _scopedArchivesProvider = FutureProvider<(RelayConfig, Set)>(( + ref, +) async { + final config = ref.watch(relayConfigProvider); + return (config, await ref.watch(identityArchivesProvider.future)); +}); + /// Identities to leave out of forward-looking choices (pickers, search and /// the Agents page), as desktop's `archiveHides`: the known-archived ones. /// None while the archive list loads or cannot be read. History (messages, /// activity, profiles) keeps showing archived identities. -final hiddenArchivedIdentitiesProvider = Provider>( - (ref) => ref.watch(identityArchivesProvider).value ?? const {}, -); +/// +/// A refresh keeps the last known set, but after a community or account +/// switch the previous set does not apply: nobody is hidden until the new +/// community's list arrives. +final hiddenArchivedIdentitiesProvider = Provider>((ref) { + final config = ref.watch(relayConfigProvider); + final known = ref.watch(_scopedArchivesProvider).value; + if (known == null || !identical(known.$1, config)) return const {}; + return known.$2; +}); Future> _parseArchives(List events, String authority) => Isolate.run(() => _archived(events, authority)); diff --git a/mobile/test/features/search/search_channels_journey_test.dart b/mobile/test/features/search/search_channels_journey_test.dart index 09ae96f7652..8fbd5b793fc 100644 --- a/mobile/test/features/search/search_channels_journey_test.dart +++ b/mobile/test/features/search/search_channels_journey_test.dart @@ -7,6 +7,7 @@ import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/search/recent_searches_provider.dart'; import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; @@ -602,6 +603,56 @@ void main() { expect(find.text('ops-room'), findsOneWidget); }, ); + + testWidgets('archived people do not crowd live people out of People', ( + tester, + ) async { + // Tall enough that every row is built. + tester.view.physicalSize = const Size(800, 4000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final session = _HeldSession(); + final actions = _HeldUserSearch(); + // The relay ranks more archived matches first than the page shows. + final archived = [ + for (var i = 0; i <= searchPeopleShown; i++) i.toRadixString(16) * 64, + ]; + final live = [for (var i = 0; i < searchPeopleShown; i++) 'f$i' * 32]; + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relayConfigProvider.overrideWith(_Community.new), + relaySessionProvider.overrideWith(() => session), + channelActionsProvider.overrideWith((ref) => actions), + channelsProvider.overrideWith(_NoMemberChannels.new), + recentSearchesProvider.overrideWith(_NoRecentSearches.new), + profileProvider.overrideWith(_Profile.new), + identityArchivesProvider.overrideWith((ref) async => {...archived}), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'scout'); + await tester.pump(const Duration(milliseconds: 300)); + for (final search in session.searches) { + search.complete(const []); + } + actions.searches.removeAt(0).complete([ + for (final key in [...archived, ...live]) + DirectoryUser(pubkey: key, displayName: 'Scout ${key.substring(0, 2)}'), + ]); + await tester.pumpAndSettle(); + + for (final key in archived) { + expect(find.byKey(ValueKey('search-person-row-$key')), findsNothing); + } + for (final key in live) { + expect(find.byKey(ValueKey('search-person-row-$key')), findsOneWidget); + } + }); } Channel _channel(String name, {required bool isMember}) => Channel( @@ -645,11 +696,12 @@ class _HeldSession extends RelaySessionNotifier { class _HeldUserSearch implements ChannelActions { final searches = >>[]; + /// Answers with at most [limit] people, as the relay does. @override Future> searchUsers(String query, {int limit = 8}) { final search = Completer>(); searches.add(search); - return search.future; + return search.future.then((users) => users.take(limit).toList()); } @override diff --git a/mobile/test/shared/identity_archives/identity_archives_test.dart b/mobile/test/shared/identity_archives/identity_archives_test.dart index cf9552077f5..230a4c61898 100644 --- a/mobile/test/shared/identity_archives/identity_archives_test.dart +++ b/mobile/test/shared/identity_archives/identity_archives_test.dart @@ -69,6 +69,73 @@ void main() { }, ); + test( + 'hides archived identities for the current community only, never the viewer', + () async { + final viewer = nostr.Keys.generate(); + final other = nostr.Keys.generate(); + final authority = nostr.Keys.generate(); + final archivedInA = 'a' * 64; + final archivedInB = 'b' * 64; + NostrEvent snapshot(List keys) => NostrEvent.fromJson( + nostr.Event.from( + kind: 13535, + content: '', + secretKey: authority.secret, + tags: [ + ['-'], + for (final key in keys) ['p', key], + ], + ).toMap(), + ); + // Community A's list also names the viewer, who must stay visible. + final relay = _ArchiveRelay(snapshot([archivedInA, viewer.public])); + final communityB = Completer(); + final client = MockClient((request) async { + if (request.url.host == 'b.test') await communityB.future; + return http.Response(jsonEncode({'self': authority.public}), 200); + }); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _Config( + RelayConfig(baseUrl: 'https://a.test', nsec: viewer.nsec), + ), + ), + meSyncProvider.overrideWith( + (ref) => MeSync(relay, ref.watch(relayConfigProvider)), + ), + relayInformationClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + container.listen(hiddenArchivedIdentitiesProvider, (_, _) {}); + Future> settled() async { + await container.read(identityArchivesProvider.future); + await pumpEventQueue(); + return container.read(hiddenArchivedIdentitiesProvider); + } + + expect(await settled(), {archivedInA}); + // A refresh in the same community keeps the known set meanwhile. + container.invalidate(identityArchivesProvider); + expect(container.read(hiddenArchivedIdentitiesProvider), {archivedInA}); + expect(await settled(), {archivedInA}); + + // Switch community and account: A's list no longer applies, so nobody + // is hidden until B's list arrives. + relay.event = snapshot([archivedInB]); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://b.test', nsec: other.nsec); + await pumpEventQueue(); + expect(container.read(hiddenArchivedIdentitiesProvider), isEmpty); + communityB.complete(); + expect(await settled(), {archivedInB}); + }, + ); + test('slow metadata has a total deadline and cancels its stream', () async { final owner = nostr.Keys.generate(); final relay = _ArchiveRelay(null); @@ -144,3 +211,10 @@ class _ArchiveRelay extends RelaySessionNotifier { return [?event]; } } + +class _Config extends RelayConfigNotifier { + _Config(this.initial); + final RelayConfig initial; + @override + RelayConfig build() => initial; +} From bf80fe28601adad97819fd26f5e2d100e4630695 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 10:47:00 -0500 Subject: [PATCH 3/7] fix(mobile): list archived members last and mark them Archived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The members sheet listed known-archived members among live ones, in the relay's order, with nothing to say they are archived. They now follow the live members in People and in Agents, and their row reads "Archived · " in place of the role, status or "Working…" line, which a screen reader reads with the name. It uses the shared hiddenArchivedIdentitiesProvider: nobody while the archive list is unknown, never the viewer. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../lib/features/channels/members_sheet.dart | 30 +++++++++-- .../features/channels/members_sheet_test.dart | 54 ++++++++++++++++++- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 7650713200a..b32a48e0209 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; +import '../../shared/identity_archives/identity_archives.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/sheet_action_section.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -37,8 +38,19 @@ class MembersSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final membersAsync = ref.watch(channelMembersProvider(channel.id)); final allMembers = membersAsync.asData?.value ?? const []; - final people = allMembers.where((member) => !member.isBot).toList(); - final bots = allMembers.where((member) => member.isBot).toList(); + // Known-archived members follow the live ones in each group, each part + // in the relay's order. + final archived = ref.watch(hiddenArchivedIdentitiesProvider); + bool isArchived(ChannelMember member) => + archived.contains(member.pubkey.toLowerCase()); + List group(bool Function(ChannelMember) inGroup) => [ + for (final member in allMembers) + if (inGroup(member) && !isArchived(member)) member, + for (final member in allMembers) + if (inGroup(member) && isArchived(member)) member, + ]; + final people = group((member) => !member.isBot); + final bots = group((member) => member.isBot); final userCache = ref.watch(userCacheProvider); final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id)); final statusCache = ref.watch(userStatusCacheProvider); @@ -113,6 +125,7 @@ class MembersSheet extends HookConsumerWidget { currentPubkey: currentPubkey, profile: userCache[member.pubkey.toLowerCase()], canManage: canManage, + isArchived: isArchived(member), isSelf: member.pubkey.toLowerCase() == currentPubkey?.toLowerCase(), @@ -136,6 +149,7 @@ class MembersSheet extends HookConsumerWidget { currentPubkey: currentPubkey, profile: userCache[bot.pubkey.toLowerCase()], canManage: canManage, + isArchived: isArchived(bot), isSelf: false, channelId: channel.id, isWorking: typingBotPubkeys.contains( @@ -196,6 +210,9 @@ class _MemberTile extends ConsumerWidget { final String? currentPubkey; final UserProfile? profile; final bool canManage; + + /// Known-archived: the row says so, in text a screen reader reads too. + final bool isArchived; final bool isSelf; final String channelId; final bool isWorking; @@ -208,6 +225,7 @@ class _MemberTile extends ConsumerWidget { required this.currentPubkey, required this.profile, required this.canManage, + this.isArchived = false, required this.isSelf, required this.channelId, this.isWorking = false, @@ -240,7 +258,7 @@ class _MemberTile extends ConsumerWidget { isAgent: member.isBot || profile?.isAgent == true, ), title: Text(label), - subtitle: isWorking + subtitle: isWorking && !isArchived ? Row( mainAxisSize: MainAxisSize.min, children: [ @@ -259,7 +277,7 @@ class _MemberTile extends ConsumerWidget { ), ], ) - : userStatus != null && !userStatus!.isEmpty + : !isArchived && userStatus != null && !userStatus!.isEmpty ? Text( '${userStatus!.emoji.isNotEmpty ? '${userStatus!.emoji} ' : ''}${userStatus!.text}', style: context.textTheme.bodySmall?.copyWith( @@ -269,7 +287,9 @@ class _MemberTile extends ConsumerWidget { overflow: TextOverflow.ellipsis, ) : Text( - _roleLabel(member.role), + isArchived + ? 'Archived \u00b7 ${_roleLabel(member.role)}' + : _roleLabel(member.role), style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), diff --git a/mobile/test/features/channels/members_sheet_test.dart b/mobile/test/features/channels/members_sheet_test.dart index e464d9efebe..4ceac370553 100644 --- a/mobile/test/features/channels/members_sheet_test.dart +++ b/mobile/test/features/channels/members_sheet_test.dart @@ -5,6 +5,7 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/members_sheet.dart'; import 'package:buzz/features/profile/user_status.dart'; import 'package:buzz/features/profile/user_status_cache_provider.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/identity_names/identity_names.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; @@ -34,6 +35,8 @@ Future _pump( WidgetTester tester, List members, { ValueChanged? onTap, + Set archived = const {}, + Set working = const {}, }) async { final names = IdentityNameSources.empty.scope( members.map((m) => m.pubkey), @@ -46,7 +49,8 @@ Future _pump( channelIdentityNamesProvider('channel').overrideWith((ref) => names), userCacheProvider.overrideWith(_Cache.new), userStatusCacheProvider.overrideWith(_Statuses.new), - workingBotPubkeysProvider('channel').overrideWith((ref) => {}), + workingBotPubkeysProvider('channel').overrideWith((ref) => working), + identityArchivesProvider.overrideWith((ref) async => archived), ], child: MaterialApp( theme: AppTheme.light(), @@ -72,7 +76,13 @@ Future _pump( ), ), ); - await tester.pumpAndSettle(); + if (working.isEmpty) { + await tester.pumpAndSettle(); + } else { + // A working indicator animates forever, so settle the data only. + await tester.pump(); + await tester.pump(); + } } List _roster(int people, int bots) => [ @@ -160,6 +170,46 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('archived members go last in each group and say so', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + // Person 1 and Agent 3 are archived. + await _pump( + tester, + _roster(3, 2), + archived: {_key(1), _key(3)}, + working: {_key(3), _key(4)}, + ); + List titles() => [ + for (final tile in tester.widgetList(find.byType(ListTile))) + (tile.title! as Text).data!, + ]; + expect(titles(), ['You', 'Person 2', 'Person 1', 'Agent 4', 'Agent 3']); + String subtitle(String name) => + ((tester + .widget( + find.ancestor( + of: find.text(name), + matching: find.byType(ListTile), + ), + ) + .subtitle)! + as Text) + .data!; + expect(subtitle('Person 1'), 'Archived \u00b7 Member'); + // An archived agent says Archived even while it reads as working. + expect(subtitle('Agent 3'), 'Archived \u00b7 Agent'); + expect(subtitle('Person 2'), 'Member'); + expect(find.text('Working\u2026'), findsOne); + expect(find.bySemanticsLabel(RegExp(r'Person 1[\s\S]*Archived')), findsOne); + expect( + find.bySemanticsLabel(RegExp(r'Person 2[\s\S]*Archived')), + findsNothing, + ); + semantics.dispose(); + }); + testWidgets('owner actions stay available after lazy scrolling', ( tester, ) async { From 5ed9f98ad85489d1004374034192a043748830e7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 11:13:58 -0500 Subject: [PATCH 4/7] fix(mobile): list archived members last in the channel info preview The members preview on the channel info page (the first five members above "See all") still listed known-archived members in roster order, with no label. It now uses the members sheet's rule: archived members after the live ones, and "Archived" before their role. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../channels/channel_actions_sheet.dart | 1 + .../channels/channel_details_page.dart | 18 +++++- .../channels/channel_detail_page_test.dart | 57 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index de5d0c482b7..c8894123eb3 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/identity_archives/identity_archives.dart'; import '../../shared/identity_names/identity_names_provider.dart'; import 'dm_channel_labels.dart'; import '../../shared/mentions/agent_identity_provider.dart'; diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 3bd79c05b0d..be5c0c4aee2 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -151,7 +151,14 @@ class ChannelDetailsPage extends HookConsumerWidget { membersAsync.value?.length ?? resolvedChannel.memberCount; final memberLabel = '$memberCount ${memberCount == 1 ? 'member' : 'members'}'; - final previewMembers = members.take(_channelMemberPreviewLimit).toList(); + // Known-archived members follow the live ones, as in the members sheet. + final archived = ref.watch(hiddenArchivedIdentitiesProvider); + bool isArchived(ChannelMember member) => + archived.contains(member.pubkey.toLowerCase()); + final previewMembers = [ + ...members.where((member) => !isArchived(member)), + ...members.where(isArchived), + ].take(_channelMemberPreviewLimit).toList(); final userCache = ref.watch(userCacheProvider); final memberLabels = watchChannelIdentityLabels( ref, @@ -450,6 +457,7 @@ class ChannelDetailsPage extends HookConsumerWidget { contextualLabel: memberLabels[member.pubkey.toLowerCase()], avatarUrl: userCache[member.pubkey.toLowerCase()]?.avatarUrl, + isArchived: isArchived(member), ), if (memberCount > _channelMemberPreviewLimit) AppListRowRaw( @@ -738,6 +746,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget { required this.displayName, required this.contextualLabel, required this.avatarUrl, + this.isArchived = false, }); final ChannelMember member; @@ -749,6 +758,9 @@ class _ChannelMemberPreviewRow extends StatelessWidget { final String? contextualLabel; final String? avatarUrl; + /// Known-archived: the row says so, in text a screen reader reads too. + final bool isArchived; + @override Widget build(BuildContext context) { final isSelf = member.pubkey.toLowerCase() == currentPubkey?.toLowerCase(); @@ -765,7 +777,9 @@ class _ChannelMemberPreviewRow extends StatelessWidget { final initial = isSelf || hasName ? name[0].toUpperCase() : (member.pubkey.isNotEmpty ? member.pubkey[0].toUpperCase() : '?'); - final roleLabel = _channelMemberRoleLabel(member.role); + final roleLabel = isArchived + ? 'Archived \u00b7 ${_channelMemberRoleLabel(member.role)}' + : _channelMemberRoleLabel(member.role); final titleStyle = context.textTheme.bodyLarge; final roleStyle = context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7757af969f0..3f6a4f619e9 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -82,6 +82,7 @@ import 'package:buzz/shared/agents/owned_agent_directory.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/features/profile/user_profile_sheet.dart'; +import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/identity_names/identity_names.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/emoji/emoji_burst.dart'; @@ -270,6 +271,7 @@ Widget _buildTestable({ Future> Function()? loadAgentOwners, UserCacheNotifier? userCacheNotifier, List members = const [], + Set? archived, List huddleMembers = const [], _MutableHuddleMembersNotifier? huddleMembersNotifier, Channel? channel, @@ -326,6 +328,8 @@ Widget _buildTestable({ retry: providerRetry ?? (disableRetries ? (_, _) => null : null), overrides: [ ownedCodexVoiceAgentsProvider.overrideWith((ref) async => voiceAgents), + if (archived != null) + identityArchivesProvider.overrideWith((ref) async => archived), if (bestie != null) defaultAgentProvider.overrideWith((ref) => Stream.value(bestie)), if (sender != null) sendMessageProvider.overrideWithValue(sender), @@ -4642,6 +4646,59 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('previews archived members after live ones, marked', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: const [], + archived: {'alice'}, + members: [ + for (final (key, role, name) in [ + ('self', 'owner', 'Self'), + ('alice', 'member', 'Alice'), + ('bob', 'member', 'Bob'), + ]) + ChannelMember( + pubkey: key, + role: role, + joinedAt: DateTime(2025), + displayName: name, + ), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + + final rows = [ + for (final key in ['self', 'bob', 'alice']) + find.byKey(ValueKey('channel-details-member-$key')), + ]; + final tops = [for (final row in rows) tester.getTopLeft(row).dy]; + expect(tops, orderedEquals([...tops]..sort())); + expect( + find.descendant( + of: rows.last, + matching: find.textContaining( + 'Archived \u00b7 Member', + findRichText: true, + ), + ), + findsOne, + ); + expect( + find.descendant( + of: rows[1], + matching: find.textContaining('Archived', findRichText: true), + ), + findsNothing, + ); + }); + testWidgets('previews five members before an icon-free See all row', ( tester, ) async { From 3e4e8a5a55e0f11f16bd679e1605cdae1cca9c48 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 11:19:09 -0500 Subject: [PATCH 5/7] fix(mobile): keep people before agents in the channel info preview The previous change moved archived members to the end of the whole preview, so an archived person could land below the agents. Archived members now go last within People and within Agents, as in the members sheet. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../lib/features/channels/channel_details_page.dart | 11 ++++++++--- .../features/channels/channel_detail_page_test.dart | 6 ++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index be5c0c4aee2..dc793697adc 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -151,13 +151,18 @@ class ChannelDetailsPage extends HookConsumerWidget { membersAsync.value?.length ?? resolvedChannel.memberCount; final memberLabel = '$memberCount ${memberCount == 1 ? 'member' : 'members'}'; - // Known-archived members follow the live ones, as in the members sheet. + // People, then agents, each with known-archived members after the live + // ones, as in the members sheet. final archived = ref.watch(hiddenArchivedIdentitiesProvider); bool isArchived(ChannelMember member) => archived.contains(member.pubkey.toLowerCase()); final previewMembers = [ - ...members.where((member) => !isArchived(member)), - ...members.where(isArchived), + for (final bot in [false, true]) + for (final archivedPart in [false, true]) + ...members.where( + (member) => + member.isBot == bot && isArchived(member) == archivedPart, + ), ].take(_channelMemberPreviewLimit).toList(); final userCache = ref.watch(userCacheProvider); final memberLabels = watchChannelIdentityLabels( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 3f6a4f619e9..a4bc0b9e248 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4657,6 +4657,7 @@ void main() { for (final (key, role, name) in [ ('self', 'owner', 'Self'), ('alice', 'member', 'Alice'), + ('agent', 'bot', 'Agent'), ('bob', 'member', 'Bob'), ]) ChannelMember( @@ -4675,14 +4676,15 @@ void main() { await tester.pumpAndSettle(); final rows = [ - for (final key in ['self', 'bob', 'alice']) + // People, then agents; archived Alice last among the people. + for (final key in ['self', 'bob', 'alice', 'agent']) find.byKey(ValueKey('channel-details-member-$key')), ]; final tops = [for (final row in rows) tester.getTopLeft(row).dy]; expect(tops, orderedEquals([...tops]..sort())); expect( find.descendant( - of: rows.last, + of: rows[2], matching: find.textContaining( 'Archived \u00b7 Member', findRichText: true, From 8c8195fa10486a0daf7276f0d9f8dcdc0f415f78 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 12:02:40 -0500 Subject: [PATCH 6/7] fix(mobile): reread archives when the Agents page refreshes Pull to refresh on Agents reread the inventory but kept the archive list from the first read, so an agent restored elsewhere stayed hidden until a reconnect. Reread the archive list with the inventory, as the desktop members refresh does. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/features/agents/agents_page.dart | 3 +++ .../features/agents/agent_ordering_test.dart | 22 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index 84ab731463b..c5d531c54d3 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -374,6 +374,9 @@ class AgentsPage extends HookConsumerWidget { if (context.mounted) { attempt.value++; ref.invalidate(ownedAgentDirectoryProvider); + // A refresh also rereads archives: an agent restored elsewhere + // reappears, and one archived elsewhere leaves. + ref.invalidate(identityArchivesProvider); } } diff --git a/mobile/test/features/agents/agent_ordering_test.dart b/mobile/test/features/agents/agent_ordering_test.dart index df158beb365..b6c82acbe90 100644 --- a/mobile/test/features/agents/agent_ordering_test.dart +++ b/mobile/test/features/agents/agent_ordering_test.dart @@ -3,6 +3,7 @@ import 'package:buzz/features/agents/agents_page.dart'; import 'package:buzz/shared/identity_archives/identity_archives.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -39,14 +40,17 @@ void main() { ); }); - testWidgets('leaves archived agents out of the directory', (tester) async { + testWidgets('leaves archived agents out of the directory until restored', ( + tester, + ) async { final h = await AgentsHarness.create(); final existing = existingDesktopKeys.public; + var archived = {fakeAgent}; await h.open( tester, directory: [existing, fakeAgent], overrides: [ - identityArchivesProvider.overrideWith((ref) async => {fakeAgent}), + identityArchivesProvider.overrideWith((ref) async => archived), ], ); final container = ProviderScope.containerOf( @@ -62,5 +66,19 @@ void main() { .map((card) => card.name), ['Zulu'], ); + // Restored elsewhere: pulling to refresh rereads the archive list. + archived = {}; + await tester.fling( + find.byType(CustomScrollView).first, + const Offset(0, 400), + 1000, + ); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(AgentCard)) + .map((card) => card.name), + ['alpha', 'Zulu'], + ); }); } From 2049ffdef5342cacd8db8a1cba29fd1e49f5af5b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 12:10:57 -0500 Subject: [PATCH 7/7] fix(mobile): give archived members their own section Archived people and agents leave People and Agents for their own Archived section after both: archived people, then archived agents, each in the relay's order. Each row keeps its Archived label. The channel info preview follows the same order. This matches the desktop members dialog. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- .../channels/channel_details_page.dart | 8 +- .../lib/features/channels/members_sheet.dart | 104 ++++++++---------- .../channels/channel_detail_page_test.dart | 6 +- .../features/channels/members_sheet_test.dart | 12 +- 4 files changed, 64 insertions(+), 66 deletions(-) diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index dc793697adc..3f005bd1da5 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -151,14 +151,14 @@ class ChannelDetailsPage extends HookConsumerWidget { membersAsync.value?.length ?? resolvedChannel.memberCount; final memberLabel = '$memberCount ${memberCount == 1 ? 'member' : 'members'}'; - // People, then agents, each with known-archived members after the live - // ones, as in the members sheet. + // People, agents, then known-archived people and agents, as in the + // members sheet. final archived = ref.watch(hiddenArchivedIdentitiesProvider); bool isArchived(ChannelMember member) => archived.contains(member.pubkey.toLowerCase()); final previewMembers = [ - for (final bot in [false, true]) - for (final archivedPart in [false, true]) + for (final archivedPart in [false, true]) + for (final bot in [false, true]) ...members.where( (member) => member.isBot == bot && isArchived(member) == archivedPart, diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index b32a48e0209..43fe47ff9d0 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -38,19 +38,24 @@ class MembersSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final membersAsync = ref.watch(channelMembersProvider(channel.id)); final allMembers = membersAsync.asData?.value ?? const []; - // Known-archived members follow the live ones in each group, each part - // in the relay's order. + // Known-archived members leave People and Agents for their own section + // after both: archived people, then archived agents, in the relay's order. final archived = ref.watch(hiddenArchivedIdentitiesProvider); bool isArchived(ChannelMember member) => archived.contains(member.pubkey.toLowerCase()); - List group(bool Function(ChannelMember) inGroup) => [ + final people = [ for (final member in allMembers) - if (inGroup(member) && !isArchived(member)) member, + if (!member.isBot && !isArchived(member)) member, + ]; + final bots = [ for (final member in allMembers) - if (inGroup(member) && isArchived(member)) member, + if (member.isBot && !isArchived(member)) member, + ]; + final archivedMembers = [ + for (final bot in [false, true]) + for (final member in allMembers) + if (member.isBot == bot && isArchived(member)) member, ]; - final people = group((member) => !member.isBot); - final bots = group((member) => member.isBot); final userCache = ref.watch(userCacheProvider); final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id)); final statusCache = ref.watch(userStatusCacheProvider); @@ -101,6 +106,28 @@ class MembersSheet extends HookConsumerWidget { return null; }, [allMembers.length]); + Widget tile(ChannelMember member) { + final key = member.pubkey.toLowerCase(); + final working = member.isBot && typingBotPubkeys.contains(key); + return _MemberTile( + member: member, + currentPubkey: currentPubkey, + profile: userCache[key], + canManage: canManage, + isArchived: isArchived(member), + isSelf: !member.isBot && key == currentPubkey?.toLowerCase(), + channelId: channel.id, + userStatus: member.isBot ? null : statusCache[key], + isWorking: working, + onViewActivity: member.isBot ? () => openActivity(member) : null, + onActivityTap: member.isBot + ? (working ? () => openActivity(member) : null) + : onMemberTap == null + ? null + : () => onMemberTap!(context, member.pubkey), + ); + } + return Padding( key: const ValueKey('members-sheet-content-padding'), padding: EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, 0), @@ -114,56 +141,19 @@ class MembersSheet extends HookConsumerWidget { shrinkWrap: true, slivers: [ const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), - if (people.isNotEmpty) - _MemberSection( - label: 'People · ${people.length}', - itemCount: people.length, - itemBuilder: (context, index) { - final member = people[index]; - return _MemberTile( - member: member, - currentPubkey: currentPubkey, - profile: userCache[member.pubkey.toLowerCase()], - canManage: canManage, - isArchived: isArchived(member), - isSelf: - member.pubkey.toLowerCase() == - currentPubkey?.toLowerCase(), - channelId: channel.id, - userStatus: statusCache[member.pubkey.toLowerCase()], - onActivityTap: onMemberTap == null - ? null - : () => onMemberTap!(context, member.pubkey), - ); - }, - ), - if (bots.isNotEmpty) - _MemberSection( - label: 'Agents · ${bots.length}', - semanticIndexOffset: people.length, - itemCount: bots.length, - itemBuilder: (context, index) { - final bot = bots[index]; - return _MemberTile( - member: bot, - currentPubkey: currentPubkey, - profile: userCache[bot.pubkey.toLowerCase()], - canManage: canManage, - isArchived: isArchived(bot), - isSelf: false, - channelId: channel.id, - isWorking: typingBotPubkeys.contains( - bot.pubkey.toLowerCase(), - ), - onViewActivity: () => openActivity(bot), - onActivityTap: - typingBotPubkeys.contains(bot.pubkey.toLowerCase()) - ? () => openActivity(bot) - : null, - ); - }, - ), - if (people.isEmpty && bots.isEmpty) + for (final (label, members, offset) in [ + ('People', people, 0), + ('Agents', bots, people.length), + ('Archived', archivedMembers, people.length + bots.length), + ]) + if (members.isNotEmpty) + _MemberSection( + label: '$label · ${members.length}', + semanticIndexOffset: offset, + itemCount: members.length, + itemBuilder: (context, index) => tile(members[index]), + ), + if (allMembers.isEmpty) SliverToBoxAdapter( child: Center( child: Text( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index a4bc0b9e248..bd23e37ae44 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4676,15 +4676,15 @@ void main() { await tester.pumpAndSettle(); final rows = [ - // People, then agents; archived Alice last among the people. - for (final key in ['self', 'bob', 'alice', 'agent']) + // People, agents, then archived Alice, as in the members sheet. + for (final key in ['self', 'bob', 'agent', 'alice']) find.byKey(ValueKey('channel-details-member-$key')), ]; final tops = [for (final row in rows) tester.getTopLeft(row).dy]; expect(tops, orderedEquals([...tops]..sort())); expect( find.descendant( - of: rows[2], + of: rows[3], matching: find.textContaining( 'Archived \u00b7 Member', findRichText: true, diff --git a/mobile/test/features/channels/members_sheet_test.dart b/mobile/test/features/channels/members_sheet_test.dart index 4ceac370553..da90fad951f 100644 --- a/mobile/test/features/channels/members_sheet_test.dart +++ b/mobile/test/features/channels/members_sheet_test.dart @@ -170,7 +170,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('archived members go last in each group and say so', ( + testWidgets('archived members get their own section and say so', ( tester, ) async { final semantics = tester.ensureSemantics(); @@ -185,7 +185,15 @@ void main() { for (final tile in tester.widgetList(find.byType(ListTile))) (tile.title! as Text).data!, ]; - expect(titles(), ['You', 'Person 2', 'Person 1', 'Agent 4', 'Agent 3']); + // Archived people, then archived agents, after People and Agents. + expect(find.text('People \u00b7 2'), findsOne); + expect(find.text('Agents \u00b7 1'), findsOne); + expect(find.text('Archived \u00b7 2'), findsOne); + expect(titles(), ['You', 'Person 2', 'Agent 4', 'Person 1', 'Agent 3']); + expect( + tester.getTopLeft(find.text('Archived \u00b7 2')).dy, + greaterThan(tester.getTopLeft(find.text('Agent 4')).dy), + ); String subtitle(String name) => ((tester .widget(