Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 84 additions & 18 deletions mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Future<void> _waitForInviteRelay(Ref ref, bool Function() isScopeCurrent) {
/// App-level bridge from invite joining to the channels feature.
class MobileInviteJoinRecovery implements InviteJoinRecovery {
final Future<List<Channel>> Function() _loadChannels;
final Future<Channel?> Function(String channelId) _fetchChannel;
final Future<Channel> Function({
required String channelId,
required String name,
Expand All @@ -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<List<Channel>> Function() loadChannels,
required Future<Channel?> Function(String channelId) fetchChannel,
required Future<Channel> Function({
required String channelId,
required String name,
Expand All @@ -112,6 +115,7 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery {
required String relayHttpOrigin,
bool Function()? isScopeCurrent,
}) : _loadChannels = loadChannels,
_fetchChannel = fetchChannel,
_createChannel = createChannel,
_joinChannel = joinChannel,
_relayHttpOrigin = relayHttpOrigin,
Expand All @@ -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<String?> ensureStarterChannels() async {
_ensureScopeCurrent();
Expand All @@ -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;
}
}

Expand All @@ -182,22 +217,45 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery {
}
}

Channel? _findStarterChannel(List<Channel> 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<Channel> 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<Channel> 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({
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions mobile/lib/features/channels/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -49,6 +53,7 @@ class Channel {
this.isMember = false,
this.ttlSeconds,
this.ttlDeadline,
this.isUnlisted = false,
});

factory Channel.fromJson(Map<String, dynamic> json) => Channel(
Expand Down Expand Up @@ -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].
Expand All @@ -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;
Expand Down Expand Up @@ -181,6 +188,7 @@ class Channel {
isMember: isMember,
ttlSeconds: details.ttlSeconds,
ttlDeadline: details.ttlDeadline,
isUnlisted: isUnlisted,
);

Channel copyWith({
Expand Down Expand Up @@ -213,6 +221,7 @@ class Channel {
isMember: isMember ?? this.isMember,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
isUnlisted: isUnlisted,
);
}

Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/features/channels/channels_page/sheets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
isMember: isMember,
ttlSeconds: data.ttlSeconds,
ttlDeadline: data.ttlDeadline,
isUnlisted: data.isUnlisted,
);
}

Expand Down
6 changes: 6 additions & 0 deletions mobile/lib/shared/relay/nostr_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -314,6 +318,7 @@ class ChannelData {
this.ttlSeconds,
this.ttlDeadline,
this.isArchived = false,
this.isUnlisted = false,
});

factory ChannelData.fromEvent(NostrEvent event) {
Expand Down Expand Up @@ -368,6 +373,7 @@ class ChannelData {
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
isArchived: isArchived,
isUnlisted: event.tags.any((t) => t.isNotEmpty && t[0] == 'unlisted'),
);
}
}
Expand Down
20 changes: 20 additions & 0 deletions mobile/lib/shared/relay/relay_closed_policy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions mobile/test/features/channels/channels_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<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);
}
Loading
Loading