From 3f2aa6f22a97142976d347a87021ae3c8e4d01f3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 30 Oct 2026 16:40:57 -0400 Subject: [PATCH 1/5] fix(mobile): show a taken channel name plainly and keep invites working The relay refuses an open channel whose name another listed channel in the community has, with `invalid: an open channel named "X" already exists`. - The create sheet showed that as `Exception: invalid: an open channel named "X" already exists`. A new relayErrorMessage helper drops the Exception wrapper and the NIP-01 prefix, so the sheet shows `An open channel named "X" already exists`. - Invite starter setup created `general` and `welcome-everyone` when it found no open, unarchived stream with that name. An archived or forum namesake keeps the name, so the create was refused and the invite failed. Starter setup now reloads after that refusal, joins an open stream with the name if one now exists, and otherwise skips that starter instead of failing the join. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/app.dart | 22 +++- .../channels/channels_page/sheets.dart | 2 +- .../lib/shared/relay/relay_closed_policy.dart | 20 ++++ .../features/channels/channels_page_test.dart | 64 ++++++++++ .../invites/invite_join_provider_test.dart | 111 +++++++++++++++++- .../relay/relay_closed_policy_test.dart | 21 ++++ 6 files changed, 236 insertions(+), 4 deletions(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 594875c4c91..6c8709f37e8 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -121,7 +121,10 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { /// /// All starter work is fenced to the community and identity that started it. /// A partial setup remains recoverable, so a failed operation deliberately - /// propagates to the invite flow instead of being reported as success. + /// propagates to the invite flow instead of being reported as success. The + /// one exception is a starter name the relay reports as already held by a + /// channel that cannot serve as the starter: that is permanent, so the + /// starter is skipped instead of blocking the join. @override Future ensureStarterChannels() async { _ensureScopeCurrent(); @@ -147,7 +150,8 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { ); _ensureScopeCurrent(); } catch (error) { - if (!_isDuplicateChannelError(error)) rethrow; + final nameTaken = _isChannelNameTakenError(error); + if (!nameTaken && !_isDuplicateChannelError(error)) rethrow; _ensureScopeCurrent(); channels = await _loadChannels(); _ensureScopeCurrent(); @@ -156,6 +160,11 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { channels .where((candidate) => candidate.id == channelId) .firstOrNull; + // The relay reserves a listed channel's name, archived ones + // included, so a taken name will never become creatable on retry. + // An archived or non-stream namesake cannot serve as the starter, so + // skip it rather than strand the invite in permanent recovery. + if (channel == null && nameTaken) continue; if (channel == null) rethrow; } } @@ -198,6 +207,15 @@ Channel? _findStarterChannel(List channels, String name) { bool _isDuplicateChannelError(Object error) => error.toString().contains('duplicate: channel already exists'); +/// The relay refuses creating an open channel whose name another listed +/// channel already holds, ignoring case: +/// `invalid: an open channel named "general" already exists`. +bool _isChannelNameTakenError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('invalid: an open channel named') && + message.contains('already exists'); +} + /// Returns desktop's deterministic per-relay UUID for a public starter. @visibleForTesting String desktopStarterChannelId({ diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 8f40c704cfc..539e07e4814 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -58,7 +58,7 @@ class _CreateChannelSheet extends HookConsumerWidget { Navigator.of(context).pop(created); } } catch (error) { - if (context.mounted) errorMessage.value = error.toString(); + if (context.mounted) errorMessage.value = relayErrorMessage(error); } finally { if (context.mounted) isSubmitting.value = false; } diff --git a/mobile/lib/shared/relay/relay_closed_policy.dart b/mobile/lib/shared/relay/relay_closed_policy.dart index 4013470c7c9..fd4d6081424 100644 --- a/mobile/lib/shared/relay/relay_closed_policy.dart +++ b/mobile/lib/shared/relay/relay_closed_policy.dart @@ -80,6 +80,26 @@ RelayClosedClass classifyRelayClosed(String message) { return RelayClosedClass.retryable; } +final _relayErrorPrefixPattern = RegExp( + r'^(?:exception:\s*)?(?:(?:invalid|blocked|restricted|duplicate|pow|unsupported):\s*)?', + caseSensitive: false, +); + +/// User-facing text for a failed relay publish or request. +/// +/// Drops the Dart `Exception: ` wrapper and a leading NIP-01 machine prefix +/// (`invalid: `, `blocked: `, ...) so a relay refusal such as +/// `invalid: an open channel named "Launch" already exists` reads as +/// `An open channel named "Launch" already exists`. +String relayErrorMessage(Object error) { + final text = error.toString().trim().replaceFirst( + _relayErrorPrefixPattern, + '', + ); + if (text.isEmpty) return error.toString().trim(); + return text[0].toUpperCase() + text.substring(1); +} + final _rateLimitRetryPattern = RegExp(r'retry in (\d+)s', caseSensitive: false); /// Parses the relay's canonical `retry in Ns` hint, when present. diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 9aa9396692e..0dbb96bfd58 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -2919,6 +2919,49 @@ void main() { }, ); + testWidgets('create channel sheet shows a taken-name refusal as plain text', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + // Same shape relay_session completes a refused publish with. + channelActionsProvider.overrideWith( + (ref) => _RefusingCreateActions( + Exception( + 'invalid: an open channel named "Launch" already exists', + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + await tester.tap(find.text('New channel')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('create-channel-name')), + 'Launch', + ); + await tester.pump(); + // The sheet submits from the keyboard's done action. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect( + find.text('An open channel named "Launch" already exists'), + findsOneWidget, + ); + expect(find.textContaining('Exception'), findsNothing); + expect(find.textContaining('invalid:'), findsNothing); + // The sheet stays open so the user can pick another name. + expect(find.text('New channel'), findsOneWidget); + }); + dmRecipientLayoutTests( (overrides) => buildTestable( keyboardInset: 300, @@ -3982,3 +4025,24 @@ class _CreateSheetActions implements ChannelActions { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _RefusingCreateActions implements ChannelActions { + _RefusingCreateActions(this.error); + + final Object error; + + @override + Future createChannel({ + String? channelId, + required String name, + required String channelType, + required String visibility, + List labels = const [], + String? description, + int? ttlSeconds, + bool waitForChannelList = true, + }) async => throw error; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 68b1b3c58fa..008b34544b2 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -365,6 +365,112 @@ void main() { }, ); + test( + 'taken starter names are skipped or converged instead of failing the invite', + () async { + const refusal = 'invalid: an open channel named "general" already exists'; + final archivedGeneral = _channel( + id: 'old-general', + name: 'General', + archivedAt: DateTime.utc(2025), + ); + final forumGeneral = _channel( + id: 'forum-general', + name: 'general', + channelType: 'forum', + ); + for (final namesake in [archivedGeneral, forumGeneral]) { + final created = []; + final joined = []; + var loadCount = 0; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async { + loadCount++; + return [namesake]; + }, + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async { + created.add(name); + if (name == 'general') throw Exception(refusal); + return _channel(id: channelId, name: name, isMember: true); + }, + joinChannel: (channelId) async => joined.add(channelId), + relayHttpOrigin: 'https://relay.example.com', + ); + + final focusChannelId = await recovery.ensureStarterChannels(); + + // The unusable namesake is neither joined nor retried; the other + // starter is still created and focused. + expect(created, ['general', 'welcome-everyone'], reason: namesake.id); + expect(joined, isEmpty, reason: namesake.id); + expect(loadCount, 2, reason: namesake.id); + expect( + focusChannelId, + desktopStarterChannelId( + relayHttpOrigin: 'https://relay.example.com', + slug: 'welcome-everyone', + ), + ); + } + + // A usable open channel that appeared after the first load (lost race) + // is joined rather than skipped. + var loadCount = 0; + final joined = []; + final raced = MobileInviteJoinRecovery( + loadChannels: () async => ++loadCount == 1 + ? const [] + : [_channel(id: 'raced', name: 'general')], + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => name == 'general' + ? throw Exception(refusal) + : _channel(id: channelId, name: name, isMember: true), + joinChannel: (channelId) async => joined.add(channelId), + relayHttpOrigin: 'https://relay.example.com', + ); + await raced.ensureStarterChannels(); + expect(joined, ['raced']); + + // The scope fence still holds after a refusal. + var current = true; + final fenced = MobileInviteJoinRecovery( + loadChannels: () async { + final channels = [archivedGeneral]; + if (loadCount++ > 1) current = false; + return channels; + }, + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => throw Exception(refusal), + joinChannel: (_) async => fail('scope changed'), + relayHttpOrigin: 'https://relay.example.com', + isScopeCurrent: () => current, + ); + await expectLater(fenced.ensureStarterChannels(), throwsStateError); + }, + ); + test('starter setup surfaces an unavailable starter for recovery', () async { final joined = []; final recovery = MobileInviteJoinRecovery( @@ -1029,10 +1135,13 @@ Channel _channel({ required String name, String visibility = 'open', bool isMember = false, + String channelType = 'stream', + DateTime? archivedAt, }) => Channel( id: id, name: name, - channelType: 'stream', + channelType: channelType, + archivedAt: archivedAt, visibility: visibility, description: '', createdBy: 'me', diff --git a/mobile/test/shared/relay/relay_closed_policy_test.dart b/mobile/test/shared/relay/relay_closed_policy_test.dart index d83f658f40e..21cfcc36e9d 100644 --- a/mobile/test/shared/relay/relay_closed_policy_test.dart +++ b/mobile/test/shared/relay/relay_closed_policy_test.dart @@ -43,4 +43,25 @@ void main() { isNull, ); }); + + group('relayErrorMessage', () { + // Messages as relay_session completes a rejected publish: Exception(message). + const cases = { + 'invalid: an open channel named "Launch" already exists': + 'An open channel named "Launch" already exists', + 'blocked: community policy': 'Community policy', + 'relay unavailable': 'Relay unavailable', + 'Event rejected': 'Event rejected', + }; + for (final entry in cases.entries) { + test(entry.key, () { + expect(relayErrorMessage(Exception(entry.key)), entry.value); + }); + } + + test('a bare string error and an empty remainder stay readable', () { + expect(relayErrorMessage('invalid: bad name'), 'Bad name'); + expect(relayErrorMessage(Exception('')), 'Exception:'); + }); + }); } From 015de08ba7fcb81cbc9eb65402fd3406f719d911 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 30 Oct 2026 17:32:58 -0400 Subject: [PATCH 2/5] fix(mobile): skip a taken starter only when its holder is unusable A refused starter create was skipped whenever the directory did not show the name's holder, so a starter another client had just created, whose kind:39000 had not arrived, was treated as finished setup. Skip only a holder the directory shows cannot serve as the starter, and apply that to an archived channel at the deterministic starter id too. A holder the directory does not show keeps the setup recoverable. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/app.dart | 51 +++--- .../invites/invite_join_provider_test.dart | 166 +++++++++++------- 2 files changed, 128 insertions(+), 89 deletions(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 6c8709f37e8..4dc0e6401a0 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -122,9 +122,9 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { /// All starter work is fenced to the community and identity that started it. /// A partial setup remains recoverable, so a failed operation deliberately /// propagates to the invite flow instead of being reported as success. The - /// one exception is a starter name the relay reports as already held by a - /// channel that cannot serve as the starter: that is permanent, so the - /// starter is skipped instead of blocking the join. + /// one exception is a starter whose name or id the relay reports as taken + /// by a channel the directory shows cannot serve as the starter: that is + /// permanent, so the starter is skipped instead of blocking the join. @override Future ensureStarterChannels() async { _ensureScopeCurrent(); @@ -155,17 +155,20 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { _ensureScopeCurrent(); channels = await _loadChannels(); _ensureScopeCurrent(); - channel = + final holder = _findStarterChannel(channels, starter.slug) ?? channels .where((candidate) => candidate.id == channelId) - .firstOrNull; - // The relay reserves a listed channel's name, archived ones - // included, so a taken name will never become creatable on retry. - // An archived or non-stream namesake cannot serve as the starter, so - // skip it rather than strand the invite in permanent recovery. - if (channel == null && nameTaken) continue; - if (channel == null) rethrow; + .firstOrNull ?? + (nameTaken ? _findListedNamesake(channels, starter.slug) : null); + // A holder the directory does not show yet may still be usable, so + // keep the setup recoverable. + if (holder == null) rethrow; + // The relay keeps a channel's id and a listed channel's name, archived + // ones included, so an unusable holder never frees the starter. Skip + // it rather than strand the invite in permanent recovery. + if (!_isUsableStarter(holder)) continue; + channel = holder; } } @@ -191,18 +194,20 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { } } -Channel? _findStarterChannel(List channels, String name) { - final normalizedName = name.trim().toLowerCase(); - return channels - .where( - (channel) => - channel.name.trim().toLowerCase() == normalizedName && - channel.isStream && - channel.visibility == 'open' && - !channel.isArchived, - ) - .firstOrNull; -} +Channel? _findStarterChannel(List channels, String name) => channels + .where((channel) => _hasName(channel, name) && _isUsableStarter(channel)) + .firstOrNull; + +/// The open channel that holds [name] in the relay's listed-name rule. +Channel? _findListedNamesake(List channels, String name) => channels + .where((channel) => _hasName(channel, name) && channel.visibility == 'open') + .firstOrNull; + +bool _hasName(Channel channel, String name) => + channel.name.trim().toLowerCase() == name.trim().toLowerCase(); + +bool _isUsableStarter(Channel channel) => + channel.isStream && channel.visibility == 'open' && !channel.isArchived; bool _isDuplicateChannelError(Object error) => error.toString().contains('duplicate: channel already exists'); diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 008b34544b2..ca7465100bd 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -368,26 +368,33 @@ void main() { test( 'taken starter names are skipped or converged instead of failing the invite', () async { + const origin = 'https://relay.example.com'; const refusal = 'invalid: an open channel named "general" already exists'; - final archivedGeneral = _channel( - id: 'old-general', - name: 'General', - archivedAt: DateTime.utc(2025), + const duplicateId = 'duplicate: channel already exists'; + final generalId = desktopStarterChannelId( + relayHttpOrigin: origin, + slug: 'general', ); - final forumGeneral = _channel( - id: 'forum-general', - name: 'general', - channelType: 'forum', + final welcomeId = desktopStarterChannelId( + relayHttpOrigin: origin, + slug: 'welcome-everyone', ); - for (final namesake in [archivedGeneral, forumGeneral]) { + ({ + List created, + List joined, + MobileInviteJoinRecovery recovery, + int Function() loads, + }) + starterSetup({ + required List Function(int load) channelsOnLoad, + required String generalError, + bool Function()? isScopeCurrent, + }) { final created = []; final joined = []; var loadCount = 0; final recovery = MobileInviteJoinRecovery( - loadChannels: () async { - loadCount++; - return [namesake]; - }, + loadChannels: () async => channelsOnLoad(++loadCount), createChannel: ({ required channelId, @@ -398,76 +405,103 @@ void main() { ttlSeconds, }) async { created.add(name); - if (name == 'general') throw Exception(refusal); + if (name == 'general') throw Exception(generalError); return _channel(id: channelId, name: name, isMember: true); }, joinChannel: (channelId) async => joined.add(channelId), - relayHttpOrigin: 'https://relay.example.com', + relayHttpOrigin: origin, + isScopeCurrent: isScopeCurrent, ); + return ( + created: created, + joined: joined, + recovery: recovery, + loads: () => loadCount, + ); + } - final focusChannelId = await recovery.ensureStarterChannels(); - - // The unusable namesake is neither joined nor retried; the other - // starter is still created and focused. - expect(created, ['general', 'welcome-everyone'], reason: namesake.id); - expect(joined, isEmpty, reason: namesake.id); - expect(loadCount, 2, reason: namesake.id); - expect( - focusChannelId, - desktopStarterChannelId( - relayHttpOrigin: 'https://relay.example.com', - slug: 'welcome-everyone', + // A holder the directory shows cannot serve as the starter is neither + // joined nor retried; the other starter is still created and focused. + final unusableHolders = [ + ( + _channel( + id: 'old-general', + name: 'General', + archivedAt: DateTime.utc(2025), + ), + refusal, + ), + ( + _channel(id: 'forum-general', name: 'general', channelType: 'forum'), + refusal, + ), + ( + _channel( + id: generalId, + name: 'general', + archivedAt: DateTime.utc(2025), ), + duplicateId, + ), + ]; + for (final (holder, error) in unusableHolders) { + final setup = starterSetup( + channelsOnLoad: (_) => [holder], + generalError: error, ); + + final focusChannelId = await setup.recovery.ensureStarterChannels(); + + expect(setup.created, [ + 'general', + 'welcome-everyone', + ], reason: holder.id); + expect(setup.joined, isEmpty, reason: holder.id); + expect(setup.loads(), 2, reason: holder.id); + expect(focusChannelId, welcomeId, reason: holder.id); } + // A taken name whose holder the directory does not show yet stays + // recoverable: it may be a usable starter another client just created. + final unseen = starterSetup( + channelsOnLoad: (_) => const [], + generalError: refusal, + ); + await expectLater( + unseen.recovery.ensureStarterChannels(), + throwsException, + ); + expect(unseen.created, ['general']); + expect(unseen.joined, isEmpty); + expect(unseen.loads(), 2); + // A usable open channel that appeared after the first load (lost race) // is joined rather than skipped. - var loadCount = 0; - final joined = []; - final raced = MobileInviteJoinRecovery( - loadChannels: () async => ++loadCount == 1 - ? const [] - : [_channel(id: 'raced', name: 'general')], - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) async => name == 'general' - ? throw Exception(refusal) - : _channel(id: channelId, name: name, isMember: true), - joinChannel: (channelId) async => joined.add(channelId), - relayHttpOrigin: 'https://relay.example.com', + final raced = starterSetup( + channelsOnLoad: (load) => + load == 1 ? const [] : [_channel(id: 'raced', name: 'general')], + generalError: refusal, ); - await raced.ensureStarterChannels(); - expect(joined, ['raced']); + await raced.recovery.ensureStarterChannels(); + expect(raced.joined, ['raced']); - // The scope fence still holds after a refusal. + // The scope fence still holds after a refusal and its reload. var current = true; - final fenced = MobileInviteJoinRecovery( - loadChannels: () async { - final channels = [archivedGeneral]; - if (loadCount++ > 1) current = false; - return channels; + final fenced = starterSetup( + channelsOnLoad: (load) { + if (load == 2) current = false; + return [unusableHolders.first.$1]; }, - createChannel: - ({ - required channelId, - required name, - required channelType, - required visibility, - description, - ttlSeconds, - }) async => throw Exception(refusal), - joinChannel: (_) async => fail('scope changed'), - relayHttpOrigin: 'https://relay.example.com', + generalError: refusal, isScopeCurrent: () => current, ); - await expectLater(fenced.ensureStarterChannels(), throwsStateError); + await expectLater( + fenced.recovery.ensureStarterChannels(), + throwsStateError, + ); + expect(fenced.created, ['general']); + expect(fenced.loads(), 2); + expect(fenced.joined, isEmpty); }, ); From febcc8f756053b055558f56919f2fa1a247602ab Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 2 Nov 2026 09:16:32 -0500 Subject: [PATCH 3/5] fix(mobile): skip an archived starter at desktop's id Desktop and mobile derive the same starter channel id, so an archived starter left at that id makes the relay answer "duplicate: channel already exists". The directory omits archived channels, so the reload found no holder and the invite stayed stuck in setup. When a create is refused as a duplicate and the reload has no match, read the channel at that id directly (kind:39000 via fetchOpenChannel) and skip the starter if it is archived. Every other case still rethrows so setup stays recoverable. Also make the scope-fence test able to fail: it now flips the scope on the reload after the last starter's refusal, so deleting the post-reload fence fails it. relayErrorMessage also strips the relay's error:, rate-limited: and auth-required: prefixes. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/app.dart | 35 ++++-- .../lib/shared/relay/relay_closed_policy.dart | 4 +- .../invites/invite_join_provider_test.dart | 101 ++++++++++++++++-- .../relay/relay_closed_policy_test.dart | 3 + 4 files changed, 125 insertions(+), 18 deletions(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 4dc0e6401a0..d4e92bb03fb 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -83,6 +83,7 @@ Future _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) { /// App-level bridge from invite joining to the channels feature. class MobileInviteJoinRecovery implements InviteJoinRecovery { final Future> Function() _loadChannels; + final Future Function(String channelId) _fetchChannel; final Future Function({ required String channelId, required String name, @@ -96,9 +97,11 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { final String _relayHttpOrigin; final bool Function() _isScopeCurrent; - /// Creates recovery from channel-loading, creation, and join operations. + /// Creates recovery from channel-loading, single-channel lookup, creation, + /// and join operations. MobileInviteJoinRecovery({ required Future> Function() loadChannels, + required Future Function(String channelId) fetchChannel, required Future Function({ required String channelId, required String name, @@ -112,6 +115,7 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { required String relayHttpOrigin, bool Function()? isScopeCurrent, }) : _loadChannels = loadChannels, + _fetchChannel = fetchChannel, _createChannel = createChannel, _joinChannel = joinChannel, _relayHttpOrigin = relayHttpOrigin, @@ -123,8 +127,9 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { /// A partial setup remains recoverable, so a failed operation deliberately /// propagates to the invite flow instead of being reported as success. The /// one exception is a starter whose name or id the relay reports as taken - /// by a channel the directory shows cannot serve as the starter: that is - /// permanent, so the starter is skipped instead of blocking the join. + /// by a channel the directory shows cannot serve as the starter, or an + /// archived channel at the starter's own id that the directory omits: both + /// are permanent, so the starter is skipped instead of blocking the join. @override Future ensureStarterChannels() async { _ensureScopeCurrent(); @@ -161,9 +166,19 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { .where((candidate) => candidate.id == channelId) .firstOrNull ?? (nameTaken ? _findListedNamesake(channels, starter.slug) : null); - // A holder the directory does not show yet may still be usable, so - // keep the setup recoverable. - if (holder == null) rethrow; + if (holder == null) { + // The directory omits archived channels, yet the relay still + // keeps the id (desktop uses the same one), so a duplicate with + // no match may be an archived starter. Read that id directly. + if (!nameTaken && _isDuplicateChannelError(error)) { + final atId = await _fetchChannel(channelId); + _ensureScopeCurrent(); + if (atId != null && atId.isArchived) continue; + } + // A holder not visible yet may still be usable, so keep the setup + // recoverable. + rethrow; + } // The relay keeps a channel's id and a listed channel's name, archived // ones included, so an unusable holder never frees the starter. Skip // it rather than strand the invite in permanent recovery. @@ -271,6 +286,14 @@ InviteJoinRecovery buildMobileInviteJoinRecovery( ensureScopeCurrent(); return channels; }, + fetchChannel: (channelId) async { + ensureScopeCurrent(); + final channel = await ref + .read(channelsProvider.notifier) + .fetchOpenChannel(channelId); + ensureScopeCurrent(); + return channel; + }, createChannel: ({ required channelId, diff --git a/mobile/lib/shared/relay/relay_closed_policy.dart b/mobile/lib/shared/relay/relay_closed_policy.dart index fd4d6081424..9f0b0dfa28f 100644 --- a/mobile/lib/shared/relay/relay_closed_policy.dart +++ b/mobile/lib/shared/relay/relay_closed_policy.dart @@ -81,14 +81,14 @@ RelayClosedClass classifyRelayClosed(String message) { } final _relayErrorPrefixPattern = RegExp( - r'^(?:exception:\s*)?(?:(?:invalid|blocked|restricted|duplicate|pow|unsupported):\s*)?', + r'^(?:exception:\s*)?(?:(?:invalid|blocked|restricted|duplicate|pow|unsupported|error|rate-limited|auth-required):\s*)?', caseSensitive: false, ); /// User-facing text for a failed relay publish or request. /// /// Drops the Dart `Exception: ` wrapper and a leading NIP-01 machine prefix -/// (`invalid: `, `blocked: `, ...) so a relay refusal such as +/// (`invalid: `, `blocked: `, `error: `, ...) so a relay refusal such as /// `invalid: an open channel named "Launch" already exists` reads as /// `An open channel named "Launch" already exists`. String relayErrorMessage(Object error) { diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index ca7465100bd..8a38d40841b 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -258,6 +258,7 @@ void main() { visibility: 'private', ), ], + fetchChannel: (_) async => fail('no direct lookup expected'), createChannel: ({ required channelId, @@ -284,6 +285,7 @@ void main() { final created = >{}; final recovery = MobileInviteJoinRecovery( loadChannels: () async => const [], + fetchChannel: (_) async => fail('no direct lookup expected'), createChannel: ({ required channelId, @@ -342,6 +344,7 @@ void main() { _channel(id: 'relay-welcome', name: 'welcome-everyone'), ]; }, + fetchChannel: (_) async => fail('no direct lookup expected'), createChannel: ({ required channelId, @@ -382,19 +385,29 @@ void main() { ({ List created, List joined, + List lookups, MobileInviteJoinRecovery recovery, int Function() loads, }) starterSetup({ required List Function(int load) channelsOnLoad, - required String generalError, + required String refusalError, + Channel? atId, + Set refused = const {'general'}, bool Function()? isScopeCurrent, + void Function()? onLookup, }) { final created = []; final joined = []; + final lookups = []; var loadCount = 0; final recovery = MobileInviteJoinRecovery( loadChannels: () async => channelsOnLoad(++loadCount), + fetchChannel: (channelId) async { + lookups.add(channelId); + onLookup?.call(); + return atId; + }, createChannel: ({ required channelId, @@ -405,7 +418,9 @@ void main() { ttlSeconds, }) async { created.add(name); - if (name == 'general') throw Exception(generalError); + if (refused.contains(name)) { + throw Exception(refusalError); + } return _channel(id: channelId, name: name, isMember: true); }, joinChannel: (channelId) async => joined.add(channelId), @@ -415,6 +430,7 @@ void main() { return ( created: created, joined: joined, + lookups: lookups, recovery: recovery, loads: () => loadCount, ); @@ -447,7 +463,7 @@ void main() { for (final (holder, error) in unusableHolders) { final setup = starterSetup( channelsOnLoad: (_) => [holder], - generalError: error, + refusalError: error, ); final focusChannelId = await setup.recovery.ensureStarterChannels(); @@ -461,11 +477,69 @@ void main() { expect(focusChannelId, welcomeId, reason: holder.id); } + // An archived channel at the starter's own id, which the directory does + // not return, is found by a direct lookup and skipped, not rethrown. + final archivedAtId = starterSetup( + channelsOnLoad: (_) => const [], + refusalError: duplicateId, + atId: _channel( + id: generalId, + name: 'general', + archivedAt: DateTime.utc(2025), + ), + ); + final focusAfterArchived = await archivedAtId.recovery + .ensureStarterChannels(); + expect(archivedAtId.created, ['general', 'welcome-everyone']); + expect(archivedAtId.lookups, [generalId]); + expect(archivedAtId.joined, isEmpty); + expect(archivedAtId.loads(), 2); + expect(focusAfterArchived, welcomeId); + + // The scope fence also holds across the direct lookup, checked on the + // last starter so the next starter's own fence cannot mask it. + var lookedUp = false; + final fencedLookup = starterSetup( + channelsOnLoad: (_) => const [], + refusalError: duplicateId, + refused: const {'welcome-everyone'}, + atId: _channel( + id: welcomeId, + name: 'welcome-everyone', + archivedAt: DateTime.utc(2025), + ), + isScopeCurrent: () => !lookedUp, + onLookup: () => lookedUp = true, + ); + await expectLater( + fencedLookup.recovery.ensureStarterChannels(), + throwsStateError, + ); + expect(fencedLookup.lookups, [welcomeId]); + + // Any other duplicate stays recoverable: nothing at the id, or a + // channel there that is not archived, may be a starter still settling. + for (final atId in [null, _channel(id: generalId, name: 'general')]) { + final settling = starterSetup( + channelsOnLoad: (_) => const [], + refusalError: duplicateId, + atId: atId, + ); + await expectLater( + settling.recovery.ensureStarterChannels(), + throwsException, + reason: '$atId', + ); + expect(settling.created, ['general']); + expect(settling.lookups, [generalId]); + expect(settling.joined, isEmpty); + } + // A taken name whose holder the directory does not show yet stays // recoverable: it may be a usable starter another client just created. final unseen = starterSetup( channelsOnLoad: (_) => const [], - generalError: refusal, + refusalError: refusal, ); await expectLater( unseen.recovery.ensureStarterChannels(), @@ -474,33 +548,38 @@ void main() { expect(unseen.created, ['general']); expect(unseen.joined, isEmpty); expect(unseen.loads(), 2); + expect(unseen.lookups, isEmpty); // A usable open channel that appeared after the first load (lost race) // is joined rather than skipped. final raced = starterSetup( channelsOnLoad: (load) => load == 1 ? const [] : [_channel(id: 'raced', name: 'general')], - generalError: refusal, + refusalError: refusal, ); await raced.recovery.ensureStarterChannels(); expect(raced.joined, ['raced']); // The scope fence still holds after a refusal and its reload. + // The reload after the last starter's refusal must be the one that + // observes the change, so only the post-reload fence can catch it. var current = true; + var fencedLoads = 0; final fenced = starterSetup( - channelsOnLoad: (load) { - if (load == 2) current = false; + channelsOnLoad: (_) { + if (++fencedLoads > 2) current = false; return [unusableHolders.first.$1]; }, - generalError: refusal, + refusalError: refusal, + refused: const {'general', 'welcome-everyone'}, isScopeCurrent: () => current, ); await expectLater( fenced.recovery.ensureStarterChannels(), throwsStateError, ); - expect(fenced.created, ['general']); - expect(fenced.loads(), 2); + expect(fenced.created, ['general', 'welcome-everyone']); + expect(fenced.loads(), 3); expect(fenced.joined, isEmpty); }, ); @@ -511,6 +590,7 @@ void main() { loadChannels: () async => [ _channel(id: 'welcome-id', name: 'welcome-everyone'), ], + fetchChannel: (_) async => fail('no direct lookup expected'), createChannel: ({ required channelId, @@ -637,6 +717,7 @@ void main() { _channel(id: 'general-id', name: 'general'), _channel(id: 'welcome-id', name: 'welcome-everyone'), ], + fetchChannel: (_) async => fail('no direct lookup expected'), createChannel: ({ required channelId, diff --git a/mobile/test/shared/relay/relay_closed_policy_test.dart b/mobile/test/shared/relay/relay_closed_policy_test.dart index 21cfcc36e9d..b9f098e429c 100644 --- a/mobile/test/shared/relay/relay_closed_policy_test.dart +++ b/mobile/test/shared/relay/relay_closed_policy_test.dart @@ -50,6 +50,9 @@ void main() { 'invalid: an open channel named "Launch" already exists': 'An open channel named "Launch" already exists', 'blocked: community policy': 'Community policy', + 'error: internal failure': 'Internal failure', + 'rate-limited: slow down, retry in 3s': 'Slow down, retry in 3s', + 'auth-required: sign in first': 'Sign in first', 'relay unavailable': 'Relay unavailable', 'Event rejected': 'Event rejected', }; From 404e68911077d53f00a99faf4667ddd5abff148d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 2 Nov 2026 09:47:08 -0500 Subject: [PATCH 4/5] fix(mobile): skip a starter whose id an archived channel holds The relay refuses a kind:9007 whose `h` is an archived channel with `invalid: channel is archived`, before its duplicate check. Desktop and mobile create a starter at the same fixed id, so an archived starter there left invite setup in permanent recovery. Treat that refusal as an archived starter: join a live namesake if the reload shows one, else skip it. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/app.dart | 14 +++++++++++++- .../invites/invite_join_provider_test.dart | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index d4e92bb03fb..98484d606a1 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -156,7 +156,10 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { _ensureScopeCurrent(); } catch (error) { final nameTaken = _isChannelNameTakenError(error); - if (!nameTaken && !_isDuplicateChannelError(error)) rethrow; + final archivedAtId = _isArchivedChannelError(error); + if (!nameTaken && !archivedAtId && !_isDuplicateChannelError(error)) { + rethrow; + } _ensureScopeCurrent(); channels = await _loadChannels(); _ensureScopeCurrent(); @@ -167,6 +170,10 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { .firstOrNull ?? (nameTaken ? _findListedNamesake(channels, starter.slug) : null); if (holder == null) { + // The relay refuses a create at an archived channel's id before + // its duplicate check, and keeps that id, so the starter can never + // be created there. + if (archivedAtId) continue; // The directory omits archived channels, yet the relay still // keeps the id (desktop uses the same one), so a duplicate with // no match may be an archived starter. Read that id directly. @@ -227,6 +234,11 @@ bool _isUsableStarter(Channel channel) => bool _isDuplicateChannelError(Object error) => error.toString().contains('duplicate: channel already exists'); +/// The relay refuses any channel event whose `h` is an archived channel, +/// a create at that id included: `invalid: channel is archived`. +bool _isArchivedChannelError(Object error) => + error.toString().contains('invalid: channel is archived'); + /// The relay refuses creating an open channel whose name another listed /// channel already holds, ignoring case: /// `invalid: an open channel named "general" already exists`. diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 8a38d40841b..541b7fc987b 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -496,6 +496,20 @@ void main() { expect(archivedAtId.loads(), 2); expect(focusAfterArchived, welcomeId); + // The relay refuses a create at an archived channel's id with + // `channel is archived`; that starter is skipped without a lookup. + final refusedAsArchived = starterSetup( + channelsOnLoad: (_) => const [], + refusalError: 'invalid: channel is archived', + ); + final focusAfterRefusal = await refusedAsArchived.recovery + .ensureStarterChannels(); + expect(refusedAsArchived.created, ['general', 'welcome-everyone']); + expect(refusedAsArchived.lookups, isEmpty); + expect(refusedAsArchived.joined, isEmpty); + expect(refusedAsArchived.loads(), 2); + expect(focusAfterRefusal, welcomeId); + // The scope fence also holds across the direct lookup, checked on the // last starter so the next starter's own fence cannot mask it. var lookedUp = false; From 52bd7d1bb89514f6dc8ab1fc66562c47d49bb403 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 2 Nov 2026 10:04:55 -0500 Subject: [PATCH 5/5] fix(mobile): count only a listed namesake as holding a starter name Starter setup skipped a starter when a taken-name refusal had a namesake in the reload that could not serve as the starter. It took any open namesake for the holder, but an unlisted channel, or a DM, holds no name in the relay's listed-name rule. A member of an unlisted forum named general then skipped general for good while the real holder was still settling. Carry the relay's bare ["unlisted"] kind:39000 flag on Channel, and treat a namesake as the holder only when it is an open, not unlisted stream or forum. Otherwise the setup stays recoverable, and a retry joins the real holder. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/app.dart | 20 +++++++++---- mobile/lib/features/channels/channel.dart | 9 ++++++ .../features/channels/channels_provider.dart | 1 + mobile/lib/shared/relay/nostr_models.dart | 6 ++++ .../channels/channels_provider_test.dart | 10 +++++-- .../invites/invite_join_provider_test.dart | 28 +++++++++++++++++++ 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 98484d606a1..301c35fd301 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -128,7 +128,7 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { /// propagates to the invite flow instead of being reported as success. The /// one exception is a starter whose name or id the relay reports as taken /// by a channel the directory shows cannot serve as the starter, or an - /// archived channel at the starter's own id that the directory omits: both + /// archived channel at the starter's own id: both /// are permanent, so the starter is skipped instead of blocking the join. @override Future ensureStarterChannels() async { @@ -174,9 +174,10 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { // its duplicate check, and keeps that id, so the starter can never // be created there. if (archivedAtId) continue; - // The directory omits archived channels, yet the relay still - // keeps the id (desktop uses the same one), so a duplicate with - // no match may be an archived starter. Read that id directly. + // The relay keeps the id (desktop uses the same one), so a + // duplicate with no match in the reload may be an archived + // starter whose metadata the reload did not return. Read that id + // directly. if (!nameTaken && _isDuplicateChannelError(error)) { final atId = await _fetchChannel(channelId); _ensureScopeCurrent(); @@ -220,9 +221,16 @@ Channel? _findStarterChannel(List channels, String name) => channels .where((channel) => _hasName(channel, name) && _isUsableStarter(channel)) .firstOrNull; -/// The open channel that holds [name] in the relay's listed-name rule. +/// The channel that holds [name] in the relay's listed-name rule: an open, +/// not unlisted stream or forum (`LISTED_CHANNEL_SQL` in buzz-db). Channel? _findListedNamesake(List channels, String name) => channels - .where((channel) => _hasName(channel, name) && channel.visibility == 'open') + .where( + (channel) => + _hasName(channel, name) && + channel.visibility == 'open' && + !channel.isUnlisted && + (channel.isStream || channel.isForum), + ) .firstOrNull; bool _hasName(Channel channel, String name) => diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 8c20799b1e1..d1409d169a3 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -30,6 +30,10 @@ class Channel { final int? ttlSeconds; final DateTime? ttlDeadline; + /// Left out of listings; such a channel does not hold its name in the + /// relay's listed-name rule. + final bool isUnlisted; + const Channel({ required this.id, required this.name, @@ -49,6 +53,7 @@ class Channel { this.isMember = false, this.ttlSeconds, this.ttlDeadline, + this.isUnlisted = false, }); factory Channel.fromJson(Map json) => Channel( @@ -79,6 +84,7 @@ class Channel { ttlDeadline: json['ttl_deadline'] != null ? DateTime.parse(json['ttl_deadline'] as String) : null, + isUnlisted: json['unlisted'] as bool? ?? false, ); /// Inverse of [Channel.fromJson]. @@ -101,6 +107,7 @@ class Channel { 'is_member': isMember, 'ttl_seconds': ttlSeconds, 'ttl_deadline': ttlDeadline?.toIso8601String(), + 'unlisted': isUnlisted, }; bool get isEphemeral => ttlSeconds != null || ttlDeadline != null; @@ -181,6 +188,7 @@ class Channel { isMember: isMember, ttlSeconds: details.ttlSeconds, ttlDeadline: details.ttlDeadline, + isUnlisted: isUnlisted, ); Channel copyWith({ @@ -213,6 +221,7 @@ class Channel { isMember: isMember ?? this.isMember, ttlSeconds: ttlSeconds, ttlDeadline: ttlDeadline, + isUnlisted: isUnlisted, ); } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 333a01b5822..88c4e70ed98 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -830,6 +830,7 @@ class ChannelsNotifier extends AsyncNotifier> { isMember: isMember, ttlSeconds: data.ttlSeconds, ttlDeadline: data.ttlDeadline, + isUnlisted: data.isUnlisted, ); } diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 551884c0dc4..82452faf45c 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -301,6 +301,10 @@ class ChannelData { final DateTime? ttlDeadline; final bool isArchived; + /// The relay's bare `["unlisted"]` flag: an unlisted channel is left out of + /// listings and does not hold its name in the listed-name rule. + final bool isUnlisted; + const ChannelData({ required this.id, required this.name, @@ -314,6 +318,7 @@ class ChannelData { this.ttlSeconds, this.ttlDeadline, this.isArchived = false, + this.isUnlisted = false, }); factory ChannelData.fromEvent(NostrEvent event) { @@ -368,6 +373,7 @@ class ChannelData { ttlSeconds: ttlSeconds, ttlDeadline: ttlDeadline, isArchived: isArchived, + isUnlisted: event.tags.any((t) => t.isNotEmpty && t[0] == 'unlisted'), ); } } diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index cd42c737fc8..28834132767 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -2849,7 +2849,7 @@ void main() { }); test( - 'archived kind:39000 metadata sets Channel.isArchived (covers TTL auto-archive)', + 'archived and unlisted kind:39000 flags reach Channel (covers TTL auto-archive)', () async { // The relay's TTL reaper auto-archives expired ephemeral channels and // republishes kind:39000 with `["archived", "true"]`. The Channel needs @@ -2864,7 +2864,7 @@ void main() { _membership(_channelB, myPk), ], metadata: [ - _meta(id: _channelA, name: 'active'), + _meta(id: _channelA, name: 'active', unlisted: true), _meta( id: _channelB, name: 'expired-ttl', @@ -2883,6 +2883,10 @@ void main() { // The active channel must not be flagged archived. final active = channels.firstWhere((c) => c.name == 'active'); expect(active.isArchived, isFalse); + // The bare `["unlisted"]` flag: an unlisted namesake does not hold a + // name in the relay's listed-name rule (starter setup relies on it). + expect(active.isUnlisted, isTrue); + expect(expired.isUnlisted, isFalse); }, ); @@ -3132,6 +3136,7 @@ NostrEvent _meta({ int createdAt = 1, int? ttlSeconds, bool archived = false, + bool unlisted = false, List participants = const [], }) => NostrEvent( id: 'meta-$id', @@ -3145,6 +3150,7 @@ NostrEvent _meta({ [visibility == 'private' ? 'private' : 'public'], if (ttlSeconds != null) ['ttl', '$ttlSeconds'], if (archived) ['archived', 'true'], + if (unlisted) ['unlisted'], for (final participant in participants) ['p', participant], ], content: '', diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 541b7fc987b..959ff296d0f 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -564,6 +564,32 @@ void main() { expect(unseen.loads(), 2); expect(unseen.lookups, isEmpty); + // An unlisted namesake does not hold the name, so it does not explain + // the refusal either: the setup stays recoverable, and a retry joins + // the real holder once it appears. + final unlistedForum = _channel( + id: 'unlisted-general', + name: 'general', + channelType: 'forum', + isUnlisted: true, + isMember: true, + ); + final realGeneral = _channel(id: 'real-general', name: 'general'); + final unlisted = starterSetup( + channelsOnLoad: (load) => + load <= 2 ? [unlistedForum] : [unlistedForum, realGeneral], + refusalError: refusal, + ); + await expectLater( + unlisted.recovery.ensureStarterChannels(), + throwsException, + ); + expect(unlisted.created, ['general']); + expect(unlisted.joined, isEmpty); + final focusAfterRetry = await unlisted.recovery.ensureStarterChannels(); + expect(unlisted.joined, ['real-general']); + expect(focusAfterRetry, welcomeId); + // A usable open channel that appeared after the first load (lost race) // is joined rather than skipped. final raced = starterSetup( @@ -1266,11 +1292,13 @@ Channel _channel({ bool isMember = false, String channelType = 'stream', DateTime? archivedAt, + bool isUnlisted = false, }) => Channel( id: id, name: name, channelType: channelType, archivedAt: archivedAt, + isUnlisted: isUnlisted, visibility: visibility, description: '', createdBy: 'me',