From 778a4cadab842979bfcb90a5b53152f272abac7c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 2 Nov 2026 10:04:55 -0500 Subject: [PATCH] fix(mobile): count only a listed namesake as holding a starter name Starter setup skipped a starter when a taken-name refusal had a namesake in the reload that could not serve as the starter. It took any open namesake for the holder, but an unlisted channel, or a DM, holds no name in the relay's listed-name rule. A member of an unlisted forum named general then skipped general for good while the real holder was still settling. Carry the relay's bare ["unlisted"] kind:39000 flag on Channel, and treat a namesake as the holder only when it is an open, not unlisted stream or forum. Otherwise the setup stays recoverable, and a retry joins the real holder. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- mobile/lib/app.dart | 20 +++++++++---- mobile/lib/features/channels/channel.dart | 9 ++++++ .../features/channels/channels_provider.dart | 1 + mobile/lib/shared/relay/nostr_models.dart | 6 ++++ .../channels/channels_provider_test.dart | 10 +++++-- .../invites/invite_join_provider_test.dart | 28 +++++++++++++++++++ 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index dc3fe9202b..aa03fedbcf 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -129,7 +129,7 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { /// 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 that the directory omits: both + /// 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 { @@ -175,9 +175,10 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery { // its duplicate check, and keeps that id, so the starter can never // be created there. if (archivedAtId) continue; - // The directory omits archived channels, yet the relay still - // keeps the id (desktop uses the same one), so a duplicate with - // no match may be an archived starter. Read that id directly. + // 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(); @@ -221,9 +222,16 @@ Channel? _findStarterChannel(List channels, String name) => channels .where((channel) => _hasName(channel, name) && _isUsableStarter(channel)) .firstOrNull; -/// The open channel that holds [name] in the relay's listed-name rule. +/// 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') + .where( + (channel) => + _hasName(channel, name) && + channel.visibility == 'open' && + !channel.isUnlisted && + (channel.isStream || channel.isForum), + ) .firstOrNull; bool _hasName(Channel channel, String name) => diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 8c20799b1e..d1409d169a 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_provider.dart b/mobile/lib/features/channels/channels_provider.dart index e06bbde3a1..d6919230fb 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -754,6 +754,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 8ed1ba92d6..f96ab2d3a9 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -298,6 +298,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, @@ -311,6 +315,7 @@ class ChannelData { this.ttlSeconds, this.ttlDeadline, this.isArchived = false, + this.isUnlisted = false, }); factory ChannelData.fromEvent(NostrEvent event) { @@ -365,6 +370,7 @@ class ChannelData { ttlSeconds: ttlSeconds, ttlDeadline: ttlDeadline, isArchived: isArchived, + isUnlisted: event.tags.any((t) => t.isNotEmpty && t[0] == 'unlisted'), ); } } diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index b768ba377e..0810801fe3 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 c8e6ed4722..eba61f327c 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -564,6 +564,32 @@ void main() { 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( @@ -1176,11 +1202,13 @@ Channel _channel({ bool isMember = false, String channelType = 'stream', DateTime? archivedAt, + bool isUnlisted = false, }) => Channel( id: id, name: name, channelType: channelType, archivedAt: archivedAt, + isUnlisted: isUnlisted, visibility: visibility, description: '', createdBy: 'me',