mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user