diff --git a/mobile/ios/Runner/NativeConfirmationDialog.swift b/mobile/ios/Runner/NativeConfirmationDialog.swift index 6a9ecbfb9ff..7d887fa8dbc 100644 --- a/mobile/ios/Runner/NativeConfirmationDialog.swift +++ b/mobile/ios/Runner/NativeConfirmationDialog.swift @@ -1,13 +1,16 @@ import Flutter import UIKit -/// Owns a UIKit confirmation alert until dismissal and replies exactly once. +/// Owns a UIKit confirmation or choice alert until dismissal and replies +/// exactly once. final class NativeConfirmationDialogCoordinator: NSObject { private let channel: FlutterMethodChannel private weak var parent: UIViewController? private weak var alert: UIAlertController? private var pendingResult: FlutterResult? private var isFinishing = false + /// What dismissing answers: false for a confirmation, nothing for a choice. + private var cancelValue: Any? = false init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) { channel = FlutterMethodChannel(name: "buzz/confirmation_dialog", binaryMessenger: messenger) @@ -20,17 +23,36 @@ final class NativeConfirmationDialogCoordinator: NSObject { } private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - guard call.method == "present" else { result(FlutterMethodNotImplemented); return } + guard call.method == "present" || call.method == "choose" else { + result(FlutterMethodNotImplemented) + return + } + let choosing = call.method == "choose" guard let data = call.arguments as? [String: Any], let title = data["title"] as? String, let message = data["message"] as? String, - let confirmLabel = data["confirmLabel"] as? String, let cancelLabel = data["cancelLabel"] as? String else { result(FlutterError(code: "invalid_arguments", message: "Expected confirmation labels.", details: nil)) return } - // Repeated taps must not create stacked alerts or multiple removals. - guard pendingResult == nil else { result(false); return } + // A confirmation has one action; a choice has its options, + // the last one preferred, and answers with the chosen index. + let labels: [String] + if choosing { + guard let options = data["options"] as? [String], !options.isEmpty else { + result(FlutterError(code: "invalid_arguments", message: "Expected choice options.", details: nil)) + return + } + labels = options + } else { + guard let confirmLabel = data["confirmLabel"] as? String else { + result(FlutterError(code: "invalid_arguments", message: "Expected confirmation labels.", details: nil)) + return + } + labels = [confirmLabel] + } + // Repeated taps must not create stacked alerts or multiple actions. + guard pendingResult == nil else { result(choosing ? nil : false); return } var presenter = parent ?? UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive } @@ -43,36 +65,42 @@ final class NativeConfirmationDialogCoordinator: NSObject { } let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light + cancelValue = choosing ? nil : false alert.addAction(UIAlertAction(title: cancelLabel, style: .cancel) { [weak self] _ in self?.cancel() }) let destructive = data["destructive"] as? Bool ?? true - alert.addAction(UIAlertAction(title: confirmLabel, style: destructive ? .destructive : .default) { [weak self] _ in - self?.finish(true) - }) + for (index, label) in labels.enumerated() { + let style: UIAlertAction.Style = choosing || !destructive ? .default : .destructive + let action = UIAlertAction(title: label, style: style) { [weak self] _ in + self?.finish(choosing ? index : true) + } + alert.addAction(action) + if choosing && index == labels.count - 1 { alert.preferredAction = action } + } pendingResult = result self.alert = alert presenter.present(alert, animated: !UIAccessibility.isReduceMotionEnabled) } - /// Dismisses an outstanding confirmation without authorizing the action. - func cancel() { finish(false) } + /// Dismisses an outstanding alert without authorizing any action. + func cancel() { finish(cancelValue) } - private func finish(_ confirmed: Bool) { + private func finish(_ value: Any?) { guard pendingResult != nil, !isFinishing else { return } isFinishing = true - guard let alert else { resolve(confirmed); return } + guard let alert else { resolve(value); return } // Finish native dismissal before Flutter removes the underlying route. alert.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in - self?.resolve(confirmed) + self?.resolve(value) } } - private func resolve(_ confirmed: Bool) { + private func resolve(_ value: Any?) { let result = pendingResult pendingResult = nil isFinishing = false alert = nil - result?(confirmed) + result?(value) } } diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index b5362d9ec53..e79b7cdec77 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -11,9 +11,11 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list_card_item.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/confirmation_dialog.dart'; import '../../shared/identity_names/identity_names_provider.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/relay/relay.dart'; +import 'channel.dart'; import 'channel_management_provider.dart'; import 'ios_add_members_sheet.dart'; import 'mentions/mention_candidates.dart'; @@ -25,10 +27,15 @@ import '../../shared/widgets/modal_presentation.dart'; bool _nativePickerOpen = false; /// Opens the platform member picker, retaining the Android sheet presentation. +/// +/// For a DM, [isDm] asks whether added people see its history, and +/// [onMovedTo] receives the DM they ended up in when it is another one. Future showAddChannelMembersSheet({ required BuildContext context, required String channelId, required Set existingPubkeys, + bool isDm = false, + void Function(Channel dm)? onMovedTo, }) async { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { if (_nativePickerOpen) return null; @@ -41,6 +48,8 @@ Future showAddChannelMembersSheet({ pageBuilder: (_, _, _) => AddChannelMembersSheet( channelId: channelId, existingPubkeys: existingPubkeys, + isDm: isDm, + onMovedTo: onMovedTo, nativePresentation: true, ), ); @@ -61,6 +70,8 @@ Future showAddChannelMembersSheet({ builder: (_) => AddChannelMembersSheet( channelId: channelId, existingPubkeys: existingPubkeys, + isDm: isDm, + onMovedTo: onMovedTo, ), ); } @@ -71,11 +82,15 @@ class AddChannelMembersSheet extends HookConsumerWidget { super.key, required this.channelId, required this.existingPubkeys, + this.isDm = false, + this.onMovedTo, this.nativePresentation = false, }); final String channelId; final Set existingPubkeys; + final bool isDm; + final void Function(Channel dm)? onMovedTo; final bool nativePresentation; @override @@ -177,12 +192,45 @@ class AddChannelMembersSheet extends HookConsumerWidget { submitError.value = null; } + // Agents join a DM with its history, as on desktop; adding a person asks. + // The relay joins anyone to this DM only with the history tag. A DM of at + // most one person and their agents never gains a person: adding one + // starts a new DM without asking. + Future askIncludeHistory(List users) async { + if (users.every((user) => user.isAgent)) return true; + final people = existingPubkeys + .where((pubkey) => userCache[pubkey]?.isAgent != true) + .length; + if (people < 2) return false; + return showDmHistoryChoice( + context: context, + title: users.length == 1 + ? 'Add ${labelFor(users.single)}?' + : 'Add ${users.length} members?', + ); + } + Future addSelectedMembers() async { if (selectedUsers.value.isEmpty || isSubmitting.value) return; final submittedUsers = List.of(selectedUsers.value); + final includeHistory = isDm + ? await askIncludeHistory(submittedUsers) + : null; + if (isDm && (includeHistory == null || !context.mounted)) return; isSubmitting.value = true; submitError.value = null; try { + if (includeHistory != null) { + final dm = await actions.addToDm( + channelId: channelId, + pubkeys: submittedUsers.map((user) => user.pubkey).toList(), + includeHistory: includeHistory, + ); + if (!context.mounted) return; + Navigator.of(context).pop(true); + if (dm.id != channelId) onMovedTo?.call(dm); + return; + } await actions.addMembers( channelId: channelId, pubkeys: submittedUsers.map((user) => user.pubkey).toList(), @@ -206,7 +254,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { submitError.value = error.message; } catch (error) { if (!context.mounted) return; - submitError.value = error.toString(); + submitError.value = error.toString().replaceFirst('Exception: ', ''); } finally { if (context.mounted) isSubmitting.value = false; } diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index de5d0c482b7..ca1e1fbad0e 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -24,6 +24,7 @@ import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/tabler_star_icon.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'channel.dart'; +import 'channel_detail_page.dart'; import 'add_members_sheet.dart'; import 'channel_identity_names_provider.dart'; import 'channel_management_provider.dart'; @@ -36,6 +37,8 @@ import 'members_sheet.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; +import '../../shared/community/community_provider.dart'; +import '../../shared/relay/relay_author_provider.dart'; import '../../shared/widgets/covered_route_keyboard_insets.dart'; part 'channel_details_page.dart'; diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index d09cd2cfc46..35f18ff3f93 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -99,6 +99,25 @@ class ChannelDetailsPage extends HookConsumerWidget { !membersAsync.hasError && !resolvedChannel.isArchived && resolvedChannel.canAddMembers(currentMember?.role); + // The relay's moderation DM only carries its notices; no one joins it. + final relayUrl = ref.watch(activeCommunityProvider).asData?.value?.relayUrl; + final relayAuthor = relayUrl == null + ? null + : ref.watch(relayAuthorProvider(relayUrl)).asData?.value; + final isModerationDm = + resolvedChannel.isDm && + relayAuthor != null && + (resolvedChannel.participantPubkeys.any( + (pubkey) => pubkey.toLowerCase() == relayAuthor, + ) || + members.any( + (member) => member.pubkey.toLowerCase() == relayAuthor, + )); + // People join a DM only through the DM add, which any member can use. + final canAddToDm = + resolvedChannel.isDm && + currentMember != null && + !resolvedChannel.isArchived; final canArchive = !resolvedChannel.isArchived && canManageLifecycle; final canUnarchive = resolvedChannel.isArchived && canManageLifecycle; final canDelete = @@ -219,6 +238,21 @@ class ChannelDetailsPage extends HookConsumerWidget { existingPubkeys: { for (final member in members) member.pubkey.toLowerCase(), }, + isDm: resolvedChannel.isDm, + // They ended up in another DM: leave these details for that DM. + onMovedTo: (dm) { + if (!context.mounted) return; + Navigator.of(context) + ..pop() + ..push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage( + channel: dm, + placement: ChannelRoutePlacement.ordinary, + ), + ), + ); + }, ); } @@ -382,16 +416,26 @@ class ChannelDetailsPage extends HookConsumerWidget { onTap: toggleMute, ), ), - if (!resolvedChannel.isDm) ...[ + if (!isModerationDm) ...[ const SizedBox(width: Grid.xxs), Expanded( - child: BuzzActionTile( - key: const ValueKey('channel-details-edit-action'), - icon: BuzzIcons.pencil, - label: 'Edit', - isEnabled: canEdit, - onTap: openManageChannel, - ), + child: resolvedChannel.isDm + ? BuzzActionTile( + key: const ValueKey('channel-details-add-action'), + icon: BuzzIcons.userPlus, + label: 'Add', + isEnabled: canAddToDm, + onTap: openAddMembers, + ) + : BuzzActionTile( + key: const ValueKey( + 'channel-details-edit-action', + ), + icon: BuzzIcons.pencil, + label: 'Edit', + isEnabled: canEdit, + onTap: openManageChannel, + ), ), ], ], diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index ae115d29da6..4aec50053d8 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -210,6 +210,49 @@ class ChannelActions { } } + /// Adds [pubkeys] to the DM [channelId] (kind:41011) and returns the DM + /// they ended up in. With [includeHistory] they join this DM and can read + /// it, unless the grown group already has a DM, which is returned instead. + /// Without it, or when a person joins a DM of at most one person, they + /// start a new DM of everyone. + Future addToDm({ + required String channelId, + required List pubkeys, + required bool includeHistory, + }) async { + _ensureCommunityValid(); + final NostrEvent result; + try { + result = await _signedEventRelay.submit( + kind: 41011, + content: '', + tags: [ + ['h', channelId], + for (final pubkey in pubkeys) ['p', pubkey], + if (includeHistory) ['history', 'include'], + ], + ); + } on StateError { + rethrow; + } catch (_) { + // Retrying cannot help the most common refusal, a DM that is full. + throw Exception(dmAdditionFailedMessage); + } + _ensureCommunityValid(); + final id = parseCommandResponse(result.content)?['channel_id'] as String?; + if (id == null || id.isEmpty) { + throw Exception('Relay did not return a DM channel id'); + } + await _refreshChannelState(channelId); + if (id == channelId) { + return (await _ref.read(channelsProvider.future)).firstWhere( + (channel) => channel.id == channelId, + orElse: () => throw Exception('DM is not visible yet'), + ); + } + return _refreshChannelsAndRead(id); + } + void _ensureCommunityValid() { if (_isCommunityValid?.call() == false) { throw StateError( diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index aceaec21f70..e48de99a554 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -27,6 +27,10 @@ part 'channel_management_actions.dart'; String _relayErrorMessage(Object error) => error.toString().replaceFirst('Exception: ', ''); +/// Why adding to a DM failed. The most common refusal is a full DM. +const dmAdditionFailedMessage = + 'Couldn’t add them. A DM holds up to 9 people, not counting agents.'; + /// Raised when one or more kind:9000 adds were rejected, keyed by pubkey. /// /// Callers surface [message] to the user — a relay rejection here (e.g. a plain diff --git a/mobile/lib/shared/widgets/confirmation_dialog.dart b/mobile/lib/shared/widgets/confirmation_dialog.dart index d613704a2ac..f6d8336fe61 100644 --- a/mobile/lib/shared/widgets/confirmation_dialog.dart +++ b/mobile/lib/shared/widgets/confirmation_dialog.dart @@ -66,3 +66,48 @@ Future showConfirmation({ ) == true; } + +/// Asks whether the people being added to a DM see its history, using a UIKit +/// alert on iOS. Returns true for Include history, false for Start new DM, and +/// null when cancelled. +Future showDmHistoryChoice({ + required BuildContext context, + required String title, +}) async { + const message = + 'Include history to add them to this DM with its earlier messages, or ' + 'start a new DM with everyone and no history.'; + const options = ['Start new DM', 'Include history']; + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { + final index = await const MethodChannel('buzz/confirmation_dialog') + .invokeMethod('choose', { + 'title': title, + 'message': message, + 'options': options, + 'cancelLabel': 'Cancel', + 'dark': context.theme.brightness == Brightness.dark, + }); + return index == null ? null : index == 1; + } + return showBuzzDialog( + context: context, + builder: (dialogContext) => AlertDialog.adaptive( + title: Text(title), + content: const Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(options[0]), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(options[1]), + ), + ], + ), + ); +} diff --git a/mobile/test/features/channels/add_members_sheet_dm_test.dart b/mobile/test/features/channels/add_members_sheet_dm_test.dart new file mode 100644 index 00000000000..3b70ae5ed7e --- /dev/null +++ b/mobile/test/features/channels/add_members_sheet_dm_test.dart @@ -0,0 +1,167 @@ +import 'package:buzz/features/channels/add_members_sheet.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../helpers/recipient_directory_session.dart'; + +const _dmId = 'dm-1'; +final _agent = 'f' * 64; +final _person = 'e' * 64; + +/// The viewer and one other person. +const _twoPeople = {'self', 'other'}; + +Channel _dm(String id) => Channel( + id: id, + name: id, + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: 'self', + createdAt: DateTime(2026), + memberCount: 3, +); + +class _DmAdds extends Fake implements ChannelActions { + _DmAdds(this.landsIn); + + final String landsIn; + final calls = <({List pubkeys, bool includeHistory})>[]; + + @override + Future addToDm({ + required String channelId, + required List pubkeys, + required bool includeHistory, + }) async { + expect(channelId, _dmId); + calls.add((pubkeys: pubkeys, includeHistory: includeHistory)); + return _dm(landsIn); + } +} + +/// Opens the DM add sheet, picks [name], and taps Add. +Future> _add( + WidgetTester tester, + _DmAdds actions, + String name, { + Set existing = _twoPeople, +}) async { + final movedTo = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + channelActionsProvider.overrideWithValue(actions), + channelMembersProvider(_dmId).overrideWith((ref) async => const []), + relaySessionProvider.overrideWith( + () => RecipientDirectorySession( + {_agent: 'Scout', _person: 'Robin'}, + agents: {_agent}, + ), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AddChannelMembersSheet( + channelId: _dmId, + existingPubkeys: existing, + isDm: true, + onMovedTo: movedTo.add, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('add-channel-members-search')), + name, + ); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining(name).last); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('add-channel-members-submit'))); + await tester.pumpAndSettle(); + return movedTo; +} + +void main() { + testWidgets('an agent joins the DM with its history without asking', ( + tester, + ) async { + final actions = _DmAdds(_dmId); + final movedTo = await _add(tester, actions, 'Scout'); + + expect(find.text('Include history'), findsNothing); + expect(actions.calls.single.pubkeys, [_agent]); + expect(actions.calls.single.includeHistory, isTrue); + expect(movedTo, isEmpty); + }); + + testWidgets('adding a person asks, and Include history keeps this DM', ( + tester, + ) async { + final actions = _DmAdds(_dmId); + final movedTo = await _add(tester, actions, 'Robin'); + + expect(find.text('Add Robin?'), findsOneWidget); + expect(find.text('Adding…'), findsNothing); + expect(actions.calls, isEmpty); + await tester.tap(find.text('Include history')); + await tester.pumpAndSettle(); + + expect(actions.calls.single.pubkeys, [_person]); + expect(actions.calls.single.includeHistory, isTrue); + expect(movedTo, isEmpty); + }); + + testWidgets('Start new DM adds without history and opens the new DM', ( + tester, + ) async { + final actions = _DmAdds('dm-2'); + final movedTo = await _add(tester, actions, 'Robin'); + + await tester.tap(find.text('Start new DM')); + await tester.pumpAndSettle(); + + expect(actions.calls.single.includeHistory, isFalse); + expect(movedTo.single.id, 'dm-2'); + }); + + testWidgets('a person added to a DM of one person starts a new DM', ( + tester, + ) async { + final actions = _DmAdds('dm-2'); + final movedTo = await _add(tester, actions, 'Robin', existing: {'self'}); + + expect(find.text('Include history'), findsNothing); + expect(actions.calls.single.pubkeys, [_person]); + expect(actions.calls.single.includeHistory, isFalse); + expect(movedTo.single.id, 'dm-2'); + }); + + testWidgets('cancelling the choice adds no one', (tester) async { + final actions = _DmAdds(_dmId); + await _add(tester, actions, 'Robin'); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(actions.calls, isEmpty); + expect( + tester + .widget( + find.byKey(const ValueKey('add-channel-members-submit')), + ) + .onPressed, + isNotNull, + ); + }); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 9949730bc7b..e9206948f67 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,3 +1,6 @@ +import 'package:buzz/shared/relay/relay_author_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/features/channels/add_members_sheet.dart'; import 'package:buzz/features/channels/default_agent_launcher.dart'; import 'package:buzz/features/channels/bestie_welcome.dart'; import 'package:buzz/features/channels/bestie_welcome_storage.dart'; @@ -39,6 +42,7 @@ import 'package:flutter/rendering.dart' import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart' show Override; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:buzz/shared/theme/buzz_icons.dart'; @@ -286,6 +290,7 @@ Widget _buildTestable({ _FakeTypingNotifier? typingNotifier, _FakeTypingNotifier? huddleTypingNotifier, String? canvasContent, + List extraOverrides = const [], String? initialMessageId, String? initialThreadRootId, InitialThreadRouteBehavior initialThreadRouteBehavior = @@ -495,6 +500,7 @@ Widget _buildTestable({ appLifecycleProvider.overrideWith(_TestAppLifecycleNotifier.new), // Compose bar drafts persist through SharedPreferences. savedPrefsProvider.overrideWithValue(_testPrefs), + ...extraOverrides, ], child: MaterialApp( navigatorKey: navigatorKey, @@ -1288,6 +1294,116 @@ void main() { expect(find.byTooltip('Start Huddle'), findsOneWidget); }); + testWidgets('a DM member can add people from its details', (tester) async { + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + relaySessionNotifier: PresenceTestRelay()..emptySnapshots = true, + channel: dmChannel, + members: [ + for (final key in ['self', 'alice']) + ChannelMember( + pubkey: key, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('dm-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('channel-details-edit-action')), + findsNothing, + ); + await tester.tap( + find.byKey(const ValueKey('channel-details-add-action')), + ); + await tester.pumpAndSettle(); + expect(find.byType(AddChannelMembersSheet), findsOneWidget); + }); + + testWidgets('the moderation DM offers no Add', (tester) async { + final relayAuthor = 'b' * 64; + final moderationDm = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: relayAuthor, + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Moderation'], + participantPubkeys: ['self', relayAuthor], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + relaySessionNotifier: PresenceTestRelay()..emptySnapshots = true, + channel: moderationDm, + members: [ + for (final key in ['self', relayAuthor]) + ChannelMember( + pubkey: key, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + extraOverrides: [ + activeCommunityProvider.overrideWith( + (ref) async => Community( + id: 'community', + name: 'Community', + relayUrl: 'wss://relay.example', + addedAt: DateTime(2025), + ), + ), + relayAuthorProvider( + 'wss://relay.example', + ).overrideWith((ref) async => relayAuthor), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('dm-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('channel-details-star-action')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('channel-details-add-action')), + findsNothing, + ); + }); + testWidgets('labels unnamed DMs with the participant key', (tester) async { const a11ce = 'a11ce00000000000000000000000000000000000000000000000000000000000';