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 <noreply@anthropic.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-11-02 10:04:55 -05:00
parent 1c88d48f16
commit 778a4cadab
6 changed files with 66 additions and 8 deletions

View File

@@ -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<String?> 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<Channel> 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<Channel> 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) =>

View File

@@ -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<String, dynamic> 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,
);
}

View File

@@ -754,6 +754,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
isMember: isMember,
ttlSeconds: data.ttlSeconds,
ttlDeadline: data.ttlDeadline,
isUnlisted: data.isUnlisted,
);
}

View File

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

View File

@@ -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<String> 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: '',

View File

@@ -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',