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 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-10-30 16:40:57 -04:00
parent c9b8f1176e
commit cca43667e8
6 changed files with 236 additions and 4 deletions

View File

@@ -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<String?> 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<Channel> 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({

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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<Channel> createChannel({
String? channelId,
required String name,
required String channelType,
required String visibility,
List<String> labels = const [],
String? description,
int? ttlSeconds,
bool waitForChannelList = true,
}) async => throw error;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

View File

@@ -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 = <String>[];
final joined = <String>[];
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 = <String>[];
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 = <String>[];
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',

View File

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