From 775b6034599d542c74eee0aadf83ec725927f161 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Fri, 10 Jul 2026 13:53:36 +0200 Subject: [PATCH] fix(mobile): add people to channels from members sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After creating a channel, mobile had no way to invite members — MembersSheet only listed existing people/bots. Wire NIP-29 put-user (kind 9000) via ChannelActions.addMember and a debounced directory search field matching desktop PR #815 policy (any active member can add non-elevated roles; DMs and archived channels stay frozen). Signed-off-by: Vincenzo Palazzo Co-Authored-By: Claude Opus 4.6 (1M context) --- .../channels/channel_management_provider.dart | 19 ++ .../lib/features/channels/members_sheet.dart | 208 ++++++++++++++++-- .../features/channels/members_sheet_test.dart | 206 +++++++++++++++++ 3 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 mobile/test/features/channels/members_sheet_test.dart diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index bb2e14636d..4595ccf05f 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -375,6 +375,25 @@ class ChannelActions { '${hex.substring(20, 32)}'; } + /// Add a person or agent to a channel via NIP-29 put-user (kind 9000). + /// + /// Matches desktop `add_channel_members`: any channel member may grant the + /// non-elevated `member` role (and optionally `guest` / `bot`). Elevated + /// roles still go through [changeMemberRole]. + Future addMember({ + required String channelId, + required String pubkey, + String role = 'member', + }) async { + final tags = >[ + ['h', channelId], + ['p', pubkey.toLowerCase()], + if (role != 'member') ['role', role], + ]; + await _signedEventRelay.submit(kind: 9000, content: '', tags: tags); + _ref.invalidate(channelMembersProvider(channelId)); + } + Future changeMemberRole({ required String channelId, required String pubkey, diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index abb488ced4..d86e71f14f 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -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>(const {}); + final addError = useState(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().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 []); + } + 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 []; + void openActivity(ChannelMember bot) { final navigator = Navigator.of(context); navigator.pop(); @@ -60,6 +113,30 @@ class MembersSheet extends HookConsumerWidget { }); } + Future 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, + }; + } + } + // 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,8 +172,36 @@ 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( @@ -100,6 +209,34 @@ class MembersSheet extends HookConsumerWidget { 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,7 +624,7 @@ class _RoleSelector extends StatelessWidget { } } -class _MemberAvatar extends HookWidget { +class _MemberAvatar extends StatelessWidget { final String? avatarUrl; final String initial; @@ -457,21 +632,18 @@ class _MemberAvatar extends HookWidget { @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, ); } } diff --git a/mobile/test/features/channels/members_sheet_test.dart b/mobile/test/features/channels/members_sheet_test.dart new file mode 100644 index 0000000000..7590726b91 --- /dev/null +++ b/mobile/test/features/channels/members_sheet_test.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/members_sheet.dart'; +import 'package:buzz/features/profile/user_cache_provider.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/features/profile/user_status_cache_provider.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; + +const _channelId = 'test-channel'; +const _selfPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const _otherPubkey = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + +final _channel = Channel( + id: _channelId, + name: 'general', + channelType: 'stream', + visibility: 'private', + description: 'General discussion', + createdBy: _selfPubkey, + createdAt: DateTime(2025), + memberCount: 1, + isMember: true, +); + +class _FakeChannelActions extends ChannelActions { + _FakeChannelActions(Ref ref) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: _selfPubkey, + ); + + final List<({String channelId, String pubkey, String role})> added = []; + List searchResults = const []; + int searchCallCount = 0; + String? lastSearchQuery; + + @override + Future> searchUsers(String query, {int limit = 8}) async { + searchCallCount += 1; + lastSearchQuery = query; + // Yield so useFuture observes a non-synchronous completion. + await Future.delayed(Duration.zero); + return searchResults + .where( + (user) => + (user.displayName ?? '').toLowerCase().contains( + query.toLowerCase(), + ) || + user.pubkey.toLowerCase().contains(query.toLowerCase()), + ) + .take(limit) + .toList(); + } + + @override + Future addMember({ + required String channelId, + required String pubkey, + String role = 'member', + }) async { + added.add((channelId: channelId, pubkey: pubkey, role: role)); + } +} + +class _FakeUserCacheNotifier extends UserCacheNotifier { + final Map _users; + _FakeUserCacheNotifier(this._users); + + @override + Map build() => _users; + + @override + void preload(List pubkeys) {} + + @override + UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; +} + +class _FakeUserStatusCacheNotifier extends UserStatusCacheNotifier { + @override + Map build() => {}; + + @override + void track(List pubkeys) {} +} + +void main() { + late _FakeChannelActions actions; + + Widget buildSheet({required List members, Channel? channel}) { + return ProviderScope( + overrides: [ + channelMembersProvider(_channelId).overrideWith((ref) async => members), + channelActionsProvider.overrideWith((ref) { + actions = _FakeChannelActions(ref) + ..searchResults = [ + const DirectoryUser( + pubkey: _otherPubkey, + displayName: 'Alice', + nip05Handle: 'alice@example.com', + ), + ]; + return actions; + }), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + userStatusCacheProvider.overrideWith(_FakeUserStatusCacheNotifier.new), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: MembersSheet( + channel: channel ?? _channel, + currentPubkey: _selfPubkey, + ), + ), + ), + ); + } + + testWidgets('member can search and add people from the members sheet', ( + tester, + ) async { + await tester.pumpWidget( + buildSheet( + members: [ + ChannelMember( + pubkey: _selfPubkey, + role: 'owner', + joinedAt: DateTime(2025), + displayName: 'You', + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Add people and agents'), findsOneWidget); + // Section labels are uppercased by _SectionLabel. + expect(find.text('PEOPLE — 1'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'ali'); + await tester.pump(); // process onChanged + // Debounce is 250ms. + await tester.pump(const Duration(milliseconds: 300)); + // Allow the search future microtask/delay to complete. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(); + + expect(actions.searchCallCount, greaterThan(0)); + expect(find.text('Alice'), findsOneWidget); + expect(find.text('NOT IN THIS CHANNEL'), findsOneWidget); + + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + expect(actions.added, hasLength(1)); + expect(actions.added.single.channelId, _channelId); + expect(actions.added.single.pubkey, _otherPubkey); + expect(actions.added.single.role, 'member'); + }); + + testWidgets('dm channels hide the add-people search field', (tester) async { + await tester.pumpWidget( + buildSheet( + members: [ + ChannelMember( + pubkey: _selfPubkey, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + channel: Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: _selfPubkey, + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Add people and agents'), findsNothing); + expect(find.byType(TextField), findsNothing); + }); +}