Skip to content
26 changes: 20 additions & 6 deletions mobile/lib/features/agents/agents_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_actions_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
);
Expand Down
23 changes: 21 additions & 2 deletions mobile/lib/features/channels/channel_details_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -738,6 +751,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget {
required this.displayName,
required this.contextualLabel,
required this.avatarUrl,
this.isArchived = false,
});

final ChannelMember member;
Expand All @@ -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();
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions mobile/lib/features/channels/dm_voice_agent_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
116 changes: 63 additions & 53 deletions mobile/lib/features/channels/members_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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: [
Expand All @@ -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(
Expand All @@ -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,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading