From 4ec7b8f0ebb7691c9e917a1ff196853f07bcedc5 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 19:26:07 +1100 Subject: [PATCH] fix(mobile): show invite sheet while join policy loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deep-link dispatcher awaits InviteJoinNotifier.prepare before it presents the invite sheet, and prepare awaited GET /api/join-policy. On a slow or blackholed relay, opening an invite did nothing visible until discovery finished or hit the 30 s request deadline. prepare now returns once the pending invite and host are established and loads the policy in the background. A new isLoadingJoinPolicy state renders a "Loading community policy…" row (the spinner owns the announcement) and keeps Join disabled until the policy is shown or confirmed absent. A per-load token fences the result: a re-prepare, reset, or claim supersedes the load, so a stale success or failure cannot overwrite a claim that confirmJoin started, e.g. from the iOS native sheet. confirmJoin still re-reads the policy before it claims. The dispatcher gains a table with a suspended /api/join-policy response. Both rows assert that the host, an enabled Cancel, the loading row, and a disabled Join are visible before the GET resolves, with no claim. The policy row then claims once with the app-minted receipt after age confirmation and acceptance. The absent row claims once without a receipt after Join. The provider claim journey now claims while discovery is still suspended, then fails that discovery and asserts the join stays successful. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../invites/invite_join_provider.dart | 30 ++++- .../features/invites/invite_join_sheet.dart | 26 ++++- .../channels/deep_link_dispatcher_test.dart | 104 ++++++++++++++++++ .../invites/invite_join_provider_test.dart | 25 ++++- 4 files changed, 180 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index 70e1697b84..40cf47baff 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; @@ -87,6 +88,9 @@ class InviteJoinState { final String? focusChannelId; final HostedJoinPolicy? joinPolicy; + /// Whether the sheet is still discovering the relay's join policy. + final bool isLoadingJoinPolicy; + const InviteJoinState({ this.status = InviteJoinStatus.idle, this.invite, @@ -98,6 +102,7 @@ class InviteJoinState { this.isStarterSetupRecovery = false, this.focusChannelId, this.joinPolicy, + this.isLoadingJoinPolicy = false, }); InviteJoinState copyWith({ @@ -111,6 +116,7 @@ class InviteJoinState { bool? isStarterSetupRecovery, Object? focusChannelId = _unset, Object? joinPolicy = _unset, + bool? isLoadingJoinPolicy, }) => InviteJoinState( joinPolicy: identical(joinPolicy, _unset) ? this.joinPolicy @@ -130,17 +136,22 @@ class InviteJoinState { focusChannelId: identical(focusChannelId, _unset) ? this.focusChannelId : focusChannelId as String?, + isLoadingJoinPolicy: isLoadingJoinPolicy ?? this.isLoadingJoinPolicy, ); } class InviteJoinNotifier extends Notifier { Community? _pendingStarterSetupCommunity; Future? _starterSetupInFlight; + Object? _joinPolicyLoad; @override InviteJoinState build() => const InviteJoinState(); + /// Establishes the pending invite so its sheet can open at once; a new + /// community's join policy then loads in the background. Future prepare(InviteDeepLink invite) async { + _joinPolicyLoad = null; validateInviteRelayUri(Uri.parse(invite.relayUrl)); final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); @@ -171,12 +182,20 @@ class InviteJoinNotifier extends Notifier { } _pendingStarterSetupCommunity = null; + final load = _joinPolicyLoad = Object(); state = InviteJoinState( status: InviteJoinStatus.confirming, invite: invite, host: _hostFromRelay(invite.relayUrl), communityName: Community.nameFromUrl(invite.relayUrl), + isLoadingJoinPolicy: true, ); + unawaited(_loadJoinPolicy(invite, load)); + } + + /// Loads the policy to show; [confirmJoin] re-reads it before any claim. + Future _loadJoinPolicy(InviteDeepLink invite, Object load) async { + bool current() => ref.mounted && identical(_joinPolicyLoad, load); try { CommunityEntryRequirements.requireSelfHosted(invite.relayUrl); final policy = await loadJoinPolicy( @@ -184,13 +203,14 @@ class InviteJoinNotifier extends Notifier { origin: Uri.parse(invite.relayUrl), allowLegacyRelay: true, ); - if (!ref.mounted || state.invite != invite) return; - state = state.copyWith(joinPolicy: policy); + if (!current()) return; + state = state.copyWith(joinPolicy: policy, isLoadingJoinPolicy: false); } catch (error) { - if (!ref.mounted || state.invite != invite) return; + if (!current()) return; state = state.copyWith( status: InviteJoinStatus.error, errorMessage: _friendlyInviteError(error), + isLoadingJoinPolicy: false, ); } } @@ -204,10 +224,13 @@ class InviteJoinNotifier extends Notifier { return; } + // The claim re-reads the policy itself, superseding any load in flight. + _joinPolicyLoad = null; state = state.copyWith( status: InviteJoinStatus.claiming, errorMessage: null, requiresFreshInvite: false, + isLoadingJoinPolicy: false, ); try { CommunityEntryRequirements.requireSelfHosted(invite.relayUrl); @@ -457,6 +480,7 @@ class InviteJoinNotifier extends Notifier { void reset() { _pendingStarterSetupCommunity = null; + _joinPolicyLoad = null; state = const InviteJoinState(); } } diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart index a9c41d79d6..f4cfef7b26 100644 --- a/mobile/lib/features/invites/invite_join_sheet.dart +++ b/mobile/lib/features/invites/invite_join_sheet.dart @@ -195,6 +195,29 @@ class InviteJoinSheet extends ConsumerWidget { ), ), ], + if (state.isLoadingJoinPolicy) ...[ + const SizedBox(height: Grid.sm), + Row( + children: [ + const BuzzLoadingIndicator( + size: 16, + semanticLabel: 'Loading community policy', + ), + const SizedBox(width: Grid.xs), + Expanded( + // The indicator owns the announcement. + child: ExcludeSemantics( + child: Text( + 'Loading community policy…', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ], const SizedBox(height: Grid.lg), if (state.joinPolicy != null && !state.requiresFreshInvite && @@ -228,7 +251,8 @@ class InviteJoinSheet extends ConsumerWidget { onPressed: isClaiming || (state.requiresFreshInvite && - !state.requiresPolicyAcceptance) + !state.requiresPolicyAcceptance) || + state.isLoadingJoinPolicy ? null : () async { final notifier = ref.read( diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index e316b51f33..709b30c03e 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -57,6 +58,109 @@ void main() { expect(find.text('Cancel'), findsOneWidget); }); + for (final policy in [ + { + 'version': 'v1', + 'terms_markdown': 'Terms', + 'age_attestation_required': true, + }, + null, + ]) { + testWidgets('opens the invite sheet before a suspended join policy ' + '${policy == null ? 'confirms absence' : 'resolves'}', (tester) async { + const link = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'code', + ); + final discovery = Completer(); + var policyReads = 0; + final claims = []; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue( + CommunityStorage(secure: FakeSecureStorage()), + ), + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + inviteJoinHttpClientProvider.overrideWithValue( + MockClient((request) async { + switch (request.url.path) { + case '/api/join-policy': + // Only discovery hangs; the claim's own read answers. + if (policyReads++ == 0) return discovery.future; + return http.Response(jsonEncode({'policy': policy}), 200); + case '/api/invites/accept-policy': + return http.Response('{"receipt":"fresh"}', 200); + default: + claims.add(jsonDecode(request.body)); + return http.Response('{"error":"invite_invalid"}', 400); + } + }), + ), + ], + ); + addTearDown(container.dispose); + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(discovery.isCompleted, isFalse); + expect(policyReads, 1); + expect(find.text('relay.example.com'), findsOneWidget); + expect(find.text('Loading community policy…'), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp('Loading community policy')), + findsOneWidget, + ); + expect( + tester + .widget( + find.widgetWithText(OutlinedButton, 'Cancel'), + ) + .onPressed, + isNotNull, + ); + expect( + tester + .widget(find.widgetWithText(FilledButton, 'Join')) + .onPressed, + isNull, + ); + + discovery.complete(http.Response(jsonEncode({'policy': policy}), 200)); + await tester.pumpAndSettle(); + expect(find.text('Loading community policy…'), findsNothing); + expect(claims, isEmpty); + + if (policy == null) { + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + } else { + final accept = find.widgetWithText(FilledButton, 'Accept and join'); + expect(find.widgetWithText(FilledButton, 'Join'), findsNothing); + expect(tester.widget(accept).onPressed, isNull); + await tester.ensureVisible(find.byType(CheckboxListTile)); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + await tester.ensureVisible(accept); + await tester.tap(accept); + } + await tester.pumpAndSettle(); + expect(claims, [ + {'code': 'code', if (policy != null) 'policy_receipt': 'fresh'}, + ]); + semantics.dispose(); + }); + } + for (final outcome in ['handled', 'declined', 'failed']) { testWidgets('hosted handler drains queued links after $outcome setup', ( tester, diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index c43f320645..2aa99ace74 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -76,6 +76,7 @@ void main() { policyReceipt: 'untrusted-browser-receipt', ), ); + await _joinPolicySettled(container); expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v1'); await notifier.confirmJoin(); expect(requests.where((path) => path != '/api/join-policy'), isEmpty); @@ -200,6 +201,8 @@ void main() { () async { final keys = nostr.Keys.generate(); http.Request? capturedRequest; + final discovery = Completer(); + var policyReads = 0; final storage = CommunityStorage(secure: FakeSecureStorage()); final auth = _RecordingAuthNotifier(); final container = ProviderContainer( @@ -211,7 +214,12 @@ void main() { (_) => _successfulRecovery(), ), inviteJoinHttpClientProvider.overrideWithValue( - _policyFreeClient((request) async { + http_testing.MockClient((request) async { + if (request.url.path == '/api/join-policy') { + // iOS native Join can claim while sheet discovery still hangs. + if (policyReads++ == 0) return discovery.future; + return http.Response('{}', 200); + } capturedRequest = request; return http.Response( jsonEncode({ @@ -246,6 +254,12 @@ void main() { final state = container.read(inviteJoinProvider); expect(state.status, InviteJoinStatus.success); expect(state.focusChannelId, 'welcome-everyone-id'); + discovery.complete(http.Response('{}', 503)); + await pumpEventQueue(); + expect( + container.read(inviteJoinProvider).status, + InviteJoinStatus.success, + ); expect(capturedRequest, isNotNull); expect( capturedRequest!.url.toString(), @@ -1007,6 +1021,7 @@ void main() { policyReceipt: 'fresh.receipt', ), ); + await _joinPolicySettled(container); expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v1'); await notifier.confirmJoin(acceptedPolicyVersion: 'v1'); expect( @@ -1485,6 +1500,14 @@ class _RecordingAuthNotifier extends AuthNotifier { } } +Future _joinPolicySettled(ProviderContainer container) { + final settled = Completer(); + final subscription = container.listen(inviteJoinProvider, (_, next) { + if (!next.isLoadingJoinPolicy && !settled.isCompleted) settled.complete(); + }, fireImmediately: true); + return settled.future.whenComplete(subscription.close); +} + http.Client _policyFreeClient( Future Function(http.Request) claim, ) => http_testing.MockClient((request) async {