diff --git a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift index 4b5bdcc414..3261ba27f8 100644 --- a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift +++ b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift @@ -6,6 +6,8 @@ struct MeAgentAccessibilityTests { @MainActor static func main() { for locked in [false, true] { for empty in [false, true] { + let savedName = "Saved agent" + var submitted: [String: String]? let controller = NativeProfileTextEditorViewController( title: "New chat", initialValue: "Chat", placeholder: "Chat name", multiline: false, pageBackgroundColor: .systemGroupedBackground, @@ -14,8 +16,8 @@ struct MeAgentAccessibilityTests { channelDescription: nil, originalName: nil, originalDescription: nil, canEditDetails: true, canEditCanvas: false, canvasContent: "", canvasLoaded: false, - chatOptions: ["fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]], - onCancel: {}, onSet: { _ in } + chatOptions: ["selectedAssistant": "agent", "savedAssistant": ["id": "agent", "name": savedName], "presentationId": "test", "assistantsLoading": empty, "fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]], + onCancel: {}, onSet: { submitted = $0 as? [String: String] } ) controller.loadViewIfNeeded() precondition(controller.numberOfSections(in: controller.tableView) == 3) @@ -27,10 +29,45 @@ struct MeAgentAccessibilityTests { precondition(project.isEnabled == !locked) precondition(project.showsMenuAsPrimaryAction && project.menu != nil) let cell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2)) + let content = cell.contentConfiguration as! UIListContentConfiguration + precondition(content.text == (empty ? (locked ? savedName : "Choose an agent") : "Agent")) + precondition((content.image != nil) == (locked || !empty)) let disabled = locked || empty precondition(cell.accessibilityTraits.contains(.notEnabled) == disabled) precondition(cell.accessibilityTraits.contains(.button) == !disabled) precondition((cell.selectionStyle == .none) == disabled) + let canSubmit = locked || !empty + precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == canSubmit) + // Invoke the action even when disabled to check its independent gate. + _ = controller.perform(NSSelectorFromString("setTapped")) + precondition(submitted?["assistant"] == (canSubmit ? "agent" : nil)) + if empty && !locked { + controller.tableView.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.tableView.reloadData() + controller.tableView.layoutIfNeeded() + precondition(controller.tableView(controller.tableView, titleForFooterInSection: 1) == nil) + precondition(controller.tableView(controller.tableView, titleForFooterInSection: 2) == "Loading agents…") + controller.updateChatAssistants([ + "presentationId": "test", + "assistants": [["id": "agent", "name": "Agent"]], + ]) + controller.tableView.layoutIfNeeded() + let updated = controller.tableView.cellForRow(at: IndexPath(row: 0, section: 2))! + precondition(updated.accessibilityTraits.contains(.button)) + precondition(!updated.accessibilityTraits.contains(.notEnabled)) + precondition(controller.tableView(controller.tableView, titleForFooterInSection: 2) == nil) + } + submitted = nil + controller.updateChatAssistants([ + "presentationId": "test", + "assistants": empty ? [] : [["id": "agent", "name": "Agent"]], + ]) + let refreshedCell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2)) + let refreshed = refreshedCell.contentConfiguration as! UIListContentConfiguration + precondition(refreshed.text == (empty ? (locked ? savedName : "Choose an agent") : "Agent")) + precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == canSubmit) + _ = controller.perform(NSSelectorFromString("setTapped")) + precondition(submitted?["assistant"] == (canSubmit ? "agent" : nil)) } } avatarTests() diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index a771c7aacb..c8544b926f 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -28,6 +28,17 @@ final class NativeProfileTextEditorCoordinator: NSObject, _ call: FlutterMethodCall, result: @escaping FlutterResult ) { + if call.method == "updateChatAssistants" { + DispatchQueue.main.async { [weak self] in + if let arguments = call.arguments as? [String: Any], + let navigation = self?.presentedController as? UINavigationController, + let editor = navigation.viewControllers.first as? NativeProfileTextEditorViewController { + editor.updateChatAssistants(arguments) + } + result(nil) + } + return + } guard call.method == "present" || call.method == "presentChannel" || call.method == "presentChat" || call.method == "presentProject" else { result(FlutterMethodNotImplemented) return @@ -241,7 +252,7 @@ private final class NativeProfileTextEditorViewController: private var hasCanvas: Bool { canvasLoaded && !canvasContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - private let chatOptions: [String: Any]? + private var chatOptions: [String: Any]? private var hasFocusedEditor = false private var selectedAssistantAvatar: UIImage? private var selectedAssistant: String? @@ -254,6 +265,8 @@ private final class NativeProfileTextEditorViewController: private var assistants: [[String: String]] { chatOptions?["assistants"] as? [[String: String]] ?? [] } + // Keep decoded artwork while switching between the form and its agent list. + private let assistantAvatars = MeAvatarLoader() private var fieldsLocked: Bool { chatOptions?["fieldsLocked"] as? Bool ?? false } private var isChannelEditor: Bool { channelDescription != nil } @@ -426,13 +439,30 @@ private final class NativeProfileTextEditorViewController: updateNavigation() } + func updateChatAssistants(_ arguments: [String: Any]) { + guard let token = arguments["presentationId"] as? String, + token == chatOptions?["presentationId"] as? String else { return } + chatOptions?["assistants"] = arguments["assistants"] + chatOptions?["assistantsLoading"] = false + chatOptions?["assistantError"] = arguments["assistantError"] + tableView.reloadSections(IndexSet(integer: 2), with: .none) + updateNavigation() + } + + // A durable retry keeps its saved recipient even if discovery hides it. + // Flutter revalidates ownership before any creation writes. + private var canSubmitAssistant: Bool { + guard let selectedAssistant else { return false } + return fieldsLocked || assistants.contains { $0["id"] == selectedAssistant } + } + private func updateNavigation() { navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges && (!isChannelEditor || !canonicalChannelName.isEmpty) && (!isChatEditor || ( !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && currentValue.count <= 120 - && (isProjectEditor || assistants.contains { $0["id"] == selectedAssistant }) + && (isProjectEditor || canSubmitAssistant) )) navigationController?.isModalInPresentation = hasUnsavedChanges placeholderLabel.isHidden = !textView.text.isEmpty @@ -474,7 +504,7 @@ private final class NativeProfileTextEditorViewController: onSet(name) } else if isChatEditor { guard let selectedAssistant, - assistants.contains(where: { $0["id"] == selectedAssistant }), + canSubmitAssistant, !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, currentValue.count <= 120 else { return } onSet(["name": currentValue, "assistant": selectedAssistant, "project": selectedProject ?? ""]) @@ -511,7 +541,7 @@ private final class NativeProfileTextEditorViewController: if isChatEditor && indexPath.section == 2 { guard !fieldsLocked && !assistants.isEmpty else { return } view.endEditing(true) - let picker = MeAssistantListController(assistants: assistants, selected: selectedAssistant, pageColor: pageBackgroundColor, rowColor: containerBackgroundColor) { [weak self] id, avatar in + let picker = MeAssistantListController(assistants: assistants, selected: selectedAssistant, pageColor: pageBackgroundColor, rowColor: containerBackgroundColor, avatars: assistantAvatars) { [weak self] id, avatar in self?.selectedAssistantAvatar = avatar self?.selectedAssistant = id self?.tableView.reloadSections(IndexSet(integer: 2), with: .none) @@ -538,6 +568,8 @@ private final class NativeProfileTextEditorViewController: override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { guard isChatEditor && section == 2 else { return nil } + if chatOptions?["assistantsLoading"] as? Bool == true { return "Loading agents…" } + if let error = chatOptions?["assistantError"] as? String { return error } if let error = chatOptions?["error"] as? String { return error } return assistants.isEmpty ? "No agents are available for this account. Connect an agent on desktop, then try again." @@ -600,8 +632,13 @@ private final class NativeProfileTextEditorViewController: cell.accessoryView = button } else if isChatEditor && indexPath.section == 2 { var content = cell.defaultContentConfiguration() - let selected = assistants.first { $0["id"] == selectedAssistant } + let saved = chatOptions?["savedAssistant"] as? [String: String] + let fallback = fieldsLocked ? selectedAssistant.map { + saved.flatMap { $0["id"] == selectedAssistant ? $0 : nil } ?? ["id": $0, "name": $0] + } : nil + let selected = assistants.first { $0["id"] == selectedAssistant } ?? fallback content.text = selected?["name"] ?? "Choose an agent" + content.textProperties.numberOfLines = 0 if let selected { content.image = selectedAssistantAvatar ?? MeAssistantListController.avatarImage(selected, photo: nil) } diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index 2608ee97bb..f8d4b18453 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -41,6 +41,7 @@ class ChannelActions { List labels = const [], String? description, int? ttlSeconds, + bool waitForChannelList = true, }) async { final resolvedChannelId = channelId ?? _newUuidV4(); final tags = buildCreateChannelTags( @@ -55,9 +56,58 @@ class ChannelActions { _ensureCommunityValid(); await _signedEventRelay.submit(kind: 9007, content: '', tags: tags); _ensureCommunityValid(); + if (!waitForChannelList) { + // Solo can open from its own confirmed metadata and roster. Reconcile + // the sidebar independently of unrelated channels' message history. + _ref.invalidate(channelsProvider); + return await findJoinedChannel(resolvedChannelId) ?? + (throw StateError('Channel was created but is not visible yet')); + } return _refreshChannelsAndRead(resolvedChannelId); } + /// Read one channel for creation recovery without reloading every membership + /// and every channel's recent messages. Missing membership fails closed. + Future findJoinedChannel(String channelId) async { + _ensureCommunityValid(); + final events = await _session.fetchHistory( + NostrFilter( + kinds: const [39000, 39002], + tags: { + '#d': [channelId], + }, + limit: 2, + ), + ); + _ensureCommunityValid(); + final metadata = events + .where((e) => e.kind == 39000 && e.getTagValue('d') == channelId) + .firstOrNull; + final roster = events + .where((e) => e.kind == 39002 && e.getTagValue('d') == channelId) + .firstOrNull; + if (metadata == null || roster == null) return null; + final members = membersFromEvent(roster); + if (!members.any((m) => m.pubkey == _currentPubkey)) return null; + final details = channelDetailsFromEvent(metadata); + return Channel( + id: channelId, + name: details.name, + channelType: details.channelType, + labels: details.labels, + visibility: details.visibility, + description: details.description, + topic: details.topic, + createdBy: details.createdBy, + createdAt: details.createdAt, + memberCount: members.length, + isMember: true, + archivedAt: details.archivedAt, + ttlSeconds: details.ttlSeconds, + ttlDeadline: details.ttlDeadline, + ); + } + /// Open (or create) a DM channel with the given pubkeys. /// /// This submits a kind:41010 command event; the relay responds with an OK diff --git a/mobile/lib/features/channels/me/me_assistant_list.dart b/mobile/lib/features/channels/me/me_assistant_list.dart index 5927a6374e..16183e863f 100644 --- a/mobile/lib/features/channels/me/me_assistant_list.dart +++ b/mobile/lib/features/channels/me/me_assistant_list.dart @@ -45,6 +45,7 @@ class MeAssistantList extends ConsumerWidget { child: AppListRowRaw( leading: AvatarImage( imageUrl: + agent.avatarUrl ?? (profiles[agent.pubkey] ?? ref .read(userCacheProvider.notifier) diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 434c0d3ace..b418d14bf4 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../../shared/profile/user_cache_provider.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/animated_avatar.dart'; import '../../../shared/widgets/app_list.dart'; import '../../../shared/widgets/concentric_sheet_surface.dart'; diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart index dc979aea66..9bf932e38c 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -48,16 +48,23 @@ Future<(Channel, String)?> _showNewMeChat( var retry = pending != null; while (context.mounted) { sync.check(); - final directory = await ref.read(meAssistantsProvider.future); + final directory = ref.read(meAssistantsProvider).asData?.value; + final directoryRequest = ref.read(meAssistantsProvider).hasError + ? ref.refresh(meAssistantsProvider.future) + : ref.read(meAssistantsProvider.future); + final presentationId = const Uuid().v4(); + var active = true; if (!context.mounted) return null; sync.check(); if (!context.mounted) return null; final theme = utilitySurfaceThemeData(Theme.of(context)); Map? draft; try { - draft = await const MethodChannel('buzz/profile_text_editor') + final presentation = const MethodChannel('buzz/profile_text_editor') .invokeMethod>('presentChat', { 'title': 'New chat', + 'presentationId': presentationId, + 'assistantsLoading': directory == null, 'initialValue': name, 'placeholder': 'Chat name', 'multiline': false, @@ -69,6 +76,19 @@ Future<(Channel, String)?> _showNewMeChat( .toARGB32(), 'containerCornerRadius': Radii.container, 'selectedAssistant': assistant, + if (retry && assistant != null) + 'savedAssistant': { + 'id': assistant, + 'name': + ref.read(userCacheProvider)[assistant]?.label ?? + directory + ?.where((a) => a.pubkey == assistant) + .firstOrNull + ?.displayName ?? + assistant, + 'avatarUrl': + ref.read(userCacheProvider)[assistant]?.avatarUrl ?? '', + }, 'selectedProject': project, 'projects': [ for (final group @@ -77,31 +97,45 @@ Future<(Channel, String)?> _showNewMeChat( ], 'fieldsLocked': retry, 'error': error, - 'assistants': [ - for (final agent in directory) - { - 'id': agent.pubkey, - 'name': agent.displayName ?? 'Agent', - 'artworkScale': - usesInsetAgentArtwork( - ref - .read(userCacheProvider.notifier) - .get(agent.pubkey) - ?.avatarUrl, - ) - ? '$animatedAvatarArtworkScale' - : '1', - 'avatarUrl': - ref - .read(userCacheProvider.notifier) - .get(agent.pubkey) - ?.avatarUrl ?? - '', - }, - ], + 'assistants': _nativeMeAssistants(directory ?? []), }); + // Send updates only to this presentation; late results cannot populate + // a reopened sheet or a different community's editor. + Future loadAssistants() async { + try { + final agents = await directoryRequest; + if (!active || !context.mounted || sync.disposed) return; + await const MethodChannel( + 'buzz/profile_text_editor', + ).invokeMethod('updateChatAssistants', { + 'presentationId': presentationId, + 'assistants': _nativeMeAssistants(agents), + }); + } catch (_) { + if (!active || !context.mounted || sync.disposed) return; + try { + await const MethodChannel( + 'buzz/profile_text_editor', + ).invokeMethod('updateChatAssistants', { + 'presentationId': presentationId, + 'assistants': [], + 'assistantError': + 'Couldn’t load agents. Close this sheet and try again.', + }); + } on PlatformException { + // The native editor may already be dismissed. + } on MissingPluginException { + // Flutter fallback has no native editor. + } + } + } + + loadAssistants(); + draft = await presentation; } on MissingPluginException { break; + } finally { + active = false; } if (draft == null || !context.mounted) return null; name = (draft['name'] as String? ?? '').trim(); @@ -150,28 +184,31 @@ Future _createMeChat( sync = _currentMeSync(ref, sync); final intent = MeChatCreation(channelId, name, assistant, project: project); await intent.save(sync); - final owned = await isOwnedMeAssistant(sync, assistant); + final actions = ref.read(channelActionsProvider); + final preflight = await Future.wait([ + isOwnedMeAssistant(sync, assistant), + sync.head(), + actions.findJoinedChannel(channelId), + ]); + final owned = preflight[0] as bool; sync.check(); if (!owned) { throw StateError('This agent is no longer available.'); } - final head = await sync.head(); + final head = preflight[1] as NostrEvent?; (head == null ? const MeGroups() : sync.decode(head)) .place(channelId, group: project) .encode(sync.community); - final actions = ref.read(channelActionsProvider); return intent.complete( sync, - findChannel: () async { - final all = await ref.read(channelsProvider.notifier).fetchFresh(); - return all.where((c) => c.id == channelId).firstOrNull; - }, + findChannel: () async => preflight[2] as Channel?, createChannel: () => actions.createChannel( channelId: channelId, name: name, channelType: 'stream', visibility: 'private', labels: const ['buzz.solo'], + waitForChannelList: false, ), hasAgent: () async { ref.invalidate(channelMembersProvider(channelId)); @@ -191,3 +228,17 @@ Future _createMeChat( sync.update((current) => current.place(channelId, group: project)), ); } + +List> _nativeMeAssistants( + List agents, +) => [ + for (final agent in agents) + { + 'id': agent.pubkey, + 'name': agent.displayName ?? 'Agent', + 'artworkScale': usesInsetAgentArtwork(agent.avatarUrl) + ? '$animatedAvatarArtworkScale' + : '1', + 'avatarUrl': agent.avatarUrl ?? '', + }, +]; diff --git a/mobile/lib/features/channels/me/new_me_chat.dart b/mobile/lib/features/channels/me/new_me_chat.dart index 57e83407b2..efd8c36055 100644 --- a/mobile/lib/features/channels/me/new_me_chat.dart +++ b/mobile/lib/features/channels/me/new_me_chat.dart @@ -28,10 +28,15 @@ class _NewMeChat extends HookConsumerWidget { ); } final choices = directory.asData?.value ?? []; - final chosen = choices - .where((agent) => agent.pubkey == selected.value) - .firstOrNull; final profile = ref.watch(userCacheProvider)[selected.value]; + final chosen = + choices.where((agent) => agent.pubkey == selected.value).firstOrNull ?? + (frozenAgent.value == null + ? null + : AgentDirectoryEntry( + pubkey: frozenAgent.value!, + displayName: profile?.label ?? frozenAgent.value!, + )); return SingleChildScrollView( padding: EdgeInsets.fromLTRB( Grid.gutter, diff --git a/mobile/lib/shared/me/me_agent_inventory.dart b/mobile/lib/shared/me/me_agent_inventory.dart new file mode 100644 index 0000000000..f5e89c6cd9 --- /dev/null +++ b/mobile/lib/shared/me/me_agent_inventory.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/relay.dart'; +import 'me_sync.dart'; + +/// Desktop's owner-authored agent inventory. A historical kind:0 ownership +/// attestation alone does not mean an agent is still in this inventory. +Future> loadMeAgentInventory(MeSync sync) async { + const pageSize = 200; + final events = []; + int? until; + String? beforeId; + for (var page = 0; page < 50; page++) { + sync.check(); + final batch = await sync.session.queryRelay([ + NostrFilter( + kinds: const [30177], + authors: [sync.pubkey], + limit: pageSize, + until: until, + extensions: {'before_id': ?beforeId}, + ), + ]); + sync.check(); + if (batch.length > pageSize || + batch.any((e) => e.kind != 30177 || e.pubkey != sync.pubkey)) { + throw StateError('Agent inventory response is outside its owner scope.'); + } + events.addAll(batch); + if (batch.length < pageSize) { + final owner = sync.pubkey; + return _parseInventory(events, owner); + } + final ordered = [...batch] + ..sort((a, b) { + final time = b.createdAt.compareTo(a.createdAt); + return time != 0 ? time : a.id.compareTo(b.id); + }); + final last = ordered.last; + if (until != null && + (last.createdAt > until || + (last.createdAt == until && last.id.compareTo(beforeId!) <= 0))) { + throw StateError('Agent inventory pagination did not advance.'); + } + until = last.createdAt; + beforeId = last.id; + } + throw StateError('Agent inventory exceeds the read budget.'); +} + +Future> _parseInventory( + List events, + String owner, +) => Isolate.run(() => _inventoryNames(events, owner)); + +Map _inventoryNames(List events, String owner) { + final heads = {}; + for (final event in events) { + if (event.pubkey != owner) continue; + final id = event.getTagValue('d'); + if (id == null || !RegExp(r'^[0-9a-f]{64}$').hasMatch(id)) continue; + // Verify before allowing a newer tombstone or record to replace a head. + try { + nostr.Event.fromJson(jsonEncode(event.toJson())); + } catch (_) { + // A corrupt record must not hide unrelated verified agents. + continue; + } + final previous = heads[id]; + if (previous == null || meEventIsNewer(event, previous)) heads[id] = event; + } + final names = {}; + for (final entry in heads.entries) { + try { + final body = jsonDecode(entry.value.content); + if (body is! Map) continue; + final name = body['name']; + if (name is String && name.trim().isNotEmpty) { + names[entry.key] = name.trim(); + } + } on FormatException { + // Empty/deleted and malformed heads never resurrect an earlier listing. + } + } + return names; +} diff --git a/mobile/lib/shared/me/me_archived_agents.dart b/mobile/lib/shared/me/me_archived_agents.dart new file mode 100644 index 0000000000..9171a8814f --- /dev/null +++ b/mobile/lib/shared/me/me_archived_agents.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/relay.dart'; +import 'me_sync.dart'; + +final meRelayInformationClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +/// Matches desktop's known-archived filtering. Unknown archive evidence does +/// not hide identities; this is presentation, never ownership or admission. +final meArchivedAgentsProvider = FutureProvider>((ref) async { + final sync = ref.watch(meSyncProvider); + final client = ref.watch(meRelayInformationClientProvider); + try { + final uri = Uri.parse(sync.community); + final response = await client + .send( + http.Request('GET', uri) + ..headers['Accept'] = 'application/nostr+json', + ) + .timeout(const Duration(seconds: 3)); + if (response.statusCode != 200) return {}; + final chunks = StreamIterator( + response.stream.timeout(const Duration(seconds: 3)), + ); + final bytes = []; + try { + await (() async { + while (await chunks.moveNext()) { + final chunk = chunks.current; + if (bytes.length + chunk.length > 256 * 1024) { + throw const FormatException( + 'Relay information exceeds size limit.', + ); + } + bytes.addAll(chunk); + } + })().timeout(const Duration(seconds: 3)); + } finally { + await chunks.cancel(); + } + sync.check(); + final info = jsonDecode(utf8.decode(bytes)); + // NIP-11 self is the signing authority; pubkey is only a contact key. + final authority = info is Map ? info['self'] : null; + if (authority is! String || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(authority)) { + return {}; + } + final events = await sync.session.fetchHistory( + NostrFilter(kinds: const [13535], authors: [authority], limit: 1), + timeout: const Duration(seconds: 3), + ); + sync.check(); + return await _parseArchives(events, authority); + } catch (_) { + return {}; + } +}); + +Future> _parseArchives(List events, String authority) => + Isolate.run(() => _archived(events, authority)); + +Set _archived(List events, String authority) { + if (events.length != 1) return {}; + final event = events.single; + final markers = event.tags.where((tag) => tag.isNotEmpty && tag.first == '-'); + if (event.kind != 13535 || + event.pubkey != authority || + event.content.isNotEmpty || + markers.length != 1 || + markers.single.length != 1) { + return {}; + } + final encoded = jsonEncode(event.toJson()); + if (utf8.encode(encoded).length > 2 * 1024 * 1024) return {}; + nostr.Event.fromJson(encoded); + return { + for (final tag in event.tags) + if (tag.length >= 2 && + tag[0] == 'p' && + RegExp(r'^[0-9a-f]{64}$').hasMatch(tag[1])) + tag[1], + }; +} diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index 03575adfe1..6f89e42f17 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:isolate'; import 'package:nostr/nostr.dart' as nostr; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -6,11 +7,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../crypto/nip_oa.dart'; import '../mentions/agent_identity_provider.dart'; import '../relay/relay.dart'; +import 'me_agent_inventory.dart'; +import 'me_archived_agents.dart'; import 'me_sync.dart'; -/// Owned assistants from verified profiles, including agents without kind 10100. -/// Agents inherit their owner's community access and need not be listed in the -/// community's human membership snapshot. +/// The owner's current inventory, with verified ownership and desktop's +/// known-archived exclusion. Never scan the community's historical profiles. final meAssistantsProvider = FutureProvider>(( ref, ) async { @@ -18,8 +20,32 @@ final meAssistantsProvider = FutureProvider>(( if (sync.connectionState != null) { ref.watch(relaySessionProvider.select((state) => state.status)); } - final session = ref.watch(relaySessionProvider.notifier); - return _loadAssistants(sync, session); + // Refresh archive evidence with the inventory, including explicit retries. + // Ordinary sheet openings reuse this provider's completed directory. + final archivedRequest = ref.refresh(meArchivedAgentsProvider.future); + final inventory = await loadMeAgentInventory(sync); + sync.check(); + final profiles = await _loadProfiles(sync, inventory.keys.toList()); + final archived = await archivedRequest; + sync.check(); + final choices = [ + for (final profile in profiles) + if (!archived.contains(profile.pubkey)) + AgentDirectoryEntry( + pubkey: profile.pubkey, + displayName: inventory[profile.pubkey], + avatarUrl: profile.avatarUrl, + respondTo: profile.respondTo, + respondToAllowlist: profile.respondToAllowlist, + channelIds: profile.channelIds, + ), + ]; + choices.sort( + (a, b) => (a.displayName ?? '').toLowerCase().compareTo( + (b.displayName ?? '').toLowerCase(), + ), + ); + return choices; }, retry: (_, _) => null); /// Freshness-controlled ownership check for one personal-chat recipient. @@ -34,76 +60,63 @@ final meAssistantOwnershipProvider = FutureProvider.family(( return isOwnedMeAssistant(sync, pubkey); }); -/// Revalidates only the chosen assistant before creating a chat. +/// Revalidate the selected recipient, including existing chats whose agent may +/// no longer be offered in the new-chat inventory. Discovery is not admission. Future isOwnedMeAssistant(MeSync sync, String pubkey) async => - (await _loadAssistants( - sync, - sync.session, - pubkey: pubkey, - )).any((agent) => agent.pubkey == pubkey); + (await _loadProfiles(sync, [ + pubkey, + ])).any((agent) => agent.pubkey == pubkey); -Future> _loadAssistants( +Future> _loadProfiles( MeSync sync, - RelaySessionNotifier session, { - String? pubkey, -}) async { - final profiles = {}; - final seen = {}; - const pageSize = 100; - // The current relay only indexes single-letter NIP-01 tag filters. It - // silently drops #auth, so owner-scoped discovery needs relay support. - // Until then fail visibly at this bound rather than drain the directory. - const maxPages = 10; - for (var page = 1; page <= maxPages; page++) { + List pubkeys, +) async { + final profiles = []; + const batchSize = 100; + for (var start = 0; start < pubkeys.length; start += batchSize) { sync.check(); - final events = await session.queryRelay([ - NostrFilter( - kinds: const [0], - authors: pubkey == null ? null : [pubkey], - limit: pageSize, - extensions: {'page': page}, - ), + final batch = pubkeys.skip(start).take(batchSize).toList(); + final events = await sync.session.queryRelay([ + NostrFilter(kinds: const [0], authors: batch, limit: batchSize), ]); sync.check(); - if (events.length > pageSize) { - throw StateError('Agent directory exceeded the requested page size.'); - } - var added = false; - for (final event in events) { - if (event.kind != 0 || (pubkey != null && event.pubkey != pubkey)) { - continue; - } - if (seen.contains(event.id)) continue; - try { - nostr.Event.fromJson(jsonEncode(event.toJson())); - } catch (_) { - continue; - } - if (!seen.add(event.id)) continue; - added = true; - final previous = profiles[event.pubkey]; - if (previous == null || - event.createdAt > previous.createdAt || - (event.createdAt == previous.createdAt && - event.id.compareTo(previous.id) < 0)) { - profiles[event.pubkey] = event; - } - } - if (events.length < pageSize) break; - if (!added || page == maxPages) { - throw StateError('Could not finish loading assistants. Try again.'); + if (events.length > batchSize) { + throw StateError('Agent profiles exceeded the requested batch size.'); } + profiles.addAll( + events.where((e) => e.kind == 0 && batch.contains(e.pubkey)), + ); } - final agents = []; - for (final event in profiles.values) { + final owner = sync.pubkey; + final result = await _parseProfiles(profiles, owner); + sync.check(); + return result; +} + +Future> _parseProfiles( + List events, + String owner, +) => Isolate.run(() => _verifiedProfiles(events, owner)); + +List _verifiedProfiles( + List events, + String owner, +) { + final latest = {}; + for (final event in events) { try { - if (verifiedProfileOaOwnerPubkey(event) == sync.pubkey) { - agents.add(AgentDirectoryEntry.fromEvent(event)); - } + nostr.Event.fromJson(jsonEncode(event.toJson())); } catch (_) { - // A malformed profile must not hide unrelated verified agents. continue; } + final previous = latest[event.pubkey]; + if (previous == null || meEventIsNewer(event, previous)) { + latest[event.pubkey] = event; + } } - return agents; + return [ + for (final event in latest.values) + if (verifiedProfileOaOwnerPubkey(event) == owner) + AgentDirectoryEntry.fromEvent(event), + ]; } diff --git a/mobile/lib/shared/me/me_chat_creation.dart b/mobile/lib/shared/me/me_chat_creation.dart index 8fb657b6ac..f366fb0df5 100644 --- a/mobile/lib/shared/me/me_chat_creation.dart +++ b/mobile/lib/shared/me/me_chat_creation.dart @@ -50,6 +50,7 @@ class MeChatCreation { previous.project != project)) { throw StateError('Resume the pending chat before starting another.'); } + // A matching cached value does not prove a previous disk write succeeded. if (sync.prefs == null || !await sync.prefs!.setString( storageKey(sync), @@ -108,11 +109,12 @@ class MeChatCreation { sync.check(); final result = existing ?? await createChannel(); sync.check(); - final member = await hasAgent(); + final status = await Future.wait([hasAgent(), isPlaced()]); + final member = status[0]; sync.check(); if (!member) await addAgent(); sync.check(); - final placed = await isPlaced(); + final placed = status[1]; sync.check(); // Placement may be confirmed while its inner journal cleanup failed. if (!placed || sync.prefs?.getString(sync.pendingKey) != null) { diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 75a9673a23..d98e937c43 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -16,6 +16,7 @@ import '../../shared/utils/string_utils.dart'; class AgentDirectoryEntry { final String pubkey; final String? displayName; + final String? avatarUrl; final String? respondTo; final List respondToAllowlist; final List channelIds; @@ -23,6 +24,7 @@ class AgentDirectoryEntry { const AgentDirectoryEntry({ required this.pubkey, this.displayName, + this.avatarUrl, this.respondTo, this.respondToAllowlist = const [], this.channelIds = const [], @@ -38,6 +40,7 @@ class AgentDirectoryEntry { return AgentDirectoryEntry( pubkey: event.pubkey.toLowerCase(), displayName: text('display_name') ?? text('name'), + avatarUrl: text('picture'), respondTo: text('respond_to'), respondToAllowlist: strings( 'respond_to_allowlist', diff --git a/mobile/test/features/channels/me/chat_preflight_test.dart b/mobile/test/features/channels/me/chat_preflight_test.dart index a136d55b17..ab0ef8495e 100644 --- a/mobile/test/features/channels/me/chat_preflight_test.dart +++ b/mobile/test/features/channels/me/chat_preflight_test.dart @@ -1,4 +1,5 @@ import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_assistants.dart'; @@ -23,6 +24,12 @@ class _Channels extends ChannelsNotifier { class _Relay extends RelaySessionNotifier { VoidCallback? beforeRead; int reads = 0; + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => []; + @override Future> queryRelay( List filters, { @@ -58,6 +65,7 @@ void main() { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( call, ) async { + if (call.method == 'updateChatAssistants') return null; forms++; if (forms > 1) return null; if (disconnect) { @@ -86,6 +94,17 @@ void main() { ), meAssistantsProvider.overrideWith((ref) async => []), channelsProvider.overrideWith(_Channels.new), + channelActionsProvider.overrideWith( + (ref) => ChannelActions( + ref: ref, + session: relay, + signedEventRelay: SignedEventRelay( + session: relay, + nsec: config.nsec, + ), + currentPubkey: sync.pubkey, + ), + ), ], child: MaterialApp(theme: AppTheme.light(), home: const MePage()), ), diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart index 009f5eba35..1e30cbb48e 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:buzz/features/channels/channel.dart'; @@ -6,9 +7,14 @@ import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_assistants.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; +import 'package:flutter/foundation.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:flutter/material.dart'; @@ -20,48 +26,27 @@ import 'package:pointycastle/digests/sha256.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _EmptyChannels extends ChannelsNotifier { - _EmptyChannels(this.existing); - final bool existing; - int freshReads = 0; @override Future> build() async => []; - @override - Future> fetchFresh({bool fetchDirectory = false}) async { - freshReads++; - return [ - if (existing) - Channel( - id: '10000000-0000-4000-8000-000000000001', - name: 'Mobile Solo', - channelType: 'stream', - visibility: 'private', - description: '', - createdBy: 'me', - createdAt: DateTime.utc(2026), - memberCount: 1, - isMember: true, - ), - ]; - } } class _Relay extends RelaySessionNotifier { final NostrEvent profile; final submitted = []; - _Relay(this.profile); + _Relay(this.profile, this.existing); + final List existing; @override Future> queryRelay( List filters, { Duration timeout = const Duration(seconds: 8), - }) async => - filters.any((filter) => filter.kinds.contains(0)) ? [profile] : []; + }) async => [profile]; @override Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => []; + }) async => filter.kinds.contains(39000) ? existing : []; @override Future publish( @@ -82,6 +67,7 @@ void main() { ? 'interrupted Solo recovery uses fresh membership before creating' : 'New Solo chat submits its discovery label through the production action', (tester) async { + final flutterSheet = defaultTargetPlatform == TargetPlatform.android; SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final owner = nostr.Keys.generate(), agent = nostr.Keys.generate(); @@ -108,29 +94,89 @@ void main() { ], ).toMap(), ); - final relay = _Relay(profile); + const channelId = '10000000-0000-4000-8000-000000000001'; + final relay = _Relay(profile, [ + if (recoverExisting) + for (final kind in [39000, 39002]) + NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + content: '', + secretKey: owner.secret, + tags: [ + ['d', channelId], + if (kind == 39000) ['name', 'Mobile Solo'], + if (kind == 39002) ['p', owner.public], + ], + ).toMap(), + ), + ]); final config = RelayConfig( baseUrl: 'https://relay.test', nsec: owner.nsec, ); final sync = MeSync(relay, config, prefs: prefs); - final channels = _EmptyChannels(recoverExisting); if (recoverExisting) { await MeChatCreation( - '10000000-0000-4000-8000-000000000001', + channelId, 'Mobile Solo', agent.public, ).save(sync); } const bridge = MethodChannel('buzz/profile_text_editor'); + // Let native chrome mount while the real verification isolate runs. + const platformViews = MethodChannel('flutter/platform_views'); + final nativeViews = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + platformViews, + (call) async { + if (call.method == 'create') { + final args = call.arguments as Map; + final channel = MethodChannel( + '${args['viewType']}/${args['id']}', + ); + nativeViews.add(channel); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => null, + ); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + platformViews, + null, + ), + ); + addTearDown(() { + for (final channel in nativeViews) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + } + }); + final directory = Completer>(); + if (flutterSheet) directory.complete([]); + final draft = Completer>(); + String? presentationId; + Map? agentUpdate; + Object? savedAssistant; var forms = 0; - final formErrors = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( call, ) async { - formErrors.add((call.arguments as Map)['error']); + if (call.method == 'updateChatAssistants') { + agentUpdate = call.arguments as Map; + return null; + } if (++forms > 1) return null; - return {'name': 'Mobile Solo', 'assistant': agent.public}; + final arguments = call.arguments as Map; + presentationId = arguments['presentationId'] as String; + savedAssistant = arguments['savedAssistant']; + return draft.future; }); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( @@ -142,14 +188,13 @@ void main() { ProviderScope( overrides: [ meSyncProvider.overrideWithValue(sync), + relaySessionProvider.overrideWith(() => relay), meGroupsProvider.overrideWith( (ref) => Stream.value(const MeGroups()), ), - meAssistantsProvider.overrideWith((ref) async => []), - channelsProvider.overrideWith(() => channels), - channelMembersProvider( - '10000000-0000-4000-8000-000000000001', - ).overrideWith((ref) async => []), + meAssistantsProvider.overrideWith((ref) => directory.future), + channelsProvider.overrideWith(_EmptyChannels.new), + channelMembersProvider(channelId).overrideWith((ref) async => []), channelActionsProvider.overrideWith( (ref) => ChannelActions( ref: ref, @@ -166,30 +211,120 @@ void main() { ), ); await tester.pumpAndSettle(); - tester + final start = tester .widget(find.byType(FrostedAppBar)) .nativeActions! .firstWhere((action) => action.label == 'New chat') - .onPressed!(); + .onPressed!; + // Ownership verification runs in a real isolate; pumpAndSettle alone + // only drains the widget test's fake clock, not that computation. + start(); await tester.pumpAndSettle(); if (recoverExisting) { await tester.tap(find.text('Resume')); await tester.pumpAndSettle(); - // Continue to member setup using the existing channel. A stale lookup - // would instead submit a second kind:9007 creation command. - expect(channels.freshReads, 1, reason: formErrors.toString()); - expect(relay.submitted.where((e) => e.kind == 9007), isEmpty); - return; } + if (flutterSheet) { + final row = find.byKey(const ValueKey('new-session-assistant')); + if (!recoverExisting) { + expect(find.text('Choose an agent'), findsOneWidget); + await tester.enterText( + find.byKey(const ValueKey('new-session-name')), + 'Mobile Solo', + ); + await tester.tap(find.text('Create chat')); + await tester.pumpAndSettle(); + expect( + find.text('Enter a name and choose an agent.'), + findsOneWidget, + ); + expect(relay.submitted, isEmpty); + expect(MeChatCreation.pending(sync), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + return; + } + final semantics = tester.ensureSemantics(); + await tester.pump(); + expect(find.text('Choose an agent'), findsNothing); + expect(find.text(agent.public), findsOneWidget); + expect(find.bySemanticsLabel(agent.public), findsOneWidget); + expect(tester.widget(row).onTap, isNull); + final container = ProviderScope.containerOf(tester.element(row)); + container + .read(userCacheProvider.notifier) + .put( + UserProfile(pubkey: agent.public, displayName: 'Saved agent'), + ); + await tester.pumpAndSettle(); + expect(find.text('Saved agent'), findsOneWidget); + expect(find.bySemanticsLabel('Saved agent'), findsOneWidget); + expect(tester.widget(row).onTap, isNull); + semantics.dispose(); + await tester.tap(find.text('Retry')); + } else { + expect( + forms, + 1, + reason: 'Compose opens even while agents are loading', + ); + if (recoverExisting) { + expect(savedAssistant, { + 'id': agent.public, + 'name': agent.public, + 'avatarUrl': '', + }); + } + directory.complete([ + AgentDirectoryEntry( + pubkey: agent.public, + displayName: 'Owned agent', + ), + ]); + await tester.pumpAndSettle(); + expect(agentUpdate?['presentationId'], presentationId); + expect( + (agentUpdate?['assistants'] as List).single['id'], + agent.public, + ); + draft.complete({'name': 'Mobile Solo', 'assistant': agent.public}); + } + for ( + var attempts = 0; + attempts < 100 && relay.submitted.isEmpty; + attempts++ + ) { + await tester.pumpAndSettle(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + } + await tester.pumpAndSettle(); final command = relay.submitted.single; - expect(command.kind, 9007); - expect(command.pubkey, owner.public); - expect(command.tags, contains(equals(['t', 'buzz.solo']))); - expect(command.tags, contains(equals(['channel_type', 'stream']))); - expect(command.tags, contains(equals(['visibility', 'private']))); - expect(command.tags.where((tag) => tag.first == 'P'), isEmpty); + if (recoverExisting) { + expect(relay.submitted.where((event) => event.kind == 9007), isEmpty); + expect( + command.kind, + 9000, + reason: 'Recovery continues with member setup', + ); + expect(MeChatCreation.pending(sync)?.id, channelId); + expect(command.tags, contains(equals(['p', agent.public]))); + } else { + expect(command.kind, 9007); + expect(command.pubkey, owner.public); + expect(command.tags, contains(equals(['t', 'buzz.solo']))); + expect(command.tags, contains(equals(['channel_type', 'stream']))); + expect(command.tags, contains(equals(['visibility', 'private']))); + expect(command.tags.where((tag) => tag.first == 'P'), isEmpty); + } + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), + variant: TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), ); } } diff --git a/mobile/test/features/channels/solo_creation_latency_test.dart b/mobile/test/features/channels/solo_creation_latency_test.dart new file mode 100644 index 0000000000..fda46d9b3f --- /dev/null +++ b/mobile/test/features/channels/solo_creation_latency_test.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'Solo creation finishes while the full channel list is stalled', + () async { + final keys = nostr.Keys.generate(); + final session = _SoloSession(keys.public); + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(keys.public), + ], + ); + addTearDown(container.dispose); + container.listen(channelsProvider, (_, _) {}); + await session.listStarted.future; + final actions = container.read( + Provider( + (ref) => ChannelActions( + ref: ref, + session: session, + signedEventRelay: SignedEventRelay( + session: session, + nsec: keys.nsec, + ), + currentPubkey: keys.public, + ), + ), + ); + + expect(await actions.findJoinedChannel('solo-id'), isNull); + final created = await actions + .createChannel( + channelId: 'solo-id', + name: 'New chat', + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + waitForChannelList: false, + ) + .timeout(const Duration(seconds: 2)); + expect(session.stalledList.isCompleted, isFalse); + expect(container.read(channelsProvider).isLoading, isTrue); + expect(created.isPersonalChat(keys.public, const []), isTrue); + expect(created.isMember, isTrue); + expect(created.memberCount, 1); + expect(session.reads, 2); + expect(session.published.single.kind, 9007); + expect( + session.published.single.tags, + contains(equals(['t', 'buzz.solo'])), + ); + expect(session.published.single.tags.any((t) => t.first == 'P'), isFalse); + // A retry reads the same channel, without a broad membership query. + expect((await actions.findJoinedChannel('solo-id'))?.id, created.id); + expect(session.published, hasLength(1)); + }, + ); + + test( + 'targeted recovery requires membership and preserves failures', + () async { + final keys = nostr.Keys.generate(); + final session = _SoloSession('another-owner')..exists = true; + var valid = true; + final container = ProviderContainer(); + addTearDown(container.dispose); + final actions = container.read( + Provider( + (ref) => ChannelActions( + ref: ref, + session: session, + signedEventRelay: SignedEventRelay( + session: session, + nsec: keys.nsec, + ), + currentPubkey: keys.public, + isCommunityValid: () => valid, + ), + ), + ); + expect(await actions.findJoinedChannel('solo-id'), isNull); + session.failure = TimeoutException('relay unavailable'); + await expectLater( + actions.findJoinedChannel('solo-id'), + throwsA(isA()), + ); + session.failure = null; + session.onRead = () => valid = false; + await expectLater(actions.findJoinedChannel('solo-id'), throwsStateError); + expect(session.published, isEmpty); + }, + ); +} + +class _SoloSession extends RelaySessionNotifier { + _SoloSession(this.owner); + final String owner; + bool exists = false; + int reads = 0; + Object? failure; + void Function()? onRead; + final published = []; + final stalledList = Completer>(); + final listStarted = Completer(); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(filters.single.tags, { + '#p': [owner], + }); + if (!listStarted.isCompleted) listStarted.complete(); + return stalledList.future; + } + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + exists = true; + return event; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + reads++; + expect(filter.kinds, [39000, 39002]); + expect(filter.tags, { + '#d': ['solo-id'], + }); + expect(filter.limit, 2); + onRead?.call(); + if (failure != null) throw failure!; + if (!exists) return []; + return [ + NostrEvent( + id: 'metadata', + pubkey: 'relay', + createdAt: 1, + kind: 39000, + tags: [ + ['d', 'solo-id'], + ['name', 'New chat'], + ['t', 'stream'], + ['t', 'buzz.solo'], + ['private'], + ['P', owner], + ], + content: '', + sig: '', + ), + NostrEvent( + id: 'roster', + pubkey: 'relay', + createdAt: 1, + kind: 39002, + tags: [ + ['d', 'solo-id'], + ['p', owner, '', 'owner'], + ], + content: '', + sig: '', + ), + ]; + } +} diff --git a/mobile/test/shared/me/me_archived_agents_test.dart b/mobile/test/shared/me/me_archived_agents_test.dart new file mode 100644 index 0000000000..99a53534ba --- /dev/null +++ b/mobile/test/shared/me/me_archived_agents_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/me/me_archived_agents.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test( + 'only a verified snapshot from NIP-11 self hides archived agents', + () async { + final owner = nostr.Keys.generate(); + final authority = nostr.Keys.generate(); + final impostor = nostr.Keys.generate(); + final target = 'a' * 64; + NostrEvent snapshot(nostr.Keys signer, {bool marker = true}) => + NostrEvent.fromJson( + nostr.Event.from( + kind: 13535, + content: '', + secretKey: signer.secret, + tags: [ + if (marker) ['-'], + ['p', target], + ], + ).toMap(), + ); + final relay = _ArchiveRelay(snapshot(authority)); + final client = MockClient((request) async { + expect(request.url.origin, 'https://relay.test'); + expect(request.headers['Accept'], 'application/nostr+json'); + return http.Response( + jsonEncode({'self': authority.public, 'pubkey': impostor.public}), + 200, + ); + }); + final container = ProviderContainer( + overrides: [ + meSyncProvider.overrideWithValue( + MeSync( + relay, + RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), + ), + ), + meRelayInformationClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + expect(await container.read(meArchivedAgentsProvider.future), {target}); + expect(relay.author, authority.public); + for (final invalid in [ + snapshot(impostor), + snapshot(authority, marker: false), + NostrEvent.fromJson(snapshot(authority).toJson()..['sig'] = '0' * 128), + ]) { + relay.event = invalid; + container.invalidate(meArchivedAgentsProvider); + expect(await container.read(meArchivedAgentsProvider.future), isEmpty); + } + relay.event = snapshot(authority); + container.invalidate(meArchivedAgentsProvider); + expect(await container.read(meArchivedAgentsProvider.future), {target}); + }, + ); + + test('slow metadata has a total deadline and cancels its stream', () async { + final owner = nostr.Keys.generate(); + final relay = _ArchiveRelay(null); + var cancelled = false; + final body = StreamController>(onCancel: () => cancelled = true); + final client = MockClient.streaming( + (_, _) async => http.StreamedResponse(body.stream, 200), + ); + final container = ProviderContainer( + overrides: [ + meSyncProvider.overrideWithValue( + MeSync( + relay, + RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), + ), + ), + meRelayInformationClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + addTearDown(body.close); + final trickle = Timer.periodic(const Duration(milliseconds: 100), (_) { + body.add([32]); + }); + addTearDown(trickle.cancel); + final result = await container + .read(meArchivedAgentsProvider.future) + .timeout(const Duration(seconds: 5)); + expect(result, isEmpty); + expect(cancelled, isTrue); + expect(relay.author, isNull); + }); + + test( + 'contact keys and unavailable metadata are not archive authority', + () async { + final owner = nostr.Keys.generate(); + final relay = _ArchiveRelay(null); + final client = MockClient( + (_) async => http.Response(jsonEncode({'pubkey': owner.public}), 200), + ); + final container = ProviderContainer( + overrides: [ + meSyncProvider.overrideWithValue( + MeSync( + relay, + RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), + ), + ), + meRelayInformationClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + expect(await container.read(meArchivedAgentsProvider.future), isEmpty); + expect(relay.author, isNull); + }, + ); +} + +class _ArchiveRelay extends RelaySessionNotifier { + _ArchiveRelay(this.event); + NostrEvent? event; + String? author; + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(filter.kinds, [13535]); + author = filter.authors!.single; + return [?event]; + } +} diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index 62aee1b2a5..3e952feace 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_archived_agents.dart'; +import 'package:buzz/shared/me/me_agent_inventory.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,10 +12,12 @@ import 'package:pointycastle/digests/sha256.dart'; class _Relay extends RelaySessionNotifier { final List> pages; - final requestedPages = []; + List? inventory; + List>? inventoryPages; + int inventoryReads = 0; final requestedTags = >[]; final requestedAuthors = ?>[]; - _Relay(this.pages); + _Relay(this.pages, {this.inventory}); @override SessionState build() => const SessionState(status: SessionStatus.connected); @override @@ -21,12 +25,19 @@ class _Relay extends RelaySessionNotifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { - expect(filters.single.kinds, [0]); - requestedTags.add(filters.single.toJson()); - requestedAuthors.add(filters.single.authors); - final page = filters.single.toJson()['page'] as int; - requestedPages.add(page); - return pages[page - 1]; + final filter = filters.single; + requestedTags.add(filter.toJson()); + if (filter.kinds.single == 30177) { + return inventoryPages?[inventoryReads++] ?? inventory ?? const []; + } + expect(filter.kinds, [0]); + expect( + filter.authors, + isNotEmpty, + reason: 'Never scan all community profiles', + ); + requestedAuthors.add(filter.authors); + return pages.expand((p) => p).toList(); } } @@ -68,7 +79,25 @@ void main() { ); } - ProviderContainer setup(_Relay relay) { + NostrEvent listing( + String pubkey, { + String name = 'Saved agent', + int time = 1, + String? content, + }) => NostrEvent.fromJson( + nostr.Event.from( + kind: 30177, + content: content ?? jsonEncode({'name': name}), + secretKey: owner.secret, + createdAt: time, + tags: [ + ['d', pubkey], + ], + ).toMap(), + ); + + ProviderContainer setup(_Relay relay, {Set archived = const {}}) { + relay.inventory ??= [listing(agent.public)]; final sync = MeSync( relay, RelayConfig( @@ -83,29 +112,41 @@ void main() { overrides: [ relaySessionProvider.overrideWith(() => relay), meSyncProvider.overrideWithValue(sync), + meArchivedAgentsProvider.overrideWith((ref) async => archived), ], ); addTearDown(container.dispose); return container; } - test('finds owned kind 0 agents without legacy directory entries', () async { - final valid = profile(agent, ownedBy: owner); - final forged = valid.toJson()..['pubkey'] = other.public; - final container = setup( - _Relay([ - [ - profile(owner), - profile(nostr.Keys.generate(), ownedBy: other), - NostrEvent.fromJson(forged), - valid, - ], - ]), - ); - final choices = await container.read(meAssistantsProvider.future); - expect(choices.map((a) => a.pubkey), [agent.public]); - expect(choices.single.displayName, 'My assistant'); - }); + test( + 'lists current inventory with verified ownership, name and artwork', + () async { + final valid = profile( + agent, + ownedBy: owner, + content: { + 'display_name': 'Profile name', + 'picture': 'https://example.com/agent.png', + }, + ); + final forged = valid.toJson()..['pubkey'] = other.public; + final container = setup( + _Relay([ + [ + profile(owner), + profile(nostr.Keys.generate(), ownedBy: other), + NostrEvent.fromJson(forged), + valid, + ], + ]), + ); + final choices = await container.read(meAssistantsProvider.future); + expect(choices.map((a) => a.pubkey), [agent.public]); + expect(choices.single.displayName, 'Saved agent'); + expect(choices.single.avatarUrl, 'https://example.com/agent.png'); + }, + ); for (final malformed in [7, true, 'wrong', {}, []]) { test( 'malformed optional profile fields do not poison directory: $malformed', @@ -135,7 +176,7 @@ void main() { final choices = await container.read(meAssistantsProvider.future); expect( choices.singleWhere((a) => a.pubkey == agent.public).displayName, - 'My assistant', + 'Saved agent', ); expect( await isOwnedMeAssistant( @@ -147,6 +188,32 @@ void main() { }, ); } + test( + 'inventory pagination advances and rejects a repeated full page', + () async { + final first = listing(agent.public, time: 2); + final last = listing(other.public, time: 1); + final page = List.filled(200, first); + final relay = _Relay([]) + ..inventoryPages = [ + page, + [last], + ]; + final sync = setup(relay).read(meSyncProvider); + expect( + (await loadMeAgentInventory(sync)).keys, + unorderedEquals([agent.public, other.public]), + ); + expect(relay.requestedTags.last['until'], 2); + expect(relay.requestedTags.last['before_id'], first.id); + expect(relay.requestedTags.first['authors'], [owner.public]); + relay.inventoryReads = 0; + relay.inventoryPages = [page, page]; + await expectLater(loadMeAgentInventory(sync), throwsStateError); + expect(relay.inventoryReads, 2); + }, + ); + test('latest profile removes obsolete ownership', () async { final container = setup( _Relay([ @@ -155,38 +222,78 @@ void main() { ); expect(await container.read(meAssistantsProvider.future), isEmpty); }); - test('reads beyond the first directory page', () async { - final relay = _Relay([ - [for (var i = 0; i < 100; i++) profile(owner, time: i + 1)], - [profile(agent, ownedBy: owner)], - ]); - expect( - (await setup(relay).read(meAssistantsProvider.future)).single.pubkey, - agent.public, - ); - expect(relay.requestedPages, [1, 2]); - }); test( - 'directory discovery is bounded even when the relay keeps paging', + 'omits historical, removed and archived agents without losing ownership checks', () async { - final repeated = [ - for (var i = 0; i < 99; i++) profile(agent, ownedBy: owner, time: i), - ]; - final relay = _Relay([ - for (var page = 0; page < 11; page++) - [...repeated, profile(agent, ownedBy: owner, time: 100 + page)], - ]); - await expectLater( - setup(relay).read(meAssistantsProvider.future), - throwsStateError, + final archivedAgent = nostr.Keys.generate(); + final removed = nostr.Keys.generate(); + final historical = nostr.Keys.generate(); + final relay = _Relay( + [ + [ + for (final key in [agent, archivedAgent, removed, historical]) + profile(key, ownedBy: owner), + ], + ], + inventory: [ + listing(agent.public), + for (final field in ['id', 'sig']) + NostrEvent.fromJson( + listing(agent.public, time: 3, content: '{}').toJson() + ..[field] = '0' * (field == 'id' ? 64 : 128), + ), + listing(archivedAgent.public), + listing(removed.public), + listing(removed.public, time: 2, content: '{}'), + ], + ); + final container = setup(relay, archived: {archivedAgent.public}); + expect( + (await container.read( + meAssistantsProvider.future, + )).map((a) => a.pubkey), + [agent.public], + ); + expect( + relay.requestedAuthors.single, + unorderedEquals([agent.public, archivedAgent.public]), + ); + expect( + await isOwnedMeAssistant( + container.read(meSyncProvider), + historical.public, + ), + isTrue, ); - expect(relay.requestedPages.length, 10); - for (final filter in relay.requestedTags) { - expect(filter.containsKey('#auth'), isFalse); - expect(filter['limit'], 100); - } }, ); + test( + 'empty inventory is authoritative and does not scan historical profiles', + () async { + final relay = _Relay([ + [profile(agent, ownedBy: owner)], + ], inventory: []); + expect(await setup(relay).read(meAssistantsProvider.future), isEmpty); + expect(relay.requestedAuthors, isEmpty); + }, + ); + test('inventory rejects records outside owner scope', () async { + final foreign = NostrEvent.fromJson( + nostr.Event.from( + kind: 30177, + content: '{"name":"Foreign"}', + secretKey: other.secret, + tags: [ + ['d', agent.public], + ], + ).toMap(), + ); + final container = setup(_Relay([], inventory: [foreign])); + await expectLater( + loadMeAgentInventory(container.read(meSyncProvider)), + throwsStateError, + ); + }); for (final conditions in ['kind=1', 'created_at>1', 'created_at<1']) { test( 'discovery and send revalidation reject inapplicable $conditions', diff --git a/mobile/test/shared/me/me_chat_creation_test.dart b/mobile/test/shared/me/me_chat_creation_test.dart index 7aac3f116f..a7e79ccdc6 100644 --- a/mobile/test/shared/me/me_chat_creation_test.dart +++ b/mobile/test/shared/me/me_chat_creation_test.dart @@ -1,11 +1,59 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + test( + 'retry confirms persistence after a failed cached journal write', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingStore(); + final previousStore = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = store; + addTearDown( + () => SharedPreferencesStorePlatform.instance = previousStore, + ); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Keys.generate().nsec, + ), + prefs: prefs, + ); + const intent = MeChatCreation('id', 'Name', 'agent'); + var reachedRelay = false; + Future run() => intent.complete( + sync, + findChannel: () async { + reachedRelay = true; + await prefs.reload(); + expect(MeChatCreation.pending(sync)?.id, intent.id); + return null; + }, + createChannel: () async => throw StateError('Simulated process stop'), + hasAgent: () async => false, + addAgent: () async {}, + isPlaced: () async => false, + place: () async {}, + ); + await expectLater(run(), throwsStateError); + expect(reachedRelay, isFalse); + expect( + MeChatCreation.pending(sync)?.id, + intent.id, + ); // Failed write remains cached. + await expectLater(run(), throwsStateError); + expect(reachedRelay, isTrue); + await prefs.reload(); + expect(MeChatCreation.pending(sync)?.id, intent.id); + }, + ); test('missing durable storage blocks every relay side effect', () async { final sync = MeSync( RelaySessionNotifier(), @@ -150,3 +198,16 @@ void main() { }); } } + +class _FailingStore extends InMemorySharedPreferencesStore { + _FailingStore() : super.empty(); + bool failNext = true; + @override + Future setValue(String valueType, String key, Object value) async { + if (failNext && key.startsWith('flutter.me-chat-creation:')) { + failNext = false; + return false; + } + return super.setValue(valueType, key, value); + } +} diff --git a/mobile/test/shared/me/me_sync_test.dart b/mobile/test/shared/me/me_sync_test.dart index 5675ec23bb..4d028b9c66 100644 --- a/mobile/test/shared/me/me_sync_test.dart +++ b/mobile/test/shared/me/me_sync_test.dart @@ -9,6 +9,7 @@ import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_archived_agents.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/me/me_recovery.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -203,6 +204,7 @@ void main() { final config = _Config(RelayConfig(baseUrl: origin, nsec: nsec)); final container = ProviderContainer( overrides: [ + meArchivedAgentsProvider.overrideWith((ref) async => {}), relayConfigProvider.overrideWith(() => config), relaySessionProvider.overrideWith(() => relay), savedPrefsProvider.overrideWithValue(prefs),