mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user