mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
## Summary
The relay is starting to refuse a new open channel when another channel
in the community already has that name
(https://github.com/block/buzz/pull/8782). Mobile handled that refusal
badly in two places:
- **Creating a channel with a taken name** showed raw error text:
`Exception: invalid: an open channel named "Launch" already exists`. The
create sheet now shows `An open channel named "Launch" already exists`
and stays open, so the user can pick another name.
- **Joining a community by invite could fail.** After an invite join,
the app joins two starter channels, `#general` and `#welcome-everyone`,
and creates either one if it can't find it. If an archived channel or a
forum already holds one of those names, the relay now refuses the
create, and the whole join failed. Now the app skips a starter whose
name is held by a channel it can't use, and the join succeeds. If the
holder is a live channel the app didn't see at first, the app joins that
channel.
Screenshots from an Android emulator against a local relay built from
https://github.com/block/buzz/pull/8782 are in the Testing section.
## Details
- **Error text.** A new `relayErrorMessage` helper
(`lib/shared/relay/relay_closed_policy.dart`) removes the `Exception: `
wrapper and the relay's machine prefix (`invalid:`, `blocked:`,
`duplicate:` and the other standard prefixes), then capitalizes the
first letter. Only the create sheet uses it. The other error paths are
unchanged.
- **Starter channels** (`ensureStarterChannels` in `lib/app.dart`).
After a taken-name refusal, the app reloads the channel list and looks
for the channel that holds the name:
- A live open stream with that name: join it.
- An archived channel or a forum: skip that starter. An archived channel
can't be joined, and a forum is the wrong type for a starter.
- No visible holder: fail as before. The setup stays retryable, because
the holder may not be visible yet.
- **Which channel counts as the holder.** Only a channel that can hold
the name under the relay's rule counts: open, not unlisted, a stream or
forum. Channels now carry the relay's "unlisted" flag for this. An
unlisted channel with the same name (for example, an unlisted forum the
user is in) doesn't hold the name, so the app doesn't skip the starter
because of it.
- **A starter that was archived.** Desktop and mobile create each
starter at the same fixed id. When the starter at that id is archived,
the relay refuses a new create there with `invalid: channel is
archived`. The app treats that as an archived starter and skips it,
unless a live channel with the name exists. When the relay answers
`duplicate` and the reload doesn't show the channel, the app reads the
channel at the fixed id and skips the starter only if that channel is
archived.
- If `#welcome-everyone` is skipped, the join opens no particular
channel. Both callers already handle that.
### Related issue
None found. Client side of https://github.com/block/buzz/pull/8782.
### Testing
Android emulator, debug build of 778a4cada, local relay built from
https://github.com/block/buzz/pull/8782 at 0df245e3c, fresh database:
**Invite join while an archived `general` holds the name.** The app
skipped `general`, created `#welcome-everyone` at its starter id and
joined it. The archived `general` did not change, and no new `general`
was created.

**Create a channel with a taken name.** `ops` is live. Creating `OPS`
shows the relay's refusal as plain text, and the sheet stays open.

Automated tests: `invite_join_provider_test.dart` has a table row for
each holder case above (archived, forum, unlisted, appears late,
archived at the fixed id for both relay answers), plus the scope fences.
`channels_page_test.dart` covers the sheet text, and
`relay_closed_policy_test.dart` covers the helper. Each new skip fails
its row when it is removed. `just mobile-check` and `just mobile-test`
pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
423 lines
12 KiB
Dart
423 lines
12 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'thread_reference.dart';
|
|
|
|
/// Nostr event kind constants.
|
|
///
|
|
/// Keep in sync with `crates/buzz-core/src/kind.rs`.
|
|
abstract final class EventKind {
|
|
/// Kind:0 user profile metadata.
|
|
static const profile = 0;
|
|
static const note = 1;
|
|
static const contactList = 3;
|
|
static const deletion = 5;
|
|
static const reaction = 7;
|
|
|
|
/// Kind:9030 event requesting that the relay add a community member.
|
|
static const relayAdminAddMember = 9030;
|
|
|
|
/// Kind:13534 event containing the current relay-community membership.
|
|
static const relayMembership = 13534;
|
|
static const streamMessage = 9;
|
|
static const nip29EditMetadata = 9002;
|
|
static const nip29DeleteEvent = 9005;
|
|
static const presenceUpdate = 20001;
|
|
static const typingIndicator = 20002;
|
|
static const auth = 22242;
|
|
static const agentObserverFrame = 24200;
|
|
static const huddleReaction = 24810;
|
|
static const readState = 30078;
|
|
static const eventReminder = 30300;
|
|
static const userStatus = 30315;
|
|
static const dmVisibility = 30622;
|
|
static const streamMessageV2 = 40002;
|
|
static const channelThreadSummary = 39005;
|
|
static const channelWindowBounds = 39006;
|
|
static const streamMessageEdit = 40003;
|
|
static const streamMessageDiff = 40008;
|
|
static const systemMessage = 40099;
|
|
static const jobRequest = 43001;
|
|
static const jobAccepted = 43002;
|
|
static const jobProgress = 43003;
|
|
static const jobResult = 43004;
|
|
static const jobCancel = 43005;
|
|
static const jobError = 43006;
|
|
static const memberAddedNotification = 44100;
|
|
static const memberRemovedNotification = 44101;
|
|
static const forumPost = 45001;
|
|
static const forumComment = 45003;
|
|
static const huddleStarted = 48100;
|
|
static const huddleParticipantJoined = 48101;
|
|
static const huddleParticipantLeft = 48102;
|
|
static const huddleEnded = 48103;
|
|
|
|
/// Event kinds that represent user-visible channel messages.
|
|
static const channelMessageEventKinds = [
|
|
streamMessage, // 9
|
|
streamMessageV2, // 40002
|
|
forumPost, // 45001
|
|
forumComment, // 45003
|
|
];
|
|
|
|
/// Event kinds that represent channel activity (messages, edits, reactions,
|
|
/// deletions, system events). Matches the desktop's `CHANNEL_EVENT_KINDS`.
|
|
static const channelEventKinds = [
|
|
deletion, // 5
|
|
reaction, // 7
|
|
nip29DeleteEvent, // 9005 — Buzz-native deletion
|
|
...channelMessageEventKinds,
|
|
40001, // legacy pre-migration stream messages
|
|
streamMessageEdit, // 40003
|
|
streamMessageDiff, // 40008
|
|
systemMessage, // 40099
|
|
huddleStarted, // 48100 — visible huddle session row
|
|
huddleParticipantJoined, // 48101 — huddle lifecycle metadata
|
|
huddleParticipantLeft, // 48102 — huddle lifecycle metadata
|
|
huddleEnded, // 48103 — visible huddle ended row
|
|
];
|
|
|
|
/// Auxiliary timeline kinds that overlay or hide existing rows.
|
|
static const channelAuxEventKinds = [
|
|
deletion,
|
|
reaction,
|
|
nip29DeleteEvent,
|
|
streamMessageEdit,
|
|
];
|
|
|
|
/// Row kinds a NIP-CW channel-window page reads. System notices (40099)
|
|
/// are read apart by [NostrFilters.channelNotices]: a page counts every row
|
|
/// kind against its limit, so a burst of joins would otherwise fill the
|
|
/// first page and hold the messages back.
|
|
static const channelWindowPageKinds = [
|
|
streamMessage,
|
|
streamMessageV2,
|
|
streamMessageDiff,
|
|
jobRequest,
|
|
jobAccepted,
|
|
jobProgress,
|
|
jobResult,
|
|
jobCancel,
|
|
jobError,
|
|
huddleStarted,
|
|
huddleEnded,
|
|
];
|
|
|
|
/// Visible content kinds of the channel timeline.
|
|
static const channelTimelineContentKinds = [
|
|
...channelWindowPageKinds,
|
|
systemMessage,
|
|
];
|
|
}
|
|
|
|
/// A Nostr event as defined by NIP-01.
|
|
@immutable
|
|
class NostrEvent {
|
|
final String id;
|
|
final String pubkey;
|
|
final int createdAt;
|
|
final int kind;
|
|
final List<List<String>> tags;
|
|
final String content;
|
|
final String sig;
|
|
|
|
const NostrEvent({
|
|
required this.id,
|
|
required this.pubkey,
|
|
required this.createdAt,
|
|
required this.kind,
|
|
required this.tags,
|
|
required this.content,
|
|
required this.sig,
|
|
});
|
|
|
|
factory NostrEvent.fromJson(Map<String, dynamic> json) {
|
|
return NostrEvent(
|
|
id: json['id'] as String,
|
|
pubkey: json['pubkey'] as String,
|
|
createdAt: json['created_at'] as int,
|
|
kind: json['kind'] as int,
|
|
tags: (json['tags'] as List<dynamic>)
|
|
.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
|
|
.toList(),
|
|
content: json['content'] as String,
|
|
sig: json['sig'] as String,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'pubkey': pubkey,
|
|
'created_at': createdAt,
|
|
'kind': kind,
|
|
'tags': tags,
|
|
'content': content,
|
|
'sig': sig,
|
|
};
|
|
|
|
/// Get the first value for a given tag key.
|
|
String? getTagValue(String key) {
|
|
for (final tag in tags) {
|
|
if (tag.isNotEmpty && tag[0] == key && tag.length > 1) {
|
|
return tag[1];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The channel/group ID from the `h` tag (NIP-29).
|
|
String? get channelId => getTagValue('h');
|
|
|
|
/// Validated, lowercase thread IDs shared by timeline and Inbox navigation.
|
|
({String? parentId, String? rootId}) get threadReference =>
|
|
parseThreadReference(tags);
|
|
|
|
/// The parent event ID from the `e` tag.
|
|
String? get parentEventId => threadReference.parentId;
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) || other is NostrEvent && id == other.id;
|
|
|
|
@override
|
|
int get hashCode => id.hashCode;
|
|
}
|
|
|
|
/// A NIP-01 subscription filter.
|
|
@immutable
|
|
class NostrFilter {
|
|
final List<int> kinds;
|
|
final List<String>? authors;
|
|
|
|
/// Specific event IDs (NIP-01 single-event lookup).
|
|
final List<String>? ids;
|
|
final int limit;
|
|
final int? since;
|
|
final int? until;
|
|
|
|
/// NIP-50 full-text search query.
|
|
final String? search;
|
|
|
|
/// Tag filters, e.g. `{'#h': ['channel-id']}`.
|
|
final Map<String, List<String>> tags;
|
|
|
|
/// Buzz relay bridge filter extensions (for example NIP-CW `top_level`).
|
|
final Map<String, Object?> extensions;
|
|
|
|
const NostrFilter({
|
|
required this.kinds,
|
|
this.authors,
|
|
this.ids,
|
|
this.limit = 100,
|
|
this.since,
|
|
this.until,
|
|
this.search,
|
|
this.tags = const {},
|
|
this.extensions = const {},
|
|
});
|
|
|
|
/// Return a copy with an updated `since` value.
|
|
NostrFilter copyWithSince(int since) => NostrFilter(
|
|
kinds: kinds,
|
|
authors: authors,
|
|
ids: ids,
|
|
limit: limit,
|
|
since: since,
|
|
until: until,
|
|
search: search,
|
|
tags: tags,
|
|
extensions: extensions,
|
|
);
|
|
|
|
Map<String, dynamic> toJson() {
|
|
final json = <String, dynamic>{'kinds': kinds, 'limit': limit};
|
|
if (authors != null) json['authors'] = authors;
|
|
if (ids != null) json['ids'] = ids;
|
|
if (since != null) json['since'] = since;
|
|
if (until != null) json['until'] = until;
|
|
if (search != null) json['search'] = search;
|
|
for (final entry in tags.entries) {
|
|
json[entry.key] = entry.value;
|
|
}
|
|
for (final entry in extensions.entries) {
|
|
json[entry.key] = entry.value;
|
|
}
|
|
return json;
|
|
}
|
|
}
|
|
|
|
/// Parsed kind:0 user profile metadata.
|
|
@immutable
|
|
class ProfileData {
|
|
final String pubkey;
|
|
final String? displayName;
|
|
final String? avatarUrl;
|
|
final String? about;
|
|
final String? nip05;
|
|
|
|
const ProfileData({
|
|
required this.pubkey,
|
|
this.displayName,
|
|
this.avatarUrl,
|
|
this.about,
|
|
this.nip05,
|
|
});
|
|
|
|
factory ProfileData.fromEvent(NostrEvent event) {
|
|
Map<String, dynamic> meta = {};
|
|
try {
|
|
final decoded = jsonDecode(event.content);
|
|
if (decoded is Map<String, dynamic>) meta = decoded;
|
|
} catch (_) {}
|
|
return ProfileData(
|
|
pubkey: event.pubkey,
|
|
displayName:
|
|
(meta['display_name'] as String?) ?? (meta['name'] as String?),
|
|
avatarUrl: meta['picture'] as String?,
|
|
about: meta['about'] as String?,
|
|
nip05: meta['nip05'] as String?,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Parsed kind:39000 channel metadata.
|
|
@immutable
|
|
class ChannelData {
|
|
final String id;
|
|
final String name;
|
|
final String channelType;
|
|
|
|
/// NIP-CL labels in `t` tags after the leading channel-type tag.
|
|
final List<String> labels;
|
|
|
|
/// Original channel creator from the relay-authored uppercase `P` tag.
|
|
final String? creator;
|
|
final String visibility;
|
|
final String description;
|
|
final String? topic;
|
|
final List<String> participantPubkeys;
|
|
final int? ttlSeconds;
|
|
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,
|
|
required this.channelType,
|
|
required this.visibility,
|
|
required this.description,
|
|
this.topic,
|
|
this.labels = const [],
|
|
this.creator,
|
|
this.participantPubkeys = const [],
|
|
this.ttlSeconds,
|
|
this.ttlDeadline,
|
|
this.isArchived = false,
|
|
this.isUnlisted = false,
|
|
});
|
|
|
|
factory ChannelData.fromEvent(NostrEvent event) {
|
|
final id = event.getTagValue('d') ?? '';
|
|
final name = event.getTagValue('name') ?? '';
|
|
// Prefer explicit ["t", type]; fall back to ["hidden"] => dm, else "stream".
|
|
// The fallback exists for relays that haven't been upgraded to emit the
|
|
// explicit type tag yet.
|
|
final explicitType = event.getTagValue('t');
|
|
final hasHidden = event.tags.any((t) => t.isNotEmpty && t[0] == 'hidden');
|
|
final channelType = explicitType ?? (hasHidden ? 'dm' : 'stream');
|
|
// Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private".
|
|
final hasPublic = event.tags.any((t) => t.isNotEmpty && t[0] == 'public');
|
|
final hasPrivate = event.tags.any((t) => t.isNotEmpty && t[0] == 'private');
|
|
final visibility = hasPublic
|
|
? 'open'
|
|
: hasPrivate
|
|
? 'private'
|
|
: 'open';
|
|
final description = event.getTagValue('about') ?? '';
|
|
final topic = event.getTagValue('topic');
|
|
final participants = [
|
|
for (final t in event.tags)
|
|
if (t.length >= 2 && t[0] == 'p') t[1],
|
|
];
|
|
final ttlRaw = event.getTagValue('ttl');
|
|
final ttlSeconds = ttlRaw != null ? int.tryParse(ttlRaw) : null;
|
|
final ttlDeadlineRaw = event.getTagValue('ttl_deadline');
|
|
final ttlDeadline = ttlDeadlineRaw != null
|
|
? DateTime.tryParse(ttlDeadlineRaw)
|
|
: null;
|
|
// Relay republishes kind:39000 with `["archived", "true"]` when a channel
|
|
// is archived (including the auto-archive emitted by the TTL reaper). The
|
|
// tag value "false" is also accepted server-side, so only treat "true" as
|
|
// archived — anything else (missing tag, "false", unexpected value) means
|
|
// active.
|
|
final isArchived = event.getTagValue('archived') == 'true';
|
|
return ChannelData(
|
|
id: id,
|
|
name: name,
|
|
channelType: channelType,
|
|
labels: event.tags
|
|
.where((t) => t.length >= 2 && t[0] == 't')
|
|
.skip(1)
|
|
.map((t) => t[1])
|
|
.toList(),
|
|
creator: event.getTagValue('P'),
|
|
visibility: visibility,
|
|
description: description,
|
|
topic: topic,
|
|
participantPubkeys: participants,
|
|
ttlSeconds: ttlSeconds,
|
|
ttlDeadline: ttlDeadline,
|
|
isArchived: isArchived,
|
|
isUnlisted: event.tags.any((t) => t.isNotEmpty && t[0] == 'unlisted'),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A single member entry parsed from a kind:39002 members event.
|
|
@immutable
|
|
class MemberEntry {
|
|
final String pubkey;
|
|
final String role;
|
|
|
|
const MemberEntry({required this.pubkey, required this.role});
|
|
}
|
|
|
|
/// Parse a kind:39002 members event into the list of `(pubkey, role)` entries.
|
|
///
|
|
/// NIP-29 members tags follow the shape `["p", <pubkey>, <relay>, <role>]`.
|
|
List<MemberEntry> membersFromEvent(NostrEvent event) {
|
|
return [
|
|
for (final t in event.tags)
|
|
if (t.length >= 2 && t[0] == 'p')
|
|
MemberEntry(pubkey: t[1], role: t.length >= 4 ? t[3] : 'member'),
|
|
];
|
|
}
|
|
|
|
/// Parse a Buzz command response from the relay's OK message content.
|
|
///
|
|
/// Command kinds (e.g. 41010, 30620, 46020) return `"response:{...}"` in the
|
|
/// OK message. Returns `null` if the message is not a command response or the
|
|
/// JSON is invalid.
|
|
Map<String, dynamic>? parseCommandResponse(String message) {
|
|
// Try the spec format first: "response:{...}".
|
|
const prefix = 'response:';
|
|
if (message.startsWith(prefix)) {
|
|
try {
|
|
final decoded = jsonDecode(message.substring(prefix.length));
|
|
if (decoded is Map<String, dynamic>) return decoded;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
// Fallback: raw JSON object (older relays, backward compat).
|
|
try {
|
|
final decoded = jsonDecode(message);
|
|
if (decoded is Map<String, dynamic>) return decoded;
|
|
} catch (_) {}
|
|
return null;
|
|
}
|