fix(mobile): hide archived people and agents from choices; give them their own members section (#9320)

## Summary
Mobile kept offering archived people and agents as choices: Add members
(including adding an agent to a Solo chat), New DM, the `@` mention
chooser, Search › People and the Agents page. An archived agent that
shares a live agent's name also forced a qualifier onto the live one,
because names were told apart across both. The members sheet listed
archived members among live ones with nothing to mark them.

Now, matching desktop:
- Archived people and agents are left out of those choices. History
(messages, activity, profiles) still shows them.
- The members sheet moves archived members out of People and Agents into
their own "Archived · N" section after both: archived people, then
archived agents. Each row reads "Archived · Member" (or Agent) in place
of the role, status or "Working…" line. A screen reader reads it with
the name. The members preview on the channel info page (the first five
members above "See all") follows the same order and label.
- Nothing is hidden or marked while the community's archive list is
unknown or cannot be read, and you are never hidden from yourself.

### Related issue
None found. The desktop members dialog gets the same Archived section in
https://github.com/block/buzz-app/pull/3203.

### Testing
Widget and provider tests cover each surface, the unknown-archive case,
the viewer rule and a community switch. Each test fails without its
change.

Android emulator, local relay, fictional names. Each archived person or
agent has a live namesake. History rows still show archived identities,
as intended.

**`@` mention chooser, typing "@Sc" (Scout `qsfn` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/6693729c-66c1-4433-8a7b-4f0e948057ee"
width="320"> | <img
src="https://github.com/user-attachments/assets/eb4f80be-780e-4dbe-8b2b-e6a5b6c3ca97"
width="320"> |

**`@` mention chooser, typing "@Ca" (Casey Lee `9mhy` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/b6d409cd-7af1-4da7-b5b9-deece5e21de5"
width="320"> | <img
src="https://github.com/user-attachments/assets/60e21387-791b-4233-b8e6-1a29f94e6810"
width="320"> |

**Add members, searching "Morgan" (Morgan Reyes `ssc2` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/1222a923-5e43-4263-843d-57564bdc7af3"
width="320"> | <img
src="https://github.com/user-attachments/assets/dbe58278-788d-4a89-83bf-f0b4bc119c5e"
width="320"> |

**Add members, searching "Atlas" (Atlas `85gl` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/55c67a21-4cf5-47c5-b6ac-1ba80d68a3ec"
width="320"> | <img
src="https://github.com/user-attachments/assets/54b19ab3-fa2f-4933-8b67-1056d6e3b3a4"
width="320"> |

**New DM, no query yet**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/6a26fd49-4a06-47bd-ba37-37e5898ab344"
width="320"> | <img
src="https://github.com/user-attachments/assets/3f33eb2b-f27a-4528-91c0-ac76d3fdf44b"
width="320"> |

**New DM, searching "Morgan"**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/22d2d617-3074-4910-8c39-e0778e47a879"
width="320"> | <img
src="https://github.com/user-attachments/assets/fa00b160-e350-49af-94d0-beb15c357bef"
width="320"> |

**Search › People, "Morgan"**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/83a28f2b-9b22-4d0c-8728-8a2dfb10e634"
width="320"> | <img
src="https://github.com/user-attachments/assets/573aed3f-75ab-478f-8c11-bedab5c6ce4f"
width="320"> |

**Agents page**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/d7980f95-825c-4d1a-ad67-95f906a52e9a"
width="320"> | <img
src="https://github.com/user-attachments/assets/6252c38a-b2ec-48dc-ab4e-824ea29bbd85"
width="320"> |

**Channel info, members preview**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/8744cdfd-c073-4ade-bd53-9d5e137cee60"
width="320"> | <img
src="https://github.com/user-attachments/assets/5240b1be-13d5-4f22-85a9-8375f1d8ac18"
width="320"> |

**Members sheet, top**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/c9241478-99b1-452e-ad41-8c38fe080182"
width="320"> | <img
src="https://github.com/user-attachments/assets/3c6f9247-60c6-47df-94b0-9175f25805bb"
width="320"> |

**Members sheet, scrolled to the end**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/7da7fcc8-28ea-4c7a-904e-91ca5a068d23"
width="320"> | <img
src="https://github.com/user-attachments/assets/024f676e-8150-457d-b7d0-67fa1a680f8d"
width="320"> |

## Details
- The archive reader (the community's NIP-IA archive list, signed by the
relay's NIP-11 `self` key) moves from `shared/me` to
`shared/identity_archives`, since it is no longer Solo-only.
`hiddenArchivedIdentitiesProvider` is the single rule the surfaces read.
- Pulling to refresh on the Agents page also rereads the archive list.
Other views reuse the last list, as on desktop; it is also reread on
reconnect and when the agent inventory changes.
- A refresh keeps the last known list. After a community or account
switch, the previous community's list does not apply: nobody is hidden
until the new list arrives.
- The shared chooser drops archived identities before names are
compared, which covers Add members, New DM and mentions.
- Search › People asks the relay for 24 people and shows the first 8
that are not archived, so archived matches do not push live people out.
- The channel info preview shows the label on one line, so with a long
name the role after "Archived" can be cut off. A screen reader still
reads it in full.
- Not changed: typing a full `@Name` by hand (without the chooser) still
resolves against archived channel members too, so an archived namesake
can still make a typed name ambiguous. Picking from the chooser is
unaffected. Possible follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Johnson
2026-11-10 14:10:49 -05:00
committed by GitHub
parent b40f22114b
commit dd2d3cf4ea
23 changed files with 568 additions and 104 deletions

View File

@@ -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 ?? <AgentDirectoryEntry>[])
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 ?? <AgentDirectoryEntry>[])
entry.pubkey,
}
.where(
(key) =>
!controller.deletedAgents.contains(key) &&
!archived.contains(key),
)
.toList();
final names = watchIdentityNames(
ref,
keys,
@@ -363,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);
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -26,7 +26,7 @@ Future<void> _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<void> _openMobileHuddle({
);
if (!confirmed || !context.mounted) return;
ref.invalidate(ownedAgentDirectoryProvider);
ref.invalidate(meArchivedAgentsProvider);
ref.invalidate(identityArchivesProvider);
final currentAgents = await ref.read(
ownedCodexVoiceAgentsProvider.future,
);

View File

@@ -151,7 +151,19 @@ class ChannelDetailsPage extends HookConsumerWidget {
membersAsync.value?.length ?? resolvedChannel.memberCount;
final memberLabel =
'$memberCount ${memberCount == 1 ? 'member' : 'members'}';
final previewMembers = members.take(_channelMemberPreviewLimit).toList();
// 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 archivedPart in [false, true])
for (final bot in [false, true])
...members.where(
(member) =>
member.isBot == bot && isArchived(member) == archivedPart,
),
].take(_channelMemberPreviewLimit).toList();
final userCache = ref.watch(userCacheProvider);
final memberLabels = watchChannelIdentityLabels(
ref,
@@ -450,6 +462,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 +751,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget {
required this.displayName,
required this.contextualLabel,
required this.avatarUrl,
this.isArchived = false,
});
final ChannelMember member;
@@ -749,6 +763,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 +782,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,

View File

@@ -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<Set<String>>((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<void>((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<String?> 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) {

View File

@@ -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,24 @@ class MembersSheet extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final membersAsync = ref.watch(channelMembersProvider(channel.id));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
final people = allMembers.where((member) => !member.isBot).toList();
final bots = allMembers.where((member) => member.isBot).toList();
// 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());
final people = [
for (final member in allMembers)
if (!member.isBot && !isArchived(member)) member,
];
final bots = [
for (final member in allMembers)
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 userCache = ref.watch(userCacheProvider);
final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id));
final statusCache = ref.watch(userStatusCacheProvider);
@@ -89,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),
@@ -102,54 +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,
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,
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(
@@ -196,6 +200,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 +215,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 +248,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 +267,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 +277,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,
),

View File

@@ -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<MentionCandidate> _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<MentionCandidate> _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

View File

@@ -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)

View File

@@ -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<SearchState> {
}
}
/// 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<void> _searchUsers(String query, int generation) async {
List<DirectoryUser>? 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.
}

View File

@@ -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<http.Client>((ref) {
final relayInformationClientProvider = Provider<http.Client>((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<Set<String>>((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<Set<String>>((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,36 @@ final meArchivedAgentsProvider = FutureProvider<Set<String>>((ref) async {
timeout: const Duration(seconds: 3),
);
sync.check();
return await _parseArchives(events, authority);
return (await _parseArchives(events, authority))..remove(sync.pubkey);
} catch (_) {
return {};
}
});
/// [identityArchivesProvider] with the relay configuration (community and
/// account) it was read for.
final _scopedArchivesProvider = FutureProvider<(RelayConfig, Set<String>)>((
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.
///
/// 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<Set<String>>((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<Set<String>> _parseArchives(List<NostrEvent> events, String authority) =>
Isolate.run(() => _archived(events, authority));

View File

@@ -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<List<AgentDirectoryEntry>>((
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());

View File

@@ -1,7 +1,9 @@
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/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -37,4 +39,46 @@ void main() {
['aardvark', 'alpha'],
);
});
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 => archived),
],
);
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<AgentCard>(find.byType(AgentCard))
.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<AgentCard>(find.byType(AgentCard))
.map((card) => card.name),
['alpha', 'Zulu'],
);
});
}

View File

@@ -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<List<String>> shownScouts(
Future<Set<String>> 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<Text>(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')));
},
);
}

View File

@@ -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<Map<String, String>> Function()? loadAgentOwners,
UserCacheNotifier? userCacheNotifier,
List<ChannelMember> members = const [],
Set<String>? archived,
List<ChannelMember> 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,61 @@ 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'),
('agent', 'bot', 'Agent'),
('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 = [
// 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[3],
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 {

View File

@@ -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<void> _pump(
WidgetTester tester,
List<ChannelMember> members, {
ValueChanged<String>? onTap,
Set<String> archived = const {},
Set<String> working = const {},
}) async {
final names = IdentityNameSources.empty.scope(
members.map((m) => m.pubkey),
@@ -46,7 +49,8 @@ Future<void> _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<void> _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<ChannelMember> _roster(int people, int bots) => [
@@ -160,6 +170,54 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('archived members get their own section 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<String> titles() => [
for (final tile in tester.widgetList<ListTile>(find.byType(ListTile)))
(tile.title! as Text).data!,
];
// 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<ListTile>(
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 {

View File

@@ -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 = <Completer<List<DirectoryUser>>>[];
/// Answers with at most [limit] people, as the relay does.
@override
Future<List<DirectoryUser>> searchUsers(String query, {int limit = 8}) {
final search = Completer<List<DirectoryUser>>();
searches.add(search);
return search.future;
return search.future.then((users) => users.take(limit).toList());
}
@override

View File

@@ -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(

View File

@@ -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<String> &&
(widget.key! as ValueKey<String>).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', (

View File

@@ -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),
],
);

View File

@@ -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,79 @@ 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});
},
);
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<String> 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<void>();
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<Set<String>> 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});
},
);
@@ -85,7 +152,7 @@ void main() {
RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec),
),
),
meRelayInformationClientProvider.overrideWithValue(client),
relayInformationClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
@@ -96,7 +163,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 +186,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);
},
);
@@ -144,3 +211,10 @@ class _ArchiveRelay extends RelaySessionNotifier {
return [?event];
}
}
class _Config extends RelayConfigNotifier {
_Config(this.initial);
final RelayConfig initial;
@override
RelayConfig build() => initial;
}

View File

@@ -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),
],
);

View File

@@ -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),