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 06c0ea648a..b335aa6ecd 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -479,13 +479,18 @@ class ComposeBar extends HookConsumerWidget { void Function()? checkPreparationCurrent; try { final submittedDraftRevision = draftRevision.value; + final submittedUploadGeneration = uploadGeneration.value; var authorizationRevision = submittedDraftRevision; final visit = authorizationVisit.value; final config = ref.read(relayConfigProvider); final readAuthorization = ref.read(agentAuthorizationReaderProvider); - bool isAuthorizationCurrent() => + bool ownsSource() => context.mounted && visit == authorizationVisit.value && + identical(config, ref.read(relayConfigProvider)); + bool isAuthorizationCurrent() => + ownsSource() && + submittedUploadGeneration == uploadGeneration.value && authorizationRevision == draftRevision.value && identical(config, ref.read(relayConfigProvider)); void ensureAuthorizationCurrent() { @@ -493,7 +498,8 @@ class ComposeBar extends HookConsumerWidget { if (!identical(config, ref.read(relayConfigProvider))) { throw StateError('Community changed during authorization'); } - if (visit != authorizationVisit.value || + if (submittedUploadGeneration != uploadGeneration.value || + visit != authorizationVisit.value || authorizationRevision != draftRevision.value) { throw const _ComposeAuthorizationCancelled(); } @@ -586,6 +592,12 @@ class ComposeBar extends HookConsumerWidget { ); } await authorize(keys); + if (queuedAttachments.isEmpty || + (intendedAgentKeys.isNotEmpty || + scan.humans.isNotEmpty || + scan.agentPubkeys.isNotEmpty)) { + ensureAuthorizationCurrent(); + } } if (queuedAttachments.isEmpty) { @@ -596,6 +608,7 @@ class ComposeBar extends HookConsumerWidget { mentionMap: mentionMap, draftRevision: draftRevision, submittedDraftRevision: submittedDraftRevision, + ownsSource: ownsSource, focusNode: focusNode, clearComposer: clearComposer, addMentionedNonMembers: addMentionedNonMembers, diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index a3436f5b61..3e085381b8 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -10,6 +10,7 @@ Future _sendTextOnlyDraft({ required ObjectRef> mentionMap, required ObjectRef draftRevision, required int submittedDraftRevision, + required bool Function() ownsSource, required FocusNode focusNode, required VoidCallback clearComposer, required Future Function() addMentionedNonMembers, @@ -23,7 +24,7 @@ Future _sendTextOnlyDraft({ int? clearedDraftRevision; void restoreClearedDraft() { - if (!context.mounted || + if (!ownsSource() || clearedDraftText == null || clearedDraftMentions == null || clearedDraftRevision == null || @@ -39,6 +40,7 @@ Future _sendTextOnlyDraft({ try { await addMentionedNonMembers(); + if (!ownsSource()) return; // Clear before optimistic insertion so the outgoing row and draft never // appear simultaneously during the send transition. If the user edited // while membership changes were pending, preserve that newer draft. @@ -86,12 +88,18 @@ void _useComposeDraftLifecycle({ required _IOSAttachmentPopoverController iosAttachmentPopover, required VoidCallback onDraftIdentityChanged, }) { + // Fence old listeners before effects restore an incoming draft. + final owner = useMemoized(Object.new, [draftKey, draftIdentity]); + final currentOwner = useRef(owner)..value = owner; final lastDraftIdentity = useRef(null); + final lastDraftKey = useRef(null); useEffect(() { final identityChanged = lastDraftIdentity.value != null && - lastDraftIdentity.value != draftIdentity; + (lastDraftIdentity.value != draftIdentity || + lastDraftKey.value != draftKey); lastDraftIdentity.value = draftIdentity; + lastDraftKey.value = draftKey; final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey); if (identityChanged) { draftRevision.value += 1; @@ -114,6 +122,7 @@ void _useComposeDraftLifecycle({ var lastPersistedText = controller.text; void persistDraft() { + if (!identical(currentOwner.value, owner)) return; final text = controller.text; if (text == lastPersistedText) return; lastPersistedText = text; diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 4d748b6aa2..fa4347a9f2 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -475,7 +475,7 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers( role: role, ); ensureCurrent(); - } on _ComposeSendCancelled { + } on _ComposeAuthorizationCancelled { rethrow; } on StateError { rethrow; 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 550381854e..dc427104ac 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 @@ -42,7 +42,6 @@ void sendLifecycleTests() { pending = true; container.invalidate(channelMembersProvider('channel-1')); await tester.pump(); - // Invoke twice without a frame: an asynchronous latch is too late. final button = tester.widget( find .ancestor( @@ -85,73 +84,102 @@ void sendLifecycleTests() { } }); } - testWidgets('a revisited source cannot finish an old membership batch', ( - tester, - ) async { - final signer = nostr.Keys.generate(); - final gate = Completer(); - final events = >[]; - var sends = 0; - final service = _testUploadService(signer.nsec); - Widget build({String? thread}) => _buildComposeBar( - uploadService: service, - currentPubkey: signer.public, - relayAgents: [ - _testAgent('b' * 64), - AgentDirectoryEntry( - pubkey: 'c' * 64, - displayName: 'Other Bot', - respondTo: 'anyone', - channelIds: const ['shared-channel'], - ), - ], - channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], - threadHeadId: thread, - onSend: (_, _, {mediaTags = const []}) async { - sends++; - }, - ); - 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, - beforeAcknowledged: (event) async { - if (event['kind'] == 9000) await gate.future; + for (final action in ['revisit', 'cancel', 'partial refusal']) { + testWidgets('$action cannot finish an old membership batch', ( + tester, + ) async { + final signer = nostr.Keys.generate(); + final gate = Completer(); + final events = >[]; + var sends = 0; + final service = _FakeVideoUploadService( + XFile.fromData(_pngBytes, name: 'clip.mp4', mimeType: 'video/mp4'), + ); + Widget build({String? thread}) => _buildComposeBar( + uploadService: service, + currentPubkey: signer.public, + relayAgents: [ + _testAgent('b' * 64), + AgentDirectoryEntry( + pubkey: 'c' * 64, + displayName: 'Other Bot', + respondTo: 'anyone', + channelIds: const ['shared-channel'], + ), + ], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + threadHeadId: thread, + onSend: (_, _, {mediaTags = const []}) async { + sends++; }, - ), - ); - 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), '@Helper Bot @oth'); - await tester.pumpAndSettle(); - await tester.tap(find.text('Other Bot')); - 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()); - gate.complete(); - 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, - '@Helper Bot @Other Bot ', - ); - }); + ); + 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, + beforeAcknowledged: (event) async { + if (event['kind'] == 9000) await gate.future; + }, + onEventAcknowledged: (event) { + if (action == 'partial refusal' && event['kind'] == 9000) { + session.debugAttachSocketForTest( + _RecordingRelaySocket( + events, + session.debugHandleSocketMessageForTest, + rejectAdds: true, + ), + ); + } + }, + ), + ); + if (action == 'cancel') { + await _openAttachmentMenu(tester); + await tester.tap(find.text('Video')); + await tester.pumpAndSettle(); + } + 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), '@Helper Bot @oth'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Other Bot')); + 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)); + if (action == 'cancel') { + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byKey(const ValueKey('compose-upload-cancel'))); + } else if (action == 'revisit') { + await tester.pumpWidget(build(thread: 'other')); + await tester.pumpWidget(build()); + } + gate.complete(); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(sends, 0); + await tester.tap(find.text('@Helper Bot @Other Bot')); + await tester.pumpAndSettle(); + expect( + events.where((e) => e['kind'] == 9000), + hasLength(action == 'partial refusal' ? 2 : 1), + ); + expect( + tester.widget(find.byType(TextField)).controller!.text, + '@Helper Bot @Other Bot ', + ); + }); + } }