Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions mobile/lib/features/channels/channel_management_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> addMember({
required String channelId,
required String pubkey,
String role = 'member',
}) async {
final tags = <List<String>>[
['h', channelId],
['p', pubkey.toLowerCase()],
if (role != 'member') ['role', role],
];
await _signedEventRelay.submit(kind: 9000, content: '', tags: tags);
_ref.invalidate(channelMembersProvider(channelId));
}

Future<void> changeMemberRole({
required String channelId,
required String pubkey,
Expand Down
208 changes: 190 additions & 18 deletions mobile/lib/features/channels/members_sheet.dart
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';
Expand All @@ -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;
Expand All @@ -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();
Expand All @@ -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);
Comment on lines +123 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add agents with the bot role

When a search result is an agent (a kind:0 profile with a valid NIP-OA auth tag), this call always uses addMember's default member role. The relay then stores the channel member as a person, while bot/activity surfaces classify agents from role == 'bot' (desktop's members sidebar passes role: 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 pass role: 'bot' for those results.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard hook state updates after sheet disposal

If the add request is slow and the user dismisses the members sheet before it completes, the await resumes after this HookWidget has been disposed. The success path checks context.mounted, but the finally block still writes to the disposed addingPubkeys notifier (and the catch path writes addError), causing the usual set-after-dispose assertion/crash in that scenario. Return before touching hook state when !context.mounted, including in catch/finally.

Useful? React with 👍 / 👎.

}
}

// Preload profiles for all members so avatars appear.
useEffect(() {
if (allMembers.isNotEmpty) {
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
);
}
}
Loading