fix(mobile): recover Welcome after invite join

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
This commit is contained in:
Tom Brow
2026-08-14 16:24:11 -07:00
co-authored by Codex
parent 51beba6038
commit 419281de2c
4 changed files with 316 additions and 6 deletions
+114
View File
@@ -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<void, String>((
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<void>();
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<List<Channel>> Function() _loadChannels;
final Future<Channel> 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<List<Channel>> Function() loadChannels,
required Future<Channel> 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<void> 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
@@ -21,6 +21,20 @@ final inviteKeyGeneratorProvider = Provider<InviteKeyGenerator>((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<void> ensureWelcomeChannel();
}
final inviteJoinRecoveryProvider = Provider<InviteJoinRecovery>((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<InviteJoinState> {
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<InviteJoinState> {
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.';
}
+5 -1
View File
@@ -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(),
),
);
@@ -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 = <String, Object?>{};
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<Community> authenticatedCommunities = [];