mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
🤖 ## 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>
295 lines
12 KiB
Dart
295 lines
12 KiB
Dart
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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|