mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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')));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user