From cca43667e89b4d8da0cb391e481767991e522ca3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 30 Oct 2026 16:40:57 -0400 Subject: [PATCH] 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 63289cb2e9..fbde133e88 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -122,7 +122,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(); @@ -148,7 +151,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(); @@ -157,6 +161,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; } } @@ -199,6 +208,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 513537aba3..1b1a95b794 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) { - errorMessage.value = error.toString(); + errorMessage.value = relayErrorMessage(error); } finally { isSubmitting.value = false; } diff --git a/mobile/lib/shared/relay/relay_closed_policy.dart b/mobile/lib/shared/relay/relay_closed_policy.dart index 4013470c7c..fd4d608142 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 231c99b897..dd9adb84d9 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -2833,6 +2833,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('Create a new channel'), findsOneWidget); + }); + dmRecipientLayoutTests( (overrides) => buildTestable( keyboardInset: 300, @@ -3844,3 +3887,24 @@ class _PendingDmActions 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 1ea3533a84..1575990338 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( @@ -939,10 +1045,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 d83f658f40..21cfcc36e9 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:'); + }); + }); }