fix(mobile): show invite sheet while join policy loads

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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-10-29 19:26:07 +11:00
parent e91e2f81ef
commit 4ec7b8f0eb
4 changed files with 180 additions and 5 deletions

View File

@@ -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<InviteJoinState> {
Community? _pendingStarterSetupCommunity;
Future<void>? _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<void> 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<InviteJoinState> {
}
_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<void> _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<InviteJoinState> {
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<InviteJoinState> {
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<InviteJoinState> {
void reset() {
_pendingStarterSetupCommunity = null;
_joinPolicyLoad = null;
state = const InviteJoinState();
}
}

View File

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

View File

@@ -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<http.Response>();
var policyReads = 0;
final claims = <Object?>[];
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<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Cancel'),
)
.onPressed,
isNotNull,
);
expect(
tester
.widget<FilledButton>(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<FilledButton>(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,

View File

@@ -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<http.Response>();
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<void> _joinPolicySettled(ProviderContainer container) {
final settled = Completer<void>();
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<http.Response> Function(http.Request) claim,
) => http_testing.MockClient((request) async {