Files
buzz/mobile/lib/features/channels/add_members_sheet.dart
Logan Johnson 7a1910e700 feat(mobile): show contextual names in lists, Search and Pulse (#7896)
🤖
## Summary

This PR finishes the change on the remaining mobile screens. After it,
every place that names a person or agent uses a contextual name:

- The channel list, DM labels, and the new-DM picker
- The channel header and details, and the members and add-members sheets
- Huddle avatars, spotlight, and overlay
- The Activity inbox
- Search results for people and messages
- Pulse notes, agent cards, and reply previews

Screens that belong to a channel compare names against its members.
Search, Pulse, and the new-DM picker have no channel, so they compare
the identities shown together. In the example below, Search showed four
results all named "murderbot". Now the human keeps the plain name, and
baxen's three agents are labeled by owner and key suffix.

Part 3 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910. The desktop search change in #6483 addresses the same
problem in a different app.

### Testing

- Widget tests cover the Activity inbox, Search, and Pulse notes with
colliding names.
- `flutter analyze` is clean, and the full `flutter test` suite passes.
- Checked on an Android emulator against the live relay by searching for
"murderbot". The after image also shows "classy-murderbot", a new
account that was created between the two captures.

| Before | After |
| --- | --- |
| <img width="300" alt="Before: Search shows four people all named
murderbot"
src="https://github.com/user-attachments/assets/21bbcc52-fea8-4b31-ab8c-ff3ec9b32a39"
/> | <img width="300" alt="After: three results show as baxen's
murderbot with a key suffix, and the human shows as murderbot"
src="https://github.com/user-attachments/assets/d59584d9-4a35-46da-b39a-83addfad3cd2"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-30 10:46:14 -04:00

295 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import 'channel_management_provider.dart';
/// Searchable multi-select used to add people or agents to a channel.
class AddChannelMembersSheet extends HookConsumerWidget {
const AddChannelMembersSheet({
super.key,
required this.channelId,
required this.existingPubkeys,
});
final String channelId;
final Set<String> existingPubkeys;
@override
Widget build(BuildContext context, WidgetRef ref) {
final queryController = useTextEditingController();
final query = useState('');
final debouncedQuery = useState('');
final selectedUsers = useState<List<DirectoryUser>>([]);
final isSubmitting = useState(false);
final submitError = useState<String?>(null);
useEffect(() {
final timer = Timer(const Duration(milliseconds: 250), () {
debouncedQuery.value = query.value.trim().toLowerCase();
});
return timer.cancel;
}, [query.value]);
final normalizedQuery = debouncedQuery.value;
final directoryAsync = normalizedQuery.isEmpty
? ref.watch(relayDirectoryUsersProvider)
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
final normalizedExisting = existingPubkeys
.map((pubkey) => pubkey.toLowerCase())
.toSet();
final selectedPubkeys = selectedUsers.value
.map((user) => user.pubkey.toLowerCase())
.toSet();
final successfulPubkeys = useState<Set<String>>(<String>{});
final excludedPubkeys = {...normalizedExisting, ...successfulPubkeys.value};
final availableUsers =
directoryAsync.asData?.value
.where(
(user) => !excludedPubkeys.contains(user.pubkey.toLowerCase()),
)
.toList() ??
const <DirectoryUser>[];
// Compare every shown choice with the channel's members, so a candidate
// who shares a member's name is told apart before being added. Members
// are only compared: their names come from the roster the details page
// already loaded, so only the shown choices' profiles are fetched.
final choices = [...availableUsers, ...selectedUsers.value];
final roster =
ref.watch(channelMembersProvider(channelId)).asData?.value ??
const <ChannelMember>[];
final names = watchIdentityNames(
ref,
[...normalizedExisting, for (final user in choices) user.pubkey],
agentPubkeys: {
for (final member in roster)
if (member.isBot) member.pubkey,
for (final user in choices)
if (user.isAgent) user.pubkey,
},
fallbackNames: {
for (final member in roster) member.pubkey: ?member.displayName,
for (final user in choices) user.pubkey: user.label,
},
shown: [for (final user in choices) user.pubkey],
);
String labelFor(DirectoryUser user) => names.labelFor(user.pubkey);
void toggleUser(DirectoryUser user) {
if (isSubmitting.value) return;
final normalized = user.pubkey.toLowerCase();
selectedUsers.value = selectedPubkeys.contains(normalized)
? [
for (final selected in selectedUsers.value)
if (selected.pubkey.toLowerCase() != normalized) selected,
]
: [...selectedUsers.value, user];
submitError.value = null;
}
Future<void> addSelectedMembers() async {
if (selectedUsers.value.isEmpty || isSubmitting.value) return;
final submittedUsers = List<DirectoryUser>.of(selectedUsers.value);
isSubmitting.value = true;
submitError.value = null;
try {
await ref
.read(channelActionsProvider)
.addMembers(
channelId: channelId,
pubkeys: submittedUsers.map((user) => user.pubkey).toList(),
);
if (context.mounted) Navigator.of(context).pop(true);
} on AddMembersException catch (error) {
final failedPubkeys = error.failures.keys
.map((pubkey) => pubkey.toLowerCase())
.toSet();
successfulPubkeys.value = {
...successfulPubkeys.value,
for (final user in submittedUsers)
if (!failedPubkeys.contains(user.pubkey.toLowerCase()))
user.pubkey.toLowerCase(),
};
selectedUsers.value = [
for (final user in submittedUsers)
if (failedPubkeys.contains(user.pubkey.toLowerCase())) user,
];
submitError.value = error.message;
} catch (error) {
submitError.value = error.toString();
} finally {
isSubmitting.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom,
),
child: SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
),
child: Column(
children: [
TextField(
key: const ValueKey('add-channel-members-search'),
controller: queryController,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
enabled: !isSubmitting.value,
onChanged: (value) => query.value = value,
decoration: const InputDecoration(
hintText: 'Search for people or agents',
prefixIcon: Icon(LucideIcons.search),
),
),
if (selectedUsers.value.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final user in selectedUsers.value)
InputChip(
key: ValueKey(
'add-channel-member-selected-${user.pubkey}',
),
label: Text(labelFor(user)),
onDeleted: isSubmitting.value
? null
: () => toggleUser(user),
),
],
),
),
],
const SizedBox(height: Grid.xxs),
Expanded(
child: directoryAsync.when(
data: (_) => availableUsers.isEmpty
? Center(
child: Text(
normalizedQuery.isEmpty
? 'Everyone available is already in this channel.'
: 'No matching people or agents.',
textAlign: TextAlign.center,
),
)
: ListView.builder(
key: const ValueKey('add-channel-members-results'),
itemCount: availableUsers.length,
itemBuilder: (context, index) {
final user = availableUsers[index];
final selected = selectedPubkeys.contains(
user.pubkey.toLowerCase(),
);
return Semantics(
key: ValueKey(
'add-channel-member-${user.pubkey}',
),
button: true,
selected: selected,
label: selected
? '${labelFor(user)}, selected'
: labelFor(user),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor:
context.colors.primaryContainer,
fallback: Text(user.initial),
isAgent: user.isAgent,
),
title: Text(
labelFor(user),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
user.secondaryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(
selected
? LucideIcons.circleCheck
: LucideIcons.plus,
color: selected
? context.colors.primary
: context.colors.onSurfaceVariant,
),
onTap: () => toggleUser(user),
),
);
},
),
loading: () => const Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: 'Loading people and agents',
),
),
error: (error, _) => Center(
child: Text(
'Couldn’t load people or agents. Try again.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
),
textAlign: TextAlign.center,
),
),
),
),
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xxs),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('add-channel-members-submit'),
onPressed: selectedUsers.value.isEmpty || isSubmitting.value
? null
: addSelectedMembers,
child: Text(
isSubmitting.value
? 'Adding…'
: selectedUsers.value.length == 1
? 'Add member'
: 'Add ${selectedUsers.value.length} members',
),
),
),
const SizedBox(height: Grid.xxs),
],
),
),
),
);
}
}