From 83d6cddd80c9aaccc00b4851164356044e0e3f8e Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:39:05 -0800 Subject: [PATCH 1/3] feat(mobile): add people and agents to a DM A DM's details page gets an Add action. Adding only agents joins them in place with the DM's history. Adding a person asks first: Include history keeps this DM and its messages; Start new DM opens a DM with everyone and no history, and the app moves to it. Both send kind 41011 with the history tag only for the in-place choice, per the relay's opt-in rule. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Carl Co-Authored-By: Claude Opus 5.5 --- .../ios/Runner/NativeConfirmationDialog.swift | 58 +++++-- .../features/channels/add_members_sheet.dart | 44 ++++- .../channels/channel_actions_sheet.dart | 1 + .../channels/channel_details_page.dart | 50 ++++-- .../channels/channel_management_actions.dart | 42 +++++ .../channels/channel_management_provider.dart | 4 + .../shared/widgets/confirmation_dialog.dart | 45 ++++++ .../channels/add_members_sheet_dm_test.dart | 151 ++++++++++++++++++ .../channels/channel_detail_page_test.dart | 51 ++++++ 9 files changed, 418 insertions(+), 28 deletions(-) create mode 100644 mobile/test/features/channels/add_members_sheet_dm_test.dart 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..05bdfdfbe19 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,39 @@ 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. + Future askIncludeHistory(List users) async { + if (users.every((user) => user.isAgent)) return true; + 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 +248,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..534133b0411 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'; diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index d09cd2cfc46..48e20ad1304 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -99,6 +99,11 @@ class ChannelDetailsPage extends HookConsumerWidget { !membersAsync.hasError && !resolvedChannel.isArchived && resolvedChannel.canAddMembers(currentMember?.role); + // 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 +224,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,18 +402,24 @@ class ChannelDetailsPage extends HookConsumerWidget { onTap: toggleMute, ), ), - if (!resolvedChannel.isDm) ...[ - const SizedBox(width: Grid.xxs), - Expanded( - child: BuzzActionTile( - key: const ValueKey('channel-details-edit-action'), - icon: BuzzIcons.pencil, - label: 'Edit', - isEnabled: canEdit, - onTap: openManageChannel, - ), - ), - ], + const SizedBox(width: Grid.xxs), + Expanded( + 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..cb75ab85aa3 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -210,6 +210,48 @@ 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, 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..18ee361ae2f --- /dev/null +++ b/mobile/test/features/channels/add_members_sheet_dm_test.dart @@ -0,0 +1,151 @@ +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; + +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, +) 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: const {}, + 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('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..ace97201a5f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,3 +1,4 @@ +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'; @@ -1288,6 +1289,56 @@ 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('labels unnamed DMs with the participant key', (tester) async { const a11ce = 'a11ce00000000000000000000000000000000000000000000000000000000000'; From 0a3c1c8e58b39b817a3565a8d7bff9b9a42eb0c1 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:07:54 -0800 Subject: [PATCH 2/3] fix(mobile): start a new DM when a person joins an agent-only DM A DM of at most one person and their agents never gains a person in place, matching the relay and main. Adding a person there skips the Include history / Start new DM question and opens the DM of everyone. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Carl Co-Authored-By: Claude Opus 5.5 --- .../features/channels/add_members_sheet.dart | 8 ++++++- .../channels/channel_management_actions.dart | 3 ++- .../channels/add_members_sheet_dm_test.dart | 22 ++++++++++++++++--- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index 05bdfdfbe19..e79b7cdec77 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -193,9 +193,15 @@ class AddChannelMembersSheet extends HookConsumerWidget { } // 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. + // 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 diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index cb75ab85aa3..4aec50053d8 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -213,7 +213,8 @@ 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, they start a new DM of everyone. + /// 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, diff --git a/mobile/test/features/channels/add_members_sheet_dm_test.dart b/mobile/test/features/channels/add_members_sheet_dm_test.dart index 18ee361ae2f..3b70ae5ed7e 100644 --- a/mobile/test/features/channels/add_members_sheet_dm_test.dart +++ b/mobile/test/features/channels/add_members_sheet_dm_test.dart @@ -13,6 +13,9 @@ 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, @@ -46,8 +49,9 @@ class _DmAdds extends Fake implements ChannelActions { Future> _add( WidgetTester tester, _DmAdds actions, - String name, -) async { + String name, { + Set existing = _twoPeople, +}) async { final movedTo = []; await tester.pumpWidget( ProviderScope( @@ -66,7 +70,7 @@ Future> _add( home: Scaffold( body: AddChannelMembersSheet( channelId: _dmId, - existingPubkeys: const {}, + existingPubkeys: existing, isDm: true, onMovedTo: movedTo.add, ), @@ -131,6 +135,18 @@ void main() { 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'); From 12d98439f865497c3db03de0a05adb34980f8207 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:00:23 -0800 Subject: [PATCH 3/3] fix(mobile): hide Add on the relay's moderation DM The moderation DM only carries the relay's notices, and the relay refuses every add to it, so its details page shows no Add action. The relay is recognized by its NIP-11 signer key. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Carl Co-Authored-By: Claude Opus 5.5 --- .../channels/channel_actions_sheet.dart | 2 + .../channels/channel_details_page.dart | 54 ++++++++++----- .../channels/channel_detail_page_test.dart | 65 +++++++++++++++++++ 3 files changed, 103 insertions(+), 18 deletions(-) diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 534133b0411..ca1e1fbad0e 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -37,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 48e20ad1304..35f18ff3f93 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -99,6 +99,20 @@ 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 && @@ -402,24 +416,28 @@ class ChannelDetailsPage extends HookConsumerWidget { onTap: toggleMute, ), ), - const SizedBox(width: Grid.xxs), - Expanded( - 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, - ), - ), + if (!isModerationDm) ...[ + const SizedBox(width: Grid.xxs), + Expanded( + 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/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index ace97201a5f..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,5 @@ +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'; @@ -40,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'; @@ -287,6 +290,7 @@ Widget _buildTestable({ _FakeTypingNotifier? typingNotifier, _FakeTypingNotifier? huddleTypingNotifier, String? canvasContent, + List extraOverrides = const [], String? initialMessageId, String? initialThreadRootId, InitialThreadRouteBehavior initialThreadRouteBehavior = @@ -496,6 +500,7 @@ Widget _buildTestable({ appLifecycleProvider.overrideWith(_TestAppLifecycleNotifier.new), // Compose bar drafts persist through SharedPreferences. savedPrefsProvider.overrideWithValue(_testPrefs), + ...extraOverrides, ], child: MaterialApp( navigatorKey: navigatorKey, @@ -1339,6 +1344,66 @@ void main() { 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';