fix(mobile): cancel remaining invitations with media send attempt

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-07 11:08:57 -04:00
parent 3e44ade3b5
commit c93d15ebc4
4 changed files with 124 additions and 74 deletions

View File

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

View File

@@ -10,6 +10,7 @@ Future<void> _sendTextOnlyDraft({
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
required ObjectRef<int> draftRevision,
required int submittedDraftRevision,
required bool Function() ownsSource,
required FocusNode focusNode,
required VoidCallback clearComposer,
required Future<void> Function() addMentionedNonMembers,
@@ -23,7 +24,7 @@ Future<void> _sendTextOnlyDraft({
int? clearedDraftRevision;
void restoreClearedDraft() {
if (!context.mounted ||
if (!ownsSource() ||
clearedDraftText == null ||
clearedDraftMentions == null ||
clearedDraftRevision == null ||
@@ -39,6 +40,7 @@ Future<void> _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<String?>(null);
final lastDraftKey = useRef<String?>(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;

View File

@@ -475,7 +475,7 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
role: role,
);
ensureCurrent();
} on _ComposeSendCancelled {
} on _ComposeAuthorizationCancelled {
rethrow;
} on StateError {
rethrow;

View File

@@ -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<GestureDetector>(
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<void>();
final events = <Map<String, dynamic>>[];
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<void>();
final events = <Map<String, dynamic>>[];
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<TextField>(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<TextField>(find.byType(TextField)).controller!.text,
'@Helper Bot @Other Bot ',
);
});
}
}