From 419281de2cfb067b8b49f9867efbc05f9e3de89e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 14 Aug 2026 16:23:56 -0700 Subject: [PATCH] fix(mobile): recover Welcome after invite join Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- mobile/lib/app.dart | 114 ++++++++++++++ .../invites/invite_join_provider.dart | 55 ++++++- mobile/lib/main.dart | 6 +- .../invites/invite_join_provider_test.dart | 147 ++++++++++++++++++ 4 files changed, 316 insertions(+), 6 deletions(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index f2726b9f1..128a6c6c2 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:app_badge_plus/app_badge_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -7,9 +9,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'features/activity/activity_provider.dart'; import 'features/activity/inbox_local_state_provider.dart'; import 'features/activity/inbox_read_state.dart'; +import 'features/channels/channel.dart'; +import 'features/channels/channel_management_provider.dart'; +import 'features/channels/channels_provider.dart'; import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; import 'features/invites/invite_create_page.dart'; +import 'features/invites/invite_join_provider.dart'; import 'features/pairing/pairing_page.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/deep_link_dispatcher.dart'; @@ -24,6 +30,114 @@ import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; +const _welcomeChannelName = 'Welcome'; +const _welcomeChannelDescription = + 'A private channel for getting oriented in this community.'; + +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; + + final connected = Completer(); + ref.listen(relaySessionProvider, (_, next) { + if (connected.isCompleted) return; + if (ref.read(relayConfigProvider).baseUrl != expectedRelayUrl) { + connected.completeError( + StateError('Active community changed during invite recovery'), + ); + } else if (next.status == SessionStatus.connected) { + connected.complete(); + } + }); + await connected.future; +}); + +/// App-level bridge from invite joining to the channels feature. +class MobileInviteJoinRecovery implements InviteJoinRecovery { + final Future> Function() _loadChannels; + final Future Function({ + required String name, + required String channelType, + required String visibility, + String? description, + int? ttlSeconds, + }) + _createChannel; + + /// Creates recovery from channel-loading and channel-creation operations. + const MobileInviteJoinRecovery({ + required Future> Function() loadChannels, + required Future Function({ + required String name, + required String channelType, + required String visibility, + String? description, + int? ttlSeconds, + }) + createChannel, + }) : _loadChannels = loadChannels, + _createChannel = createChannel; + + /// Reuses the current private Welcome channel or creates it when missing. + @override + Future ensureWelcomeChannel() async { + final channels = await _loadChannels(); + final hasWelcomeChannel = channels.any( + (channel) => + channel.name == _welcomeChannelName && + channel.isStream && + channel.isPrivate && + channel.memberCount <= 1 && + !channel.isArchived, + ); + if (hasWelcomeChannel) return; + + await _createChannel( + name: _welcomeChannelName, + channelType: 'stream', + visibility: 'private', + description: _welcomeChannelDescription, + ); + } +} + +/// Builds invite recovery against the active app-level provider container. +InviteJoinRecovery buildMobileInviteJoinRecovery(Ref ref) { + return MobileInviteJoinRecovery( + loadChannels: () async { + await ref.read(activeCommunityProvider.future); + final relayUrl = ref.read(relayConfigProvider).baseUrl; + await ref + .read(_inviteRelayConnectedProvider(relayUrl).future) + .timeout(const Duration(seconds: 15)); + await ref.read(channelsProvider.notifier).refresh(); + return ref.read(channelsProvider.future); + }, + createChannel: + ({ + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) => ref + .read(channelActionsProvider) + .createChannel( + name: name, + channelType: channelType, + visibility: visibility, + description: description, + ttlSeconds: ttlSeconds, + ), + ); +} + /// App-shell projection that joins Activity state for the Home navigation. /// /// This belongs at the composition root because it deliberately aggregates diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index b1d2680c9..c0bd93611 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -21,6 +21,20 @@ final inviteKeyGeneratorProvider = Provider((ref) { typedef InviteKeyGenerator = nostr.Keys Function(); +const _unset = Object(); + +/// Recovers the required channel state after an invite membership claim. +abstract interface class InviteJoinRecovery { + /// Reuses or creates the active identity's private Welcome channel. + Future ensureWelcomeChannel(); +} + +final inviteJoinRecoveryProvider = Provider((ref) { + throw StateError( + 'inviteJoinRecoveryProvider must be configured by the app root', + ); +}); + enum InviteJoinStatus { idle, confirming, @@ -37,6 +51,7 @@ class InviteJoinState { final String? communityName; final String? errorMessage; final bool requiresFreshInvite; + final bool claimCompleted; const InviteJoinState({ this.status = InviteJoinStatus.idle, @@ -45,6 +60,7 @@ class InviteJoinState { this.communityName, this.errorMessage, this.requiresFreshInvite = false, + this.claimCompleted = false, }); InviteJoinState copyWith({ @@ -52,15 +68,19 @@ class InviteJoinState { InviteDeepLink? invite, String? host, String? communityName, - String? errorMessage, + Object? errorMessage = _unset, bool? requiresFreshInvite, + bool? claimCompleted, }) => InviteJoinState( status: status ?? this.status, invite: invite ?? this.invite, host: host ?? this.host, communityName: communityName ?? this.communityName, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: identical(errorMessage, _unset) + ? this.errorMessage + : errorMessage as String?, requiresFreshInvite: requiresFreshInvite ?? this.requiresFreshInvite, + claimCompleted: claimCompleted ?? this.claimCompleted, ); } @@ -102,8 +122,18 @@ class InviteJoinNotifier extends Notifier { return; } - state = state.copyWith(status: InviteJoinStatus.claiming); + final resumingRecovery = state.claimCompleted; + state = state.copyWith( + status: InviteJoinStatus.claiming, + errorMessage: null, + ); try { + if (resumingRecovery) { + await ref.read(inviteJoinRecoveryProvider).ensureWelcomeChannel(); + state = state.copyWith(status: InviteJoinStatus.success); + return; + } + final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); if (existing != null) { @@ -163,15 +193,25 @@ class InviteJoinNotifier extends Notifier { await ref .read(authProvider.notifier) .authenticateWithCommunity(community); + state = state.copyWith( + claimCompleted: true, + communityName: community.name, + ); + await ref.read(inviteJoinRecoveryProvider).ensureWelcomeChannel(); state = state.copyWith( status: InviteJoinStatus.success, communityName: community.name, ); } catch (error) { - final requiresFreshInvite = _requiresFreshInvite(error); + final claimCompleted = state.claimCompleted; + final requiresFreshInvite = claimCompleted + ? false + : _requiresFreshInvite(error); state = state.copyWith( status: InviteJoinStatus.error, - errorMessage: _friendlyInviteError(error), + errorMessage: claimCompleted + ? _friendlyRecoveryError(error) + : _friendlyInviteError(error), requiresFreshInvite: requiresFreshInvite, ); } @@ -284,3 +324,8 @@ String _friendlyInviteError(Object error) { } return 'Could not join this community: $message'; } + +String _friendlyRecoveryError(Object error) { + final message = error.toString().replaceFirst('Exception: ', ''); + return 'You joined this community, but Buzz could not set up your Welcome channel: $message. Try again.'; +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 833d48b20..788ce5d6b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'features/invites/invite_join_provider.dart'; import 'shared/theme/theme_provider.dart'; void main() async { @@ -13,7 +14,10 @@ void main() async { runApp( ProviderScope( - overrides: [savedPrefsProvider.overrideWithValue(prefs)], + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + inviteJoinRecoveryProvider.overrideWith(buildMobileInviteJoinRecovery), + ], child: const App(), ), ); diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index d3a253238..1109dbc94 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -7,6 +7,8 @@ import 'package:http/testing.dart' as http_testing; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import 'package:buzz/app.dart'; +import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; @@ -88,6 +90,7 @@ void main() { communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinRecoveryProvider.overrideWithValue(_successfulRecovery()), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { capturedRequest = request; @@ -174,6 +177,125 @@ void main() { expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle); }); + test( + 'invite recovery creates the required private Welcome channel', + () async { + final created = {}; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async => const [], + createChannel: + ({ + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async { + created.addAll({ + 'name': name, + 'channelType': channelType, + 'visibility': visibility, + 'description': description, + }); + return _welcomeChannel; + }, + ); + + await recovery.ensureWelcomeChannel(); + + expect(created['name'], 'Welcome'); + expect(created['channelType'], 'stream'); + expect(created['visibility'], 'private'); + expect( + created['description'], + 'A private channel for getting oriented in this community.', + ); + }, + ); + + test( + 'failed Welcome recovery retries without reclaiming or replacing identity', + () async { + final keys = nostr.Keys.generate(); + var generatedKeys = 0; + var claimRequests = 0; + var recoveryAttempts = 0; + final storage = CommunityStorage(secure: FakeSecureStorage()); + final auth = _RecordingAuthNotifier(); + final recovery = MobileInviteJoinRecovery( + loadChannels: () async { + recoveryAttempts++; + if (recoveryAttempts == 1) { + throw Exception('relay disconnected'); + } + return [_welcomeChannel]; + }, + createChannel: + ({ + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => throw StateError('Welcome already exists'), + ); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + authProvider.overrideWith(() => auth), + inviteKeyGeneratorProvider.overrideWithValue(() { + generatedKeys++; + return keys; + }), + inviteJoinRecoveryProvider.overrideWithValue(recovery), + inviteJoinHttpClientProvider.overrideWithValue( + http_testing.MockClient((request) async { + claimRequests++; + return http.Response( + jsonEncode({ + 'status': 'joined', + 'host': 'relay.example.com', + 'role': 'member', + }), + 200, + ); + }), + ), + ], + ); + addTearDown(container.dispose); + + await container + .read(inviteJoinProvider.notifier) + .prepare( + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'code', + ), + ); + await container.read(inviteJoinProvider.notifier).confirmJoin(); + + final failed = container.read(inviteJoinProvider); + expect(failed.status, InviteJoinStatus.error); + expect(failed.claimCompleted, isTrue); + expect(failed.requiresFreshInvite, isFalse); + expect( + failed.errorMessage, + contains('could not set up your Welcome channel'), + ); + + await container.read(inviteJoinProvider.notifier).confirmJoin(); + + final recovered = container.read(inviteJoinProvider); + expect(recovered.status, InviteJoinStatus.success); + expect(recovered.errorMessage, isNull); + expect(generatedKeys, 1); + expect(claimRequests, 1); + expect(recoveryAttempts, 2); + expect(auth.authenticatedCommunities, hasLength(1)); + }, + ); + test('join_policy_required requires a fresh link and cannot retry', () async { final keys = nostr.Keys.generate(); var attempts = 0; @@ -272,6 +394,7 @@ void main() { communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinRecoveryProvider.overrideWithValue(_successfulRecovery()), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { attempts++; @@ -319,6 +442,30 @@ void main() { }); } +InviteJoinRecovery _successfulRecovery() => MobileInviteJoinRecovery( + loadChannels: () async => [_welcomeChannel], + createChannel: + ({ + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => throw StateError('Welcome already exists'), +); + +final _welcomeChannel = Channel( + id: 'welcome-id', + name: 'Welcome', + channelType: 'stream', + visibility: 'private', + description: 'A private channel for getting oriented in this community.', + createdBy: 'me', + createdAt: DateTime.utc(2026), + memberCount: 1, + isMember: true, +); + class _RecordingAuthNotifier extends AuthNotifier { final List authenticatedCommunities = [];