From 7dfd5b12403205a17661e05ad61236ce424dddcb Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 12:56:04 -0400 Subject: [PATCH] fix(mobile): distinguish cancelled authorization from access denial Signed-off-by: Logan Johnson --- .../compose_bar/compose_bar_widget.dart | 54 +++++--- .../channels/compose_bar/draft_lifecycle.dart | 6 + .../compose_bar_test/publication_tests.dart | 119 ++++++++++-------- 3 files changed, 112 insertions(+), 67 deletions(-) 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 351885d07f..3c9883bb2a 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -487,6 +487,37 @@ class ComposeBar extends HookConsumerWidget { visit == authorizationVisit.value && authorizationRevision == draftRevision.value && identical(config, ref.read(relayConfigProvider)); + void ensureAuthorizationCurrent() { + if (!context.mounted) throw const _ComposeAuthorizationCancelled(); + if (visit != authorizationVisit.value || + !identical(config, ref.read(relayConfigProvider))) { + throw StateError('Community changed during authorization'); + } + if (authorizationRevision != draftRevision.value) { + throw const _ComposeAuthorizationCancelled(); + } + } + + Future authorize(Set keys, {bool prepare = false}) async { + if (keys.isEmpty) return; + ensureAuthorizationCurrent(); + try { + await authorizeAgentMentions( + readAuthorization, + keys, + currentPubkey, + channelId, + isAuthorizationCurrent, + prepare: prepare, + ); + } catch (_) { + // A stale request is cancellation, not an access decision. + ensureAuthorizationCurrent(); + rethrow; + } + ensureAuthorizationCurrent(); + } + // Resolved before any await: see // `_reportSendCancelledByCommunitySwitch`. final messenger = ScaffoldMessenger.maybeOf(context); @@ -533,14 +564,7 @@ class ComposeBar extends HookConsumerWidget { // Agent failures stop publication; the original draft keeps its keys. Future addMentionedNonMembers() async { final keys = intendedAgentKeys.intersection(outgoing.pubkeys.toSet()); - await authorizeAgentMentions( - readAuthorization, - keys, - currentPubkey, - channelId, - isAuthorizationCurrent, - prepare: true, - ); + await authorize(keys, prepare: true); await outgoing.addNonMembers( channelActions, scan: scan, @@ -551,13 +575,7 @@ class ComposeBar extends HookConsumerWidget { 'Mention invitation failed. Draft kept; retry or remove the mention.', ); } - await authorizeAgentMentions( - readAuthorization, - keys, - currentPubkey, - channelId, - isAuthorizationCurrent, - ); + await authorize(keys); } if (queuedAttachments.isEmpty) { @@ -643,9 +661,13 @@ class ComposeBar extends HookConsumerWidget { outgoing.pubkeys, mediaTags: [...payload.mediaTags, ...outgoing.referenceTags], ); + } on _ComposeAuthorizationCancelled { + // Keep the newer draft without displaying a false access error. } catch (error) { if (cancellation.isCancelled) return; - if (context.mounted) { + if (error is StateError) { + _reportSendCancelledByCommunitySwitch(messenger); + } else if (context.mounted) { uploadError.value = _formatUploadError(error); } if (context.mounted && diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index b712091b71..a3436f5b61 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -1,5 +1,9 @@ part of '../compose_bar.dart'; +class _ComposeAuthorizationCancelled implements Exception { + const _ComposeAuthorizationCancelled(); +} + Future _sendTextOnlyDraft({ required BuildContext context, required _MarkdownEditingController controller, @@ -49,6 +53,8 @@ Future _sendTextOnlyDraft({ outgoing.pubkeys, mediaTags: [...payload.mediaTags, ...outgoing.referenceTags], ); + } on _ComposeAuthorizationCancelled { + restoreClearedDraft(); } on StateError { restoreClearedDraft(); _reportSendCancelledByCommunitySwitch(messenger); diff --git a/mobile/test/features/channels/compose_bar_test/publication_tests.dart b/mobile/test/features/channels/compose_bar_test/publication_tests.dart index d016c9764d..1d73efd3e5 100644 --- a/mobile/test/features/channels/compose_bar_test/publication_tests.dart +++ b/mobile/test/features/channels/compose_bar_test/publication_tests.dart @@ -70,57 +70,74 @@ void _publicationTests() { }); } - testWidgets( - 'editing while authorization waits cancels publication without overwriting new draft', - (tester) async { - final key = 'd' * 64; - final signer = nostr.Keys.generate(); - final pending = Completer>(); - var reads = 0; - var sent = 0; - await tester.pumpWidget( - _buildComposeBar( - uploadService: _testUploadService(signer.nsec), - currentPubkey: signer.public, - relayAgents: [_testAgent(key)], - channels: [ - _makeCurrentChannel(channelType: 'dm'), - _makeSharedMemberChannel(), - ], - authorizationReader: (_, _, _, _) { - reads++; - return pending.future; - }, - onSend: (_, _, {mediaTags = const []}) async { - sent++; - }, - ), - ); - 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.enterText(find.byType(TextField), 'hello @Helper Bot'); - await tester.tap(find.byIcon(LucideIcons.arrowUp)); - await tester.pump(); - expect(reads, 1); - await tester.enterText(find.byType(TextField), 'new intent'); - pending.complete([ - AgentDirectoryEntry( - pubkey: key, - respondTo: 'anyone', - channelIds: ['channel-1'], - ), - ]); - await tester.pumpAndSettle(); - expect(sent, 0); - expect( - tester.widget(find.byType(TextField)).controller!.text, - 'new intent', - ); - }, - ); + for (final switchCommunity in [false, true]) { + testWidgets( + 'authorization cancellation reports scope change only: $switchCommunity', + (tester) async { + final key = 'd' * 64; + final signer = nostr.Keys.generate(); + final pending = Completer>(); + var reads = 0; + var sent = 0; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayConfig: () => _SwitchableRelayConfigNotifier( + RelayConfig(baseUrl: 'https://relay.example', nsec: signer.nsec), + ), + relayAgents: [_testAgent(key)], + channels: [ + _makeCurrentChannel(channelType: 'dm'), + _makeSharedMemberChannel(), + ], + authorizationReader: (_, _, _, _) { + reads++; + return pending.future; + }, + onSend: (_, _, {mediaTags = const []}) async { + sent++; + }, + ), + ); + 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.enterText(find.byType(TextField), 'hello @Helper Bot'); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pump(); + expect(reads, 1); + if (switchCommunity) { + ProviderScope.containerOf(tester.element(find.byType(ComposeBar))) + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://other.example', nsec: signer.nsec); + await tester.pump(); + } else { + await tester.enterText(find.byType(TextField), 'new intent'); + } + pending.complete([ + AgentDirectoryEntry( + pubkey: key, + respondTo: 'anyone', + channelIds: ['channel-1'], + ), + ]); + await tester.pumpAndSettle(); + expect(sent, 0); + expect( + find.text('Message not sent: the community changed'), + switchCommunity ? findsOneWidget : findsNothing, + ); + expect(find.textContaining('Could not authorize'), findsNothing); + expect( + tester.widget(find.byType(TextField)).controller!.text, + switchCommunity ? '' : 'new intent', + ); + }, + ); + } testWidgets('revocation after upload retains agent draft and attachment', ( tester, ) async {