Skip to content
245 changes: 245 additions & 0 deletions mobile/lib/features/channels/add_members_sheet.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
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 '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 availableUsers =
directoryAsync.asData?.value
.where(
(user) => !normalizedExisting.contains(user.pubkey.toLowerCase()),
)
Comment thread
klopez4212 marked this conversation as resolved.
.toList() ??
const <DirectoryUser>[];

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;
isSubmitting.value = true;
submitError.value = null;
try {
await ref
.read(channelActionsProvider)
.addMembers(
channelId: channelId,
pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(),
);
if (context.mounted) Navigator.of(context).pop(true);
} on AddMembersException catch (error) {
submitError.value = error.message;
Comment thread
klopez4212 marked this conversation as resolved.
} 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(user.label),
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 ListTile(
key: ValueKey(
'add-channel-member-${user.pubkey}',
),
contentPadding: EdgeInsets.zero,
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor:
context.colors.primaryContainer,
fallback: Text(user.initial),
),
title: Text(
user.label,
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),
],
),
),
),
);
}
}
6 changes: 4 additions & 2 deletions mobile/lib/features/channels/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,18 @@ class Channel {
);

Channel copyWith({
String? name,
String? description,
Object? lastMessageAt = _sentinel,
Object? archivedAt = _sentinel,
int? memberCount,
bool? isMember,
}) => Channel(
id: id,
name: name,
name: name ?? this.name,
channelType: channelType,
visibility: visibility,
description: description,
description: description ?? this.description,
topic: topic,
purpose: purpose,
createdBy: createdBy,
Expand Down
15 changes: 14 additions & 1 deletion mobile/lib/features/channels/channel_actions_sheet.dart
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';

import '../../shared/clipboard_utils.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_action_tile.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/sheet_divider.dart';
import 'channel.dart';
import 'add_members_sheet.dart';
import 'channel_management_provider.dart';
import 'channel_mutes/channel_mutes_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_stars/channel_stars_provider.dart';
import 'channels_provider.dart';
import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile_sheet.dart';
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';

part 'channel_details_page.dart';

/// Opens the mobile channel actions sheet and returns whether its parent page
/// should close after a successful lifecycle action.
Future<bool?> showChannelActionsSheet({
Expand Down Expand Up @@ -161,7 +174,7 @@ class ChannelActionsSheet extends ConsumerWidget {
),
const SizedBox(height: Grid.xs),
],
if (!channel.isDm)
if (!channel.isDm && canManageLifecycle)
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.folderInput),
Expand Down
Loading
Loading