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:'); + }); + }); }