mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user