mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
fix(mobile): require explicit non-member agent invitations
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(' ')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user