fix(mobile): require explicit non-member agent invitations

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-05 16:35:39 -04:00
parent 05c7cf00c4
commit 3e44ade3b5
5 changed files with 160 additions and 30 deletions

View File

@@ -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(

View File

@@ -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<String> pubkeys;
final List<List<String>> referenceTags = [];
List<String> _invitedHumanPubkeys = const [];
bool _inviteAgents = false;
_OutgoingMentions(List<MentionCandidate> selectedMentions)
: pubkeys = LinkedHashSet<String>.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<MentionCandidate> humans,
List<MentionCandidate> 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<void> 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(' ')}');
}
}
}

View File

@@ -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<void> Function(Map<String, dynamic> 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<TextField>(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);

View File

@@ -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 = <Map<String, dynamic>>[];
List<String>? recipients;
List<List<String>> 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<TextField>(find.byType(TextField)).controller!.text,
'@Helper Bot ',
);
}
expect(
events.where((e) => e['kind'] == 9000),
hasLength(choice == 'failure' ? 1 : 0),
);
});
}
}

View File

@@ -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<TextField>(find.byType(TextField)).controller!.text,