diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 594875c4c91..301c35fd301 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, @@ -121,7 +125,11 @@ 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 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: both + /// are permanent, so the starter is skipped instead of blocking the join. @override Future ensureStarterChannels() async { _ensureScopeCurrent(); @@ -147,16 +155,43 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { ); _ensureScopeCurrent(); } catch (error) { - if (!_isDuplicateChannelError(error)) rethrow; + final nameTaken = _isChannelNameTakenError(error); + final archivedAtId = _isArchivedChannelError(error); + if (!nameTaken && !archivedAtId && !_isDuplicateChannelError(error)) { + rethrow; + } _ensureScopeCurrent(); channels = await _loadChannels(); _ensureScopeCurrent(); - channel = + final holder = _findStarterChannel(channels, starter.slug) ?? channels .where((candidate) => candidate.id == channelId) - .firstOrNull; - if (channel == null) rethrow; + .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 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(); + 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. + if (!_isUsableStarter(holder)) continue; + channel = holder; } } @@ -182,22 +217,45 @@ 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 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' && + !channel.isUnlisted && + (channel.isStream || channel.isForum), + ) + .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'); +/// 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`. +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({ @@ -248,6 +306,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/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_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/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/lib/shared/relay/relay_closed_policy.dart b/mobile/lib/shared/relay/relay_closed_policy.dart index 4013470c7c9..9f0b0dfa28f 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|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: `, `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) { + 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/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 68b1b3c58fa..959ff296d0f 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, @@ -365,12 +368,269 @@ 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'; + const duplicateId = 'duplicate: channel already exists'; + final generalId = desktopStarterChannelId( + relayHttpOrigin: origin, + slug: 'general', + ); + final welcomeId = desktopStarterChannelId( + relayHttpOrigin: origin, + slug: 'welcome-everyone', + ); + ({ + List created, + List joined, + List lookups, + MobileInviteJoinRecovery recovery, + int Function() loads, + }) + starterSetup({ + required List Function(int load) channelsOnLoad, + 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, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async { + created.add(name); + if (refused.contains(name)) { + throw Exception(refusalError); + } + return _channel(id: channelId, name: name, isMember: true); + }, + joinChannel: (channelId) async => joined.add(channelId), + relayHttpOrigin: origin, + isScopeCurrent: isScopeCurrent, + ); + return ( + created: created, + joined: joined, + lookups: lookups, + recovery: recovery, + loads: () => loadCount, + ); + } + + // 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], + refusalError: 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); + } + + // 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 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; + 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 [], + refusalError: refusal, + ); + await expectLater( + unseen.recovery.ensureStarterChannels(), + throwsException, + ); + expect(unseen.created, ['general']); + expect(unseen.joined, isEmpty); + 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( + channelsOnLoad: (load) => + load == 1 ? const [] : [_channel(id: 'raced', name: 'general')], + 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: (_) { + if (++fencedLoads > 2) current = false; + return [unusableHolders.first.$1]; + }, + refusalError: refusal, + refused: const {'general', 'welcome-everyone'}, + isScopeCurrent: () => current, + ); + await expectLater( + fenced.recovery.ensureStarterChannels(), + throwsStateError, + ); + expect(fenced.created, ['general', 'welcome-everyone']); + expect(fenced.loads(), 3); + expect(fenced.joined, isEmpty); + }, + ); + test('starter setup surfaces an unavailable starter for recovery', () async { final joined = []; final recovery = MobileInviteJoinRecovery( loadChannels: () async => [ _channel(id: 'welcome-id', name: 'welcome-everyone'), ], + fetchChannel: (_) async => fail('no direct lookup expected'), createChannel: ({ required channelId, @@ -497,6 +757,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, @@ -1029,10 +1290,15 @@ Channel _channel({ required String name, String visibility = 'open', bool isMember = false, + String channelType = 'stream', + DateTime? archivedAt, + bool isUnlisted = false, }) => Channel( id: id, name: name, - channelType: 'stream', + channelType: channelType, + archivedAt: archivedAt, + isUnlisted: isUnlisted, 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..b9f098e429c 100644 --- a/mobile/test/shared/relay/relay_closed_policy_test.dart +++ b/mobile/test/shared/relay/relay_closed_policy_test.dart @@ -43,4 +43,28 @@ 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', + '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', + }; + 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:'); + }); + }); }