From 3e44ade3b5a20913fb8db87681b471f70b04b707 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 5 Sep 2026 16:35:39 -0400 Subject: [PATCH] fix(mobile): require explicit non-member agent invitations Signed-off-by: Logan Johnson --- .../compose_bar/compose_bar_widget.dart | 17 ++-- .../channels/compose_bar/helpers.dart | 42 +++++---- .../features/channels/compose_bar_test.dart | 38 +++++++- .../compose_bar_test/invitation_tests.dart | 88 +++++++++++++++++++ .../send_lifecycle_tests.dart | 5 ++ 5 files changed, 160 insertions(+), 30 deletions(-) create mode 100644 mobile/test/features/channels/compose_bar_test/invitation_tests.dart diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 6b9539b2b8..06c0ea648a 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -543,21 +543,26 @@ class ComposeBar extends HookConsumerWidget { ); ensureAuthorizationCurrent(); - // Mentioning humans outside the channel prompts "Invite" / "Do - // nothing" (send without inviting) — mirrors desktop's - // NonMemberMentionDialog. Agents keep the existing silent auto-add. - if (scan.humans.isNotEmpty) { + // Agents and humans both require deliberate invitation intent. + final nonMembers = [ + ...scan.humans, + ...selectedMentions.where( + (candidate) => + scan.agentPubkeys.contains(candidate.pubkey.toLowerCase()), + ), + ]; + if (nonMembers.isNotEmpty) { if (!context.mounted) return; final choice = await _promptNonMemberMention( context, - names: [for (final candidate in scan.humans) candidate.label], + names: [for (final candidate in nonMembers) candidate.label], canInvite: scan.canAddMembers, ); ensureAuthorizationCurrent(); if (choice == null) { return; // Dismissed — keep the draft, send nothing. } - outgoing.resolveHumanChoice(choice, scan.humans); + outgoing.resolveChoice(choice, nonMembers); } final queuedAttachments = List<_PendingAttachment>.of( diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index a65df6bafa..4d748b6aa2 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -324,7 +324,7 @@ String _composeSendErrorMessage(Object error) { return error.toString().replaceFirst('Exception: ', ''); } -/// Ask whether to invite mentioned humans who aren't channel members, or +/// Ask whether to invite mentioned identities who aren't channel members, or /// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`. /// [canInvite] false (a private channel the sender doesn't own/administer) /// drops the Invite action — the relay rejects that add, so offering it would @@ -338,7 +338,7 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention( return showBuzzDialog<_NonMemberMentionChoice>( context: context, builder: (dialogContext) => AlertDialog( - title: const Text('Mention people outside this channel?'), + title: const Text('Invite mentioned people or agents?'), content: Text( canInvite ? '${names.join(', ')} $verb not in this channel. Invite them to ' @@ -352,7 +352,7 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention( onPressed: () => Navigator.of( dialogContext, ).pop(_NonMemberMentionChoice.sendWithoutInviting), - child: Text(canInvite ? 'Do nothing' : 'Send anyway'), + child: const Text('Send without inviting'), ), if (canInvite) TextButton( @@ -435,8 +435,8 @@ class _NonMemberAddOutcome { /// Adds mentioned non-members to the channel before a send. /// -/// Agents are added silently with the `bot` role; humans are only passed here -/// after they have been explicitly invited from the mention prompt. +/// Agents use the `bot` role, humans the `member` role. Both require an explicit +/// invitation choice before reaching this helper. /// /// A rejection is reported, never thrown: the send is fire-and-forget, so an /// escaping error would drop the message with nothing shown. [StateError] still @@ -567,13 +567,13 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions( /// The p-tags and `mention` reference tags an outgoing message should carry. /// -/// Anyone who ends up *not* added is demoted from a p-tag to a reference tag so -/// their name still renders without notifying a non-member — mirrors desktop's -/// `mergeOutgoingTagsWithReferenceMentions`. +/// Only an explicit reference-only choice demotes p-tags. Failed invitation +/// preparation must preserve intent and prevent publication. class _OutgoingMentions { List pubkeys; final List> referenceTags = []; List _invitedHumanPubkeys = const []; + bool _inviteAgents = false; _OutgoingMentions(List selectedMentions) : pubkeys = LinkedHashSet.from( @@ -593,22 +593,23 @@ class _OutgoingMentions { } /// Applies the mention prompt's outcome: invite them, or send without. - void resolveHumanChoice( + void resolveChoice( _NonMemberMentionChoice choice, - List humans, + List nonMembers, ) { - final humanPubkeys = [ - for (final candidate in humans) candidate.pubkey.toLowerCase(), - ]; switch (choice) { case _NonMemberMentionChoice.invite: - _invitedHumanPubkeys = humanPubkeys; + _inviteAgents = true; + _invitedHumanPubkeys = [ + for (final candidate in nonMembers) + if (!candidate.isAgent) candidate.pubkey.toLowerCase(), + ]; case _NonMemberMentionChoice.sendWithoutInviting: - demote(humanPubkeys); + demote(nonMembers.map((candidate) => candidate.pubkey)); } } - /// Adds the scanned non-members, demoting and reporting whatever didn't land. + /// Adds explicitly invited non-members, failing the send on any refusal. Future addNonMembers( ChannelActions channelActions, { required _NonMemberMentionScan scan, @@ -618,16 +619,13 @@ class _OutgoingMentions { final outcome = await _addMentionedNonMembers( channelActions, channelId: scan.channelId, - agentPubkeys: scan.agentPubkeys, + agentPubkeys: _inviteAgents ? scan.agentPubkeys : const [], humanPubkeys: _invitedHumanPubkeys, canAddMembers: scan.canAddMembers, ensureCurrent: ensureCurrent, ); - demote(outcome.notAdded); - if (outcome.errors.isNotEmpty) { - messenger?.showSnackBar( - SnackBar(content: Text(outcome.errors.join(' '))), - ); + if (outcome.notAdded.isNotEmpty) { + throw Exception('Message not sent. ${outcome.errors.join(' ')}'); } } } diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 284c751956..7b9b6d29ff 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -32,6 +32,7 @@ import 'package:shared_preferences/shared_preferences.dart'; part 'compose_bar_test/publication_tests.dart'; part 'compose_bar_test/send_lifecycle_tests.dart'; +part 'compose_bar_test/invitation_tests.dart'; final _pngBytes = Uint8List.fromList([ 0x89, @@ -585,6 +586,7 @@ class _RecordingRelaySocket extends RelaySocket { /// Invoked after an event is handed to the socket but before its relay /// acknowledgement is delivered. Tests may defer that acknowledgement. final Future Function(Map event)? beforeAcknowledged; + final bool rejectAdds; /// Invoked after an event has been recorded and acknowledged, before the /// caller's `await` resumes. Lets a test interleave state changes (such as @@ -595,6 +597,7 @@ class _RecordingRelaySocket extends RelaySocket { this.events, this.handleMessage, { this.beforeAcknowledged, + this.rejectAdds = false, this.onEventAcknowledged, }) : super( wsUrl: 'ws://localhost', @@ -614,12 +617,22 @@ class _RecordingRelaySocket extends RelaySocket { final id = event['id'] as String; final pending = beforeAcknowledged?.call(event); if (pending == null) { - super.debugHandleOkForTest(['OK', id, true, '']); + super.debugHandleOkForTest([ + 'OK', + id, + !(rejectAdds && event['kind'] == 9000), + 'test relay refusal', + ]); onEventAcknowledged?.call(event); } else { unawaited( pending.then((_) { - super.debugHandleOkForTest(['OK', id, true, '']); + super.debugHandleOkForTest([ + 'OK', + id, + !(rejectAdds && event['kind'] == 9000), + 'test relay refusal', + ]); onEventAcknowledged?.call(event); }), ); @@ -663,6 +676,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier { void main() { _publicationTests(); sendLifecycleTests(); + invitationTests(); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { @@ -3141,6 +3155,9 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Invite')); await tester.pumpAndSettle(); // The cancelled send must not reach the relay. @@ -3284,6 +3301,9 @@ void main() { .text; await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Invite')); + await tester.pump(); expect( publishedEvents.where((event) => event['kind'] == 9000), @@ -3312,6 +3332,8 @@ void main() { publishedEvents.where((event) => event['kind'] == 9000), isEmpty, ); + await tester.tap(find.text('hello @Helper Bot')); + await tester.pumpAndSettle(); expect( tester.widget(find.byType(TextField)).controller!.text, submittedText, @@ -4198,6 +4220,9 @@ void main() { ); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Invite')); await tester.pumpAndSettle(); expect(sentContent, 'hello @Helper Bot'); @@ -4267,12 +4292,18 @@ void main() { await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Invite')); + await tester.pump(); expect( publishedEvents.where((event) => event['kind'] == 9000), hasLength(1), ); + await tester.tap(find.text('hello @Helper Bot')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); await tester.enterText(find.byType(TextField), 'newer draft'); addMemberAcknowledgement.complete(); await tester.pumpAndSettle(); @@ -4512,6 +4543,9 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Invite')); await tester.pumpAndSettle(); expect(didSend, isTrue); diff --git a/mobile/test/features/channels/compose_bar_test/invitation_tests.dart b/mobile/test/features/channels/compose_bar_test/invitation_tests.dart new file mode 100644 index 0000000000..5773f5f61e --- /dev/null +++ b/mobile/test/features/channels/compose_bar_test/invitation_tests.dart @@ -0,0 +1,88 @@ +part of '../compose_bar_test.dart'; + +void invitationTests() { + for (final choice in ['reference', 'cancel', 'failure', 'revisit']) { + testWidgets('agent invitation $choice preserves deliberate audience', ( + tester, + ) async { + final signer = nostr.Keys.generate(); + final agent = 'a' * 64; + final events = >[]; + List? recipients; + List> tags = []; + final service = _testUploadService(signer.nsec); + Widget build({String? thread}) => _buildComposeBar( + uploadService: service, + currentPubkey: signer.public, + relayAgents: [_testAgent(agent)], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + threadHeadId: thread, + onSend: (_, keys, {mediaTags = const []}) async { + recipients = keys; + tags = mediaTags; + }, + ); + await tester.pumpWidget(build()); + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + session.debugAttachSocketForTest( + _RecordingRelaySocket( + events, + session.debugHandleSocketMessageForTest, + rejectAdds: choice == 'failure', + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Invite mentioned people or agents?'), findsOneWidget); + expect(events.where((e) => e['kind'] == 9000), isEmpty); + expect(recipients, isNull); + if (choice == 'revisit') { + await tester.pumpWidget(build(thread: 'other')); + await tester.pumpWidget(build()); + } + if (choice == 'cancel') { + Navigator.of(tester.element(find.byType(AlertDialog))).pop(); + } else { + await tester.tap( + find.text(choice == 'reference' ? 'Send without inviting' : 'Invite'), + ); + } + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); + if (choice == 'reference') { + expect(recipients, isEmpty); + expect(tags, [ + ['mention', agent], + ]); + } else { + expect(recipients, isNull); + if (choice == 'failure') { + expect(find.textContaining('Message not sent.'), findsOneWidget); + ScaffoldMessenger.of( + tester.element(find.byType(ComposeBar)), + ).removeCurrentSnackBar(); + await tester.pumpAndSettle(); + } + await tester.tap(find.text('@Helper Bot')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + '@Helper Bot ', + ); + } + expect( + events.where((e) => e['kind'] == 9000), + hasLength(choice == 'failure' ? 1 : 0), + ); + }); + } +} diff --git a/mobile/test/features/channels/compose_bar_test/send_lifecycle_tests.dart b/mobile/test/features/channels/compose_bar_test/send_lifecycle_tests.dart index b39112cc4a..550381854e 100644 --- a/mobile/test/features/channels/compose_bar_test/send_lifecycle_tests.dart +++ b/mobile/test/features/channels/compose_bar_test/send_lifecycle_tests.dart @@ -136,6 +136,9 @@ void sendLifecycleTests() { await tester.pumpAndSettle(); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Invite')); + await tester.pump(); expect(events.where((e) => e['kind'] == 9000), hasLength(1)); await tester.pumpWidget(build(thread: 'other')); await tester.pumpWidget(build()); @@ -143,6 +146,8 @@ void sendLifecycleTests() { await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 300)); expect(sends, 0); + await tester.tap(find.text('@Helper Bot @Other Bot')); + await tester.pumpAndSettle(); expect(events.where((e) => e['kind'] == 9000), hasLength(1)); expect( tester.widget(find.byType(TextField)).controller!.text,