-
Notifications
You must be signed in to change notification settings - Fork 943
Refine mobile navigation and creation flows #2810
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
@@ -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. | ||
|
|
@@ -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(); | ||
| final directoryEvents = await session.queryRelay([ | ||
| const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}), | ||
| ]); | ||
|
Comment on lines
+247
to
+249
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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 | ||
|
|
@@ -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; | ||
|
|
@@ -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); | ||
| } | ||
|
|
@@ -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 || | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When two communities use the same signing key, switching communities leaves this non-auto-dispose provider cached because it watches only the preserved
relaySessionProvider.notifieridentity 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; watchrelayConfigProvideroractiveCommunityProviderin both new directory providers so their data is refreshed at the community boundary.AGENTS.md reference: AGENTS.md:L120-L120
Useful? React with 👍 / 👎.