Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 249 additions & 21 deletions mobile/lib/features/channels/channel_management_provider.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math';

import 'package:flutter/foundation.dart';
Expand Down Expand Up @@ -87,8 +88,72 @@ class DirectoryUser {
}
return pubkey.length > 16 ? '${pubkey.substring(0, 16)}…' : pubkey;
}

/// First visible character used when no avatar image is available.
String get initial => label.isNotEmpty ? label[0].toUpperCase() : '?';
}

/// Whether the mobile DM directory should show local preview identities.
const bool mockDmDirectoryEnabled =
kDebugMode && bool.fromEnvironment('BUZZ_MOCK_DM_DIRECTORY');

/// Whether the new-DM picker should use local preview identities.
///
/// Debug builds fall back automatically while disconnected. Production builds
/// always use the active relay directory.
final dmDirectoryPreviewEnabledProvider = Provider<bool>((ref) {
if (mockDmDirectoryEnabled) {
return true;
}
final relayStatus = ref.watch(
relaySessionProvider.select((session) => session.status),
);
return kDebugMode && relayStatus != SessionStatus.connected;
});

String _mockEmojiAvatar(String emoji, String color) {
return Uri.dataFromString(
'<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128">'
'<rect width="128" height="128" fill="$color"/>'
'<text x="64" y="80" text-anchor="middle" font-size="58">$emoji</text>'
'</svg>',
mimeType: 'image/svg+xml',
encoding: utf8,
).toString();
}

/// Local identities used to preview the new-DM picker without a relay.
final dmDirectoryPreviewUsers = List<DirectoryUser>.unmodifiable([
DirectoryUser(
pubkey: '1111111111111111111111111111111111111111111111111111111111111111',
displayName: 'Maya Chen',
avatarUrl: _mockEmojiAvatar('🎨', '#8AADF4'),
nip05Handle: 'maya@demo.buzz',
),
DirectoryUser(
pubkey: '2222222222222222222222222222222222222222222222222222222222222222',
displayName: 'Jordan Brooks',
avatarUrl: _mockEmojiAvatar('🌱', '#A6DA95'),
nip05Handle: 'jordan@demo.buzz',
),
const DirectoryUser(
pubkey: '3333333333333333333333333333333333333333333333333333333333333333',
displayName: 'Priya Shah',
nip05Handle: 'priya@demo.buzz',
),
DirectoryUser(
pubkey: '4444444444444444444444444444444444444444444444444444444444444444',
displayName: 'Theo Martin',
avatarUrl: _mockEmojiAvatar('💻', '#C6A0F6'),
nip05Handle: 'theo@demo.buzz',
),
const DirectoryUser(
pubkey: '5555555555555555555555555555555555555555555555555555555555555555',
displayName: 'Sam Rivera',
nip05Handle: 'sam@demo.buzz',
),
]);

final currentPubkeyProvider = Provider<String?>((ref) {
// Prefer the explicitly-derived pubkey from nsec — this is the signing
// identity used for events.
Expand All @@ -110,6 +175,153 @@ final currentPubkeyProvider = Provider<String?>((ref) {
return null;
});

/// Extracts the unique member pubkeys advertised by relay membership events.
///
/// Buzz relays use `member` tags, while older NIP-29-compatible relays may
/// still expose the same directory through `p` tags.
@visibleForTesting
List<String> relayMemberPubkeysFromEvents(List<NostrEvent> events) {
final pubkeys = <String>{};
for (final event in events) {
for (final tag in event.tags) {
if (tag.length < 2 || (tag[0] != 'member' && tag[0] != 'p')) {
continue;
}
final pubkey = tag[1].trim().toLowerCase();
if (pubkey.isNotEmpty) {
pubkeys.add(pubkey);
}
}
}
return pubkeys.toList();
}

/// Converts kind:0 events into a deduplicated, alphabetized people directory.
@visibleForTesting
List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) {
continue;
}
final pubkey = event.pubkey.toLowerCase();
final current = latestByPubkey[pubkey];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pubkey] = event;
}
}

return [
for (final event in latestByPubkey.values)
if (ProfileData.fromEvent(event) case final profile)
DirectoryUser(
pubkey: profile.pubkey.toLowerCase(),
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
}

/// People and agents discoverable on the active relay.
///
/// This mirrors desktop's empty-query people directory by listing kind:0
/// profiles through the HTTP bridge. The relay membership snapshot remains a
/// fallback for older relays that do not support directory listing.
final relayDirectoryUsersProvider = FutureProvider<List<DirectoryUser>>((
ref,
) async {
if (mockDmDirectoryEnabled) {
return dmDirectoryPreviewUsers;
}

final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
Comment on lines +245 to +246

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate the directory when the community changes

When two communities use the same signing key, switching communities leaves this non-auto-dispose provider cached because it watches only the preserved relaySessionProvider.notifier identity and a pubkey whose value remains unchanged. Reopening New message can therefore display users fetched from the previous relay and submit their pubkeys to the current relay; watch relayConfigProvider or activeCommunityProvider in both new directory providers so their data is refreshed at the community boundary.

AGENTS.md reference: AGENTS.md:L120-L120

Useful? React with 👍 / 👎.

final directoryEvents = await session.queryRelay([
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
]);
Comment on lines +247 to +249

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Page beyond the first 50 directory profiles

For communities with more than 50 kind-0 profiles, this hard-codes page 1 and the sheet exposes no fetch-more path, so every later profile is omitted from the browseable recipient list. The relay bridge already supports the page extension, and the desktop new-message directory advances its cursor when a full page is returned; mobile should likewise retain and fetch subsequent pages rather than treating this first page as the complete directory.

Useful? React with 👍 / 👎.

var users = directoryUsersFromProfileEvents(directoryEvents);
if (users.isNotEmpty) {
return users
.where((user) => user.pubkey.toLowerCase() != currentPubkey)
.toList();
Comment on lines +251 to +254

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Merge relay agents into the recipient directory

On a normal relay containing at least one kind-0 profile, this early return prevents the membership fallback from contributing agents, while the search provider below also queries only kind 0. Agents represented solely by kind 10100 or relay membership—an explicitly supported case in mobile/lib/features/pulse/pulse_provider.dart—can never be selected for a DM; the desktop recipient picker avoids this by merging relay and managed-agent queries with profile results.

Useful? React with 👍 / 👎.

}

final membershipEvents = await session.fetchHistory(
NostrFilters.relayMembers(),
);
final memberPubkeys = relayMemberPubkeysFromEvents(
membershipEvents,
).where((pubkey) => pubkey != currentPubkey).toList();
if (memberPubkeys.isEmpty) {
return const [];
}

final profileEvents = await session.queryRelay([
NostrFilters.profilesBatch(memberPubkeys),
]);
final profilesByPubkey = {
for (final event in profileEvents)
event.pubkey.toLowerCase(): ProfileData.fromEvent(event),
};
users =
[
for (final pubkey in memberPubkeys)
if (profilesByPubkey[pubkey] case final profile?)
DirectoryUser(
pubkey: pubkey,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
)
else
DirectoryUser(pubkey: pubkey),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
return users;
});

/// Prefix-searches the active relay's kind:0 people directory.
final relayDirectorySearchProvider =
FutureProvider.family<List<DirectoryUser>, String>((ref, query) async {
final trimmed = query.trim();
if (mockDmDirectoryEnabled) {
final normalizedQuery = trimmed.toLowerCase();
return dmDirectoryPreviewUsers
.where(
(user) =>
user.label.toLowerCase().contains(normalizedQuery) ||
user.secondaryLabel.toLowerCase().contains(normalizedQuery),
)
.toList();
}
if (trimmed.isEmpty) {
return ref.watch(relayDirectoryUsersProvider.future);
}

final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final events = await session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: 50),
]);
return directoryUsersFromProfileEvents(
events,
).where((user) => user.pubkey.toLowerCase() != currentPubkey).toList();
});

/// Build [ChannelDetails] from a kind:39000 metadata event.
///
/// Exposed as a pure function so the mapping can be unit-tested without
Expand Down Expand Up @@ -227,6 +439,30 @@ List<List<String>> buildDeleteMessageTags({
];
}

/// Builds the signed kind:9007 tags used by mobile channel creation.
List<List<String>> buildCreateChannelTags({
required String channelId,
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) {
if (ttlSeconds != null && ttlSeconds <= 0) {
throw ArgumentError.value(ttlSeconds, 'ttlSeconds', 'must be positive');
}

return [
['h', channelId],
['name', name],
['visibility', visibility],
['channel_type', channelType],
if (description case final about? when about.trim().isNotEmpty)
['about', about.trim()],
if (ttlSeconds != null) ['ttl', ttlSeconds.toString()],
];
}

class ChannelActions {
final Ref _ref;
final RelaySessionNotifier _session;
Expand All @@ -248,16 +484,17 @@ class ChannelActions {
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) async {
final channelId = _newUuidV4();
final tags = <List<String>>[
['h', channelId],
['name', name],
['visibility', visibility],
['channel_type', channelType],
if (description case final about? when about.trim().isNotEmpty)
['about', about.trim()],
];
final tags = buildCreateChannelTags(
channelId: channelId,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
ttlSeconds: ttlSeconds,
);
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
return _refreshChannelsAndRead(channelId);
}
Expand Down Expand Up @@ -349,19 +586,10 @@ class ChannelActions {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];

final events = await _session.fetchHistory(
NostrFilter(kinds: [0], search: trimmed, limit: limit),
);
return events
.map((event) {
final data = ProfileData.fromEvent(event);
return DirectoryUser(
pubkey: data.pubkey,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
nip05Handle: data.nip05,
);
})
final events = await _session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: limit),
]);
return directoryUsersFromProfileEvents(events)
.where(
(user) =>
_currentPubkey == null ||
Expand Down
Loading
Loading