-
Notifications
You must be signed in to change notification settings - Fork 40
Add people to channels from mobile members sheet #1706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import 'dart:async'; | ||
|
|
||
| import 'package:flutter/material.dart'; | ||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||
|
|
@@ -13,6 +15,9 @@ import 'agent_activity/working_bots_provider.dart'; | |
| import 'channel.dart'; | ||
| import 'channel_management_provider.dart'; | ||
|
|
||
| /// Minimum characters before we hit NIP-50 user search (matches desktop). | ||
| const _memberSearchMinQueryLength = 2; | ||
|
|
||
| class MembersSheet extends HookConsumerWidget { | ||
| final Channel channel; | ||
| final String? currentPubkey; | ||
|
|
@@ -33,16 +38,64 @@ class MembersSheet extends HookConsumerWidget { | |
| final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id)); | ||
| final statusCache = ref.watch(userStatusCacheProvider); | ||
|
|
||
| // Determine if the current user can manage members. | ||
| final queryController = useTextEditingController(); | ||
| final query = useState(''); | ||
| final debouncedQuery = useState(''); | ||
| final addingPubkeys = useState<Set<String>>(const <String>{}); | ||
| final addError = useState<String?>(null); | ||
|
|
||
| useEffect(() { | ||
| final timer = Timer(const Duration(milliseconds: 250), () { | ||
| debouncedQuery.value = query.value.trim(); | ||
| }); | ||
| return timer.cancel; | ||
| }, [query.value]); | ||
|
|
||
| // Any active channel member can add people (desktop parity, PR #815). | ||
| // DMs have a fixed participant set and archived channels are frozen. | ||
| final currentMember = allMembers.cast<ChannelMember?>().firstWhere( | ||
| (m) => m!.pubkey.toLowerCase() == currentPubkey?.toLowerCase(), | ||
| orElse: () => null, | ||
| ); | ||
| final canAddMembers = | ||
| !channel.isDm && | ||
| !channel.isArchived && | ||
| (currentMember != null || channel.visibility == 'open'); | ||
| final canManage = | ||
| currentMember != null && | ||
| currentMember.isElevated && | ||
| !channel.isArchived; | ||
|
|
||
| final memberPubkeys = { | ||
| for (final member in allMembers) member.pubkey.toLowerCase(), | ||
| }; | ||
|
|
||
| // Same search pattern as the DM composer sheet — useMemoized + useFuture | ||
| // so Flutter Hooks tracks the future lifecycle correctly in tests. | ||
| final searchFuture = useMemoized(() { | ||
| if (!canAddMembers || | ||
| debouncedQuery.value.length < _memberSearchMinQueryLength) { | ||
| return Future.value(const <DirectoryUser>[]); | ||
| } | ||
| return ref | ||
| .read(channelActionsProvider) | ||
| .searchUsers(debouncedQuery.value, limit: 12); | ||
| }, [canAddMembers, debouncedQuery.value, memberPubkeys.length]); | ||
| final searchSnapshot = useFuture(searchFuture); | ||
| final isSearching = | ||
| canAddMembers && | ||
| debouncedQuery.value.length >= _memberSearchMinQueryLength && | ||
| searchSnapshot.connectionState == ConnectionState.waiting; | ||
| final availableResults = | ||
| searchSnapshot.data | ||
| ?.where( | ||
| (user) => | ||
| !memberPubkeys.contains(user.pubkey.toLowerCase()) && | ||
| user.pubkey.toLowerCase() != currentPubkey?.toLowerCase(), | ||
| ) | ||
| .toList() ?? | ||
| const <DirectoryUser>[]; | ||
|
|
||
| void openActivity(ChannelMember bot) { | ||
| final navigator = Navigator.of(context); | ||
| navigator.pop(); | ||
|
|
@@ -60,6 +113,30 @@ class MembersSheet extends HookConsumerWidget { | |
| }); | ||
| } | ||
|
|
||
| Future<void> handleAdd(DirectoryUser user) async { | ||
| final pubkey = user.pubkey.toLowerCase(); | ||
| if (addingPubkeys.value.contains(pubkey)) return; | ||
|
|
||
| addingPubkeys.value = {...addingPubkeys.value, pubkey}; | ||
| addError.value = null; | ||
| try { | ||
| await ref | ||
| .read(channelActionsProvider) | ||
| .addMember(channelId: channel.id, pubkey: user.pubkey); | ||
| if (!context.mounted) return; | ||
| queryController.clear(); | ||
| query.value = ''; | ||
| debouncedQuery.value = ''; | ||
| } catch (error) { | ||
| addError.value = error.toString(); | ||
| } finally { | ||
| addingPubkeys.value = { | ||
| for (final candidate in addingPubkeys.value) | ||
| if (candidate != pubkey) candidate, | ||
| }; | ||
|
Comment on lines
+132
to
+136
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the add request is slow and the user dismisses the members sheet before it completes, the Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| // Preload profiles for all members so avatars appear. | ||
| useEffect(() { | ||
| if (allMembers.isNotEmpty) { | ||
|
|
@@ -78,6 +155,10 @@ class MembersSheet extends HookConsumerWidget { | |
| return null; | ||
| }, [allMembers.length]); | ||
|
|
||
| final showSearchResults = | ||
| canAddMembers && | ||
| debouncedQuery.value.length >= _memberSearchMinQueryLength; | ||
|
|
||
| return Padding( | ||
| padding: EdgeInsets.fromLTRB( | ||
| Grid.gutter, | ||
|
|
@@ -91,15 +172,71 @@ class MembersSheet extends HookConsumerWidget { | |
| crossAxisAlignment: CrossAxisAlignment.start, | ||
| children: [ | ||
| Text('Members', style: context.textTheme.titleMedium), | ||
| if (!channel.isDm) ...[ | ||
| const SizedBox(height: Grid.xxs), | ||
| TextField( | ||
| controller: queryController, | ||
| enabled: canAddMembers, | ||
| decoration: InputDecoration( | ||
| prefixIcon: const Icon(LucideIcons.search, size: 18), | ||
| hintText: canAddMembers | ||
| ? 'Add people and agents' | ||
| : 'Search people and agents', | ||
| isDense: true, | ||
| ), | ||
| onChanged: (value) { | ||
| query.value = value; | ||
| addError.value = null; | ||
| }, | ||
| textInputAction: TextInputAction.search, | ||
| ), | ||
| if (addError.value case final error?) ...[ | ||
| const SizedBox(height: Grid.half), | ||
| Text( | ||
| error, | ||
| style: context.textTheme.bodySmall?.copyWith( | ||
| color: context.colors.error, | ||
| ), | ||
| ), | ||
| ], | ||
| ], | ||
| const SizedBox(height: Grid.xxs), | ||
| if (!channel.isDm) ...[const Divider(height: 1)], | ||
| const Divider(height: 1), | ||
| ConstrainedBox( | ||
| constraints: const BoxConstraints(maxHeight: 400), | ||
| child: membersAsync.when( | ||
| data: (_) => ListView( | ||
| shrinkWrap: true, | ||
| padding: const EdgeInsets.only(top: Grid.xxs), | ||
| children: [ | ||
| if (showSearchResults) ...[ | ||
| _SectionLabel( | ||
| label: availableResults.isEmpty && !isSearching | ||
| ? 'No matching people' | ||
| : 'Not in this channel', | ||
| ), | ||
| if (isSearching) | ||
| const Padding( | ||
| padding: EdgeInsets.symmetric(vertical: Grid.xxs), | ||
| child: Center( | ||
| child: SizedBox( | ||
| width: 18, | ||
| height: 18, | ||
| child: CircularProgressIndicator(strokeWidth: 2), | ||
| ), | ||
| ), | ||
| ), | ||
| for (final user in availableResults) | ||
| _AddMemberTile( | ||
| user: user, | ||
| isAdding: addingPubkeys.value.contains( | ||
| user.pubkey.toLowerCase(), | ||
| ), | ||
| onAdd: () => handleAdd(user), | ||
| ), | ||
| if (people.isNotEmpty || bots.isNotEmpty) | ||
| const SizedBox(height: Grid.xxs), | ||
| ], | ||
| if (people.isNotEmpty) ...[ | ||
| _SectionLabel(label: 'People — ${people.length}'), | ||
| for (final member in people) | ||
|
|
@@ -138,7 +275,7 @@ class MembersSheet extends HookConsumerWidget { | |
| : null, | ||
| ), | ||
| ], | ||
| if (people.isEmpty && bots.isEmpty) | ||
| if (people.isEmpty && bots.isEmpty && !showSearchResults) | ||
| Center( | ||
| child: Text( | ||
| 'No members found.', | ||
|
|
@@ -188,6 +325,44 @@ class _SectionLabel extends StatelessWidget { | |
| } | ||
| } | ||
|
|
||
| class _AddMemberTile extends StatelessWidget { | ||
| final DirectoryUser user; | ||
| final bool isAdding; | ||
| final VoidCallback onAdd; | ||
|
|
||
| const _AddMemberTile({ | ||
| required this.user, | ||
| required this.isAdding, | ||
| required this.onAdd, | ||
| }); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final label = user.label; | ||
| final initial = label.isNotEmpty ? label[0].toUpperCase() : '?'; | ||
|
|
||
| return ListTile( | ||
| contentPadding: EdgeInsets.zero, | ||
| leading: _MemberAvatar(avatarUrl: user.avatarUrl, initial: initial), | ||
| title: Text(label), | ||
| subtitle: Text( | ||
| user.secondaryLabel, | ||
| style: context.textTheme.bodySmall?.copyWith( | ||
| color: context.colors.onSurfaceVariant, | ||
| ), | ||
| ), | ||
| trailing: isAdding | ||
| ? const SizedBox( | ||
| width: 18, | ||
| height: 18, | ||
| child: CircularProgressIndicator(strokeWidth: 2), | ||
| ) | ||
| : TextButton(onPressed: onAdd, child: const Text('Add')), | ||
| onTap: isAdding ? null : onAdd, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const _changeableRoles = ['admin', 'member', 'guest']; | ||
|
|
||
| String _roleLabel(String role) { | ||
|
|
@@ -449,29 +624,26 @@ class _RoleSelector extends StatelessWidget { | |
| } | ||
| } | ||
|
|
||
| class _MemberAvatar extends HookWidget { | ||
| class _MemberAvatar extends StatelessWidget { | ||
| final String? avatarUrl; | ||
| final String initial; | ||
|
|
||
| const _MemberAvatar({required this.avatarUrl, required this.initial}); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final failed = useState(false); | ||
|
|
||
| useEffect(() { | ||
| failed.value = false; | ||
| return null; | ||
| }, [avatarUrl]); | ||
|
|
||
| final url = avatarUrl; | ||
| if (url == null || failed.value) { | ||
| return CircleAvatar(child: Text(initial)); | ||
| } | ||
| return CircleAvatar( | ||
| backgroundImage: NetworkImage(url), | ||
| onBackgroundImageError: (_, _) => failed.value = true, | ||
| child: null, | ||
| radius: 18, | ||
| backgroundColor: context.colors.primaryContainer, | ||
| backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl!) : null, | ||
| child: avatarUrl == null | ||
| ? Text( | ||
| initial, | ||
| style: context.textTheme.labelMedium?.copyWith( | ||
| color: context.colors.onPrimaryContainer, | ||
| ), | ||
| ) | ||
| : null, | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a search result is an agent (a kind:0 profile with a valid NIP-OA auth tag), this call always uses
addMember's defaultmemberrole. The relay then stores the channel member as a person, while bot/activity surfaces classify agents fromrole == 'bot'(desktop's members sidebar passesrole: user.isAgent ? "bot" : "member"). Agents added from mobile will appear under People and miss bot/activity affordances; carry the agent marker from the search event and passrole: 'bot'for those results.Useful? React with 👍 / 👎.