diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 110309806d..0f0b357a46 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -57,29 +57,26 @@ const _starterChannels = [ ), ]; -final _inviteRelayConnectedProvider = FutureProvider.family(( - ref, - expectedRelayUrl, -) async { - final currentConfig = ref.read(relayConfigProvider); - if (currentConfig.baseUrl != expectedRelayUrl) { - throw StateError('Active community changed before invite recovery'); - } - if (ref.read(relaySessionProvider).status == SessionStatus.connected) return; - +/// Waits for this attempt's community to hold an authenticated connection. +/// +/// Each attempt observes the live session, so a retry never reuses an earlier +/// attempt's failure or a connection that has since been lost. +Future _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) { final connected = Completer(); - ref.listen(relaySessionProvider, (_, next) { + final sub = ref.listen(relaySessionProvider, (_, next) { if (connected.isCompleted) return; - if (ref.read(relayConfigProvider).baseUrl != expectedRelayUrl) { + if (!isScopeCurrent()) { connected.completeError( StateError('Active community changed during invite recovery'), ); } else if (next.status == SessionStatus.connected) { connected.complete(); } - }); - await connected.future; -}); + }, fireImmediately: true); + return connected.future + .timeout(const Duration(seconds: 15)) + .whenComplete(sub.close); +} /// App-level bridge from invite joining to the channels feature. class MobileInviteJoinRecovery implements InviteJoinRecovery { @@ -241,9 +238,7 @@ InviteJoinRecovery buildMobileInviteJoinRecovery( ensureScopeCurrent(); await ref.read(activeCommunityProvider.future); ensureScopeCurrent(); - await ref - .read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future) - .timeout(const Duration(seconds: 15)); + await _waitForInviteRelay(ref, isScopeCurrent); ensureScopeCurrent(); final channels = await ref .read(channelsProvider.notifier) diff --git a/mobile/test/features/invites/invite_relay_wait_test.dart b/mobile/test/features/invites/invite_relay_wait_test.dart new file mode 100644 index 0000000000..4a00448965 --- /dev/null +++ b/mobile/test/features/invites/invite_relay_wait_test.dart @@ -0,0 +1,119 @@ +import 'package:buzz/app.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/invites/invite_join_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +const _relay = 'https://relay.example'; + +void main() { + late nostr.Keys keys; + late InviteJoinRecoveryScope scope; + late _Channels channels; + late ProviderContainer container; + late Provider recovery; + + setUp(() { + keys = nostr.Keys.generate(); + scope = InviteJoinRecoveryScope(relayHttpOrigin: _relay, nsec: keys.nsec); + channels = _Channels(); + // Production builds a fresh recovery for every attempt. + recovery = Provider((ref) => buildMobileInviteJoinRecovery(ref, scope)); + container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => _Config(keys.nsec)), + relaySessionProvider.overrideWith(_Session.new), + activeCommunityProvider.overrideWith( + (ref) async => + Community.create(name: 'Test', relayUrl: _relay, nsec: keys.nsec), + ), + channelsProvider.overrideWith(() => channels), + ], + ); + addTearDown(container.dispose); + }); + + Future attempt() { + container.invalidate(recovery); + return container.read(recovery).ensureStarterChannels(); + } + + void setStatus(SessionStatus status) => + (container.read(relaySessionProvider.notifier) as _Session).set(status); + + _Config config() => container.read(relayConfigProvider.notifier) as _Config; + + test('retry succeeds after an interrupted connection wait', () async { + await container.read(activeCommunityProvider.future); + setStatus(SessionStatus.connecting); + final first = attempt(); + await pumpEventQueue(); + config().update(baseUrl: 'https://other.example', nsec: keys.nsec); + setStatus(SessionStatus.reconnecting); + await expectLater(first, throwsA(isA())); + + config().update(baseUrl: _relay, nsec: keys.nsec); + setStatus(SessionStatus.connected); + expect(await attempt(), 'welcome-everyone'); + }); + + test('retry waits for a connection lost after an earlier wait', () async { + await container.read(activeCommunityProvider.future); + setStatus(SessionStatus.connected); + expect(await attempt(), 'welcome-everyone'); + expect(channels.freshReads, 1); + + setStatus(SessionStatus.disconnected); + final retry = attempt(); + await pumpEventQueue(); + expect(channels.freshReads, 1); + + setStatus(SessionStatus.connected); + expect(await retry, 'welcome-everyone'); + expect(channels.freshReads, 2); + }); +} + +class _Channels extends ChannelsNotifier { + int freshReads = 0; + final _starters = [ + for (final name in ['general', 'welcome-everyone']) + Channel( + id: name, + name: name, + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'me', + createdAt: DateTime.utc(2026), + memberCount: 1, + isMember: true, + ), + ]; + @override + Future> build() async => _starters; + @override + Future> fetchFresh({bool fetchDirectory = false}) async { + freshReads++; + return _starters; + } +} + +class _Config extends RelayConfigNotifier { + _Config(this.nsec); + final String nsec; + @override + RelayConfig build() => RelayConfig(baseUrl: _relay, nsec: nsec); +} + +class _Session extends RelaySessionNotifier { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); + void set(SessionStatus status) => state = SessionState(status: status); +}