mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
Merge remote-tracking branch 'origin/main' into kennylopez-iphone-duo-devicehub
Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> # Conflicts: # mobile/ios/Runner/NativeProfileTextEditor.swift
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -37,6 +37,17 @@ final class NativeProfileTextEditorCoordinator: NSObject,
|
||||
}
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -295,7 +306,7 @@ 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?
|
||||
@@ -308,6 +319,8 @@ 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 }
|
||||
|
||||
@@ -491,13 +504,30 @@ 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
|
||||
@@ -539,7 +569,7 @@ 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 ?? ""])
|
||||
@@ -576,7 +606,7 @@ 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)
|
||||
@@ -603,6 +633,8 @@ 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."
|
||||
@@ -665,8 +697,13 @@ 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)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class ChannelActions {
|
||||
List<String> 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<Channel?> 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
|
||||
|
||||
@@ -45,6 +45,7 @@ class MeAssistantList extends ConsumerWidget {
|
||||
child: AppListRowRaw(
|
||||
leading: AvatarImage(
|
||||
imageUrl:
|
||||
agent.avatarUrl ??
|
||||
(profiles[agent.pubkey] ??
|
||||
ref
|
||||
.read(userCacheProvider.notifier)
|
||||
|
||||
@@ -7,6 +7,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';
|
||||
|
||||
@@ -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<Object?, Object?>? draft;
|
||||
try {
|
||||
draft = await const MethodChannel('buzz/profile_text_editor')
|
||||
final presentation = const MethodChannel('buzz/profile_text_editor')
|
||||
.invokeMethod<Map<Object?, Object?>>('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<void> loadAssistants() async {
|
||||
try {
|
||||
final agents = await directoryRequest;
|
||||
if (!active || !context.mounted || sync.disposed) return;
|
||||
await const MethodChannel(
|
||||
'buzz/profile_text_editor',
|
||||
).invokeMethod<void>('updateChatAssistants', {
|
||||
'presentationId': presentationId,
|
||||
'assistants': _nativeMeAssistants(agents),
|
||||
});
|
||||
} catch (_) {
|
||||
if (!active || !context.mounted || sync.disposed) return;
|
||||
try {
|
||||
await const MethodChannel(
|
||||
'buzz/profile_text_editor',
|
||||
).invokeMethod<void>('updateChatAssistants', {
|
||||
'presentationId': presentationId,
|
||||
'assistants': <Object>[],
|
||||
'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<Channel> _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<Object?>([
|
||||
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<Channel>(
|
||||
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<Channel> _createMeChat(
|
||||
sync.update((current) => current.place(channelId, group: project)),
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, String>> _nativeMeAssistants(
|
||||
List<AgentDirectoryEntry> agents,
|
||||
) => [
|
||||
for (final agent in agents)
|
||||
{
|
||||
'id': agent.pubkey,
|
||||
'name': agent.displayName ?? 'Agent',
|
||||
'artworkScale': usesInsetAgentArtwork(agent.avatarUrl)
|
||||
? '$animatedAvatarArtworkScale'
|
||||
: '1',
|
||||
'avatarUrl': agent.avatarUrl ?? '',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
89
mobile/lib/shared/me/me_agent_inventory.dart
Normal file
89
mobile/lib/shared/me/me_agent_inventory.dart
Normal file
@@ -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<Map<String, String>> loadMeAgentInventory(MeSync sync) async {
|
||||
const pageSize = 200;
|
||||
final events = <NostrEvent>[];
|
||||
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<Map<String, String>> _parseInventory(
|
||||
List<NostrEvent> events,
|
||||
String owner,
|
||||
) => Isolate.run(() => _inventoryNames(events, owner));
|
||||
|
||||
Map<String, String> _inventoryNames(List<NostrEvent> events, String owner) {
|
||||
final heads = <String, NostrEvent>{};
|
||||
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 = <String, String>{};
|
||||
for (final entry in heads.entries) {
|
||||
try {
|
||||
final body = jsonDecode(entry.value.content);
|
||||
if (body is! Map<String, dynamic>) 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;
|
||||
}
|
||||
94
mobile/lib/shared/me/me_archived_agents.dart
Normal file
94
mobile/lib/shared/me/me_archived_agents.dart
Normal file
@@ -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<http.Client>((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<Set<String>>((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 = <int>[];
|
||||
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<String, dynamic> ? 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<Set<String>> _parseArchives(List<NostrEvent> events, String authority) =>
|
||||
Isolate.run(() => _archived(events, authority));
|
||||
|
||||
Set<String> _archived(List<NostrEvent> 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],
|
||||
};
|
||||
}
|
||||
@@ -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<List<AgentDirectoryEntry>>((
|
||||
ref,
|
||||
) async {
|
||||
@@ -18,8 +20,32 @@ final meAssistantsProvider = FutureProvider<List<AgentDirectoryEntry>>((
|
||||
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<bool, String>((
|
||||
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<bool> 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<List<AgentDirectoryEntry>> _loadAssistants(
|
||||
Future<List<AgentDirectoryEntry>> _loadProfiles(
|
||||
MeSync sync,
|
||||
RelaySessionNotifier session, {
|
||||
String? pubkey,
|
||||
}) async {
|
||||
final profiles = <String, NostrEvent>{};
|
||||
final seen = <String>{};
|
||||
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<String> pubkeys,
|
||||
) async {
|
||||
final profiles = <NostrEvent>[];
|
||||
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 = <AgentDirectoryEntry>[];
|
||||
for (final event in profiles.values) {
|
||||
final owner = sync.pubkey;
|
||||
final result = await _parseProfiles(profiles, owner);
|
||||
sync.check();
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<AgentDirectoryEntry>> _parseProfiles(
|
||||
List<NostrEvent> events,
|
||||
String owner,
|
||||
) => Isolate.run(() => _verifiedProfiles(events, owner));
|
||||
|
||||
List<AgentDirectoryEntry> _verifiedProfiles(
|
||||
List<NostrEvent> events,
|
||||
String owner,
|
||||
) {
|
||||
final latest = <String, NostrEvent>{};
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String> respondToAllowlist;
|
||||
final List<String> 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',
|
||||
|
||||
@@ -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<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [];
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> 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()),
|
||||
),
|
||||
|
||||
@@ -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<List<Channel>> build() async => [];
|
||||
@override
|
||||
Future<List<Channel>> 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 = <NostrEvent>[];
|
||||
_Relay(this.profile);
|
||||
_Relay(this.profile, this.existing);
|
||||
final List<NostrEvent> existing;
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async =>
|
||||
filters.any((filter) => filter.kinds.contains(0)) ? [profile] : [];
|
||||
}) async => [profile];
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [];
|
||||
}) async => filter.kinds.contains(39000) ? existing : [];
|
||||
|
||||
@override
|
||||
Future<NostrEvent> 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 = <MethodChannel>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
platformViews,
|
||||
(call) async {
|
||||
if (call.method == 'create') {
|
||||
final args = call.arguments as Map<Object?, Object?>;
|
||||
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<List<AgentDirectoryEntry>>();
|
||||
if (flutterSheet) directory.complete([]);
|
||||
final draft = Completer<Map<String, String>>();
|
||||
String? presentationId;
|
||||
Map<Object?, Object?>? agentUpdate;
|
||||
Object? savedAssistant;
|
||||
var forms = 0;
|
||||
final formErrors = <Object?>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
|
||||
call,
|
||||
) async {
|
||||
formErrors.add((call.arguments as Map)['error']);
|
||||
if (call.method == 'updateChatAssistants') {
|
||||
agentUpdate = call.arguments as Map<Object?, Object?>;
|
||||
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<FrostedAppBar>(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<AppListRowRaw>(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<AppListRowRaw>(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<void>.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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
187
mobile/test/features/channels/solo_creation_latency_test.dart
Normal file
187
mobile/test/features/channels/solo_creation_latency_test.dart
Normal file
@@ -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<TimeoutException>()),
|
||||
);
|
||||
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 = <NostrEvent>[];
|
||||
final stalledList = Completer<List<NostrEvent>>();
|
||||
final listStarted = Completer<void>();
|
||||
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
expect(filters.single.tags, {
|
||||
'#p': [owner],
|
||||
});
|
||||
if (!listStarted.isCompleted) listStarted.complete();
|
||||
return stalledList.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NostrEvent> publish(
|
||||
NostrEvent event, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
published.add(event);
|
||||
exists = true;
|
||||
return event;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> 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: '',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
146
mobile/test/shared/me/me_archived_agents_test.dart
Normal file
146
mobile/test/shared/me/me_archived_agents_test.dart
Normal file
@@ -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<List<int>>(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<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
expect(filter.kinds, [13535]);
|
||||
author = filter.authors!.single;
|
||||
return [?event];
|
||||
}
|
||||
}
|
||||
@@ -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<List<NostrEvent>> pages;
|
||||
final requestedPages = <int>[];
|
||||
List<NostrEvent>? inventory;
|
||||
List<List<NostrEvent>>? inventoryPages;
|
||||
int inventoryReads = 0;
|
||||
final requestedTags = <Map<String, dynamic>>[];
|
||||
final requestedAuthors = <List<String>?>[];
|
||||
_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<NostrFilter> 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<String> 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', <String, Object>{}, <Object>[]]) {
|
||||
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',
|
||||
|
||||
@@ -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<String> run() => intent.complete<String>(
|
||||
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<bool> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user