fix(mobile): distinguish cancelled authorization from access denial

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 12:56:04 -04:00
parent 95b2465b6e
commit 5537d8b614
3 changed files with 112 additions and 67 deletions

View File

@@ -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<void> authorize(Set<String> 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<void> 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 &&

View File

@@ -1,5 +1,9 @@
part of '../compose_bar.dart';
class _ComposeAuthorizationCancelled implements Exception {
const _ComposeAuthorizationCancelled();
}
Future<void> _sendTextOnlyDraft({
required BuildContext context,
required _MarkdownEditingController controller,
@@ -49,6 +53,8 @@ Future<void> _sendTextOnlyDraft({
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} on _ComposeAuthorizationCancelled {
restoreClearedDraft();
} on StateError {
restoreClearedDraft();
_reportSendCancelledByCommunitySwitch(messenger);

View File

@@ -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<List<AgentDirectoryEntry>>();
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<TextField>(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<List<AgentDirectoryEntry>>();
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<TextField>(find.byType(TextField)).controller!.text,
switchCommunity ? '' : 'new intent',
);
},
);
}
testWidgets('revocation after upload retains agent draft and attachment', (
tester,
) async {