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
|
||||
/// 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) =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -754,6 +754,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
isMember: isMember,
|
||||
ttlSeconds: data.ttlSeconds,
|
||||
ttlDeadline: data.ttlDeadline,
|
||||
isUnlisted: data.isUnlisted,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user