mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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:
@@ -129,7 +129,7 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery {
|
|||||||
/// propagates to the invite flow instead of being reported as success. The
|
/// 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
|
/// 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
|
/// 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.
|
/// are permanent, so the starter is skipped instead of blocking the join.
|
||||||
@override
|
@override
|
||||||
Future<String?> ensureStarterChannels() async {
|
Future<String?> ensureStarterChannels() async {
|
||||||
@@ -175,9 +175,10 @@ class MobileInviteJoinRecovery implements InviteJoinRecovery {
|
|||||||
// its duplicate check, and keeps that id, so the starter can never
|
// its duplicate check, and keeps that id, so the starter can never
|
||||||
// be created there.
|
// be created there.
|
||||||
if (archivedAtId) continue;
|
if (archivedAtId) continue;
|
||||||
// The directory omits archived channels, yet the relay still
|
// The relay keeps the id (desktop uses the same one), so a
|
||||||
// keeps the id (desktop uses the same one), so a duplicate with
|
// duplicate with no match in the reload may be an archived
|
||||||
// no match may be an archived starter. Read that id directly.
|
// starter whose metadata the reload did not return. Read that id
|
||||||
|
// directly.
|
||||||
if (!nameTaken && _isDuplicateChannelError(error)) {
|
if (!nameTaken && _isDuplicateChannelError(error)) {
|
||||||
final atId = await _fetchChannel(channelId);
|
final atId = await _fetchChannel(channelId);
|
||||||
_ensureScopeCurrent();
|
_ensureScopeCurrent();
|
||||||
@@ -221,9 +222,16 @@ Channel? _findStarterChannel(List<Channel> channels, String name) => channels
|
|||||||
.where((channel) => _hasName(channel, name) && _isUsableStarter(channel))
|
.where((channel) => _hasName(channel, name) && _isUsableStarter(channel))
|
||||||
.firstOrNull;
|
.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
|
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;
|
.firstOrNull;
|
||||||
|
|
||||||
bool _hasName(Channel channel, String name) =>
|
bool _hasName(Channel channel, String name) =>
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ class Channel {
|
|||||||
final int? ttlSeconds;
|
final int? ttlSeconds;
|
||||||
final DateTime? ttlDeadline;
|
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({
|
const Channel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -49,6 +53,7 @@ class Channel {
|
|||||||
this.isMember = false,
|
this.isMember = false,
|
||||||
this.ttlSeconds,
|
this.ttlSeconds,
|
||||||
this.ttlDeadline,
|
this.ttlDeadline,
|
||||||
|
this.isUnlisted = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
|
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
|
||||||
@@ -79,6 +84,7 @@ class Channel {
|
|||||||
ttlDeadline: json['ttl_deadline'] != null
|
ttlDeadline: json['ttl_deadline'] != null
|
||||||
? DateTime.parse(json['ttl_deadline'] as String)
|
? DateTime.parse(json['ttl_deadline'] as String)
|
||||||
: null,
|
: null,
|
||||||
|
isUnlisted: json['unlisted'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Inverse of [Channel.fromJson].
|
/// Inverse of [Channel.fromJson].
|
||||||
@@ -101,6 +107,7 @@ class Channel {
|
|||||||
'is_member': isMember,
|
'is_member': isMember,
|
||||||
'ttl_seconds': ttlSeconds,
|
'ttl_seconds': ttlSeconds,
|
||||||
'ttl_deadline': ttlDeadline?.toIso8601String(),
|
'ttl_deadline': ttlDeadline?.toIso8601String(),
|
||||||
|
'unlisted': isUnlisted,
|
||||||
};
|
};
|
||||||
|
|
||||||
bool get isEphemeral => ttlSeconds != null || ttlDeadline != null;
|
bool get isEphemeral => ttlSeconds != null || ttlDeadline != null;
|
||||||
@@ -181,6 +188,7 @@ class Channel {
|
|||||||
isMember: isMember,
|
isMember: isMember,
|
||||||
ttlSeconds: details.ttlSeconds,
|
ttlSeconds: details.ttlSeconds,
|
||||||
ttlDeadline: details.ttlDeadline,
|
ttlDeadline: details.ttlDeadline,
|
||||||
|
isUnlisted: isUnlisted,
|
||||||
);
|
);
|
||||||
|
|
||||||
Channel copyWith({
|
Channel copyWith({
|
||||||
@@ -213,6 +221,7 @@ class Channel {
|
|||||||
isMember: isMember ?? this.isMember,
|
isMember: isMember ?? this.isMember,
|
||||||
ttlSeconds: ttlSeconds,
|
ttlSeconds: ttlSeconds,
|
||||||
ttlDeadline: ttlDeadline,
|
ttlDeadline: ttlDeadline,
|
||||||
|
isUnlisted: isUnlisted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -754,6 +754,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
|||||||
isMember: isMember,
|
isMember: isMember,
|
||||||
ttlSeconds: data.ttlSeconds,
|
ttlSeconds: data.ttlSeconds,
|
||||||
ttlDeadline: data.ttlDeadline,
|
ttlDeadline: data.ttlDeadline,
|
||||||
|
isUnlisted: data.isUnlisted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,10 @@ class ChannelData {
|
|||||||
final DateTime? ttlDeadline;
|
final DateTime? ttlDeadline;
|
||||||
final bool isArchived;
|
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({
|
const ChannelData({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -311,6 +315,7 @@ class ChannelData {
|
|||||||
this.ttlSeconds,
|
this.ttlSeconds,
|
||||||
this.ttlDeadline,
|
this.ttlDeadline,
|
||||||
this.isArchived = false,
|
this.isArchived = false,
|
||||||
|
this.isUnlisted = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ChannelData.fromEvent(NostrEvent event) {
|
factory ChannelData.fromEvent(NostrEvent event) {
|
||||||
@@ -365,6 +370,7 @@ class ChannelData {
|
|||||||
ttlSeconds: ttlSeconds,
|
ttlSeconds: ttlSeconds,
|
||||||
ttlDeadline: ttlDeadline,
|
ttlDeadline: ttlDeadline,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
|
isUnlisted: event.tags.any((t) => t.isNotEmpty && t[0] == 'unlisted'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2849,7 +2849,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
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 {
|
() async {
|
||||||
// The relay's TTL reaper auto-archives expired ephemeral channels and
|
// The relay's TTL reaper auto-archives expired ephemeral channels and
|
||||||
// republishes kind:39000 with `["archived", "true"]`. The Channel needs
|
// republishes kind:39000 with `["archived", "true"]`. The Channel needs
|
||||||
@@ -2864,7 +2864,7 @@ void main() {
|
|||||||
_membership(_channelB, myPk),
|
_membership(_channelB, myPk),
|
||||||
],
|
],
|
||||||
metadata: [
|
metadata: [
|
||||||
_meta(id: _channelA, name: 'active'),
|
_meta(id: _channelA, name: 'active', unlisted: true),
|
||||||
_meta(
|
_meta(
|
||||||
id: _channelB,
|
id: _channelB,
|
||||||
name: 'expired-ttl',
|
name: 'expired-ttl',
|
||||||
@@ -2883,6 +2883,10 @@ void main() {
|
|||||||
// The active channel must not be flagged archived.
|
// The active channel must not be flagged archived.
|
||||||
final active = channels.firstWhere((c) => c.name == 'active');
|
final active = channels.firstWhere((c) => c.name == 'active');
|
||||||
expect(active.isArchived, isFalse);
|
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 createdAt = 1,
|
||||||
int? ttlSeconds,
|
int? ttlSeconds,
|
||||||
bool archived = false,
|
bool archived = false,
|
||||||
|
bool unlisted = false,
|
||||||
List<String> participants = const [],
|
List<String> participants = const [],
|
||||||
}) => NostrEvent(
|
}) => NostrEvent(
|
||||||
id: 'meta-$id',
|
id: 'meta-$id',
|
||||||
@@ -3145,6 +3150,7 @@ NostrEvent _meta({
|
|||||||
[visibility == 'private' ? 'private' : 'public'],
|
[visibility == 'private' ? 'private' : 'public'],
|
||||||
if (ttlSeconds != null) ['ttl', '$ttlSeconds'],
|
if (ttlSeconds != null) ['ttl', '$ttlSeconds'],
|
||||||
if (archived) ['archived', 'true'],
|
if (archived) ['archived', 'true'],
|
||||||
|
if (unlisted) ['unlisted'],
|
||||||
for (final participant in participants) ['p', participant],
|
for (final participant in participants) ['p', participant],
|
||||||
],
|
],
|
||||||
content: '',
|
content: '',
|
||||||
|
|||||||
@@ -564,6 +564,32 @@ void main() {
|
|||||||
expect(unseen.loads(), 2);
|
expect(unseen.loads(), 2);
|
||||||
expect(unseen.lookups, isEmpty);
|
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)
|
// A usable open channel that appeared after the first load (lost race)
|
||||||
// is joined rather than skipped.
|
// is joined rather than skipped.
|
||||||
final raced = starterSetup(
|
final raced = starterSetup(
|
||||||
@@ -1176,11 +1202,13 @@ Channel _channel({
|
|||||||
bool isMember = false,
|
bool isMember = false,
|
||||||
String channelType = 'stream',
|
String channelType = 'stream',
|
||||||
DateTime? archivedAt,
|
DateTime? archivedAt,
|
||||||
|
bool isUnlisted = false,
|
||||||
}) => Channel(
|
}) => Channel(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
channelType: channelType,
|
channelType: channelType,
|
||||||
archivedAt: archivedAt,
|
archivedAt: archivedAt,
|
||||||
|
isUnlisted: isUnlisted,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
description: '',
|
description: '',
|
||||||
createdBy: 'me',
|
createdBy: 'me',
|
||||||
|
|||||||
Reference in New Issue
Block a user