From b1d60f98e2f7842afb07cd2a163b6eb5973bd1bf Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 15 Oct 2026 17:07:23 -0400 Subject: [PATCH] Bring synced Solo chats and projects to mobile (#8337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Bring desktop Solo chats and project organization to mobile, with synced collapsible sections, shared unread highlights and tab badges, agent selection, shared search, native iOS creation sheets, and inline typing bubbles that transition into replies. Resolve Solo placement for every chat entry route, retain encrypted organization snapshots for offline reading, and let users resume or discard unfinished chat and project setup without deleting existing messages. ### Related issue Related: #6565; expandable agent transcripts remain separate work. ### Testing - Fresh Solo read state seeds each active chat from its captured initial last-message timestamp before exposing row/section/tab unread state. Baselines survive intervening channel updates and reset across identity/community boundaries while surviving read-state readiness changes. Regressions cover multiple chats, events before read-state readiness and during/after seeding, and identity changes; removing either the seed or the display gate fails them. - Chat completion reconciles a retained Solo-save journal even when placement is already confirmed. False/throwing cleanup regressions ensure the outer intent remains until reconciliation and only one placement is published. Typing terminal-close loops remain bounded; the retry budget resets after an uninterrupted eight-second health interval or transport reconnect. - Typing retry budgets reset after sustained recovery; invalid Solo updates retain cached rows with a retry warning that survives failed refreshes. Project names use the editors’ 120-grapheme limit, including emoji and combining characters. Regression coverage exercises repeated recovery, invalid live/refresh heads, and character-limit boundaries. - Rebased onto current main, preserving the newer Search controls, Huddle timeline filtering, and message-media layout. - Failed project journal writes reload storage before claiming recovery and reopen the entered name for retry. Typing subscription rejections are caught; terminal closures clear stale indicators and retry with a bounded backoff, canceled on disposal. Regression tests cover both false/throwing storage writes and initial/late subscription rejection. - Explicit ordinary destinations skip optional Solo lookup waits. Generic destinations perform one bounded lookup in the destination (search no longer duplicates it); an unavailable classification falls back to ordinary sending per the product owner's explicit choice. Positively identified Solo destinations use Solo providers. - Terminal Solo subscription closure preserves cached rows and exposes live-update Retry. Page coverage verifies warning visibility, usable rows during recovery, and a subsequent terminal close. Removing the warning or the immediate ordinary-route guard fails its regression. - Generic channel entries share an ordinary default when Solo classification is unavailable, per the requested product behavior. Known Solo placement takes precedence; callers can explicitly require unresolved placement to remain blocked. Bounded initial lookup preserves known Solo deep-link routing. - Dirty newest snapshots and offline-save warnings survive transport reconnects; Retry persists that same event even if the relay returns an older head. Community changes replace the service, and agent-directory reconnect refresh remains intact. - Pending-save discard reloads failed removals. Repeated reconnect testing confirms typing subscriptions are released on provider rebuild and disposal; removing the existing cleanup fails the regression. - Ordinary Home/create routes explicitly preserve private-channel sending during Solo failures; identified Solo sends use the Solo sender and explicit unresolved routes remain fail-closed. Solo unread replies beyond the initial 200-event page are loaded through the oldest-unread boundary. - Chat/project/agent cleanup reloads failed removals, retains same-process recovery, and protects replaced records. False/throw regressions verify retries do not duplicate creation or membership. Route, unread, and cleanup guard-removal mutations each fail their regression. - Ordinary channels, DMs, and forums remain sendable when optional Solo sync fails; explicit unresolved placement stays blocked. Controlled cache races retain the newest verified snapshot, keep usable chats visible, and retry offline persistence without stale writes. Both protection-removal regressions fail as expected. - Confirmed journal cleanup checks false/throwing removal and exact-record identity. Interrupted agent choices are visible immediately, identify the saved agent, and cover Resume/Later/Discard with accessible name/key-fallback controls. - Notification deep links preserve the contiguous history cursor; same-second messages infer the newest agent consistently. Failed offline-cache writes surface Retry, with false/throwing storage failures and offline restart covered. - Composite history cursors cover 350 same-second events plus older history; removing the ID cursor makes the regression fail. Agent changes now journal before granting access, with restart/failure and Resume/Later/Discard widget coverage. Signed malformed profiles remain isolated. - Native iOS avatar tests passed on the simulator: visible-row loading, three-request concurrency, streamed byte limits, response validation, downsampling/pixel limits, cancellation, and visible avatar updates. - Solo and Home share observed-event/read-marker logic. Widget coverage verifies unread rows, collapsed-section indicators, the tab badge before first Solo visit, message/thread reads, forced unread, and filtering archived/nonmember/unplaced chats. - Chat intent is saved before ownership and organization preflight; tests cover interrupted ownership lookup and a disconnect while the native creation form is open. - Mobile analysis, formatting, file-size checks, and full mobile tests passed at `1d4c41354`. - Project creation persists its encrypted name/UUID before connectivity checks or relay reads. Restart tests cover Resume/Later/Discard, scope isolation, stale-intent rejection, and lost acknowledgements. Removing the pre-network save makes all four production-flow regressions fail. - Regression coverage includes notification/Activity routing, inline replies, unknown-placement send protection, restart recovery/discard, and encrypted offline cache isolation/refresh. - Earlier feature build exercised on a physical iPhone with existing account/community data; review follow-ups have not been reinstalled on the device. UIKit agent-row accessibility checks passed on an iOS simulator. - Earlier cross-platform `just ci` stopped on the existing `buzz-acp` test `signals_cancel_startup_turn_and_pending_stdin_without_hanging` (process exit-code assertion); no Rust production changes are included in this PR. ### Remaining review limitation Agent discovery is bounded to 10 pages / 1,000 profiles with automatic retries disabled. Owner-scoped discovery remains open because the current relay ignores `#auth` filters; supporting that query requires a relay change. ### Snapshots Sample data, shared Flutter/Android surface. Native iOS uses platform sheets and controls. | Solo: projects and chats | New chat | Choose an agent | | --- | --- | --- | | Solo with collapsible projects and chats | New chat form | Agent selection list | --------- Signed-off-by: kenny lopez Signed-off-by: Logan Johnson Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .../MeAgentAccessibilityTests.swift | 128 +++ mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + .../ios/Runner/ConcentricSheetSurface.swift | 2 +- mobile/ios/Runner/IosNavigationBar.swift | 20 +- mobile/ios/Runner/MeAvatarLoader.swift | 138 +++ .../ios/Runner/NativeProfileTextEditor.swift | 193 ++++- mobile/lib/app.dart | 6 + .../channels/agent_reply_handoff.dart | 103 +++ .../channels/agent_typing_bubble.dart | 74 ++ .../channels/channel_detail_page.dart | 134 ++- .../channel_detail_page/empty_messages.dart | 35 + .../channel_detail_page/message_bubble.dart | 150 ++-- .../channel_detail_page/message_list.dart | 66 +- .../channel_detail_page/placement_route.dart | 60 ++ .../channels/channel_messages_provider.dart | 72 +- .../channel_messages_provider/pagination.dart | 72 ++ .../channels/channel_typing_provider.dart | 150 +++- .../lib/features/channels/channel_unread.dart | 51 ++ .../lib/features/channels/channels_page.dart | 111 +-- .../features/channels/channels_page/body.dart | 4 +- .../channels_page/header_actions.dart | 41 + .../channels_page/quick_actions_launcher.dart | 5 +- .../channels/channels_page/sections.dart | 103 +-- .../channels/inline_agent_typing.dart | 82 ++ .../channels/me/me_assistant_list.dart | 75 ++ .../channels/me/me_assistant_picker.dart | 39 + .../me/me_assistant_picker_sheet.dart | 36 + .../channels/me/me_chat_assistant.dart | 250 ++++++ .../channels/me/me_messages_provider.dart | 11 + mobile/lib/features/channels/me/me_page.dart | 319 +++++++ .../features/channels/me/me_page_helpers.dart | 182 ++++ .../lib/features/channels/me/me_section.dart | 107 +++ .../channels/me/me_unread_provider.dart | 87 ++ .../channels/me/native_new_me_chat.dart | 172 ++++ .../lib/features/channels/me/new_me_chat.dart | 163 ++++ .../features/channels/me/new_me_project.dart | 93 ++ .../channels/send_message_provider.dart | 28 +- .../features/channels/timeline_message.dart | 6 + mobile/lib/features/home/home_page.dart | 75 +- .../features/profile/user_profile_sheet.dart | 7 +- mobile/lib/features/search/search_page.dart | 522 ++++-------- .../search/search_page/message_tile.dart | 15 +- mobile/lib/shared/animated_avatar.dart | 14 + mobile/lib/shared/crypto/nip_oa.dart | 41 +- mobile/lib/shared/me/me_agent_change.dart | 98 +++ mobile/lib/shared/me/me_assistants.dart | 109 +++ mobile/lib/shared/me/me_chat_creation.dart | 116 +++ mobile/lib/shared/me/me_groups.dart | 101 +++ mobile/lib/shared/me/me_journal.dart | 28 + mobile/lib/shared/me/me_project_creation.dart | 100 +++ mobile/lib/shared/me/me_recovery.dart | 50 ++ mobile/lib/shared/me/me_search_placement.dart | 19 + mobile/lib/shared/me/me_sync.dart | 516 ++++++++++++ .../mentions/agent_identity_provider.dart | 23 +- mobile/lib/shared/relay/nostr_filters.dart | 2 + mobile/lib/shared/widgets/avatar_image.dart | 10 +- .../shared/widgets/avatar_image_native.dart | 13 +- .../widgets/collapsible_section_body.dart | 115 +++ .../lib/shared/widgets/filter_chip_bar.dart | 60 ++ .../shared/widgets/ios_navigation_bar.dart | 72 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../channels/agent_reply_handoff_test.dart | 56 ++ .../channels/channel_detail_page_test.dart | 599 +++++++++++-- .../channel_messages_provider_test.dart | 88 ++ .../channel_typing_provider_test.dart | 209 +++++ .../features/channels/channels_page_test.dart | 51 ++ .../channels/inline_agent_typing_test.dart | 84 ++ .../channels/me/chat_preflight_test.dart | 118 +++ .../channels/me/me_chat_assistant_test.dart | 423 ++++++++++ .../features/channels/me/me_page_test.dart | 340 ++++++++ .../features/channels/me/me_unread_test.dart | 284 +++++++ .../channels/me/offline_save_page_test.dart | 183 ++++ .../channels/me/project_creation_test.dart | 292 +++++++ .../channels/timeline_message_test.dart | 18 + mobile/test/features/home/home_page_test.dart | 122 +-- .../features/search/search_page_test.dart | 592 ++++--------- .../settings/settings_overlay_test.dart | 4 +- mobile/test/shared/crypto/nip_oa_test.dart | 41 + .../test/shared/me/me_agent_change_test.dart | 120 +++ mobile/test/shared/me/me_assistants_test.dart | 239 ++++++ .../test/shared/me/me_chat_creation_test.dart | 137 +++ mobile/test/shared/me/me_groups_test.dart | 93 ++ .../shared/me/me_journal_cleanup_test.dart | 163 ++++ .../shared/me/me_search_placement_test.dart | 66 ++ mobile/test/shared/me/me_sync_test.dart | 797 ++++++++++++++++++ .../shared/widgets/avatar_image_test.dart | 23 + scripts/test-mobile-me-agent-accessibility.sh | 21 + 88 files changed, 9248 insertions(+), 1396 deletions(-) create mode 100644 mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift create mode 100644 mobile/ios/Runner/MeAvatarLoader.swift create mode 100644 mobile/lib/features/channels/agent_reply_handoff.dart create mode 100644 mobile/lib/features/channels/agent_typing_bubble.dart create mode 100644 mobile/lib/features/channels/channel_detail_page/empty_messages.dart create mode 100644 mobile/lib/features/channels/channel_detail_page/placement_route.dart create mode 100644 mobile/lib/features/channels/channel_messages_provider/pagination.dart create mode 100644 mobile/lib/features/channels/channel_unread.dart create mode 100644 mobile/lib/features/channels/channels_page/header_actions.dart create mode 100644 mobile/lib/features/channels/inline_agent_typing.dart create mode 100644 mobile/lib/features/channels/me/me_assistant_list.dart create mode 100644 mobile/lib/features/channels/me/me_assistant_picker.dart create mode 100644 mobile/lib/features/channels/me/me_assistant_picker_sheet.dart create mode 100644 mobile/lib/features/channels/me/me_chat_assistant.dart create mode 100644 mobile/lib/features/channels/me/me_messages_provider.dart create mode 100644 mobile/lib/features/channels/me/me_page.dart create mode 100644 mobile/lib/features/channels/me/me_page_helpers.dart create mode 100644 mobile/lib/features/channels/me/me_section.dart create mode 100644 mobile/lib/features/channels/me/me_unread_provider.dart create mode 100644 mobile/lib/features/channels/me/native_new_me_chat.dart create mode 100644 mobile/lib/features/channels/me/new_me_chat.dart create mode 100644 mobile/lib/features/channels/me/new_me_project.dart create mode 100644 mobile/lib/shared/me/me_agent_change.dart create mode 100644 mobile/lib/shared/me/me_assistants.dart create mode 100644 mobile/lib/shared/me/me_chat_creation.dart create mode 100644 mobile/lib/shared/me/me_groups.dart create mode 100644 mobile/lib/shared/me/me_journal.dart create mode 100644 mobile/lib/shared/me/me_project_creation.dart create mode 100644 mobile/lib/shared/me/me_recovery.dart create mode 100644 mobile/lib/shared/me/me_search_placement.dart create mode 100644 mobile/lib/shared/me/me_sync.dart create mode 100644 mobile/lib/shared/widgets/collapsible_section_body.dart create mode 100644 mobile/test/features/channels/agent_reply_handoff_test.dart create mode 100644 mobile/test/features/channels/channel_typing_provider_test.dart create mode 100644 mobile/test/features/channels/inline_agent_typing_test.dart create mode 100644 mobile/test/features/channels/me/chat_preflight_test.dart create mode 100644 mobile/test/features/channels/me/me_chat_assistant_test.dart create mode 100644 mobile/test/features/channels/me/me_page_test.dart create mode 100644 mobile/test/features/channels/me/me_unread_test.dart create mode 100644 mobile/test/features/channels/me/offline_save_page_test.dart create mode 100644 mobile/test/features/channels/me/project_creation_test.dart create mode 100644 mobile/test/shared/me/me_agent_change_test.dart create mode 100644 mobile/test/shared/me/me_assistants_test.dart create mode 100644 mobile/test/shared/me/me_chat_creation_test.dart create mode 100644 mobile/test/shared/me/me_groups_test.dart create mode 100644 mobile/test/shared/me/me_journal_cleanup_test.dart create mode 100644 mobile/test/shared/me/me_search_placement_test.dart create mode 100644 mobile/test/shared/me/me_sync_test.dart create mode 100755 scripts/test-mobile-me-agent-accessibility.sh diff --git a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift new file mode 100644 index 0000000000..b3618230fe --- /dev/null +++ b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift @@ -0,0 +1,128 @@ +import Flutter +import UIKit + +@main +struct MeAgentAccessibilityTests { + @MainActor static func main() { + for locked in [false, true] { + for empty in [false, true] { + let controller = NativeProfileTextEditorViewController( + title: "New chat", initialValue: "Chat", placeholder: "Chat name", + multiline: false, pageBackgroundColor: .systemGroupedBackground, + containerBackgroundColor: .secondarySystemGroupedBackground, + containerCornerRadius: 16, allowUnchangedSubmission: false, + 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 } + ) + controller.loadViewIfNeeded() + let cell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 1)) + let disabled = locked || empty + precondition(cell.accessibilityTraits.contains(.notEnabled) == disabled) + precondition(cell.accessibilityTraits.contains(.button) == !disabled) + precondition((cell.selectionStyle == .none) == disabled) + } + } + avatarTests() + print("Me agent accessibility and bounded avatar loading passed") + } +} + +private final class AvatarProtocol: URLProtocol { + static let lock = NSLock() + static var starts = 0 + static var stops = 0 + static var png = Data() + static func counts() -> (Int, Int) { + lock.lock(); defer { lock.unlock() } + return (starts, stops) + } + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + Self.lock.lock(); Self.starts += 1; Self.lock.unlock() + let path = request.url!.path + if path.hasPrefix("/hold") { return } + let headers = ["Content-Type": path == "/text" ? "text/html" : "image/png", + "Content-Length": path == "/large" ? "9000000" : "-1"] + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: path == "/error" ? 404 : 200, + httpVersion: nil, headerFields: headers)!, cacheStoragePolicy: .notAllowed) + if path == "/chunked" { + for _ in 0..<3 { client?.urlProtocol(self, didLoad: Data(repeating: 0, count: 1024 * 1024)) } + } else { + client?.urlProtocol(self, didLoad: Self.png) + } + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() { + Self.lock.lock(); Self.stops += 1; Self.lock.unlock() + } +} + +@MainActor private func avatarTests() { + func pump(until predicate: () -> Bool) { + let deadline = Date().addingTimeInterval(4) + while !predicate() && Date() < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + } + precondition(predicate(), "Avatar operation timed out") + } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [AvatarProtocol.self] + let loader = MeAvatarLoader(configuration: config) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + AvatarProtocol.png = UIGraphicsImageRenderer(size: CGSize(width: 240, height: 240), format: format).pngData { _ in + UIColor.red.setFill(); UIRectFill(CGRect(x: 0, y: 0, width: 240, height: 240)) + } + // No eager request on presenting even a thousand agents; only visible rows load. + let controller = MeAssistantListController(assistants: (0..<1000).map { + ["id": "agent-\($0)", "name": "Agent \($0)", "avatarUrl": "https://avatar.test/hold/\($0)"] + }, selected: nil, pageColor: .white, rowColor: .white, avatars: loader, onSelect: { _, _ in }) + controller.loadViewIfNeeded() + precondition(AvatarProtocol.counts().0 == 0) + for row in 0..<20 { + let path = IndexPath(row: row, section: 0) + let cell = controller.tableView(controller.tableView, cellForRowAt: path) + controller.tableView(controller.tableView, willDisplay: cell, forRowAt: path) + } + pump { AvatarProtocol.counts().0 == MeAvatarLoader.concurrency } + controller.viewDidDisappear(false) + pump { AvatarProtocol.counts().1 == MeAvatarLoader.concurrency } + precondition(AvatarProtocol.counts().0 == MeAvatarLoader.concurrency) + + for path in ["large", "chunked", "error", "text", "ok"] { + var done = false + var result: UIImage? + loader.load(URL(string: "https://avatar.test/\(path)")!) { image in + result = image; done = true + } + pump { done } + precondition((result != nil) == (path == "ok")) + if let result { precondition(max(result.size.width, result.size.height) <= 120) } + } + let image = loader.image(for: URL(string: "https://avatar.test/ok")) + precondition(image != nil) + let rendered = MeAssistantListController.avatarImage(["name": "Agent"], photo: image) + precondition(rendered.size == CGSize(width: 40, height: 40)) + let visibleLoader = MeAvatarLoader(configuration: config) + let visible = MeAssistantListController(assistants: [["id": "visible", "name": "Visible", "avatarUrl": "https://avatar.test/visible"]], + selected: nil, pageColor: .white, rowColor: .white, avatars: visibleLoader, onSelect: { _, _ in }) + visible.loadViewIfNeeded() + visible.tableView.frame = CGRect(x: 0, y: 0, width: 320, height: 400) + visible.tableView.reloadData() + visible.tableView.layoutIfNeeded() + pump { visibleLoader.image(for: URL(string: "https://avatar.test/visible")) != nil } + let cell = visible.tableView.cellForRow(at: IndexPath(row: 0, section: 0))! + let content = cell.contentConfiguration as! UIListContentConfiguration + precondition(content.image?.pngData() == MeAssistantListController.avatarImage(["name": "Visible"], photo: image).pngData()) + visible.viewDidDisappear(false) + // Source metadata is inspected before ImageIO is allowed to decode large pixels. + autoreleasepool { + let huge = UIGraphicsImageRenderer(size: CGSize(width: 4100, height: 4100), format: format).pngData { _ in } + precondition(MeAvatarLoader.thumbnail(huge) == nil) + } + loader.cancelAll() +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 4fb91d069d..2dce208df3 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */; }; C0AF00000000000000000001 /* NativeConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000002 /* NativeConfirmationDialog.swift */; }; C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000022 /* NativePresenceMenu.swift */; }; C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AF00000000000000000012 /* NativeAddMembersSheet.swift */; }; @@ -93,6 +94,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeAvatarLoader.swift; sourceTree = ""; }; C0AF00000000000000000002 /* NativeConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeConfirmationDialog.swift; sourceTree = ""; }; C0AF00000000000000000022 /* NativePresenceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePresenceMenu.swift; sourceTree = ""; }; C0AF00000000000000000012 /* NativeAddMembersSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAddMembersSheet.swift; sourceTree = ""; }; @@ -291,6 +293,7 @@ C0AF00000000000000000022 /* NativePresenceMenu.swift */, C0AF00000000000000000012 /* NativeAddMembersSheet.swift */, 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, + 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */, 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */, CA8300022F50000100A17E01 /* IosMediaCarousel.swift */, @@ -628,6 +631,7 @@ C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */, C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */, 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, + 4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */, 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */, CA8300012F50000100A17E01 /* IosMediaCarousel.swift in Sources */, diff --git a/mobile/ios/Runner/ConcentricSheetSurface.swift b/mobile/ios/Runner/ConcentricSheetSurface.swift index 71f0d8ebb7..f959c093d7 100644 --- a/mobile/ios/Runner/ConcentricSheetSurface.swift +++ b/mobile/ios/Runner/ConcentricSheetSurface.swift @@ -154,7 +154,7 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { glassView.effect = effect } glassView.backgroundColor = .clear - glassView.contentView.backgroundColor = surfaceColor.withAlphaComponent(0.12) + glassView.contentView.backgroundColor = surfaceColor.withAlphaComponent(min(surfaceColor.cgColor.alpha, 0.12)) } else { surfaceView.backgroundColor = surfaceColor } diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 614796e830..902d109082 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -593,15 +593,25 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { item.accessibilityLabel = data["label"] as? String item.isEnabled = data["enabled"] as? Bool == true if data["avatarInitial"] is String { item.title = nil } + if let encoded = data["iconData"] as? String, + let bytes = Data(base64Encoded: encoded), let image = UIImage(data: bytes) { + item.image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { _ in + image.draw(in: CGRect(x: 0, y: 0, width: 24, height: 24)) + }.withRenderingMode(.alwaysTemplate) + item.title = nil + } + if let encoded = data["imageData"] as? String, let bytes = Data(base64Encoded: encoded), let image = UIImage(data: bytes) { - // Fill the 44-point native button with a 4-point inset. Keep the - // original 24-point alignment footprint so UIKit does not widen it. - let size = CGSize(width: 36, height: 36) + // Character artwork floats on the glass; photos fill its circular face. + // Preserve the 24-point alignment footprint so UIKit does not widen it. + let diameter: CGFloat = data["avatarIsAgent"] as? Bool == true && data["avatarArtwork"] as? Bool != true ? 44 : 36 + let inset = (diameter - 24) / 2 + let size = CGSize(width: diameter, height: diameter) item.image = UIGraphicsImageRenderer(size: size).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }.withRenderingMode(.alwaysOriginal).withAlignmentRectInsets( - UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6) + UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset) ) } if item.image == nil, let initial = data["avatarInitial"] as? String { @@ -612,7 +622,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { item.image = UIGraphicsImageRenderer(size: size).image { _ in Self.color(data["avatarBackground"]).setFill() UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), - cornerRadius: size.width * (data["avatarIsAgent"] as? Bool == true ? 0.3 : 0.5)).fill() + cornerRadius: size.width * 0.5).fill() let text = initial as NSString let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 16, weight: .medium), diff --git a/mobile/ios/Runner/MeAvatarLoader.swift b/mobile/ios/Runner/MeAvatarLoader.swift new file mode 100644 index 0000000000..a9bec24740 --- /dev/null +++ b/mobile/ios/Runner/MeAvatarLoader.swift @@ -0,0 +1,138 @@ +import UIKit +import ImageIO + +/// Visible-row avatar loading with bounded network, decoded memory, and lifetime. +/// All entry points and URLSession callbacks run on the main queue. +final class MeAvatarLoader: NSObject, URLSessionDataDelegate, @unchecked Sendable { + static let byteLimit = 2 * 1024 * 1024 + static let pixelLimit = 16_000_000 + static let concurrency = 3 + private let configuration: URLSessionConfiguration + private var session: URLSession? + private let cache = NSCache() + private struct Request { + let url: URL + let completion: (UIImage?) -> Void + var task: URLSessionDataTask? + var data = Data() + } + private var requests: [UUID: Request] = [:] + private var queue: [UUID] = [] + + init(configuration: URLSessionConfiguration = .ephemeral) { + self.configuration = configuration + super.init() + cache.totalCostLimit = 4 * 1024 * 1024 + cache.countLimit = 128 + } + + func image(for url: URL?) -> UIImage? { + guard let url else { return nil } + return cache.object(forKey: url as NSURL) + } + + @discardableResult + func load(_ url: URL, completion: @escaping (UIImage?) -> Void) -> UUID { + let id = UUID() + guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { + completion(nil) + return id + } + if let cached = image(for: url) { + completion(cached) + return id + } + requests[id] = Request(url: url, completion: completion) + queue.append(id) + startQueued() + return id + } + + func cancel(_ id: UUID) { + requests.removeValue(forKey: id)?.task?.cancel() + queue.removeAll { $0 == id } + startQueued() + } + + func cancelAll() { + queue.removeAll() + requests.removeAll() + session?.invalidateAndCancel() + session = nil + } + + private func startQueued() { + while requests.values.filter({ $0.task != nil }).count < Self.concurrency, + !queue.isEmpty { + let id = queue.removeFirst() + guard let request = requests[id] else { continue } + if session == nil { + configuration.urlCache = nil + configuration.timeoutIntervalForRequest = 15 + session = URLSession(configuration: configuration, delegate: self, delegateQueue: .main) + } + let task = session!.dataTask(with: request.url) + requests[id]?.task = task + task.resume() + } + } + + private func id(for task: URLSessionTask) -> UUID? { + requests.first { $0.value.task === task }?.key + } + + private func finish(_ id: UUID, image: UIImage?) { + guard let request = requests.removeValue(forKey: id) else { return } + if let image, let cgImage = image.cgImage { + cache.setObject(image, forKey: request.url as NSURL, cost: cgImage.bytesPerRow * cgImage.height) + } + request.completion(image) + startQueued() + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let id = id(for: dataTask) else { completionHandler(.cancel); return } + guard let response = response as? HTTPURLResponse, + (200...299).contains(response.statusCode), + response.mimeType?.hasPrefix("image/") == true, + response.expectedContentLength <= Self.byteLimit else { + completionHandler(.cancel) + finish(id, image: nil) + return + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let id = id(for: dataTask), let request = requests[id] else { return } + guard data.count <= Self.byteLimit - request.data.count else { + dataTask.cancel() + finish(id, image: nil) + return + } + requests[id]?.data.append(data) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let id = id(for: task), let request = requests[id] else { return } + finish(id, image: error == nil ? Self.thumbnail(request.data) : nil) + } + + static func thumbnail(_ data: Data) -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, + [kCGImageSourceShouldCache: false] as CFDictionary), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0, width <= pixelLimit / height, + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 120, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary) else { return nil } + return UIImage(cgImage: image) + } +} diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index b9d3434ca4..51f400b963 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -28,7 +28,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, _ call: FlutterMethodCall, result: @escaping FlutterResult ) { - guard call.method == "present" || call.method == "presentChannel" else { + guard call.method == "present" || call.method == "presentChannel" || call.method == "presentChat" || call.method == "presentProject" else { result(FlutterMethodNotImplemented) return } @@ -73,6 +73,8 @@ final class NativeProfileTextEditorCoordinator: NSObject, canEditCanvas: arguments["canEditCanvas"] as? Bool ?? false, canvasContent: arguments["canvasContent"] as? String ?? "", canvasLoaded: arguments["canvasLoaded"] as? Bool ?? false, + chatOptions: (call.method == "presentChat" || call.method == "presentProject") + ? arguments.merging(["projectOnly": call.method == "presentProject"]) { _, new in new } : nil, result: result ) } @@ -96,6 +98,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, canEditCanvas: Bool, canvasContent: String, canvasLoaded: Bool, + chatOptions: [String: Any]?, result: @escaping FlutterResult ) { guard presentedController == nil else { @@ -139,6 +142,7 @@ final class NativeProfileTextEditorCoordinator: NSObject, canEditCanvas: canEditCanvas, canvasContent: canvasContent, canvasLoaded: canvasLoaded, + chatOptions: chatOptions, onCancel: { [weak self] in self?.finish(value: nil) }, onSet: { [weak self] value in self?.finish(value: value) } ) @@ -237,6 +241,16 @@ private final class NativeProfileTextEditorViewController: private var hasCanvas: Bool { canvasLoaded && !canvasContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private let chatOptions: [String: Any]? + private var hasFocusedEditor = false + private var selectedAssistantAvatar: UIImage? + private var selectedAssistant: String? + private var isChatEditor: Bool { chatOptions != nil } + private var isProjectEditor: Bool { chatOptions?["projectOnly"] as? Bool ?? false } + private var assistants: [[String: String]] { + chatOptions?["assistants"] as? [[String: String]] ?? [] + } + private var fieldsLocked: Bool { chatOptions?["fieldsLocked"] as? Bool ?? false } private var isChannelEditor: Bool { channelDescription != nil } private lazy var textField: UITextField = { @@ -248,7 +262,7 @@ private final class NativeProfileTextEditorViewController: field.adjustsFontForContentSizeCategory = true field.clearButtonMode = .whileEditing field.returnKeyType = isChannelEditor ? .next : .done - field.isEnabled = canEditDetails + field.isEnabled = canEditDetails && !fieldsLocked field.accessibilityLabel = placeholder field.autocapitalizationType = .sentences field.delegate = self @@ -299,6 +313,7 @@ private final class NativeProfileTextEditorViewController: canEditCanvas: Bool, canvasContent: String, canvasLoaded: Bool, + chatOptions: [String: Any]?, onCancel: @escaping () -> Void, onSet: @escaping (Any) -> Void ) { @@ -316,6 +331,8 @@ private final class NativeProfileTextEditorViewController: self.canEditCanvas = canEditCanvas self.canvasContent = canvasContent self.canvasLoaded = canvasLoaded + self.chatOptions = chatOptions + self.selectedAssistant = chatOptions?["selectedAssistant"] as? String self.onCancel = onCancel self.onSet = onSet super.init(style: .insetGrouped) @@ -351,7 +368,7 @@ private final class NativeProfileTextEditorViewController: action: #selector(cancelTapped) ) navigationItem.rightBarButtonItem = UIBarButtonItem( - title: isChannelEditor ? "Save" : "Set", + title: isChatEditor ? (fieldsLocked ? "Retry" : "Create") : (isChannelEditor ? "Save" : "Set"), style: .done, target: self, action: #selector(setTapped) @@ -361,7 +378,8 @@ private final class NativeProfileTextEditorViewController: override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - guard canEditDetails else { return } + guard canEditDetails && !hasFocusedEditor else { return } + hasFocusedEditor = true if multiline { textView.becomeFirstResponder() } else { @@ -374,6 +392,10 @@ private final class NativeProfileTextEditorViewController: } private var hasUnsavedChanges: Bool { + if isChatEditor { + return !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || selectedAssistant != nil + } if isChannelEditor { return allowUnchangedSubmission || currentValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -402,6 +424,11 @@ private final class NativeProfileTextEditorViewController: 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 }) + )) navigationController?.isModalInPresentation = hasUnsavedChanges placeholderLabel.isHidden = !textView.text.isEmpty } @@ -436,7 +463,17 @@ private final class NativeProfileTextEditorViewController: @objc private func setTapped() { guard canEditDetails && hasUnsavedChanges else { return } - if isChannelEditor { + if isProjectEditor { + let name = currentValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, currentValue.count <= 120 else { return } + onSet(name) + } else if isChatEditor { + guard let selectedAssistant, + assistants.contains(where: { $0["id"] == selectedAssistant }), + !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + currentValue.count <= 120 else { return } + onSet(["name": currentValue, "assistant": selectedAssistant]) + } else if isChannelEditor { guard !canonicalChannelName.isEmpty else { return } onSet(["action": "save", "name": canonicalChannelName, "description": textView.text ?? ""]) } else { @@ -466,6 +503,18 @@ private final class NativeProfileTextEditorViewController: override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) + if isChatEditor && indexPath.section == 1 { + 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 + self?.selectedAssistantAvatar = avatar + self?.selectedAssistant = id + self?.tableView.reloadSections(IndexSet(integer: 1), with: .none) + self?.updateNavigation() + } + navigationController?.pushViewController(picker, animated: true) + return + } guard isChannelEditor && canEditCanvas else { return } if indexPath.section == 2 { editCanvasTapped() } if indexPath.section == 3 { removeCanvasTapped() } @@ -480,7 +529,15 @@ private final class NativeProfileTextEditorViewController: return false } - override func numberOfSections(in tableView: UITableView) -> Int { isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 } + override func numberOfSections(in tableView: UITableView) -> Int { isProjectEditor ? 1 : isChatEditor ? 2 : isChannelEditor ? (hasCanvas && canEditCanvas ? 4 : 3) : 1 } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + guard isChatEditor && section == 1 else { return nil } + 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." + : nil + } override func tableView( _ tableView: UITableView, @@ -491,6 +548,7 @@ private final class NativeProfileTextEditorViewController: _ tableView: UITableView, heightForRowAt indexPath: IndexPath ) -> CGFloat { + if isChatEditor && indexPath.section == 1 && selectedAssistant != nil { return 64 } if isChannelEditor && indexPath.section >= 2 { return UITableView.automaticDimension } return (isChannelEditor ? indexPath.section == 1 : multiline) ? 132 : 52 } @@ -505,7 +563,19 @@ private final class NativeProfileTextEditorViewController: cell.layer.cornerRadius = containerCornerRadius cell.layer.cornerCurve = .continuous cell.clipsToBounds = true - if isChannelEditor && indexPath.section >= 2 { + if isChatEditor && indexPath.section == 1 { + var content = cell.defaultContentConfiguration() + let selected = assistants.first { $0["id"] == selectedAssistant } + content.text = selected?["name"] ?? "Choose an agent" + if let selected { + content.image = selectedAssistantAvatar ?? MeAssistantListController.avatarImage(selected, photo: nil) + } + content.textProperties.font = .preferredFont(forTextStyle: .body) + cell.contentConfiguration = content + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = fieldsLocked || assistants.isEmpty ? .none : .default + cell.accessibilityTraits = fieldsLocked || assistants.isEmpty ? .notEnabled : .button + } else if isChannelEditor && indexPath.section >= 2 { var content = cell.defaultContentConfiguration() content.textProperties.font = .preferredFont(forTextStyle: .body) if indexPath.section == 3 { @@ -563,3 +633,112 @@ private extension UIColor { ) } } + +/// A member-style list inside the New chat sheet, rather than an action menu. +private final class MeAssistantListController: UITableViewController { + private let assistants: [[String: String]] + private let selected: String? + private let onSelect: (String, UIImage) -> Void + private let pageColor: UIColor + private let rowColor: UIColor + private let avatars: MeAvatarLoader + private var requests: [IndexPath: UUID] = [:] + + init(assistants: [[String: String]], selected: String?, pageColor: UIColor, rowColor: UIColor, avatars: MeAvatarLoader = MeAvatarLoader(), onSelect: @escaping (String, UIImage) -> Void) { + self.assistants = assistants + self.selected = selected + self.onSelect = onSelect + self.pageColor = pageColor + self.rowColor = rowColor + self.avatars = avatars + super.init(style: .insetGrouped) + title = "Agent" + } + + required init?(coder: NSCoder) { nil } + + deinit { avatars.cancelAll() } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.backgroundColor = pageColor + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 64 + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tableView.reloadData() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + avatars.cancelAll() + requests.removeAll() + } + + private func avatarURL(_ assistant: [String: String]) -> URL? { + assistant["avatarUrl"].flatMap(URL.init(string:)) + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let assistant = assistants[indexPath.row] + guard let url = avatarURL(assistant) else { return } + requests[indexPath] = avatars.load(url) { [weak self, weak cell] image in + guard self != nil, let cell, tableView.indexPath(for: cell) == indexPath else { return } + var content = cell.defaultContentConfiguration() + content.text = assistant["name"] ?? "Agent" + content.image = Self.avatarImage(assistant, photo: image) + cell.contentConfiguration = content + } + } + + override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + if let request = requests.removeValue(forKey: indexPath) { avatars.cancel(request) } + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + assistants.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let assistant = assistants[indexPath.row] + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + var content = cell.defaultContentConfiguration() + let name = assistant["name"] ?? "Agent" + content.text = name + content.image = Self.avatarImage(assistant, photo: avatars.image(for: avatarURL(assistant))) + cell.backgroundColor = rowColor + cell.contentConfiguration = content + cell.accessoryType = assistant["id"] == selected ? .checkmark : .none + cell.accessibilityTraits = assistant["id"] == selected ? [.button, .selected] : .button + return cell + } + + static func avatarImage(_ assistant: [String: String], photo: UIImage?) -> UIImage { + let name = assistant["name"] ?? "Agent" + return UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { _ in + let bounds = CGRect(x: 0, y: 0, width: 40, height: 40) + UIBezierPath(roundedRect: bounds, cornerRadius: 12).addClip() + UIColor.secondarySystemFill.setFill() + UIRectFill(bounds) + if let photo { + let artworkScale = CGFloat(Double(assistant["artworkScale"] ?? "1") ?? 1) + let scale = max(40 / photo.size.width, 40 / photo.size.height) * artworkScale + let size = CGSize(width: photo.size.width * scale, height: photo.size.height * scale) + photo.draw(in: CGRect(x: (40 - size.width) / 2, y: (40 - size.height) / 2, width: size.width, height: size.height)) + } else { + let initial = String(name.prefix(1)).uppercased() as NSString + let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 18, weight: .medium), .foregroundColor: UIColor.label] + let size = initial.size(withAttributes: attributes) + initial.draw(at: CGPoint(x: (40 - size.width) / 2, y: (40 - size.height) / 2), withAttributes: attributes) + } + }.withRenderingMode(.alwaysOriginal) + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let id = assistants[indexPath.row]["id"] else { return } + onSelect(id, Self.avatarImage(assistants[indexPath.row], photo: avatars.image(for: avatarURL(assistants[indexPath.row])))) + navigationController?.popViewController(animated: true) + } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 000827e44e..a30a353c18 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -20,6 +20,8 @@ import 'features/channels/channel_management_provider.dart'; import 'features/channels/channels_provider.dart'; import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; +import 'features/channels/me/me_page.dart'; +import 'features/channels/me/me_unread_provider.dart'; import 'features/invites/invite_create_page.dart'; import 'features/invites/invite_join_provider.dart'; import 'features/pairing/pairing_page.dart'; @@ -324,6 +326,7 @@ class App extends HookConsumerWidget { // Eagerly initialize websocket session and lifecycle observer when // authenticated. These providers connect and manage the websocket. var hasUnreadInbox = false; + var hasUnreadSolo = false; if (ageSignalState != AgeSignalState.restricted && authState.value?.status == AuthStatus.authenticated) { ref.watch(relaySessionProvider); @@ -336,6 +339,7 @@ class App extends HookConsumerWidget { ref.watch(pushSubscriptionSyncProvider); } hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; + hasUnreadSolo = ref.watch(meUnreadChannelIdsProvider).isNotEmpty; } // Start listening for buzz:// links immediately (even pre-auth) so a @@ -412,10 +416,12 @@ class App extends HookConsumerWidget { data: (state) => switch (state.status) { AuthStatus.authenticated => DeepLinkDispatcher( child: HomePage( + mePageBuilder: (_) => const MePage(), settingsPageBuilder: _buildSettingsPage, communityInvitePageBuilder: (_) => const CommunityInvitePage(), communityAppearancePageBuilder: (_) => const ThemePickerPage(), hasUnreadInbox: hasUnreadInbox, + hasUnreadSolo: hasUnreadSolo, ), ), _ => const DeepLinkDispatcher( diff --git a/mobile/lib/features/channels/agent_reply_handoff.dart b/mobile/lib/features/channels/agent_reply_handoff.dart new file mode 100644 index 0000000000..e4f63559fb --- /dev/null +++ b/mobile/lib/features/channels/agent_reply_handoff.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'agent_typing_bubble.dart'; +import 'channel_typing_provider.dart'; +import 'timeline_message.dart'; + +/// Keeps completion handoffs scoped to newly arriving replies, never history. +({Map messageIds, List typing}) useAgentReplyHandoffs( + String channelId, + List typing, + List messages, +) { + final tracker = useMemoized(_ReplyHandoffs.new, [channelId]); + return tracker.update(typing, messages); +} + +/// Limits the handoff to a fresh arrival, avoiding replay when scrolling back. +bool isRecentAgentReplyHandoff(Map entries, String id) => + (entries[id] ?? 0) > DateTime.now().millisecondsSinceEpoch; + +class _ReplyHandoffs { + Set? seen; + final pending = <(String, String?), TypingEntry>{}; + final completed = <(String, String?), int>{}; + final handoffs = {}; + + ({Map messageIds, List typing}) update( + List typing, + List messages, + ) { + final now = DateTime.now().millisecondsSinceEpoch; + pending.removeWhere((_, entry) => entry.expiresAtMs <= now); + completed.removeWhere((_, at) => at + 8000 <= now); + for (final entry in typing) { + final scope = (entry.pubkey.toLowerCase(), entry.threadHeadId); + if ((completed[scope] ?? -1) < entry.expiresAtMs - 8000) { + pending[scope] = entry; + } + } + final ids = messages.map((message) => message.id).toSet(); + handoffs.removeWhere((id, expires) => !ids.contains(id) || expires <= now); + if (seen != null) { + for (final message in messages) { + if (seen!.contains(message.id) || message.isSystem) continue; + final scope = ( + message.pubkey.toLowerCase(), + message.rootId ?? message.parentId, + ); + final activity = pending[scope]; + if (activity != null && + message.createdAt * 1000 >= activity.expiresAtMs - 8000) { + handoffs[message.id] = now + 1000; + completed[scope] = message.createdAt * 1000; + pending.remove(scope); + } + } + } + seen = ids; + return ( + messageIds: handoffs, + typing: [ + for (final entry in typing) + if ((completed[(entry.pubkey.toLowerCase(), entry.threadHeadId)] ?? + -1) < + entry.expiresAtMs - 8000) + entry, + ], + ); + } +} + +/// Grows the pending bubble into the real message using its final content size. +class AgentReplyHandoff extends HookWidget { + const AgentReplyHandoff({ + super.key, + required this.animate, + required this.child, + }); + final bool animate; + final Widget child; + + @override + Widget build(BuildContext context) { + final reduced = MediaQuery.disableAnimationsOf(context); + final revealed = useState(!animate || reduced); + useEffect(() { + if (!revealed.value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) revealed.value = true; + }); + } + return null; + }, const []); + if (!animate || reduced) return child; + return AnimatedSize( + key: const ValueKey('agent-reply-handoff'), + duration: const Duration(milliseconds: 220), + curve: const Cubic(0.23, 1, 0.32, 1), + alignment: Alignment.topLeft, + child: revealed.value ? child : const AgentTypingBubble(), + ); + } +} diff --git a/mobile/lib/features/channels/agent_typing_bubble.dart b/mobile/lib/features/channels/agent_typing_bubble.dart new file mode 100644 index 0000000000..b8b5b14b34 --- /dev/null +++ b/mobile/lib/features/channels/agent_typing_bubble.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import '../../shared/theme/theme.dart'; + +/// Pending reply with three staggered dots using the desktop typing bounce. +class AgentTypingBubble extends HookWidget { + const AgentTypingBubble({super.key}); + + @override + Widget build(BuildContext context) { + final animation = useAnimationController( + duration: const Duration(milliseconds: 1200), + ); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + useEffect(() { + if (reducedMotion) { + animation.stop(); + } else { + animation.repeat(); + } + return animation.stop; + }, [animation, reducedMotion]); + return Container( + key: const ValueKey('agent-typing-bubble'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + borderRadius: BorderRadius.circular(Radii.container), + ), + child: RepaintBoundary( + child: AnimatedBuilder( + animation: animation, + builder: (context, _) => SizedBox( + height: 24, + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (index) { + // Desktop: 1.2s linear cycle, 80ms stagger, peak at 20%, + // resting from 40% onward. Keep all three dots still for Reduce Motion. + final phase = (animation.value - index * 80 / 1200) % 1; + final hop = reducedMotion || phase >= 0.4 + ? 0.0 + : phase < 0.2 + ? phase / 0.2 + : (0.4 - phase) / 0.2; + return Padding( + padding: EdgeInsets.only(left: index == 0 ? 0 : Grid.quarter), + child: Transform.translate( + key: ValueKey('agent-typing-dot-$index'), + offset: Offset(0, -2 * hop), + child: Opacity( + opacity: reducedMotion ? 1 : 0.4 + 0.6 * hop, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: context.colors.onSurfaceVariant, + shape: BoxShape.circle, + ), + ), + ), + ), + ); + }), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index c542652094..b7e4251c7f 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,3 +1,4 @@ +import 'agent_reply_handoff.dart'; import 'huddle_timeline.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/ios_scroll_to_top.dart'; @@ -17,6 +18,11 @@ import 'package:buzz/shared/theme/buzz_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/emoji/emoji_burst.dart'; +import '../../shared/me/me_sync.dart'; +import '../../shared/me/me_search_placement.dart'; +import 'me/me_assistant_picker.dart'; +import 'me/me_chat_assistant.dart'; +import 'me/me_messages_provider.dart'; import '../../shared/huddle/huddle.dart'; import '../../shared/identity_names/identity_names.dart'; import '../../shared/identity_names/identity_names_provider.dart'; @@ -50,6 +56,7 @@ import 'channel_sections/channel_sections_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; +import 'inline_agent_typing.dart'; import 'channels_provider.dart'; import 'unread_badge/observed_unread_event.dart'; import 'compose_bar.dart'; @@ -87,6 +94,8 @@ import 'reading_marks.dart'; import 'timeline_message.dart'; part 'channel_detail_page/message_list.dart'; +part 'channel_detail_page/placement_route.dart'; +part 'channel_detail_page/empty_messages.dart'; part 'channel_detail_page/oldest_navigation.dart'; part 'channel_detail_page/system_rows.dart'; part 'channel_detail_page/activity_members_sheet.dart'; @@ -104,14 +113,11 @@ part 'channel_detail_page/app_bar.dart'; /// Fetch deep-link targets that may be outside the loaded channel window. Future _loadDeepLinkEvents( - WidgetRef ref, - String channelId, + ChannelMessagesNotifier notifier, Set eventIds, ) async { try { - await ref - .read(channelMessagesProvider(channelId).notifier) - .loadEventsById(eventIds); + await notifier.loadEventsById(eventIds); } catch (error) { debugPrint('deep-link: failed to load target messages: $error'); } @@ -250,17 +256,25 @@ enum InitialThreadRouteBehavior { replaceCurrentRoute, } -class ChannelDetailPage extends HookConsumerWidget { +class _ResolvedChannelDetailPage extends HookConsumerWidget { final Channel channel; + + /// Uses personal assistant routing for a chat placed in Me. + final bool personalChat; + final bool placementKnown; + final String? initialAssistant; final String? initialMessageId; final String? initialThreadRootId; /// How the automatically opened initial thread affects the route stack. final InitialThreadRouteBehavior initialThreadRouteBehavior; - const ChannelDetailPage({ + const _ResolvedChannelDetailPage({ super.key, required this.channel, + required this.personalChat, + required this.placementKnown, + this.initialAssistant, this.initialMessageId, this.initialThreadRootId, this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push, @@ -268,6 +282,9 @@ class ChannelDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final messagesProvider = personalChat + ? meMessagesProvider(channel.id) + : channelMessagesProvider(channel.id); final stickyDateHeaderState = useValueNotifier( StickyDateHeaderState.hidden, [channel.id], @@ -275,10 +292,21 @@ class ChannelDetailPage extends HookConsumerWidget { final composerDockHeight = useState(0.0); final composerFocusNode = useFocusNode(); final restoreComposerFocus = useRef(null); - final sendMessage = ref.read(sendMessageProvider); + final sendMessage = ref.read( + personalChat ? meSendMessageProvider : sendMessageProvider, + ); + final messagesState = ref.watch(messagesProvider); + final assistant = useMeChatAssistant( + context, + ref, + enabled: personalChat, + channelId: channel.id, + initial: initialAssistant, + messages: messagesState.value ?? const [], + ); + final mePlacement = personalChat ? ref.watch(meGroupsProvider) : null; final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); - final messagesState = ref.watch(channelMessagesProvider(channel.id)); final huddleLifecycle = ref.watch(huddleLifecycleProvider(channel.id)).value ?? const []; final sessionStatus = ref.watch(relaySessionProvider).status; @@ -295,7 +323,7 @@ class ChannelDetailPage extends HookConsumerWidget { .observedUnreadEventsByChannel[channel.id] ?.values ?? const []) - if (event.rootId == null && + if ((personalChat || event.rootId == null) && event.createdAt > (observedUnreadEventReadAt( event, @@ -325,10 +353,11 @@ class ChannelDetailPage extends HookConsumerWidget { initialForcedUnreadMessageIdsRef.value; // Ownership comes from the signing identity, not reloadable profile metadata. final currentPubkey = ref.watch(currentPubkeyProvider); - // Only show channel-level typing (exclude thread-scoped entries and self). + // Me flattens replies into this timeline, so include their typing signals. + // Ordinary channels keep thread activity inside the thread view. final typingEntries = ref .watch(channelTypingProvider(channel.id)) - .where((e) => e.threadHeadId == null) + .where((e) => personalChat || e.threadHeadId == null) .where( (e) => currentPubkey == null || @@ -488,9 +517,7 @@ class ChannelDetailPage extends HookConsumerWidget { showsComposer && !isAgentIdentityUnresolved && !_isOneToOneAgentDm(resolvedChannel, agentPubkeys); - final messagesNotifier = ref.read( - channelMessagesProvider(channel.id).notifier, - ); + final messagesNotifier = ref.read(messagesProvider.notifier); final isConnectionInProgress = sessionStatus == SessionStatus.connecting || sessionStatus == SessionStatus.reconnecting; @@ -538,8 +565,8 @@ class ChannelDetailPage extends HookConsumerWidget { ...initialForcedUnreadMessageIds, }; if (eventIds.isEmpty) return null; - final notifier = ref.read(channelMessagesProvider(channel.id).notifier); - unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds)); + final notifier = ref.read(messagesProvider.notifier); + unawaited(_loadDeepLinkEvents(notifier, eventIds)); return () => notifier.releaseDeepLinkEvents(eventIds); }, [ @@ -621,9 +648,8 @@ class ChannelDetailPage extends HookConsumerWidget { (message) => !isCollapsedHuddleEnding(message, timelineMessages), ) .toList(), - relaySummaries: ref - .read(channelMessagesProvider(channel.id).notifier) - .threadSummaries, + flat: personalChat, + relaySummaries: ref.read(messagesProvider.notifier).threadSummaries, ); return FrostedScaffold( @@ -683,7 +709,9 @@ class ChannelDetailPage extends HookConsumerWidget { }, onNativeTitlePressed: openChannelDetails, nativeActions: [ - if (resolvedChannel.isDm ? showsHuddleAction : showsComposer) + if (personalChat) + assistant.nativeAction + else if (resolvedChannel.isDm ? showsHuddleAction : showsComposer) _huddleNavigationAction(context, ref, resolvedChannel, [ ...messagesState.value ?? const [], ...huddleLifecycle, @@ -701,7 +729,9 @@ class ChannelDetailPage extends HookConsumerWidget { onTap: openChannelDetails, ), actions: [ - if (resolvedChannel.isDm ? showsHuddleAction : showsComposer) + if (personalChat) + assistant.action + else if (resolvedChannel.isDm ? showsHuddleAction : showsComposer) _HuddleButton( channel: resolvedChannel, events: [...messagesState.value ?? const [], ...huddleLifecycle], @@ -781,18 +811,25 @@ class ChannelDetailPage extends HookConsumerWidget { ), child: LoadErrorView( message: 'Failed to load messages', - onRetry: () => ref.invalidate( - channelMessagesProvider(channel.id), - ), + onRetry: () => ref.invalidate(messagesProvider), ), ), data: (events) { return _MessageList( + notifier: ref.watch(messagesProvider.notifier), + inlineTyping: personalChat + ? typingEntries + : const [], stickyDateHeaderState: stickyDateHeaderState, entries: timelineEntries, allMessages: timelineMessages, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, + initialMessageId: personalChat + ? initialMessageId ?? initialThreadRootId + : initialMessageId, + initialThreadRootId: + personalChat || !placementKnown + ? null + : initialThreadRootId, initialThreadRouteBehavior: initialThreadRouteBehavior, initialOrdinaryUnreadMessageIds: @@ -838,7 +875,7 @@ class ChannelDetailPage extends HookConsumerWidget { : const Duration(milliseconds: 180), curve: Curves.easeOutCubic, alignment: Alignment.bottomCenter, - child: typingEntries.isEmpty + child: personalChat || typingEntries.isEmpty ? const SizedBox.shrink() : ChannelTypingIndicator( channelId: resolvedChannel.id, @@ -869,13 +906,14 @@ class ChannelDetailPage extends HookConsumerWidget { : const Duration(milliseconds: 180), curve: Curves.easeOutCubic, alignment: Alignment.bottomCenter, - child: typingEntries.isEmpty + child: personalChat || typingEntries.isEmpty ? const SizedBox.shrink() : ChannelTypingIndicator( channelId: resolvedChannel.id, entries: typingEntries, ), ), + ?assistant.pendingNotice, ComposeBar( channelId: channel.id, focusNode: composerFocusNode, @@ -889,13 +927,37 @@ class ChannelDetailPage extends HookConsumerWidget { content, mentionPubkeys, { mediaTags = const >[], - }) => sendMessage.call( - channelId: channel.id, - content: content, - mentionPubkeys: mentionPubkeys, - channel: resolvedChannel, - mediaTags: mediaTags, - ), + }) async { + if (!placementKnown) { + ref.invalidate(meGroupsProvider); + throw StateError( + 'Reconnect to verify this chat before sending.', + ); + } + var recipients = mentionPubkeys; + if (personalChat) { + if (mePlacement?.asData?.value.channels + .contains(channel.id) != + true) { + throw StateError( + 'Refresh Solo before sending to this chat.', + ); + } + recipients = await meRecipients( + ref, + channel.id, + assistant.selected, + mentionPubkeys, + ); + } + await sendMessage.call( + channelId: channel.id, + content: content, + mentionPubkeys: recipients, + channel: resolvedChannel, + mediaTags: mediaTags, + ); + }, ), ], ), diff --git a/mobile/lib/features/channels/channel_detail_page/empty_messages.dart b/mobile/lib/features/channels/channel_detail_page/empty_messages.dart new file mode 100644 index 0000000000..4eebc54f71 --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/empty_messages.dart @@ -0,0 +1,35 @@ +part of '../channel_detail_page.dart'; + +class _EmptyMessages extends StatelessWidget { + const _EmptyMessages(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + BuzzIcons.messageSquare, + size: Grid.xl, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(height: Grid.xxs), + Text( + 'No messages yet', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.half), + Text( + 'Be the first to say something!', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 661e1525fc..c71f0f570b 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -3,6 +3,7 @@ part of '../channel_detail_page.dart'; class _MessageBubble extends HookConsumerWidget { final TimelineMessage message; final bool showAuthor; + final bool typingHandoff; final bool showAvatar; final bool hasReplies; final Widget? replyFooter; @@ -18,6 +19,7 @@ class _MessageBubble extends HookConsumerWidget { const _MessageBubble({ required this.message, required this.showAuthor, + this.typingHandoff = false, this.showAvatar = true, required this.hasReplies, this.replyFooter, @@ -303,78 +305,86 @@ class _MessageBubble extends HookConsumerWidget { ], ), ), - MessageContent( - content: message.content, - bubble: true, - hasReactions: - message.reactions.isNotEmpty || - replyFooter != null, - bubbleEnd: showAvatar, - bubbleStart: showAuthor, - ownMessage: - currentPubkey?.toLowerCase() == pk, - mentionNames: resolvedMentionNames, - mentionLabels: mentionLabels, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, + AgentReplyHandoff( + key: ValueKey( + 'reply-handoff-${message.id}', ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: - currentChannelId, - currentPubkey: - currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of( - viewerContext, - ).maybePop(); - } - }, - ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet( - context, - pubkey, - names: channelIdentityNamesProvider( - currentChannelId, + animate: typingHandoff, + child: MessageContent( + content: message.content, + bubble: true, + hasReactions: + message.reactions.isNotEmpty || + replyFooter != null, + bubbleEnd: showAvatar, + bubbleStart: showAuthor, + ownMessage: + currentPubkey?.toLowerCase() == pk, + mentionNames: resolvedMentionNames, + mentionLabels: mentionLabels, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: + Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ThreadDetailPage( + threadHead: message, + allMessages: + allMessages!, + channelId: + currentChannelId, + currentPubkey: + currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, ), - ), + onChannelTap: (channelId) { + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider( + currentChannelId, + ), + ), + ), ), ], ), diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index ff3dd98278..36331abe72 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -3,6 +3,7 @@ part of '../channel_detail_page.dart'; class _MessageList extends HookConsumerWidget { final ValueNotifier stickyDateHeaderState; final List entries; + final List inlineTyping; final List allMessages; final String? initialMessageId; final String? initialThreadRootId; @@ -12,6 +13,7 @@ class _MessageList extends HookConsumerWidget { final Set initialForcedUnreadMessageIds; final bool hasInitialUnread; final String channelId; + final ChannelMessagesNotifier notifier; final bool isDm; final String? currentPubkey; final bool isMember; @@ -23,6 +25,7 @@ class _MessageList extends HookConsumerWidget { const _MessageList({ required this.entries, + this.inlineTyping = const [], required this.stickyDateHeaderState, required this.allMessages, required this.initialMessageId, @@ -33,6 +36,7 @@ class _MessageList extends HookConsumerWidget { required this.initialForcedUnreadMessageIds, required this.hasInitialUnread, required this.channelId, + required this.notifier, required this.isDm, required this.currentPubkey, required this.isMember, @@ -45,6 +49,8 @@ class _MessageList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final handoff = useAgentReplyHandoffs(channelId, inlineTyping, allMessages); + final displayedTyping = handoff.typing; final appView = View.of(context); final localSendAnimations = ref.watch( localMessageSendAnimationProvider(channelId), @@ -87,7 +93,6 @@ class _MessageList extends HookConsumerWidget { final unreadBoundaryFetchCount = useRef(0); final hasUnreadDeepLink = initialMessageId != null || initialThreadRootId != null; - final notifier = ref.read(channelMessagesProvider(channelId).notifier); final dayTimestampByReversedIndex = {}; final dayStartByReversedIndex = {}; final dayHeaderTimestampByReversedIndex = {}; @@ -524,9 +529,6 @@ class _MessageList extends HookConsumerWidget { olderFetchPaused.value) { return; } - final notifier = ref.read( - channelMessagesProvider(channelId).notifier, - ); if (notifier.reachedOldest) return; isLoadingOlder.value = true; notifier @@ -789,34 +791,24 @@ class _MessageList extends HookConsumerWidget { }, ); - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - BuzzIcons.messageSquare, - size: Grid.xl, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(height: Grid.xxs), - Text( - 'No messages yet', - style: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - const SizedBox(height: Grid.half), - Text( - 'Be the first to say something!', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], + if (entries.isEmpty && displayedTyping.isNotEmpty) { + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + composerBottomInset, + ), + child: InlineAgentTyping( + channelId: channelId, + entries: displayedTyping, + ), ), ); } + if (entries.isEmpty) return const _EmptyMessages(); // Build channel names map once for all message bubbles. final channelsAsync = ref.watch(channelsProvider); @@ -985,7 +977,16 @@ class _MessageList extends HookConsumerWidget { else ...[ _MessageBubble( message: message, - showAuthor: showAuthor, + showAuthor: + showAuthor || + isRecentAgentReplyHandoff( + handoff.messageIds, + message.id, + ), + typingHandoff: isRecentAgentReplyHandoff( + handoff.messageIds, + message.id, + ), showAvatar: showAvatar, hasReplies: entry.summary != null, replyFooter: entry.summary == null @@ -1009,6 +1010,11 @@ class _MessageList extends HookConsumerWidget { restoreComposerFocus: restoreComposerFocus, ), ], + if (index == 0 && displayedTyping.isNotEmpty) + InlineAgentTyping( + channelId: channelId, + entries: displayedTyping, + ), ], ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/placement_route.dart b/mobile/lib/features/channels/channel_detail_page/placement_route.dart new file mode 100644 index 0000000000..26c0cdcd82 --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/placement_route.dart @@ -0,0 +1,60 @@ +part of '../channel_detail_page.dart'; + +/// Explicit destination identity; generic entries allow ordinary sending. +enum ChannelRoutePlacement { lookup, ordinary, solo, unresolved } + +/// Resolves Solo placement at the destination, including Activity/deep links. +class ChannelDetailPage extends ConsumerWidget { + final Channel channel; + final bool personalChat; + + /// Caller identity; known Solo organization always takes precedence. + final ChannelRoutePlacement placement; + final String? initialAssistant; + final String? initialMessageId; + final String? initialThreadRootId; + final InitialThreadRouteBehavior initialThreadRouteBehavior; + + const ChannelDetailPage({ + super.key, + required this.channel, + this.personalChat = false, + this.placement = ChannelRoutePlacement.lookup, + this.initialAssistant, + this.initialMessageId, + this.initialThreadRootId, + this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final groups = ref.watch(meGroupsProvider).value; + final placed = + personalChat || + placement == ChannelRoutePlacement.solo || + (groups?.channels.contains(channel.id) ?? false); + final lookup = ref.watch(meSearchPlacementProvider(channel.id)); + if (!placed && + placement != ChannelRoutePlacement.ordinary && + groups == null && + lookup.isLoading) { + return Scaffold( + appBar: AppBar(title: Text(channel.name)), + body: const Center(child: BuzzLoadingIndicator()), + ); + } + return _ResolvedChannelDetailPage( + key: ValueKey((channel.id, placed)), + channel: channel, + personalChat: placed, + placementKnown: + placed || + groups != null || + placement != ChannelRoutePlacement.unresolved, + initialAssistant: initialAssistant, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + initialThreadRouteBehavior: initialThreadRouteBehavior, + ); + } +} diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index a87fdf7c7f..42d6fd66e7 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -11,6 +11,7 @@ import 'thread_reply_ownership.dart'; import 'timeline_message.dart'; part 'channel_messages_provider/thread_summaries.dart'; +part 'channel_messages_provider/pagination.dart'; // Channel history keeps partial reply evidence; full threads stay route-scoped. const _maxCachedRepliesPerRoot = 256; @@ -27,10 +28,12 @@ const _channelLiveEventKinds = [ /// relay does not return a valid NIP-CW bounds overlay. class ChannelMessagesNotifier extends Notifier>> { final String channelId; + final bool flatTimeline; void Function()? _unsubscribe; bool _reachedOldest = false; bool _initInFlight = false; bool _usingChannelWindow = false; + NostrEvent? _legacyHistoryCursor; bool _initialWindowQueryInFlight = false; int _initVersion = 0; ChannelWindowStore _windowStore = const ChannelWindowStore.empty(); @@ -68,7 +71,7 @@ class ChannelMessagesNotifier extends Notifier>> { }, ); - ChannelMessagesNotifier(this.channelId); + ChannelMessagesNotifier(this.channelId, {this.flatTimeline = false}); bool get _summaryMounted => ref.mounted; RelaySessionNotifier get _summarySession => @@ -193,6 +196,7 @@ class ChannelMessagesNotifier extends Notifier>> { final historyVersion = ++_threadQuerySerial; final history = await _fetchNewestHistory(session); if (!_isCurrentInit(initVersion)) return; + if (!_usingChannelWindow) _legacyHistoryCursor = history.firstOrNull; _confirmLocalMessages(history.map((event) => event.id)); final existing = state.value ?? const []; @@ -228,6 +232,14 @@ class ChannelMessagesNotifier extends Notifier>> { Future> _fetchNewestHistory( RelaySessionNotifier session, ) async { + if (flatTimeline) { + _usingChannelWindow = false; + final history = await session.fetchHistory( + NostrFilters.messages(channelId), + ); + history.sort(compareChannelTimelineEventsChronologically); + return history; + } try { _initialWindowQueryInFlight = true; final pageVersion = ++_threadQuerySerial; @@ -460,6 +472,7 @@ class ChannelMessagesNotifier extends Notifier>> { } List _boundEventReplies(List events) { + if (flatTimeline) return events; final replies = events.where( (event) => EventKind.channelTimelineContentKinds.contains(event.kind) && @@ -919,61 +932,10 @@ class ChannelMessagesNotifier extends Notifier>> { ]..sort(compareChannelTimelineEventsChronologically); } - Future fetchOlder() async { - if (_reachedOldest || _initInFlight) return false; + AsyncValue> get _historyState => state; + set _historyState(AsyncValue> value) => state = value; - final session = ref.read(relaySessionProvider.notifier); - if (_usingChannelWindow) { - final cursor = channelWindowNextCursor(_windowStore); - if (cursor == null) { - _reachedOldest = true; - return false; - } - try { - final pageVersion = ++_threadQuerySerial; - final page = await _fetchWindowPage(session, cursor); - _windowStore = appendOlderChannelWindow(_windowStore, page); - _reconcilePageSummaries(page, pageVersion); - _reachedOldest = !channelWindowHasMore(_windowStore); - final flattened = _withDeepLinkEvents( - flattenChannelWindowEvents(_windowStore), - ); - _lastKnownMessages = flattened; - _reconcileRetainedSummaryPayloads(page.rows.map((row) => row.event)); - state = AsyncData(flattened); - return page.rows.isNotEmpty || page.aux.isNotEmpty; - } catch (error) { - debugPrint( - '[ChannelMessagesNotifier] failed to fetch older channel window page for $channelId: $error', - ); - return false; - } - } - - final currentEvents = state.value; - if (currentEvents == null || currentEvents.isEmpty) return false; - final oldest = currentEvents.first.createdAt; - final older = await session.fetchHistory( - NostrFilters.messages(channelId, limit: 100, until: oldest), - ); - if (older.isEmpty) { - _reachedOldest = true; - return false; - } - final currentIds = state.value?.map((e) => e.id).toSet() ?? {}; - final deduped = older.where((e) => !currentIds.contains(e.id)).toList(); - if (deduped.isEmpty) { - _reachedOldest = true; - return false; - } - state = state.whenData((events) { - final merged = [...deduped, ...events]; - merged.sort(compareChannelTimelineEventsChronologically); - _lastKnownMessages = merged; - return merged; - }); - return true; - } + Future fetchOlder() => _fetchOlder(); } int _currentUnixSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000; diff --git a/mobile/lib/features/channels/channel_messages_provider/pagination.dart b/mobile/lib/features/channels/channel_messages_provider/pagination.dart new file mode 100644 index 0000000000..692fda5ea3 --- /dev/null +++ b/mobile/lib/features/channels/channel_messages_provider/pagination.dart @@ -0,0 +1,72 @@ +part of '../channel_messages_provider.dart'; + +extension _ChannelHistoryPagination on ChannelMessagesNotifier { + Future _fetchOlder() async { + if (_reachedOldest || _initInFlight) return false; + + final session = _summarySession; + if (_usingChannelWindow) { + final cursor = channelWindowNextCursor(_windowStore); + if (cursor == null) { + _reachedOldest = true; + return false; + } + try { + final pageVersion = ++_threadQuerySerial; + final page = await _fetchWindowPage(session, cursor); + _windowStore = appendOlderChannelWindow(_windowStore, page); + _reconcilePageSummaries(page, pageVersion); + _reachedOldest = !channelWindowHasMore(_windowStore); + final flattened = _withDeepLinkEvents( + flattenChannelWindowEvents(_windowStore), + ); + _lastKnownMessages = flattened; + _reconcileRetainedSummaryPayloads(page.rows.map((row) => row.event)); + _historyState = AsyncData(flattened); + return page.rows.isNotEmpty || page.aux.isNotEmpty; + } catch (error) { + debugPrint( + '[ChannelMessagesNotifier] failed to fetch older channel window page for $channelId: $error', + ); + return false; + } + } + + // Retained notification targets are isolated rows, not a history page. + final oldest = _legacyHistoryCursor; + if (oldest == null) return false; + final older = await session.fetchHistory( + NostrFilters.messages( + channelId, + limit: 100, + until: oldest.createdAt, + beforeId: oldest.id, + ), + ); + if (older.isEmpty) { + _reachedOldest = true; + return false; + } + final preceding = + older + .where( + (event) => + compareChannelTimelineEventsChronologically(event, oldest) < + 0, + ) + .toList() + ..sort(compareChannelTimelineEventsChronologically); + if (preceding.isEmpty) return false; + _legacyHistoryCursor = preceding.first; + final currentIds = _historyState.value?.map((e) => e.id).toSet() ?? {}; + final deduped = preceding.where((e) => !currentIds.contains(e.id)).toList(); + // Advance even when a page consists entirely of previously retained rows. + _historyState = _historyState.whenData((events) { + final merged = [...deduped, ...events]; + merged.sort(compareChannelTimelineEventsChronologically); + _lastKnownMessages = merged; + return merged; + }); + return true; + } +} diff --git a/mobile/lib/features/channels/channel_typing_provider.dart b/mobile/lib/features/channels/channel_typing_provider.dart index 73c7990ca2..1d04360c08 100644 --- a/mobile/lib/features/channels/channel_typing_provider.dart +++ b/mobile/lib/features/channels/channel_typing_provider.dart @@ -21,7 +21,7 @@ class TypingEntry { /// Tracks who is currently typing in a specific channel. /// -/// Subscribes to kind:20002 (typing indicator) events via websocket. +/// Subscribes to typing and message events so a reply ends its typing signal. /// Entries expire after 8 seconds (matching the desktop TTL). class ChannelTypingNotifier extends Notifier> { static const _ttlMs = 8000; @@ -30,61 +30,124 @@ class ChannelTypingNotifier extends Notifier> { final String channelId; void Function()? _unsubscribe; Timer? _pruneTimer; + Timer? _retryTimer; + Timer? _healthyTimer; + int _generation = 0; + final _activity = <(String, String?), _TypingActivity>{}; ChannelTypingNotifier(this.channelId); @override List build() { final sessionState = ref.watch(relaySessionProvider); + final generation = ++_generation; + _activity.clear(); ref.onDispose(() { + _generation++; + _activity.clear(); _unsubscribe?.call(); _unsubscribe = null; _pruneTimer?.cancel(); _pruneTimer = null; + _retryTimer?.cancel(); + _retryTimer = null; + _healthyTimer?.cancel(); + _healthyTimer = null; }); if (sessionState.status == SessionStatus.connected) { - _subscribeLive(); + unawaited(_subscribeLive(generation)); } return []; } - void _subscribeLive() async { + Future _subscribeLive(int generation, [int attempt = 0]) async { final session = ref.read(relaySessionProvider.notifier); - _unsubscribe = await session.subscribe( - NostrFilter( - kinds: [EventKind.typingIndicator], - tags: { - '#h': [channelId], + var closed = false; + var failures = attempt; + void failed() { + if (closed) return; + closed = true; + // subscribe may invoke onClosed synchronously during provider build. + scheduleMicrotask(() { + if (generation != _generation) return; + _unsubscribe?.call(); + _unsubscribe = null; + _healthyTimer?.cancel(); + _healthyTimer = null; + _activity.clear(); + state = []; + _pruneTimer?.cancel(); + _pruneTimer = null; + // Bound retries for policy rejections; a transport reconnect resets it. + if (failures < 3) { + _retryTimer = Timer(Duration(seconds: 1 << failures), () { + _retryTimer = null; + if (generation == _generation) { + unawaited(_subscribeLive(generation, failures + 1)); + } + }); + } + }); + } + + try { + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: [ + EventKind.typingIndicator, + ...EventKind.channelMessageEventKinds, + EventKind.streamMessageDiff, + ], + tags: { + '#h': [channelId], + }, + limit: 10, + ), + (event) { + if (!closed && generation == _generation) _handleTypingEvent(event); }, - limit: 10, - ), - _handleTypingEvent, - ); + onClosed: (_) => failed(), + ); + if (closed || generation != _generation) { + unsubscribe(); + } else { + // Admission alone is not health: repeated terminal rejections may + // arrive after subscribe's readiness timeout. Reset only after a full + // typing TTL of uninterrupted subscription health. + _healthyTimer = Timer(const Duration(milliseconds: _ttlMs), () { + if (!closed && generation == _generation) failures = 0; + }); + _unsubscribe = unsubscribe; + } + } catch (_) { + failed(); + } } void _handleTypingEvent(NostrEvent event) { final now = DateTime.now().millisecondsSinceEpoch; - final entry = TypingEntry( - pubkey: event.pubkey, - threadHeadId: event.getTagValue('e'), - expiresAtMs: now + _ttlMs, - ); - - // Upsert: replace existing entry for same pubkey+thread, or add. - final updated = - state - .where( - (e) => - !(e.pubkey == entry.pubkey && - e.threadHeadId == entry.threadHeadId), - ) - .toList() - ..add(entry); - - state = updated; + final at = event.createdAt * 1000; + if (event.channelId != channelId || at > now || at + _ttlMs <= now) return; + final scope = (event.pubkey.toLowerCase(), event.threadReference.rootId); + _activity.removeWhere((_, value) => value.retainUntil <= now); + if (!_activity.containsKey(scope) && _activity.length >= 1024) return; + final record = _activity.putIfAbsent(scope, _TypingActivity.new); + if (event.kind == EventKind.typingIndicator) { + if (at <= record.lastActivity || at <= record.lastMessage) return; + record.lastActivity = at; + if (now >= record.quietUntil) record.visibleUntil = at + _ttlMs; + } else { + if (at <= record.lastMessage) return; + record.lastMessage = at; + if (at >= record.lastActivity) { + record.visibleUntil = 0; + record.quietUntil = now + 2000; + } + } + _prune(); _ensurePruneTimer(); } @@ -97,10 +160,17 @@ class ChannelTypingNotifier extends Notifier> { void _prune() { final now = DateTime.now().millisecondsSinceEpoch; - final pruned = state.where((e) => e.expiresAtMs > now).toList(); - state = pruned; - - if (pruned.isEmpty) { + _activity.removeWhere((_, value) => value.retainUntil <= now); + state = [ + for (final entry in _activity.entries) + if (entry.value.visibleUntil > now) + TypingEntry( + pubkey: entry.key.$1, + threadHeadId: entry.key.$2, + expiresAtMs: entry.value.visibleUntil, + ), + ]; + if (_activity.isEmpty) { _pruneTimer?.cancel(); _pruneTimer = null; } @@ -111,3 +181,15 @@ final channelTypingProvider = NotifierProvider.family, String>( ChannelTypingNotifier.new, ); + +class _TypingActivity { + int lastActivity = -1; + int lastMessage = -1; + int visibleUntil = 0; + int quietUntil = 0; + int get retainUntil { + final expiry = + (lastActivity > lastMessage ? lastActivity : lastMessage) + 8000; + return expiry > quietUntil ? expiry : quietUntil; + } +} diff --git a/mobile/lib/features/channels/channel_unread.dart b/mobile/lib/features/channels/channel_unread.dart new file mode 100644 index 0000000000..dbcb70f701 --- /dev/null +++ b/mobile/lib/features/channels/channel_unread.dart @@ -0,0 +1,51 @@ +import '../../shared/read_state/read_state_provider.dart'; +import 'channel.dart'; +import 'channels_provider.dart'; +import 'unread_badge/observed_unread_event.dart'; + +/// Shared channel highlights, including reply and individual-message read marks. +Set computeUnreadChannelIds({ + required Iterable channels, + required ReadStateState readState, + required ChannelsNotifier channelsNotifier, +}) { + if (!readState.isReady) { + return const {}; + } + + final latestObservedByChannel = channelsNotifier.latestObservedByChannel; + final observedEventsByChannel = + channelsNotifier.observedUnreadEventsByChannel; + final ids = {}; + + for (final channel in channels) { + if (readState.locallyForcedChannelIds.contains(channel.id)) { + ids.add(channel.id); + continue; + } + + final latestObserved = latestObservedByChannel[channel.id]; + if (latestObserved == null) continue; + + final channelReadAt = readState.effectiveTimestamp(channel.id); + if (channelReadAt != null && latestObserved <= channelReadAt) continue; + + final observedEvents = observedEventsByChannel[channel.id]; + int? readAtForObservedEvent(ObservedUnreadEvent event) => + observedUnreadEventReadAt( + event, + channel.id, + readState.effectiveTimestamp, + ); + + final unreadCount = countUnreadObservedEvents( + observedEvents, + readAtForObservedEvent, + ); + if (unreadCount == 0) continue; + + ids.add(channel.id); + } + + return ids; +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index e655c82084..91c2225ac2 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -1,3 +1,6 @@ +import 'channel_unread.dart'; +import '../../shared/widgets/collapsible_section_body.dart'; +import '../../shared/me/me_sync.dart'; import 'dart:async'; import 'dart:io'; import 'dart:math' show max, min, pi; @@ -55,7 +58,6 @@ import 'channels_provider.dart'; import '../../shared/read_state/deferred_read_state_update.dart'; import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; -import 'unread_badge/observed_unread_event.dart'; part 'channels_page/body.dart'; part 'channels_page/browse_channels_sheet.dart'; @@ -69,6 +71,7 @@ part 'channels_page/community_switcher.dart'; part 'channels_page/community_switcher_action.dart'; part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions_launcher.dart'; +part 'channels_page/header_actions.dart'; enum _QuickAction { createChannel, newDm, browseChannels } @@ -95,69 +98,13 @@ const double _kTopSectionBottomPadding = Grid.xxs; /// Inset for the community and profile avatar row. const double _kTopSectionInset = Grid.twelve; -const Duration _kSectionExpandDuration = Duration(milliseconds: 220); -const Duration _kSectionCollapseDuration = Duration(milliseconds: 170); -const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1); -const Curve _kSectionCollapseCurve = Curves.easeInCubic; -const double _kSectionCollapsedScaleY = 0.98; const double _kHeaderFrostScrollDistance = Grid.xxl; const double _kHeaderFrostMaxBlurSigma = 23.12; -class _UnreadChannelState { - final Set ids; - - const _UnreadChannelState({required this.ids}); -} - -_UnreadChannelState _computeUnreadChannelState({ - required Iterable channels, - required ReadStateState readState, - required ChannelsNotifier channelsNotifier, -}) { - if (!readState.isReady) { - return const _UnreadChannelState(ids: {}); - } - - final latestObservedByChannel = channelsNotifier.latestObservedByChannel; - final observedEventsByChannel = - channelsNotifier.observedUnreadEventsByChannel; - final ids = {}; - - for (final channel in channels) { - if (readState.locallyForcedChannelIds.contains(channel.id)) { - ids.add(channel.id); - continue; - } - - final latestObserved = latestObservedByChannel[channel.id]; - if (latestObserved == null) continue; - - final channelReadAt = readState.effectiveTimestamp(channel.id); - if (channelReadAt != null && latestObserved <= channelReadAt) continue; - - final observedEvents = observedEventsByChannel[channel.id]; - int? readAtForObservedEvent(ObservedUnreadEvent event) => - observedUnreadEventReadAt( - event, - channel.id, - readState.effectiveTimestamp, - ); - - final unreadCount = countUnreadObservedEvents( - observedEvents, - readAtForObservedEvent, - ); - if (unreadCount == 0) continue; - - ids.add(channel.id); - } - - return _UnreadChannelState(ids: ids); -} - class ChannelsPage extends HookConsumerWidget { const ChannelsPage({ required this.settingsPageBuilder, + this.onSearch, this.communityInvitePageBuilder, this.communityAppearancePageBuilder, required this.onSettingsTransitionProgress, @@ -167,6 +114,9 @@ class ChannelsPage extends HookConsumerWidget { final WidgetBuilder settingsPageBuilder; + /// Opens global search from the header. + final VoidCallback? onSearch; + /// Builds the invite destination opened from the community sheet. final WidgetBuilder? communityInvitePageBuilder; @@ -270,12 +220,19 @@ class ChannelsPage extends HookConsumerWidget { if (channelsAsync.asData?.value case final data?) { cachedChannels.value = data; } - final channels = cachedChannels.value; + final personalIds = + ref.watch(meGroupsProvider).asData?.value.channels ?? const []; + final channels = cachedChannels.value + ?.where((channel) => !personalIds.contains(channel.id)) + .toList(); Future openChannel(Channel channel) async { if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), + builder: (_) => ChannelDetailPage( + channel: channel, + placement: ChannelRoutePlacement.ordinary, + ), ), ); } @@ -490,6 +447,12 @@ class ChannelsPage extends HookConsumerWidget { onPressed: openCommunitySwitcher, ), nativeActions: [ + if (onSearch != null) + IosNavigationAction( + label: 'Search', + symbol: 'magnifyingglass', + onPressed: onSearch, + ), IosNavigationAction( label: 'Settings', avatarIdentity: '${activeCommunity?.id}:${activeCommunity?.pubkey}', @@ -528,23 +491,17 @@ class ChannelsPage extends HookConsumerWidget { onTap: openCommunitySwitcher, ), actions: [ - SizedBox( - width: Grid.xl, - height: Grid.xl, - child: Center( - child: ProfileAvatar( - size: _kTopSectionProfileAvatarSize, - showPresence: false, - onTap: () { - unawaited(HapticFeedback.lightImpact()); - final route = _SettingsPageRoute( - builder: settingsPageBuilder, - onTransitionProgress: onSettingsTransitionProgress, - ); - Navigator.of(context).push(route); - }, - ), - ), + _ChannelHeaderActions( + onSearch: onSearch, + onSettings: () { + unawaited(HapticFeedback.lightImpact()); + Navigator.of(context).push( + _SettingsPageRoute( + builder: settingsPageBuilder, + onTransitionProgress: onSettingsTransitionProgress, + ), + ); + }, ), ], bottomHeight: _kTopSectionBottomPadding, diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index ad477e8355..ce2d3fb8d6 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -183,13 +183,13 @@ class _SliverChannelsList extends HookConsumerWidget { }); }, [readState.isReady, readState.pubkey, visibleChannels]); - final unreadState = _computeUnreadChannelState( + final unreadState = computeUnreadChannelIds( channels: visibleChannels, readState: readState, channelsNotifier: ref.read(channelsProvider.notifier), ); final unreadChannelIds = { - for (final channelId in unreadState.ids) + for (final channelId in unreadState) if (seedCompleteForPubkey || readState.effectiveTimestamp(channelId) != null) channelId, diff --git a/mobile/lib/features/channels/channels_page/header_actions.dart b/mobile/lib/features/channels/channels_page/header_actions.dart new file mode 100644 index 0000000000..7732438224 --- /dev/null +++ b/mobile/lib/features/channels/channels_page/header_actions.dart @@ -0,0 +1,41 @@ +part of '../channels_page.dart'; + +class _ChannelHeaderActions extends StatelessWidget { + const _ChannelHeaderActions({ + required this.onSearch, + required this.onSettings, + }); + + final VoidCallback? onSearch; + final VoidCallback onSettings; + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: ColoredBox( + color: context.colors.primaryContainer, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onSearch != null) + IconButton( + tooltip: 'Search', + icon: const Icon(BuzzIcons.search300), + onPressed: onSearch, + ), + SizedBox( + width: Grid.xl, + height: Grid.xl, + child: Center( + child: ProfileAvatar( + size: _kTopSectionProfileAvatarSize, + showPresence: false, + onTap: onSettings, + ), + ), + ), + ], + ), + ), + ); +} diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index b298c192d0..b203b567c9 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -71,7 +71,10 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), + builder: (_) => ChannelDetailPage( + channel: channel, + placement: ChannelRoutePlacement.ordinary, + ), ), ); } diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index bc110f700e..2a1bd87e3d 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -62,7 +62,7 @@ class _CustomChannelSection extends StatelessWidget { sortMode: sortMode, onSortModeChange: onSortModeChange, ), - _AnimatedSectionBody( + CollapsibleSectionBody( expanded: expanded, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -365,7 +365,7 @@ class _ChannelSection extends StatelessWidget { sortMode: sortMode, onSortModeChange: onSortModeChange, ), - _AnimatedSectionBody( + CollapsibleSectionBody( expanded: expanded, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -581,104 +581,9 @@ class _SectionChevron extends StatelessWidget { return AnimatedRotation( turns: expanded ? 0 : -0.25, - duration: reducedMotion ? Duration.zero : _kSectionExpandDuration, - curve: _kSectionExpandCurve, + duration: reducedMotion ? Duration.zero : sectionExpandDuration, + curve: sectionExpandCurve, child: Icon(BuzzIcons.chevronDown, size: _kChannelIconSize, color: color), ); } } - -class _AnimatedSectionBody extends HookWidget { - final bool expanded; - final Widget child; - - const _AnimatedSectionBody({required this.expanded, required this.child}); - - @override - Widget build(BuildContext context) { - final reducedMotion = MediaQuery.of(context).disableAnimations; - final controller = useAnimationController( - duration: reducedMotion ? Duration.zero : _kSectionExpandDuration, - reverseDuration: reducedMotion - ? Duration.zero - : _kSectionCollapseDuration, - initialValue: expanded ? 1 : 0, - ); - final curvedAnimation = useMemoized( - () => CurvedAnimation( - parent: controller, - curve: _kSectionExpandCurve, - reverseCurve: _kSectionCollapseCurve, - ), - [controller], - ); - final shouldRender = useState(expanded); - - useEffect(() => curvedAnimation.dispose, [curvedAnimation]); - - useEffect(() { - void handleStatus(AnimationStatus status) { - if (status == AnimationStatus.dismissed && !expanded) { - shouldRender.value = false; - } - } - - controller.addStatusListener(handleStatus); - return () => controller.removeStatusListener(handleStatus); - }, [controller, expanded]); - - useEffect(() { - controller.duration = reducedMotion - ? Duration.zero - : _kSectionExpandDuration; - controller.reverseDuration = reducedMotion - ? Duration.zero - : _kSectionCollapseDuration; - - if (expanded) { - shouldRender.value = true; - if (reducedMotion) { - controller.value = 1; - } else { - unawaited(controller.forward()); - } - } else if (reducedMotion) { - controller.value = 0; - shouldRender.value = false; - } else { - unawaited(controller.reverse()); - } - - return null; - }, [controller, expanded, reducedMotion]); - - return ClipRect( - child: AnimatedBuilder( - animation: curvedAnimation, - child: shouldRender.value ? child : const SizedBox.shrink(), - builder: (context, child) { - final value = curvedAnimation.value.clamp(0.0, 1.0); - final scaleY = - _kSectionCollapsedScaleY + - ((1 - _kSectionCollapsedScaleY) * value); - - return Align( - alignment: Alignment.topCenter, - heightFactor: value, - child: Opacity( - opacity: value, - child: Transform.scale( - alignment: Alignment.topCenter, - scaleY: scaleY, - child: ExcludeSemantics( - excluding: !expanded, - child: IgnorePointer(ignoring: !expanded, child: child), - ), - ), - ), - ); - }, - ), - ); - } -} diff --git a/mobile/lib/features/channels/inline_agent_typing.dart b/mobile/lib/features/channels/inline_agent_typing.dart new file mode 100644 index 0000000000..511c56d669 --- /dev/null +++ b/mobile/lib/features/channels/inline_agent_typing.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; +import 'channel_identity_names_provider.dart'; +import 'channel_typing_provider.dart'; +import 'agent_typing_bubble.dart'; + +/// Ephemeral personal-chat message rows, using desktop's three-dot work signal. +class InlineAgentTyping extends ConsumerWidget { + const InlineAgentTyping({ + super.key, + required this.channelId, + required this.entries, + }); + final String channelId; + final List entries; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final keys = entries.map((entry) => entry.pubkey.toLowerCase()).toSet(); + final profiles = ref.watch(userCacheProvider); + final labels = watchChannelIdentityLabels(ref, channelId, keys); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final key in keys) + Semantics( + key: ValueKey('inline-agent-typing-$key'), + liveRegion: true, + label: '${labels[key]} is working', + child: ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only(top: Grid.xs, bottom: Grid.xxs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + AvatarImage( + imageUrl: + (profiles[key] ?? + ref.read(userCacheProvider.notifier).get(key)) + ?.avatarUrl, + radius: messageAvatarSize / 2, + isAgent: true, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + profiles[key]?.initial ?? + key.substring(0, 1).toUpperCase(), + ), + ), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: Grid.xs), + child: Text( + labels[key] ?? 'Assistant', + style: messageUsernameTextStyle.copyWith( + color: context.colors.onSurface, + fontSize: 13.1, + height: 17 / 13.1, + ), + ), + ), + const SizedBox(height: Grid.xxs), + const AgentTypingBubble(), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/channels/me/me_assistant_list.dart b/mobile/lib/features/channels/me/me_assistant_list.dart new file mode 100644 index 0000000000..9951c2d226 --- /dev/null +++ b/mobile/lib/features/channels/me/me_assistant_list.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../shared/mentions/agent_identity_provider.dart'; +import '../../../shared/profile/user_cache_provider.dart'; +import '../../../shared/theme/theme.dart'; +import '../../../shared/theme/buzz_icons.dart'; +import '../../../shared/widgets/app_list.dart'; +import '../../../shared/widgets/avatar_image.dart'; +import '../../../shared/widgets/sheet_action_section.dart'; + +/// The grouped member-style assistant chooser shared by personal chat sheets. +class MeAssistantList extends ConsumerWidget { + const MeAssistantList({ + super.key, + required this.choices, + required this.selected, + }); + + final List choices; + final String? selected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profiles = ref.watch(userCacheProvider); + return SizedBox( + height: (MediaQuery.sizeOf(context).height * 0.6).clamp(0.0, 400.0), + child: choices.isEmpty + ? const Center(child: Text('No agents are available.')) + : ListView( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + children: [ + SheetActionSection( + label: 'Agents Β· ${choices.length}', + dividerIndent: Grid.xs + 40 + Grid.xs, + children: [ + for (final agent in choices) + Semantics( + selected: agent.pubkey == selected, + child: AppListRowRaw( + leading: AvatarImage( + imageUrl: + (profiles[agent.pubkey] ?? + ref + .read(userCacheProvider.notifier) + .get(agent.pubkey)) + ?.avatarUrl, + radius: 20, + isAgent: true, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + agent.displayName?.isNotEmpty == true + ? agent.displayName![0] + : 'A', + ), + ), + title: Text(agent.displayName ?? 'Agent'), + trailing: agent.pubkey == selected + ? const Icon(BuzzIcons.check) + : null, + onTap: () => Navigator.pop(context, agent.pubkey), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/me/me_assistant_picker.dart b/mobile/lib/features/channels/me/me_assistant_picker.dart new file mode 100644 index 0000000000..b590a1a17b --- /dev/null +++ b/mobile/lib/features/channels/me/me_assistant_picker.dart @@ -0,0 +1,39 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../shared/me/me_sync.dart'; +import '../../../shared/me/me_assistants.dart'; +import '../channel_management_provider.dart'; + +/// Revalidates current membership and ownership before personal chat delivery. +Future> meRecipients( + WidgetRef ref, + String channelId, + String? selected, + List explicit, +) async { + final sync = ref.read(meSyncProvider); + final recipients = explicit.isNotEmpty ? explicit : [?selected]; + if (recipients.isEmpty) { + throw StateError('Choose an agent in the chat header before sending.'); + } + sync.check(); + for (final recipient in recipients.toSet()) { + ref.invalidate(meAssistantOwnershipProvider(recipient)); + } + ref.invalidate(channelMembersProvider(channelId)); + // Start both independent checks together instead of scanning the directory + // and then fetching membership on every message. + final (ownership, members) = await ( + Future.wait([ + for (final recipient in recipients) + ref.read(meAssistantOwnershipProvider(recipient).future), + ]), + ref.read(channelMembersProvider(channelId).future), + ).wait; + sync.check(); + if (ownership.any((owned) => !owned) || + recipients.any((key) => !members.any((member) => member.pubkey == key))) { + throw StateError('Choose an available agent in this chat before sending.'); + } + return recipients; +} diff --git a/mobile/lib/features/channels/me/me_assistant_picker_sheet.dart b/mobile/lib/features/channels/me/me_assistant_picker_sheet.dart new file mode 100644 index 0000000000..09c5c572fd --- /dev/null +++ b/mobile/lib/features/channels/me/me_assistant_picker_sheet.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../shared/me/me_assistants.dart'; +import '../../../shared/theme/theme.dart'; +import 'me_assistant_list.dart'; + +/// Opens immediately with cached assistants; first-load progress stays in-sheet. +class MeAssistantPickerSheet extends ConsumerWidget { + const MeAssistantPickerSheet({super.key, required this.selected}); + final String? selected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assistants = ref.watch(meAssistantsProvider); + if (assistants.value case final choices?) { + return MeAssistantList(choices: choices, selected: selected); + } + return SizedBox( + height: 240, + child: Center( + child: assistants.hasError + ? TextButton( + onPressed: () => ref.invalidate(meAssistantsProvider), + child: const Text('Retry loading agents'), + ) + : Padding( + padding: const EdgeInsets.all(Grid.gutter), + child: const CircularProgressIndicator( + semanticsLabel: 'Loading agents', + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/me/me_chat_assistant.dart b/mobile/lib/features/channels/me/me_chat_assistant.dart new file mode 100644 index 0000000000..e14dca8e14 --- /dev/null +++ b/mobile/lib/features/channels/me/me_chat_assistant.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../shared/me/me_assistants.dart'; +import '../../../shared/me/me_agent_change.dart'; +import '../../../shared/me/me_sync.dart'; +import '../../../shared/profile/user_cache_provider.dart'; +import '../../../shared/relay/relay.dart'; +import '../../../shared/theme/theme.dart'; +import '../../../shared/theme/buzz_icons.dart'; +import '../../../shared/widgets/avatar_image.dart'; +import '../../../shared/widgets/frosted_app_bar.dart'; +import '../../../shared/widgets/modal_presentation.dart'; +import '../channel_management_provider.dart'; +import '../channel_event_order.dart'; +import 'me_assistant_picker_sheet.dart'; + +/// Resolves a remembered choice or the assistant already used in a synced chat. +/// Ambiguous chats require a choice instead of silently picking another agent. +String? resolveMeAssistant({ + required String? selected, + required Set available, + required String viewer, + required List messages, +}) { + if (selected != null) return available.contains(selected) ? selected : null; + if (available.length == 1) return available.single; + final sent = + messages + .where( + (event) => + event.pubkey == viewer && + EventKind.channelMessageEventKinds.contains(event.kind), + ) + .toList() + ..sort((a, b) => compareChannelTimelineEventsChronologically(b, a)); + for (final event in sent) { + final recipients = event.tags + .where((tag) => tag.length > 1 && tag[0] == 'p') + .map((tag) => tag[1]) + .where(available.contains) + .toSet(); + if (recipients.length == 1) return recipients.single; + if (recipients.length > 1) return null; + } + return null; +} + +/// Header avatar and persistent mobile selection for a personal chat. +({ + String? selected, + IosNavigationAction nativeAction, + Widget action, + Widget? pendingNotice, +}) +useMeChatAssistant( + BuildContext context, + WidgetRef ref, { + required bool enabled, + required String channelId, + required String? initial, + required List messages, +}) { + final sync = enabled ? ref.watch(meSyncProvider) : null; + final key = sync == null + ? '' + : 'me-assistant:${sync.community}:${sync.pubkey}:$channelId'; + final prefs = sync?.prefs; + final selection = useState((key, initial ?? prefs?.getString(key))); + final busy = useState(false); + final agents = enabled ? ref.watch(meAssistantsProvider).value ?? [] : []; + final members = enabled + ? ref.watch(channelMembersProvider(channelId)).value ?? [] + : []; + final selected = resolveMeAssistant( + selected: selection.value.$1 == key + ? selection.value.$2 + : prefs?.getString(key), + available: { + for (final agent in agents) + if (members.any((member) => member.pubkey == agent.pubkey)) + agent.pubkey, + }, + viewer: sync?.pubkey ?? '', + messages: messages, + ); + final profile = enabled && selected != null + ? ref.watch(userCacheProvider)[selected] ?? + ref.read(userCacheProvider.notifier).get(selected) + : null; + final name = + profile?.displayName ?? + agents + .where((agent) => agent.pubkey == selected) + .firstOrNull + ?.displayName; + String agentName(String pubkey) { + final names = [ + ref.read(userCacheProvider)[pubkey]?.displayName, + agents.where((a) => a.pubkey == pubkey).firstOrNull?.displayName, + ]; + return names + .whereType() + .where((name) => name.trim().isNotEmpty) + .firstOrNull ?? + (pubkey.length > 12 + ? '${pubkey.substring(0, 8)}…${pubkey.substring(pubkey.length - 4)}' + : pubkey); + } + + MeAgentChange? pending; + if (sync != null) { + try { + pending = MeAgentChange.pending(sync, channelId); + } catch (_) { + // The action surfaces an unreadable journal without crashing the chat. + } + } + final pendingName = pending == null ? null : agentName(pending.agent); + final label = pendingName != null + ? 'Pending agent change: $pendingName' + : selected == null + ? 'Choose an agent' + : 'Change agent: ${name ?? 'Agent'}'; + Future choose() async { + if (busy.value || sync == null) return; + busy.value = true; + try { + var intent = MeAgentChange.pending(sync, channelId); + if (intent != null) { + final intentName = agentName(intent.agent); + final resume = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Finish changing your agent?'), + content: Text( + 'Your choice of $intentName is saved. Resume to finish selecting this agent. Discarding the choice keeps any access already granted.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'later'), + child: const Text('Later'), + ), + TextButton( + onPressed: () => Navigator.pop(context, 'resume'), + child: const Text('Resume'), + ), + TextButton( + onPressed: () => Navigator.pop(context, 'discard'), + child: const Text('Discard choice'), + ), + ], + ), + ); + if (!context.mounted) return; + if (resume == 'discard') { + await intent.discard(sync, channelId); + return; + } + if (resume != 'resume') return; + } else { + final picked = await showBuzzModalBottomSheet( + context: context, + title: 'Agent', + isScrollControlled: true, + showDragHandle: true, + builder: (_) => MeAssistantPickerSheet(selected: selected), + ); + if (picked == null || !context.mounted) return; + intent = MeAgentChange.create(sync, channelId, picked); + } + final picked = intent.agent; + await intent.complete( + sync, + channelId, + ensureMembership: () async { + ref.invalidate(meAssistantsProvider); + final current = await ref.read(meAssistantsProvider.future); + sync.check(); + if (!current.any((agent) => agent.pubkey == picked)) { + throw StateError('This agent is no longer available.'); + } + ref.invalidate(channelMembersProvider(channelId)); + final members = await ref.read( + channelMembersProvider(channelId).future, + ); + sync.check(); + if (!members.any((member) => member.pubkey == picked)) { + await ref + .read(channelActionsProvider) + .addMembers(channelId: channelId, pubkeys: [picked]); + ref.invalidate(channelMembersProvider(channelId)); + } + }, + ); + if (context.mounted) selection.value = (key, picked); + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString().replaceFirst('Bad state: ', '')), + ), + ); + } + } finally { + if (context.mounted) busy.value = false; + } + } + + return ( + selected: selected, + pendingNotice: pendingName == null + ? null + : Semantics( + liveRegion: true, + child: TextButton.icon( + onPressed: busy.value ? null : choose, + icon: const Icon(Icons.pending_outlined), + label: Text('Finish choosing $pendingName'), + ), + ), + nativeAction: IosNavigationAction( + label: label, + symbol: 'person.crop.circle', + avatarIdentity: selected, + avatarIsAgent: selected != null, + imageUrl: profile?.avatarUrl, + avatarInitial: + profile?.initial ?? (name?.isNotEmpty == true ? name![0] : '?'), + onPressed: busy.value ? null : choose, + ), + action: IconButton( + key: const ValueKey('me-assistant-button'), + tooltip: label, + onPressed: busy.value ? null : choose, + icon: AvatarImage( + imageUrl: profile?.avatarUrl, + radius: 16, + isAgent: selected != null, + backgroundColor: context.colors.primaryContainer, + fallback: selected == null + ? const Icon(BuzzIcons.plus, size: 20) + : Text( + profile?.initial ?? (name?.isNotEmpty == true ? name![0] : '?'), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/channels/me/me_messages_provider.dart b/mobile/lib/features/channels/me/me_messages_provider.dart new file mode 100644 index 0000000000..5a6126f265 --- /dev/null +++ b/mobile/lib/features/channels/me/me_messages_provider.dart @@ -0,0 +1,11 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../shared/relay/relay.dart'; +import '../channel_messages_provider.dart'; + +/// Me reads roots and replies as a continuous conversation. +final meMessagesProvider = + NotifierProvider.family< + ChannelMessagesNotifier, + AsyncValue>, + String + >((id) => ChannelMessagesNotifier(id, flatTimeline: true)); diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart new file mode 100644 index 0000000000..8cf2ae7c0e --- /dev/null +++ b/mobile/lib/features/channels/me/me_page.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../shared/profile/user_cache_provider.dart'; +import '../../../shared/animated_avatar.dart'; +import '../../../shared/widgets/app_list.dart'; +import 'me_assistant_list.dart'; +import 'me_section.dart'; +import 'me_unread_provider.dart'; +import '../../../shared/widgets/avatar_image.dart'; +import '../../../shared/me/me_groups.dart'; +import '../../../shared/me/me_chat_creation.dart'; +import '../../../shared/me/me_project_creation.dart'; +import '../../../shared/me/me_sync.dart'; +import '../../../shared/me/me_recovery.dart'; +import '../../../shared/me/me_assistants.dart'; +import '../../../shared/theme/theme.dart'; +import '../../../shared/theme/buzz_icons.dart'; +import '../../../shared/widgets/frosted_app_bar.dart'; +import '../../../shared/widgets/frosted_scaffold.dart'; +import '../../../shared/widgets/buzz_loading_indicator.dart'; +import '../../../shared/widgets/modal_presentation.dart'; +import '../channel.dart'; +import '../channel_detail_page.dart'; +import '../channel_management_provider.dart'; +import '../channels_provider.dart'; + +part 'me_page_helpers.dart'; +part 'new_me_chat.dart'; +part 'native_new_me_chat.dart'; +part 'new_me_project.dart'; + +/// Personal chats and projects shared with the desktop Me workspace. +class MePage extends HookConsumerWidget { + const MePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final headerTitleStyle = context.textTheme.titleMedium?.copyWith( + fontSize: 22, + fontWeight: FontWeight.w600, + color: navigationPrimaryForeground(context), + ); + final topSectionHeight = frostedAppBarHeight( + context, + nativeLargeTitle: true, + titleStyle: headerTitleStyle, + bottomHeight: Grid.xxs, + ); + // Warm the directory while browsing Me, before the creation form opens. + final assistants = ref.watch(meAssistantsProvider).value; + useEffect(() { + if (assistants != null) { + ref.read(userCacheProvider.notifier).preload([ + for (final agent in assistants) agent.pubkey, + ]); + } + return null; + }, [assistants]); + final groups = ref.watch(meGroupsProvider); + final offlineSaveFailed = + ref.watch(meOfflineSaveWarningProvider).value ?? false; + final syncClosed = ref.watch(meSyncWarningProvider).value ?? false; + final channels = ref.watch(channelsProvider); + final unreadIds = ref.watch(meUnreadChannelIdsProvider); + final busy = useState(false); + Future perform(Future Function() action) async { + if (busy.value) return; + busy.value = true; + try { + await action(); + } catch (error) { + if (error is MeRecoveryConflict && context.mounted) { + await _offerMeRecovery(context, ref.read(meSyncProvider)); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString().replaceFirst('Bad state: ', '')), + ), + ); + } + } finally { + if (context.mounted) busy.value = false; + } + } + + final VoidCallback? newProject = busy.value || !groups.hasValue + ? null + : () => perform(() async { + await _createMeProject(context, ref); + }); + final VoidCallback? newChat = busy.value || !groups.hasValue + ? null + : () => perform(() async { + final result = await _showNewMeChat(context, ref); + if (result == null || !context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage( + channel: result.$1, + personalChat: true, + initialAssistant: result.$2, + ), + ), + ); + }); + + return FrostedScaffold( + backgroundColor: context.colors.surface, + appBar: FrostedAppBar( + nativeTitle: 'Solo', + nativeLargeTitle: true, + automaticallyImplyLeading: false, + nativeActions: [ + IosNavigationAction( + label: 'New project', + symbol: 'folder.badge.plus', + icon: BuzzIcons.folderPlus, + onPressed: newProject, + ), + IosNavigationAction( + label: 'New chat', + symbol: 'plus', + icon: BuzzIcons.plus, + onPressed: newChat, + ), + ], + horizontalInset: Grid.gutter, + showBottomDivider: true, + bottomDividerOpacity: 0.07, + title: Text('Solo', style: headerTitleStyle), + titleStyle: headerTitleStyle, + actions: [ + ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: ColoredBox( + color: context.colors.primaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'New project', + icon: const Icon(BuzzIcons.folderPlus, size: 20), + onPressed: newProject, + ), + IconButton( + tooltip: 'New chat', + icon: const Icon(BuzzIcons.plus, size: 20), + onPressed: newChat, + ), + ], + ), + ), + ), + ), + ], + bottomHeight: Grid.xxs, + bottom: const SizedBox.expand(), + ), + body: groups.when( + loading: () => const Center(child: BuzzLoadingIndicator()), + error: (_, _) => + _MeRetry(onRetry: () => ref.invalidate(meGroupsProvider)), + data: (organization) => channels.when( + loading: () => const Center(child: BuzzLoadingIndicator()), + error: (_, _) => + _MeRetry(onRetry: () => ref.invalidate(channelsProvider)), + data: (all) { + final visible = + all + .where( + (c) => + c.isMember && + !c.isArchived && + organization.channels.contains(c.id), + ) + .toList() + ..sort( + (a, b) => (b.lastMessageAt ?? b.createdAt).compareTo( + a.lastMessageAt ?? a.createdAt, + ), + ); + Widget row(Channel channel) => _MeChatRow( + name: channel.name, + isUnread: unreadIds.contains(channel.id), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ChannelDetailPage(channel: channel, personalChat: true), + ), + ), + ); + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(meGroupsProvider); + await ref.read(channelsProvider.notifier).refresh(); + await ref.read(meGroupsProvider.future); + }, + child: ListView( + padding: EdgeInsets.fromLTRB( + Grid.gutter - Grid.xxs, + topSectionHeight + Grid.xxs, + Grid.gutter - Grid.xxs, + MediaQuery.paddingOf(context).bottom + Grid.gutter, + ), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (syncClosed) + Semantics( + liveRegion: true, + child: Column( + children: [ + const Text( + 'Live updates stopped. Your saved chats are still available.', + ), + TextButton( + onPressed: () => ref.invalidate(meGroupsProvider), + child: const Text('Retry live updates'), + ), + ], + ), + ), + if (offlineSaveFailed) + Semantics( + liveRegion: true, + child: Column( + children: [ + const Text( + 'Your chats are available, but couldn’t be saved for offline use.', + ), + TextButton( + onPressed: () => ref.invalidate(meGroupsProvider), + child: const Text('Retry offline save'), + ), + ], + ), + ), + if (organization.channels.isEmpty && + organization.groups.isEmpty) + Padding( + padding: const EdgeInsets.all(Grid.gutter), + child: Column( + children: [ + const Icon(Icons.chat_bubble_outline, size: 48), + const SizedBox(height: Grid.gutter), + Text( + 'A place to work with your agents', + style: context.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: Grid.xs), + const Text( + 'Your chats and projects appear here on mobile and desktop.', + textAlign: TextAlign.center, + ), + ], + ), + ), + for (final group in organization.groups) + MeSection( + key: ValueKey(group['id']), + icon: BuzzIcons.folder, + label: group['name'] as String, + hasUnread: visible.any( + (c) => + organization.assignments[c.id] == group['id'] && + unreadIds.contains(c.id), + ), + children: [ + for (final channel in visible.where( + (c) => organization.assignments[c.id] == group['id'], + )) + row(channel), + if (!visible.any( + (c) => organization.assignments[c.id] == group['id'], + )) + const ListTile( + title: Text('No chats in this project'), + ), + ], + ), + if (organization.channels.isNotEmpty || + organization.groups.isNotEmpty) + MeSection( + key: const ValueKey('me-ungrouped-chats'), + icon: BuzzIcons.messageCircle, + label: 'Chats', + hasUnread: visible.any( + (c) => + !organization.assignments.containsKey(c.id) && + unreadIds.contains(c.id), + ), + children: [ + for (final channel in visible.where( + (c) => !organization.assignments.containsKey(c.id), + )) + row(channel), + if (!visible.any( + (c) => !organization.assignments.containsKey(c.id), + )) + const ListTile(title: Text('No chats yet')), + ], + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/me/me_page_helpers.dart b/mobile/lib/features/channels/me/me_page_helpers.dart new file mode 100644 index 0000000000..25e0924f9b --- /dev/null +++ b/mobile/lib/features/channels/me/me_page_helpers.dart @@ -0,0 +1,182 @@ +part of 'me_page.dart'; + +class _MeRetry extends StatelessWidget { + final VoidCallback onRetry; + const _MeRetry({required this.onRetry}); + @override + Widget build(BuildContext context) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Couldn’t load your chats and projects.'), + TextButton(onPressed: onRetry, child: const Text('Try again')), + ], + ), + ); +} + +Future _name( + BuildContext context, + String title, { + String initialValue = '', +}) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + final theme = utilitySurfaceThemeData(Theme.of(context)); + try { + return await const MethodChannel( + 'buzz/profile_text_editor', + ).invokeMethod('presentProject', { + 'title': title, + 'initialValue': initialValue, + 'placeholder': 'Project name', + 'multiline': false, + 'brightness': theme.brightness.name, + 'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(), + 'containerBackgroundArgb': theme.colorScheme.surfaceContainerHighest + .toARGB32(), + 'containerCornerRadius': Radii.container, + }); + } on MissingPluginException { + // Use the shared sheet when the native editor is unavailable. + } + } + if (!context.mounted) return null; + return showBuzzModalBottomSheet( + context: context, + title: title, + isScrollControlled: true, + requestFocus: true, + builder: (_) => _NewMeProject(initialValue: initialValue), + ); +} + +class _NewMeProject extends HookConsumerWidget { + final String initialValue; + const _NewMeProject({this.initialValue = ''}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final name = controller.text.trim(); + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.md, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.gutter, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + autofocus: true, + maxLength: 120, + decoration: const InputDecoration(labelText: 'Project name'), + ), + const SizedBox(height: Grid.md), + FilledButton( + onPressed: name.isEmpty ? null : () => Navigator.pop(context, name), + child: const Text('Create project'), + ), + ], + ), + ); + } +} + +class _MeChatRow extends StatelessWidget { + const _MeChatRow({ + required this.name, + required this.onTap, + required this.isUnread, + }); + final String name; + final bool isUnread; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = navigationPrimaryForeground( + context, + ).withValues(alpha: isUnread ? 1 : 0.8); + return Semantics( + label: isUnread ? '$name, unread' : name, + onTap: onTap, + excludeSemantics: true, + button: true, + child: InkWell( + borderRadius: BorderRadius.circular(Radii.md), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.twelve, + ), + child: Row( + children: [ + SizedBox( + width: 22, + child: Align( + alignment: Alignment.centerLeft, + child: Icon(BuzzIcons.messageCircle, size: 18, color: color), + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: contentListTitleTextStyle.copyWith( + color: color, + fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400, + fontSize: 17, + height: 22 / 17, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +Future _offerMeRecovery(BuildContext context, MeSync sync) async { + final pending = sync.prefs?.getString(sync.pendingKey); + if (pending == null) return; + final discard = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Conflicting Solo changes'), + content: const Text( + 'Desktop and this phone changed the same project or chat. Keep the pending phone change to retry later, or discard it and use the current desktop organization. Your chat messages will be kept.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Keep pending'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Discard phone change'), + ), + ], + ), + ); + if (discard == true) await sync.discardPending(pending); +} + +// Reconnects replace the transport service while an editor may still be open. +// Retain the reviewed account/community boundary but use its current connection. +MeSync _currentMeSync(WidgetRef ref, MeSync reviewed) { + final current = ref.read(meSyncProvider); + if (current.community != reviewed.community || + current.pubkey != reviewed.pubkey) { + throw StateError('Your account or community changed. Open Solo again.'); + } + return current; +} diff --git a/mobile/lib/features/channels/me/me_section.dart b/mobile/lib/features/channels/me/me_section.dart new file mode 100644 index 0000000000..c6e1017f23 --- /dev/null +++ b/mobile/lib/features/channels/me/me_section.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../../../shared/theme/theme.dart'; +import '../../../shared/theme/buzz_icons.dart'; +import '../../../shared/widgets/collapsible_section_body.dart'; + +/// A project or unassigned-chat section matching the Channels list headings. +class MeSection extends HookWidget { + const MeSection({ + super.key, + required this.label, + required this.icon, + required this.children, + this.hasUnread = false, + }); + + final String label; + + /// Keeps unread activity visible when this section is collapsed. + final bool hasUnread; + final IconData icon; + final List children; + + @override + Widget build(BuildContext context) { + final expanded = useState(true); + final color = navigationSectionForeground(context); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + label: hasUnread ? '$label, unread' : label, + excludeSemantics: true, + onTap: () => expanded.value = !expanded.value, + button: true, + expanded: expanded.value, + child: InkWell( + borderRadius: BorderRadius.circular(Radii.md), + onTap: () => expanded.value = !expanded.value, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.twelve, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 28), + child: Row( + children: [ + SizedBox( + width: 22, + child: Align( + alignment: Alignment.centerLeft, + child: Icon(icon, size: 18, color: color), + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + label, + style: contentListTitleTextStyle.copyWith( + color: color, + fontSize: 17, + height: 22 / 17, + fontWeight: FontWeight.w600, + ), + ), + ), + if (hasUnread) ...[ + Container( + key: ValueKey('solo-section-unread-$label'), + width: 8, + height: 8, + decoration: BoxDecoration( + color: context.colors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: Grid.xxs), + ], + AnimatedRotation( + turns: expanded.value ? 0 : -0.25, + duration: reducedMotion + ? Duration.zero + : sectionExpandDuration, + curve: sectionExpandCurve, + child: Icon( + BuzzIcons.chevronDown, + size: 18, + color: color, + ), + ), + ], + ), + ), + ), + ), + ), + CollapsibleSectionBody( + expanded: expanded.value, + child: Column(children: children), + ), + ], + ); + } +} diff --git a/mobile/lib/features/channels/me/me_unread_provider.dart b/mobile/lib/features/channels/me/me_unread_provider.dart new file mode 100644 index 0000000000..d4df38591f --- /dev/null +++ b/mobile/lib/features/channels/me/me_unread_provider.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../../shared/me/me_sync.dart'; +import '../../../shared/read_state/read_state_provider.dart'; +import '../../../shared/read_state/read_state_time.dart'; +import '../../../shared/relay/relay.dart'; +import '../channel_unread.dart'; +import '../channels_provider.dart'; + +/// Solo uses Home's initial read baseline even before the tab is visited. +final meUnreadChannelIdsProvider = + NotifierProvider>(MeUnreadChannelIds.new); + +/// Seeds initial history once per channel and identity before displaying unread. +class MeUnreadChannelIds extends Notifier> { + @override + Set build() { + final organization = ref.watch(meGroupsProvider).value; + final channels = ref.watch(channelsProvider).value ?? []; + final reads = ref.watch(readStateProvider); + final seeds = ref.watch(_initialSeedsProvider); + final baselines = seeds.baselines; + final seeded = seeds.completed; + if (reads.pubkey == null || organization == null) return const {}; + final active = channels + .where( + (c) => + c.isMember && + !c.isArchived && + organization.channels.contains(c.id), + ) + .toList(); + final pending = active.where((c) => !seeded.contains(c.id)).toList(); + for (final channel in pending) { + // Capture before deferring: a newer event must not move this baseline. + baselines.putIfAbsent( + channel.id, + () => dateTimeToUnixSeconds(channel.lastMessageAt), + ); + } + if (!reads.isReady) return const {}; + var disposed = false; + ref.onDispose(() => disposed = true); + if (pending.isNotEmpty) { + scheduleMicrotask(() { + if (disposed) return; + final notifier = ref.read(readStateProvider.notifier); + for (final channel in pending) { + final baseline = baselines[channel.id]; + if (ref.read(readStateProvider).effectiveTimestamp(channel.id) == + null && + baseline != null) { + notifier.seedContextRead(channel.id, baseline); + } + // Empty chats also complete initialization; their first event is new. + seeded.add(channel.id); + } + state = computeUnreadChannelIds( + channels: active, + readState: ref.read(readStateProvider), + channelsNotifier: ref.read(channelsProvider.notifier), + ); + }); + } + return computeUnreadChannelIds( + channels: active.where( + (c) => seeded.contains(c.id) || reads.effectiveTimestamp(c.id) != null, + ), + readState: reads, + channelsNotifier: ref.read(channelsProvider.notifier), + ); + } +} + +// Retain the captured baseline across channel/read-marker rebuilds, but never +// across an identity or community boundary. Readiness must preserve history. +final _initialSeedsProvider = Provider((ref) { + ref.watch(readStateProvider.select((s) => s.pubkey)); + ref.watch(relayConfigProvider.select((c) => c.baseUrl)); + return _InitialSeeds(); +}); + +class _InitialSeeds { + final baselines = {}; + final completed = {}; +} diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart new file mode 100644 index 0000000000..0920284bcc --- /dev/null +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -0,0 +1,172 @@ +part of 'me_page.dart'; + +Future<(Channel, String)?> _showNewMeChat( + BuildContext context, + WidgetRef ref, +) async { + final initialSync = ref.read(meSyncProvider); + final unfinished = MeChatCreation.pending(initialSync); + if (unfinished != null) { + final resume = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unfinished chat'), + content: Text( + 'Resume β€œ${unfinished.name}”, or discard its setup to start another chat. Any channel and messages already created are kept in Home or Solo.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Later'), + ), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Discard setup'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Resume'), + ), + ], + ), + ); + if (resume == null || !context.mounted) return null; + if (!resume) { + await unfinished.discard(initialSync); + ref.invalidate(channelsProvider); + } + } + if (!context.mounted) return null; + if (defaultTargetPlatform == TargetPlatform.iOS) { + final sync = ref.read(meSyncProvider); + final pending = MeChatCreation.pending(sync); + final id = pending?.id ?? const Uuid().v4(); + String name = pending?.name ?? ''; + String? assistant = pending?.agent; + String? error = pending == null ? null : 'Resume your unfinished chat.'; + var retry = pending != null; + while (context.mounted) { + sync.check(); + final directory = await ref.read(meAssistantsProvider.future); + 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') + .invokeMethod>('presentChat', { + 'title': 'New chat', + 'initialValue': name, + 'placeholder': 'Chat name', + 'multiline': false, + 'brightness': theme.brightness.name, + 'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(), + 'containerBackgroundArgb': theme + .colorScheme + .surfaceContainerHighest + .toARGB32(), + 'containerCornerRadius': Radii.container, + 'selectedAssistant': assistant, + '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 ?? + '', + }, + ], + }); + } on MissingPluginException { + break; + } + if (draft == null || !context.mounted) return null; + name = (draft['name'] as String? ?? '').trim(); + assistant = draft['assistant'] as String?; + if (name.isEmpty || assistant == null) return null; + retry = true; + try { + final created = await _createMeChat(ref, sync, id, name, assistant); + return (created, assistant); + } catch (failure) { + if (failure is MeRecoveryConflict && context.mounted) { + await _offerMeRecovery(context, sync); + } + error = + '${failure.toString().replaceFirst('Bad state: ', '')}\nRetry continues this same chat.'; + } + } + } + if (!context.mounted) return null; + return showBuzzModalBottomSheet<(Channel, String)>( + context: context, + title: 'New chat', + isScrollControlled: true, + requestFocus: true, + builder: (_) => const _NewMeChat(), + ); +} + +Future _createMeChat( + WidgetRef ref, + MeSync sync, + String channelId, + String name, + String assistant, +) async { + sync = _currentMeSync(ref, sync); + final intent = MeChatCreation(channelId, name, assistant); + await intent.save(sync); + final owned = await isOwnedMeAssistant(sync, assistant); + sync.check(); + if (!owned) { + throw StateError('This agent is no longer available.'); + } + final head = await sync.head(); + (head == null ? const MeGroups() : sync.decode(head)) + .place(channelId) + .encode(sync.community); + final actions = ref.read(channelActionsProvider); + return intent.complete( + sync, + findChannel: () async { + ref.invalidate(channelsProvider); + final all = await ref.read(channelsProvider.future); + return all.where((c) => c.id == channelId).firstOrNull; + }, + createChannel: () => actions.createChannel( + channelId: channelId, + name: name, + channelType: 'stream', + visibility: 'private', + ), + hasAgent: () async { + ref.invalidate(channelMembersProvider(channelId)); + final members = await ref.read(channelMembersProvider(channelId).future); + return members.any((m) => m.pubkey == assistant); + }, + addAgent: () => + actions.addMembers(channelId: channelId, pubkeys: [assistant]), + isPlaced: () async { + final latest = await sync.head(); + return latest != null && sync.decode(latest).channels.contains(channelId); + }, + place: () => sync.update((current) => current.place(channelId)), + ); +} diff --git a/mobile/lib/features/channels/me/new_me_chat.dart b/mobile/lib/features/channels/me/new_me_chat.dart new file mode 100644 index 0000000000..6b5f9d80b9 --- /dev/null +++ b/mobile/lib/features/channels/me/new_me_chat.dart @@ -0,0 +1,163 @@ +part of 'me_page.dart'; + +class _NewMeChat extends HookConsumerWidget { + const _NewMeChat(); + @override + Widget build(BuildContext context, WidgetRef ref) { + final sync = ref.watch(meSyncProvider); + final pending = useMemoized(() => MeChatCreation.pending(sync)); + final name = useTextEditingController(text: pending?.name ?? ''); + final selected = useState(pending?.agent); + final channelId = useMemoized(() => pending?.id ?? const Uuid().v4()); + final frozenName = useState(pending?.name); + final frozenAgent = useState(pending?.agent); + final busy = useState(false); + final error = useState( + pending == null ? null : 'Resume your unfinished chat.', + ); + final directory = ref.watch(meAssistantsProvider); + final originalScope = useMemoized(() => (sync.community, sync.pubkey)); + if (originalScope != (sync.community, sync.pubkey)) { + return const Padding( + padding: EdgeInsets.all(Grid.gutter), + child: Text( + 'Your account or community changed. Return to Solo to start a chat.', + ), + ); + } + final choices = directory.asData?.value ?? []; + final chosen = choices + .where((agent) => agent.pubkey == selected.value) + .firstOrNull; + final profile = ref.watch(userCacheProvider)[selected.value]; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('new-session-name'), + controller: name, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + enabled: !busy.value && frozenName.value == null, + maxLength: 120, + decoration: const InputDecoration(hintText: 'Chat name'), + onChanged: (_) => error.value = null, + ), + const SizedBox(height: Grid.xs), + AppListRowRaw( + key: const ValueKey('new-session-assistant'), + leading: chosen == null + ? const Icon(BuzzIcons.plus) + : AvatarImage( + imageUrl: profile?.avatarUrl, + radius: 20, + isAgent: true, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + profile?.initial ?? + (chosen.displayName?.isNotEmpty == true + ? chosen.displayName![0] + : 'A'), + ), + ), + title: Text(chosen?.displayName ?? 'Choose an agent'), + trailing: const Icon(BuzzIcons.chevronRight, size: 20), + onTap: busy.value || frozenName.value != null + ? null + : () async { + FocusScope.of(context).unfocus(); + if (!context.mounted) return; + final picked = await showBuzzModalBottomSheet( + context: context, + title: 'Agent', + isScrollControlled: true, + showDragHandle: true, + builder: (_) => MeAssistantList( + choices: choices, + selected: selected.value, + ), + ); + if (context.mounted && picked != null) { + selected.value = picked; + } + }, + ), + if (directory.isLoading) const LinearProgressIndicator(), + if (directory.hasError) + TextButton( + onPressed: () { + ref.invalidate(meAssistantsProvider); + }, + child: const Text('Reload agents'), + ), + if (!directory.isLoading && !directory.hasError && choices.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: Grid.gutter), + child: Text( + 'No agents are available for this account. Connect an agent on desktop, then refresh.', + ), + ), + if (error.value != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.gutter), + child: Text(error.value!), + ), + const SizedBox(height: Grid.gutter), + FilledButton( + onPressed: busy.value + ? null + : () async { + if (name.text.trim().isEmpty || selected.value == null) { + error.value = 'Enter a name and choose an agent.'; + return; + } + busy.value = true; + error.value = null; + try { + frozenName.value ??= name.text.trim(); + frozenAgent.value ??= selected.value; + final created = await _createMeChat( + ref, + sync, + channelId, + frozenName.value!, + frozenAgent.value!, + ); + if (context.mounted) { + Navigator.of( + context, + ).pop((created, frozenAgent.value!)); + } + } catch (e) { + if (e is MeRecoveryConflict && context.mounted) { + await _offerMeRecovery(context, sync); + } + if (context.mounted) { + error.value = + '${e.toString().replaceFirst('Bad state: ', '')}\nRetry continues this same chat.'; + } + } finally { + if (context.mounted) busy.value = false; + } + }, + child: Text( + busy.value + ? 'Creating chat…' + : frozenName.value != null + ? 'Retry' + : 'Create chat', + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/me/new_me_project.dart b/mobile/lib/features/channels/me/new_me_project.dart new file mode 100644 index 0000000000..6e8e3b946e --- /dev/null +++ b/mobile/lib/features/channels/me/new_me_project.dart @@ -0,0 +1,93 @@ +part of 'me_page.dart'; + +Future _createMeProject(BuildContext context, WidgetRef ref) async { + final sync = ref.read(meSyncProvider); + var intent = MeProjectCreation.pending(sync); + if (intent != null) { + final reviewed = intent; + final resume = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unfinished project'), + content: Text( + 'Resume β€œ${reviewed.name}”, or discard its setup to start another project. Any project already sent or awaiting confirmation is kept.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Later'), + ), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Discard setup'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Resume'), + ), + ], + ), + ); + if (resume == null || !context.mounted) return; + if (!resume) { + await reviewed.discard(sync); + intent = null; + } + } + if (!context.mounted) return; + if (intent == null) { + final name = await _name(context, 'New project'); + if (name == null) return; + intent = MeProjectCreation(const Uuid().v4(), name); + } + while (context.mounted) { + try { + await intent!.complete(_currentMeSync(ref, sync)); + return; + } catch (error) { + if (error is MeRecoveryConflict) rethrow; + // A failed preferences write can still update its optimistic cache. + // Only advertise recovery after rereading the durable journal. + var saved = false; + try { + await sync.prefs?.reload(); + saved = MeProjectCreation.pending(sync) != null; + } catch (_) { + // Storage could not be verified. Retain the entered form below. + } + if (saved) { + throw StateError( + 'Project setup saved on this phone. Open New project to resume or discard it. ${error.toString().replaceFirst('Bad state: ', '')}', + ); + } + if (!context.mounted || sync.disposed) rethrow; + final retry = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Project was not saved'), + content: const Text( + 'Couldn’t save your project on this phone. Try again to keep your entered name.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Try again'), + ), + ], + ), + ); + if (retry != true || !context.mounted) return; + final name = await _name( + context, + 'New project', + initialValue: intent!.name, + ); + if (name == null) return; + intent = MeProjectCreation(intent.id, name); + } + } +} diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index e97c9198f2..f790028016 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -6,6 +6,7 @@ import '../channels/channel_management_provider.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import 'channel.dart'; +import 'me/me_messages_provider.dart'; import 'channel_messages_provider.dart'; import 'message_mention_pubkeys.dart'; import 'local_message_send_animation_provider.dart'; @@ -256,7 +257,15 @@ class SendMessage { } } -final sendMessageProvider = Provider((ref) { +final sendMessageProvider = Provider((ref) => _sender(ref)); + +/// Shares signing and delivery with channels, with Me's flat optimistic reader. +final meSendMessageProvider = Provider( + (ref) => _sender(ref, personal: true), +); + +SendMessage _sender(Ref ref, {bool personal = false}) { + final messages = personal ? meMessagesProvider : channelMessagesProvider; final config = ref.watch(relayConfigProvider); return SendMessage( signedEventRelay: SignedEventRelay( @@ -266,22 +275,19 @@ final sendMessageProvider = Provider((ref) { fetchMembers: (channelId) => ref.read(channelMembersProvider(channelId).future), readUserCache: () => ref.read(userCacheProvider), - addLocalMessage: (channelId, event) => ref - .read(channelMessagesProvider(channelId).notifier) - .addLocalMessage(event), + addLocalMessage: (channelId, event) => + ref.read(messages(channelId).notifier).addLocalMessage(event), markLocalMessageForAnimation: (channelId, eventId) => ref .read(localMessageSendAnimationProvider(channelId).notifier) .mark(eventId), - completeLocalMessage: (channelId, eventId) => ref - .read(channelMessagesProvider(channelId).notifier) - .completeLocalMessage(eventId), - removeLocalMessage: (channelId, eventId) => ref - .read(channelMessagesProvider(channelId).notifier) - .removeLocalMessage(eventId), + completeLocalMessage: (channelId, eventId) => + ref.read(messages(channelId).notifier).completeLocalMessage(eventId), + removeLocalMessage: (channelId, eventId) => + ref.read(messages(channelId).notifier).removeLocalMessage(eventId), isDeliveryValid: () { final currentConfig = ref.read(relayConfigProvider); return currentConfig.baseUrl == config.baseUrl && currentConfig.nsec == config.nsec; }, ); -}); +} diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index ed932f60c6..af711253ab 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -558,7 +558,13 @@ List formatTimeline( List buildMainTimelineEntries( List messages, { Map? relaySummaries, + bool flat = false, }) { + if (flat) { + return [ + for (final message in messages) MainTimelineEntry(message: message), + ]; + } // Index descendant stats by ancestor, so a nested reply updates every summary // above it and not only the summary of its direct parent. final descendantStats = _buildDescendantStats(messages); diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index c5ab27ccf9..f9142ed901 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -20,13 +21,18 @@ class HomePage extends HookConsumerWidget { const HomePage({ required this.settingsPageBuilder, this.communityInvitePageBuilder, + this.mePageBuilder, this.communityAppearancePageBuilder, required this.hasUnreadInbox, + this.hasUnreadSolo = false, super.key, }); final WidgetBuilder settingsPageBuilder; + /// Personal chat workspace supplied by the app composition root. + final WidgetBuilder? mePageBuilder; + /// Builds the invite destination opened from the community sheet. final WidgetBuilder? communityInvitePageBuilder; @@ -34,6 +40,9 @@ class HomePage extends HookConsumerWidget { final WidgetBuilder? communityAppearancePageBuilder; final bool hasUnreadInbox; + /// Whether a visible Solo chat has unread activity. + final bool hasUnreadSolo; + static const double _tabBarHeight = mobileTabBarHeight; static const double _tabBarRadius = _tabBarHeight / 2; static const double _tabBarInnerInset = Grid.half; @@ -65,9 +74,9 @@ class HomePage extends HookConsumerWidget { label: 'Activity', ), _HomeDestination( - icon: BuzzIcons.search300, - selectedIcon: BuzzIcons.search500, - label: 'Search', + icon: Icons.chat_bubble_outline, + selectedIcon: Icons.chat_bubble, + label: 'Solo', ), ]; @@ -85,7 +94,6 @@ class HomePage extends HookConsumerWidget { ); final homeReselection = useValueNotifier(0); final activityReselection = useValueNotifier(0); - final searchReselection = useValueNotifier(0); final settingsTransitionProgress = useValueNotifier(0.0); final reducedMotion = MediaQuery.of(context).disableAnimations; final tabContentTransitionProgress = reducedMotion @@ -96,10 +104,48 @@ class HomePage extends HookConsumerWidget { MediaQuery.sizeOf(context).width, _destinations.length, ); - final pages = [ ChannelsPage( settingsPageBuilder: settingsPageBuilder, + onSearch: () => Navigator.of(context).push( + PageRouteBuilder( + // Keep Android backdrop filters composited while search is open. + // UIKit controls must instead be hidden beneath the iOS route. + opaque: defaultTargetPlatform == TargetPlatform.iOS, + allowSnapshotting: false, + transitionDuration: reducedMotion + ? Duration.zero + : defaultTargetPlatform == TargetPlatform.iOS + ? CupertinoRouteTransitionMixin.kTransitionDuration + : const Duration(milliseconds: 260), + reverseTransitionDuration: reducedMotion + ? Duration.zero + : defaultTargetPlatform == TargetPlatform.iOS + ? CupertinoRouteTransitionMixin.kTransitionDuration + : const Duration(milliseconds: 220), + transitionsBuilder: (_, animation, secondaryAnimation, child) => + reducedMotion + ? child + : defaultTargetPlatform == TargetPlatform.iOS + ? CupertinoFullscreenDialogTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: false, + child: child, + ) + : SlideTransition( + position: animation.drive( + Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)), + ), + child: child, + ), + pageBuilder: (_, animation, secondaryAnimation) => + const SearchPage(), + ), + ), communityInvitePageBuilder: communityInvitePageBuilder, communityAppearancePageBuilder: communityAppearancePageBuilder, tabReselection: homeReselection, @@ -114,7 +160,7 @@ class HomePage extends HookConsumerWidget { else const SizedBox.shrink(), if (visitedTabs.value.contains(2)) - SearchPage(tabReselection: searchReselection) + mePageBuilder?.call(context) ?? const SizedBox.shrink() else const SizedBox.shrink(), ]; @@ -202,6 +248,7 @@ class HomePage extends HookConsumerWidget { bottomNavigationBar: _FloatingTabBar( selectedIndex: tabIndex.value, hasUnreadInbox: hasUnreadInbox, + hasUnreadSolo: hasUnreadSolo, onDestinationSelected: (i) { if (i == tabIndex.value) { switch (i) { @@ -209,8 +256,6 @@ class HomePage extends HookConsumerWidget { homeReselection.value++; case 1: activityReselection.value++; - case 2: - searchReselection.value++; } return; } @@ -302,12 +347,14 @@ class _HomeDestination { class _FloatingTabBar extends StatelessWidget { final int selectedIndex; final bool hasUnreadInbox; + final bool hasUnreadSolo; final ValueChanged onDestinationSelected; final List<_HomeDestination> destinations; const _FloatingTabBar({ required this.selectedIndex, required this.hasUnreadInbox, + this.hasUnreadSolo = false, required this.onDestinationSelected, required this.destinations, }); @@ -373,7 +420,9 @@ class _FloatingTabBar extends StatelessWidget { child: _FloatingTabDestination( destination: destinations[i], selected: i == safeSelectedIndex, - showUnreadBadge: i == 1 && hasUnreadInbox, + showUnreadBadge: + (i == 1 && hasUnreadInbox) || + (i == 2 && hasUnreadSolo), onTap: () => onDestinationSelected(i), ), ), @@ -522,7 +571,9 @@ class _FloatingTabDestination extends StatelessWidget { top: 0, right: 0, child: AnimatedScale( - key: const ValueKey('activity-tab-unread-dot-scale'), + key: ValueKey( + '${destination.label.toLowerCase()}-tab-unread-dot-scale', + ), scale: selected ? 0 : 1, alignment: const Alignment(-0.5, 0.5), duration: reducedMotion @@ -530,7 +581,9 @@ class _FloatingTabDestination extends StatelessWidget { : HomePage._tabUnreadBadgeDuration, curve: Curves.easeOutCubic, child: Container( - key: const ValueKey('activity-tab-unread-dot'), + key: ValueKey( + '${destination.label.toLowerCase()}-tab-unread-dot', + ), width: 12, height: 12, padding: const EdgeInsets.all(2), diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index b11947e00b..11e0328cfd 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -452,7 +452,12 @@ class _ProfileAvatar extends HookWidget { borderRadius: BorderRadius.circular( constraints.biggest.shortestSide * 0.3, ), - child: avatar, + child: !usesInsetAgentArtwork(avatarUrl) + ? avatar + : Transform.scale( + scale: animatedAvatarArtworkScale, + child: avatar, + ), ); }, ), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index f5adadee6c..84746b8c5b 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -11,9 +11,8 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/buzz_search_field.dart'; import '../../shared/widgets/filter_chip_bar.dart'; -import '../../shared/widgets/frosted_app_bar.dart'; -import '../../shared/widgets/frosted_scaffold.dart'; -import '../../shared/widgets/ios_navigation_metrics.dart'; +import '../../shared/widgets/concentric_sheet_surface.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; @@ -34,49 +33,12 @@ part 'search_page/message_tile.dart'; enum _SearchFilter { all, messages, channels, people } -const _searchFieldMinHeight = 36.0; -const _searchIdleFieldHeight = 45.0; +const _searchIdleFieldHeight = 48.0; const _searchIdleTextSize = 15.0; const _searchFieldVerticalPadding = Grid.xxs; -const _searchFieldMoveDuration = Duration(milliseconds: 160); -const _searchTitleReturnDuration = Duration(milliseconds: 80); -const _searchCancelEnterDuration = Duration(milliseconds: 80); -const _searchCancelExitDuration = Duration(milliseconds: 60); -const _searchIdleFieldTopInset = Grid.half; -const _searchControlsToFiltersGap = Grid.xxs; const _searchFilterChipVerticalPadding = Grid.xxs; const _searchFilterBarVerticalPadding = Grid.xxs; const _searchHeaderFiltersMinHeight = Grid.xl; -const _searchActiveFieldRightInsetMin = 72.0; - -/// Reserves the Cancel action's scaled label, padding, and app-bar edge inset. -double _searchActiveFieldRightInset(BuildContext context) { - final textPainter = TextPainter( - text: TextSpan( - text: 'Cancel', - style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w500), - ), - textScaler: MediaQuery.textScalerOf(context), - textDirection: Directionality.of(context), - )..layout(); - final cancelWidth = - textPainter.width + Grid.half * 2 + Grid.gutter + Grid.xxs; - return cancelWidth > _searchActiveFieldRightInsetMin - ? cancelWidth - : _searchActiveFieldRightInsetMin; -} - -double _searchFieldHeight(BuildContext context) { - const style = searchInputTextStyle; - final scaledFontSize = MediaQuery.textScalerOf( - context, - ).scale(style.fontSize ?? 15); - final contentHeight = - scaledFontSize * (style.height ?? 1) + _searchFieldVerticalPadding * 2; - return contentHeight > _searchFieldMinHeight - ? contentHeight - : _searchFieldMinHeight; -} double _idleSearchFieldHeight(BuildContext context) { final scaledFontSize = MediaQuery.textScalerOf( @@ -116,105 +78,42 @@ class SearchPage extends HookConsumerWidget { .whenData((value) => value?.pubkey) .value; final activeFilter = useState(_SearchFilter.all); - final textController = useTextEditingController(); + final textController = useTextEditingController(text: searchState.query); final focusNode = useFocusNode(); - final isSearchEditing = useState(false); - final showSearchTitle = useState(true); - final isTabActivationInFlight = useRef(false); - final reduceMotion = MediaQuery.disableAnimationsOf(context); + final nativeIos = defaultTargetPlatform == TargetPlatform.iOS; final searchSurfaceColor = navigationSearchSurface(context); final searchPrimaryColor = navigationPrimaryForeground(context); final searchPlaceholderColor = navigationSecondaryForeground(context); - final headerTitleStyle = context.textTheme.titleMedium?.copyWith( - fontSize: 22, - fontWeight: FontWeight.w600, - ); - final compactSearchFieldHeight = _searchFieldHeight(context); - final idleSearchFieldHeight = _idleSearchFieldHeight(context); - final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context); - final searchActiveFieldRightInset = _searchActiveFieldRightInset(context); - final nativeIos = defaultTargetPlatform == TargetPlatform.iOS; - final searchBottomOverlap = nativeIos - ? IosNavigationMetrics.of(context).compactHeight - : _searchIdleFieldTopInset + - compactSearchFieldHeight + - _searchControlsToFiltersGap; - final activeFieldTop = nativeIos - ? ((searchBottomOverlap - compactSearchFieldHeight) / 2).clamp( - 0.0, - double.infinity, - ) - : _searchIdleFieldTopInset; - final filtersTop = nativeIos - ? activeFieldTop + - compactSearchFieldHeight + - _searchControlsToFiltersGap - : searchBottomOverlap; - // Cancel remains an accessible target without giving the text action a - // visual button treatment. - final searchControlHeight = compactSearchFieldHeight > Grid.xl - ? compactSearchFieldHeight - : Grid.xl; - final searchHeaderBottomHeight = isSearchEditing.value - ? filtersTop - - searchBottomOverlap + - searchHeaderFiltersHeight + - _searchControlsToFiltersGap - : idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs; - final topSectionHeight = frostedAppBarHeight( - context, - titleStyle: headerTitleStyle, - bottomHeight: searchHeaderBottomHeight, - ); + final fieldHeight = _idleSearchFieldHeight(context); + final filtersHeight = nativeIos + ? (MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16).clamp( + 48.0, + double.infinity, + ) + + Grid.xxs * 2 + : _searchHeaderFiltersHeight(context); - void activateSearch() { - showSearchTitle.value = false; - isSearchEditing.value = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted && isSearchEditing.value) { - focusNode.requestFocus(); - } - }); - } - - void deactivateSearch() { - if (!isSearchEditing.value) return; - // The field is painted above the title while it returns to its idle - // position. Start this fade now so the title is already there beneath it - // and is revealed by the field's motion instead of arriving afterward. - showSearchTitle.value = true; - isSearchEditing.value = false; - } + void activateSearch() => focusNode.requestFocus(); useEffect(() { - final tabReselection = this.tabReselection; - if (tabReselection == null) return null; - + final reselection = tabReselection; + if (reselection == null) return null; void reactivateSearch() { - // The same tab gesture can report focus loss after this callback. Keep - // that notification from starting a competing return animation while - // the normal field activation path restores focus. - isTabActivationInFlight.value = true; - activateSearch(); + focusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { - isTabActivationInFlight.value = false; + if (context.mounted) focusNode.requestFocus(); }); + WidgetsBinding.instance.ensureVisualUpdate(); } - tabReselection.addListener(reactivateSearch); - return () => tabReselection.removeListener(reactivateSearch); + reselection.addListener(reactivateSearch); + return () => reselection.removeListener(reactivateSearch); }, [tabReselection, focusNode]); - useEffect(() { - void resetIdlePromptWhenFocusLeaves() { - if (!focusNode.hasFocus && !isTabActivationInFlight.value) { - deactivateSearch(); - } - } - - focusNode.addListener(resetIdlePromptWhenFocusLeaves); - return () => focusNode.removeListener(resetIdlePromptWhenFocusLeaves); - }, [focusNode]); + void closeSearch() { + focusNode.unfocus(); + Navigator.of(context).maybePop(); + } void runRecentSearch(String query) { textController.value = TextEditingValue( @@ -226,265 +125,119 @@ class SearchPage extends HookConsumerWidget { ref.read(searchProvider.notifier).search(query); } - return FrostedScaffold( - nativePinnedBody: true, + return Scaffold( backgroundColor: context.colors.surface, - // Keep the empty state centered in the page rather than the portion left - // above the keyboard. resizeToAvoidBottomInset: false, - appBar: FrostedAppBar( - nativeTitle: isSearchEditing.value ? '' : 'Search', - nativeLayoutDuration: reduceMotion - ? Duration.zero - : _searchFieldMoveDuration, - nativeLargeTitle: !isSearchEditing.value, - nativeActions: const [], - automaticallyImplyLeading: false, - horizontalInset: Grid.twelve, - showBottomDivider: true, - bottomDividerOpacity: 0.07, - titleStyle: headerTitleStyle, - // Keep this mounted through the search-field morph so it can fade in - // beneath the returning field rather than popping in afterward. - title: IgnorePointer( - child: AnimatedOpacity( - key: const Key('search-header-title-opacity'), - duration: reduceMotion ? Duration.zero : _searchTitleReturnDuration, - curve: Curves.easeOutCubic, - opacity: showSearchTitle.value ? 1 : 0, - child: Text( - 'Search', - key: const ValueKey('search-header-title'), - style: headerTitleStyle, - ), - ), - ), - actions: [ - AnimatedSwitcher( - duration: reduceMotion ? Duration.zero : _searchCancelEnterDuration, - reverseDuration: reduceMotion - ? Duration.zero - : _searchCancelExitDuration, - transitionBuilder: (child, animation) { - final curvedAnimation = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - return SizeTransition( - sizeFactor: curvedAnimation, - axis: Axis.horizontal, - axisAlignment: 1, - child: FadeTransition( - opacity: curvedAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.35, 0), - end: Offset.zero, - ).animate(curvedAnimation), - child: child, - ), - ), - ); - }, - child: isSearchEditing.value - ? Semantics( - key: const Key('search-cancel'), - button: true, - label: 'Cancel search', - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - textController.clear(); - ref.read(searchProvider.notifier).clear(); - deactivateSearch(); - focusNode.unfocus(); - }, - child: SizedBox( - height: searchControlHeight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.half, - ), - child: Center( - child: Text( - 'Cancel', - style: filterChipTextStyle.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ), - ), - ) - : const SizedBox.shrink(key: ValueKey('search-cancel-hidden')), - ), - ], - bottomHeight: searchHeaderBottomHeight, - bottomOverlap: searchBottomOverlap, - bottom: Stack( - clipBehavior: Clip.none, + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AnimatedPositioned( - duration: reduceMotion ? Duration.zero : _searchFieldMoveDuration, - curve: Curves.easeInOutCubic, - left: Grid.gutter, - right: isSearchEditing.value - ? searchActiveFieldRightInset - : Grid.gutter, - top: isSearchEditing.value - ? activeFieldTop - : searchBottomOverlap + _searchIdleFieldTopInset, - height: isSearchEditing.value - ? compactSearchFieldHeight - : idleSearchFieldHeight, - // Do not key this subtree by the visual state: replacing the - // TextField immediately after its first tap can detach its - // native input connection before the keyboard is shown. - child: SizedBox( - key: const Key('search-field-container'), - child: BuzzSearchField( - controller: textController, - focusNode: focusNode, - hintText: 'Search messages, channels, and people', - iconColor: searchPrimaryColor, - inputColor: searchPrimaryColor, - placeholderColor: searchPlaceholderColor, - surfaceColor: searchSurfaceColor, - isEditing: isSearchEditing.value, - reduceMotion: reduceMotion, - motionDuration: _searchFieldMoveDuration, - onTap: activateSearch, - onChanged: (value) => - ref.read(searchProvider.notifier).search(value), - onSubmitted: (value) { - final query = value.trim(); - if (query.isEmpty) return; - ref.read(recentSearchesProvider.notifier).record(query); - }, - ), + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.half, + Grid.gutter, + Grid.xxs, ), - ), - if (nativeIos && isSearchEditing.value) - Positioned( - top: activeFieldTop, - right: Grid.gutter, - width: searchActiveFieldRightInset - Grid.gutter - Grid.xxs, - height: searchControlHeight, - child: Semantics( - button: true, - label: 'Cancel search', - child: GestureDetector( - key: const Key('search-ios-cancel'), - behavior: HitTestBehavior.opaque, - onTap: () { - textController.clear(); - ref.read(searchProvider.notifier).clear(); - deactivateSearch(); - focusNode.unfocus(); - }, - child: Align( - alignment: Alignment.topRight, - child: SizedBox( - height: compactSearchFieldHeight, - child: Align( - alignment: Alignment.centerRight, - child: Text( - 'Cancel', - style: filterChipTextStyle.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w500, - ), - ), + child: Row( + children: [ + Expanded( + child: SizedBox( + key: const Key('search-field-container'), + height: fieldHeight, + child: ConcentricSheetSurface( + enabled: nativeIos, + usesGlass: true, + providesSheetSurface: false, + padding: EdgeInsets.zero, + color: searchSurfaceColor, + minimumRadius: fieldHeight / 2, + contentClipRadius: fieldHeight / 2, + child: BuzzSearchField( + controller: textController, + focusNode: focusNode, + hintText: 'Search messages, channels, and people', + iconColor: searchPrimaryColor, + inputColor: searchPrimaryColor, + placeholderColor: searchPlaceholderColor, + surfaceColor: nativeIos + ? Colors.transparent + : searchSurfaceColor, + isEditing: false, + reduceMotion: true, + motionDuration: Duration.zero, + onTap: activateSearch, + onChanged: (value) => + ref.read(searchProvider.notifier).search(value), + onSubmitted: (value) { + final query = value.trim(); + if (query.isNotEmpty) { + ref + .read(recentSearchesProvider.notifier) + .record(query); + } + }, ), ), ), ), - ), - ), - Positioned.fill( - child: AnimatedSwitcher( - duration: reduceMotion - ? Duration.zero - : _searchCancelEnterDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.2), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ), - child: isSearchEditing.value - ? Align( - alignment: Alignment.topCenter, - child: Padding( - padding: EdgeInsets.only(top: filtersTop), - child: SizedBox( - key: const ValueKey('search-header-filters'), - height: searchHeaderFiltersHeight, - child: FilterChipBar<_SearchFilter>( - expandItems: true, - visualDensity: const VisualDensity( - horizontal: -2, - ), - chipVerticalPadding: - _searchFilterChipVerticalPadding, - barVerticalPadding: - _searchFilterBarVerticalPadding, - selected: activeFilter.value, - onSelected: (f) => activeFilter.value = f, - items: [ - for (final f in _SearchFilter.values) - FilterChipItem(id: f, label: f.label), - ], - ), - ), + const SizedBox(width: Grid.xxs), + if (nativeIos) + IosGlassNavigationButton( + key: const Key('search-close'), + icon: IosGlassNavigationIcon.close, + semanticLabel: 'Close search', + onPressed: closeSearch, + controlSize: 48, + foregroundColor: searchPrimaryColor, + ) + else + SizedBox.square( + key: const Key('search-close'), + dimension: 48, + child: IconButton( + tooltip: 'Close search', + onPressed: closeSearch, + style: IconButton.styleFrom( + backgroundColor: searchSurfaceColor, + foregroundColor: searchPrimaryColor, ), - ) - : const SizedBox.shrink( - key: ValueKey('search-header-filters-hidden'), + icon: const Icon(BuzzIcons.x), ), + ), + ], + ), + ), + SizedBox( + key: const ValueKey('search-header-filters'), + height: filtersHeight, + child: FilterChipBar<_SearchFilter>( + useNativeGlass: nativeIos, + expandItems: true, + visualDensity: const VisualDensity(horizontal: -2), + chipVerticalPadding: _searchFilterChipVerticalPadding, + barVerticalPadding: _searchFilterBarVerticalPadding, + selected: activeFilter.value, + onSelected: (filter) => activeFilter.value = filter, + items: [ + for (final filter in _SearchFilter.values) + FilterChipItem(id: filter, label: filter.label), + ], + ), + ), + const SizedBox(height: Grid.xxs), + Expanded( + child: _SearchBody( + state: searchState, + filter: activeFilter.value, + currentPubkey: currentPubkey, + onRecentSearchSelected: runRecentSearch, ), ), ], ), ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(height: topSectionHeight), - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(Radii.dialog), - ), - child: Material( - color: context.colors.surface, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: _SearchBody( - state: searchState, - filter: activeFilter.value, - currentPubkey: currentPubkey, - onRecentSearchSelected: runRecentSearch, - ), - ), - ], - ), - ), - ), - ), - ], - ), ); } } @@ -706,7 +459,7 @@ class _RecentSearches extends StatelessWidget { } } -class _ChannelsSection extends StatelessWidget { +class _ChannelsSection extends ConsumerWidget { final List channels; final VoidCallback onResultSelected; const _ChannelsSection({ @@ -714,7 +467,7 @@ class _ChannelsSection extends StatelessWidget { required this.onResultSelected, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -762,11 +515,7 @@ class _ChannelsSection extends StatelessWidget { : null, onTap: () { onResultSelected(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), - ), - ); + _openSearchChannel(context, channel); }, ), ], @@ -889,3 +638,22 @@ extension on _SearchFilter { _SearchFilter.people => 'People', }; } + +/// Let the destination resolve placement once, including on a fresh launch. +Future _openSearchChannel(BuildContext context, Channel channel) async { + try { + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + } catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Couldn’t open this conversation. Try again.'), + ), + ); + } +} diff --git a/mobile/lib/features/search/search_page/message_tile.dart b/mobile/lib/features/search/search_page/message_tile.dart index 720b5a84fb..ca2fcf5ba6 100644 --- a/mobile/lib/features/search/search_page/message_tile.dart +++ b/mobile/lib/features/search/search_page/message_tile.dart @@ -146,12 +146,17 @@ class _MessageTile extends ConsumerWidget { ), onTap: () { onResultSelected(); - _navigateToHit(context, hit, channel); + _navigateToHit(context, ref, hit, channel); }, ); } - void _navigateToHit(BuildContext context, SearchHit hit, Channel? channel) { + void _navigateToHit( + BuildContext context, + WidgetRef ref, + SearchHit hit, + Channel? channel, + ) { if (channel == null) return; if (hit.kind == 45001) { @@ -167,11 +172,7 @@ class _MessageTile extends ConsumerWidget { ), ); } else { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), - ), - ); + _openSearchChannel(context, channel); } } } diff --git a/mobile/lib/shared/animated_avatar.dart b/mobile/lib/shared/animated_avatar.dart index f44321746e..5393628e60 100644 --- a/mobile/lib/shared/animated_avatar.dart +++ b/mobile/lib/shared/animated_avatar.dart @@ -1,3 +1,6 @@ +/// Artwork scale inside an animated agent's unchanged avatar frame. +const animatedAvatarArtworkScale = 0.85; + // Animated avatar URL scheme shared with the desktop client. // // The static poster is the normal selected frame. The animated PNG URL lives @@ -61,3 +64,14 @@ bool _isHttpUrl(String value) { (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty; } + +/// Character presets can be saved as plain poster URLs without buzz-anim. +/// Keep photographs and ordinary emoji avatars at their existing size. +bool usesInsetAgentArtwork(String? url) { + if (parseAnimatedAvatarUrl(url) != null) return true; + final uri = url == null ? null : Uri.tryParse(url); + return uri != null && + uri.scheme == 'https' && + uri.host == 'dwwgwmfqqjotj.cloudfront.net' && + RegExp(r'^/avatars/[^/]+/poster/[^/]+/[^/]+\.png$').hasMatch(uri.path); +} diff --git a/mobile/lib/shared/crypto/nip_oa.dart b/mobile/lib/shared/crypto/nip_oa.dart index f9d00ef1c5..6f89116757 100644 --- a/mobile/lib/shared/crypto/nip_oa.dart +++ b/mobile/lib/shared/crypto/nip_oa.dart @@ -3,6 +3,35 @@ import 'dart:typed_data'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import '../relay/nostr_models.dart'; + +/// Verify the signed profile and all owner-attestation conditions against it. +String? verifiedProfileOaOwnerPubkey(NostrEvent event) { + if (event.kind != 0) return null; + final tags = event.tags + .where((tag) => tag.isNotEmpty && tag[0] == 'auth') + .toList(); + if (tags.length != 1 || tags.single.length != 4) return null; + try { + nostr.Event.fromJson(jsonEncode(event.toJson())); + } catch (_) { + return null; + } + final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); + if (owner == null) return null; + final conditions = tags.single[2]; + for (final clause + in conditions.isEmpty ? [] : conditions.split('&')) { + if (clause.startsWith('kind=')) { + if (int.parse(clause.substring(5)) != event.kind) return null; + } else if (clause.startsWith('created_at<')) { + if (event.createdAt >= int.parse(clause.substring(11))) return null; + } else if (clause.startsWith('created_at>')) { + if (event.createdAt <= int.parse(clause.substring(11))) return null; + } + } + return owner; +} /// NIP-OA (Owner Attestation) β€” verify the `auth` tag on a kind:0 profile /// that proves an owner key authorized an agent key. @@ -15,15 +44,23 @@ import 'package:pointycastle/digests/sha256.dart'; /// verified against the profile event author, so a forged or stale marker /// cannot turn a person into an agent. /// -/// Returns the owner pubkey (lowercase hex) for the first valid auth tag, +/// Low-level tag verification. Use verifiedProfileOaOwnerPubkey for admission. +/// Returns the owner pubkey (lowercase hex) for exactly one valid auth tag, /// or null if none verifies. String? verifiedOaOwnerPubkey(List> tags, String agentPubkey) { final agent = agentPubkey.toLowerCase(); + if (tags.where((tag) => tag.isNotEmpty && tag[0] == 'auth').length != 1) { + return null; + } for (final tag in tags) { if (tag.length != 4 || tag[0] != 'auth') continue; - final owner = tag[1].toLowerCase(); + final owner = tag[1]; + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(owner) || + !RegExp(r'^[0-9a-f]{128}$').hasMatch(tag[3])) { + continue; + } final conditions = tag[2]; final sig = tag[3]; diff --git a/mobile/lib/shared/me/me_agent_change.dart b/mobile/lib/shared/me/me_agent_change.dart new file mode 100644 index 0000000000..4a727e5768 --- /dev/null +++ b/mobile/lib/shared/me/me_agent_change.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:uuid/uuid.dart'; + +import '../crypto/nip44.dart'; +import 'me_sync.dart'; +import 'me_journal.dart'; + +/// Durable intent written before granting a selected agent channel access. +class MeAgentChange { + /// Agent selected by the user. + final String agent; + final String _scope; + final String _record; + + MeAgentChange._(this.agent, this._scope, this._record); + + /// Scope both the journal and active preference to this chat and identity. + static String selectionKey(MeSync sync, String channel) => + 'me-assistant:${sync.community}:${sync.pubkey}:$channel'; + + /// Create a distinct reviewed intent, even when choosing the same agent again. + factory MeAgentChange.create(MeSync sync, String channel, String agent) => + MeAgentChange._( + agent, + selectionKey(sync, channel), + nip44Encrypt( + sync.key, + jsonEncode({'agent': agent, 'revision': const Uuid().v4()}), + ), + ); + + /// Restore locally without needing a relay connection. + static MeAgentChange? pending(MeSync sync, String channel) { + final scope = selectionKey(sync, channel); + final record = sync.prefs?.getString('$scope:pending'); + if (record == null) return null; + final value = jsonDecode(nip44Decrypt(sync.key, record)) as Map; + return MeAgentChange._(value['agent'] as String, scope, record); + } + + void _check(MeSync sync, String channel) { + if (sync.disposed || _scope != selectionKey(sync, channel)) { + throw StateError( + 'Your account or community changed. Open the chat again.', + ); + } + } + + /// Save before network reads or writes; refuse to overwrite another intent. + Future save(MeSync sync, String channel) async { + _check(sync, channel); + final existing = sync.prefs?.getString('$_scope:pending'); + if (existing != null && existing != _record) { + throw StateError('Resume your unfinished agent change first.'); + } + if (sync.prefs == null || + !await sync.prefs!.setString('$_scope:pending', _record)) { + throw StateError('Could not save your agent choice. Try again.'); + } + _check(sync, channel); + } + + /// Compare the exact reviewed record before clearing it. + Future discard(MeSync sync, String channel) async { + _check(sync, channel); + if (sync.prefs?.getString('$_scope:pending') != _record) { + throw StateError('The pending agent change changed. Review it again.'); + } + await clearMeJournal( + sync.prefs!, + '$_scope:pending', + _record, + checkScope: () => _check(sync, channel), + ); + } + + /// Reconcile membership before committing the preference. Ambiguous results + /// retain the journal so a restart can check membership instead of adding twice. + Future complete( + MeSync sync, + String channel, { + required Future Function() ensureMembership, + }) async { + await save(sync, channel); + sync.check(); + await ensureMembership(); + sync.check(); + _check(sync, channel); + if (sync.prefs?.getString('$_scope:pending') != _record) { + throw StateError('The pending agent change changed. Review it again.'); + } + if (!await sync.prefs!.setString(_scope, agent)) { + throw StateError('Could not save your agent choice. Retry to finish.'); + } + await discard(sync, channel); + } +} diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart new file mode 100644 index 0000000000..03575adfe1 --- /dev/null +++ b/mobile/lib/shared/me/me_assistants.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:nostr/nostr.dart' as nostr; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../crypto/nip_oa.dart'; +import '../mentions/agent_identity_provider.dart'; +import '../relay/relay.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. +final meAssistantsProvider = FutureProvider>(( + ref, +) async { + final sync = ref.watch(meSyncProvider); + if (sync.connectionState != null) { + ref.watch(relaySessionProvider.select((state) => state.status)); + } + final session = ref.watch(relaySessionProvider.notifier); + return _loadAssistants(sync, session); +}, retry: (_, _) => null); + +/// Freshness-controlled ownership check for one personal-chat recipient. +final meAssistantOwnershipProvider = FutureProvider.family(( + ref, + pubkey, +) { + final sync = ref.watch(meSyncProvider); + if (sync.connectionState != null) { + ref.watch(relaySessionProvider.select((state) => state.status)); + } + return isOwnedMeAssistant(sync, pubkey); +}); + +/// Revalidates only the chosen assistant before creating a chat. +Future isOwnedMeAssistant(MeSync sync, String pubkey) async => + (await _loadAssistants( + sync, + sync.session, + pubkey: pubkey, + )).any((agent) => agent.pubkey == pubkey); + +Future> _loadAssistants( + 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++) { + sync.check(); + final events = await session.queryRelay([ + NostrFilter( + kinds: const [0], + authors: pubkey == null ? null : [pubkey], + limit: pageSize, + extensions: {'page': page}, + ), + ]); + 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.'); + } + } + final agents = []; + for (final event in profiles.values) { + try { + if (verifiedProfileOaOwnerPubkey(event) == sync.pubkey) { + agents.add(AgentDirectoryEntry.fromEvent(event)); + } + } catch (_) { + // A malformed profile must not hide unrelated verified agents. + continue; + } + } + return agents; +} diff --git a/mobile/lib/shared/me/me_chat_creation.dart b/mobile/lib/shared/me/me_chat_creation.dart new file mode 100644 index 0000000000..74f0488db8 --- /dev/null +++ b/mobile/lib/shared/me/me_chat_creation.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; +import '../crypto/nip44.dart'; +import 'me_sync.dart'; +import 'me_journal.dart'; + +/// Durable, encrypted intent reused after a failed or interrupted chat creation. +class MeChatCreation { + /// Stable channel UUID, reused for every retry. + final String id; + + /// User-supplied name frozen before the first relay mutation. + final String name; + + /// Selected agent public key. + final String agent; + + /// Create a new intent; persistence happens before [complete] performs writes. + const MeChatCreation(this.id, this.name, this.agent); + + /// Account and community boundary for durable creation storage. + static String storageKey(MeSync sync) => + 'me-chat-creation:${sync.community}:${sync.pubkey}'; + + /// Restore an unfinished intent without performing relay writes. + static MeChatCreation? pending(MeSync sync) { + final saved = sync.prefs?.getString(storageKey(sync)); + if (saved == null) return null; + final value = + jsonDecode(nip44Decrypt(sync.key, saved)) as Map; + return MeChatCreation( + value['id'] as String, + value['name'] as String, + value['agent'] as String, + ); + } + + /// Persist before any relay mutation. Do not replace another pending intent. + Future save(MeSync sync) async { + if (sync.disposed) throw StateError('Your community changed. Try again.'); + final previous = pending(sync); + if (previous != null && + (previous.id != id || + previous.name != name || + previous.agent != agent)) { + throw StateError('Resume the pending chat before starting another.'); + } + if (sync.prefs == null || + !await sync.prefs!.setString( + storageKey(sync), + nip44Encrypt( + sync.key, + jsonEncode({'id': id, 'name': name, 'agent': agent}), + ), + )) { + throw StateError('Couldn’t save this chat on your phone. Try again.'); + } + if (sync.disposed) throw StateError('Your community changed. Try again.'); + } + + /// Abandon only this reviewed intent, preserving any relay channel/messages. + /// Partial channels remain accessible from Home (or Solo if already placed). + Future discard(MeSync sync) async { + if (sync.disposed) throw StateError('Your community changed. Try again.'); + final current = pending(sync); + if (current?.id != id || current?.name != name || current?.agent != agent) { + throw StateError('The unfinished chat changed. Review it again.'); + } + await clearMeJournal( + sync.prefs!, + storageKey(sync), + sync.prefs!.getString(storageKey(sync))!, + checkScope: () { + if (sync.disposed) { + throw StateError('Your community changed. Try again.'); + } + }, + ); + } + + /// Keep the journal through every relay step, including Me placement. + Future complete( + MeSync sync, { + required Future Function() findChannel, + required Future Function() createChannel, + required Future Function() hasAgent, + required Future Function() addAgent, + required Future Function() isPlaced, + required Future Function() place, + }) async { + await save(sync); + final record = sync.prefs!.getString(storageKey(sync))!; + sync.check(); + final existing = await findChannel(); + sync.check(); + final result = existing ?? await createChannel(); + sync.check(); + final member = await hasAgent(); + sync.check(); + if (!member) await addAgent(); + sync.check(); + final placed = await isPlaced(); + sync.check(); + // Placement may be confirmed while its inner journal cleanup failed. + if (!placed || sync.prefs?.getString(sync.pendingKey) != null) { + await place(); + } + sync.check(); + await clearMeJournal( + sync.prefs!, + storageKey(sync), + record, + checkScope: sync.check, + ); + return result; + } +} diff --git a/mobile/lib/shared/me/me_groups.dart b/mobile/lib/shared/me/me_groups.dart new file mode 100644 index 0000000000..117b4dc4f1 --- /dev/null +++ b/mobile/lib/shared/me/me_groups.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +import 'package:characters/characters.dart'; + +/// Desktop-compatible personal project and chat placement record. +class MeGroups { + static const tag = 'buzz-me-kit-v1'; + final List> groups; + final Map assignments; + final List channels; + + const MeGroups({ + this.groups = const [], + this.assignments = const {}, + this.channels = const [], + }); + + static String coordinate(String community) => + '$tag:${Uri.encodeComponent(community)}:groups:me'; + + factory MeGroups.decode(String plaintext, String community) { + if (utf8.encode(plaintext).length > 16384) { + throw const FormatException('Solo storage is full'); + } + final raw = jsonDecode(plaintext) as Map; + final value = raw['value'] as Map; + if (raw['version'] != 1 || + raw['community'] != community || + raw['deleted'] is! bool || + value['type'] != 'groups' || + value['id'] != 'me') { + throw const FormatException('Unsupported Solo record'); + } + final groups = (value['groups'] as List) + .map((g) => Map.from(g as Map)) + .toList(); + final assignments = Map.from(value['assignments'] as Map); + final channels = List.from(value['channels'] as List? ?? []); + final identifier = RegExp(r'^[a-zA-Z0-9_-]{1,128}$'); + final channelId = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + ); + final ids = groups.map((g) => g['id']).toSet(); + if (groups.length > 100 || + ids.length != groups.length || + groups.any( + (g) => + g['id'] is! String || + !identifier.hasMatch(g['id'] as String) || + g['name'] is! String || + (g['name'] as String).trim().isEmpty || + (g['name'] as String).characters.length > 120 || + g['defaultTemplateId'] is! String || + (g['defaultTemplateId'] != '' && + !identifier.hasMatch(g['defaultTemplateId'] as String)), + ) || + assignments.length > 1000 || + assignments.entries.any( + (e) => !channelId.hasMatch(e.key) || !ids.contains(e.value), + ) || + channels.length > 1000 || + channels.toSet().length != channels.length || + channels.any((id) => !channelId.hasMatch(id))) { + throw const FormatException('Invalid Solo projects'); + } + if (raw['deleted'] == true) return const MeGroups(); + return MeGroups( + groups: groups, + assignments: assignments, + channels: channels, + ); + } + + String encode(String community) { + final result = jsonEncode({ + 'version': 1, + 'community': community, + 'deleted': false, + 'value': { + 'type': 'groups', + 'id': 'me', + 'groups': groups, + 'assignments': assignments, + 'channels': channels, + }, + }); + MeGroups.decode(result, community); + return result; + } + + MeGroups place(String channel, {String? group}) { + if (group != null && !groups.any((g) => g['id'] == group)) { + throw StateError('This project changed. Refresh and try again.'); + } + return MeGroups( + groups: groups, + assignments: {...assignments, channel: ?group}, + channels: [...channels, if (!channels.contains(channel)) channel], + ); + } +} diff --git a/mobile/lib/shared/me/me_journal.dart b/mobile/lib/shared/me/me_journal.dart new file mode 100644 index 0000000000..53e496a1ce --- /dev/null +++ b/mobile/lib/shared/me/me_journal.dart @@ -0,0 +1,28 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Remove only the reviewed record and restore memory after failed disk removal. +Future clearMeJournal( + SharedPreferences storage, + String key, + String expected, { + required void Function() checkScope, +}) async { + await storage.reload(); + checkScope(); + final current = storage.getString(key); + if (current == null) return; + if (current != expected) { + throw StateError('The pending action changed. Review it again.'); + } + try { + if (!await storage.remove(key)) { + throw StateError('Local cleanup failed.'); + } + } catch (_) { + // SharedPreferences removes the memory entry before the disk result arrives. + await storage.reload(); + throw StateError( + 'Couldn’t clear the pending action on this phone. Retry to finish.', + ); + } +} diff --git a/mobile/lib/shared/me/me_project_creation.dart b/mobile/lib/shared/me/me_project_creation.dart new file mode 100644 index 0000000000..2503e3366c --- /dev/null +++ b/mobile/lib/shared/me/me_project_creation.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import '../crypto/nip44.dart'; +import 'me_groups.dart'; +import 'me_sync.dart'; +import 'me_journal.dart'; + +/// An accepted project name and stable ID, retained until confirmed or discarded. +class MeProjectCreation { + /// Stable UUID reused across restarts and retries. + final String id; + + /// Reviewed name retained before the first network dependency. + final String name; + + /// Build an intent; [complete] persists it before syncing. + const MeProjectCreation(this.id, this.name); + + /// Isolate private drafts by both account and community. + static String storageKey(MeSync sync) => + 'me-project-creation:${sync.community}:${sync.pubkey}'; + + /// Restore without requiring connectivity. + static MeProjectCreation? pending(MeSync sync) { + final saved = sync.prefs?.getString(storageKey(sync)); + if (saved == null) return null; + final value = + jsonDecode(nip44Decrypt(sync.key, saved)) as Map; + return MeProjectCreation(value['id'] as String, value['name'] as String); + } + + void _checkScope(MeSync sync) { + if (sync.disposed) { + throw StateError('Your community changed. Open Solo again.'); + } + } + + /// Persist before connectivity checks or relay reads, including while offline. + Future save(MeSync sync) async { + _checkScope(sync); + final previous = pending(sync); + if (previous != null && (previous.id != id || previous.name != name)) { + throw StateError( + 'Review your unfinished project before starting another.', + ); + } + if (sync.prefs == null || + !await sync.prefs!.setString( + storageKey(sync), + nip44Encrypt(sync.key, jsonEncode({'id': id, 'name': name})), + )) { + throw StateError('Couldn’t save this project on your phone. Try again.'); + } + _checkScope(sync); + } + + /// Compare before removing the reviewed intent. Existing projects are kept. + Future discard(MeSync sync) async { + _checkScope(sync); + final current = pending(sync); + if (current?.id != id || current?.name != name) { + throw StateError('The unfinished project changed. Review it again.'); + } + await clearMeJournal( + sync.prefs!, + storageKey(sync), + sync.prefs!.getString(storageKey(sync))!, + checkScope: () => _checkScope(sync), + ); + } + + /// Retry using the same ID, preserving projects already confirmed on desktop. + Future complete(MeSync sync) async { + await save(sync); + final record = sync.prefs!.getString(storageKey(sync))!; + final head = await sync.head(); + final exists = + head != null && sync.decode(head).groups.any((g) => g['id'] == id); + if (!exists || sync.prefs?.getString(sync.pendingKey) != null) { + await sync.update( + (current) => current.groups.any((g) => g['id'] == id) + ? current + : MeGroups( + groups: [ + ...current.groups, + {'id': id, 'name': name, 'defaultTemplateId': ''}, + ], + assignments: current.assignments, + channels: current.channels, + ), + ); + } + await clearMeJournal( + sync.prefs!, + storageKey(sync), + record, + checkScope: () => _checkScope(sync), + ); + } +} diff --git a/mobile/lib/shared/me/me_recovery.dart b/mobile/lib/shared/me/me_recovery.dart new file mode 100644 index 0000000000..81392258a5 --- /dev/null +++ b/mobile/lib/shared/me/me_recovery.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; +import 'me_groups.dart'; + +/// Concurrent edits require an explicit user decision. +class MeRecoveryConflict implements Exception { + @override + String toString() => + 'Solo has conflicting edits on another device. Your pending change is still saved on this phone.'; +} + +/// Reapply only local edits to a newer desktop record. Concurrent edits to the +/// same field are surfaced, never silently overwritten. +MeGroups rebaseMeGroups(MeGroups base, MeGroups local, MeGroups remote) { + bool same(Object? a, Object? b) => jsonEncode(a) == jsonEncode(b); + Map merge( + Map before, + Map after, + Map current, + ) { + final result = {...current}; + for (final id in {...before.keys, ...after.keys}) { + if (same(before[id], after[id])) continue; + if (!same(current[id], before[id]) && !same(current[id], after[id])) { + throw MeRecoveryConflict(); + } + if (after.containsKey(id)) { + result[id] = after[id] as T; + } else { + result.remove(id); + } + } + return result; + } + + Map> groups(MeGroups value) => { + for (final group in value.groups) group['id'] as String: group, + }; + Map channels(MeGroups value) => { + for (final id in value.channels) id: true, + }; + return MeGroups( + groups: merge(groups(base), groups(local), groups(remote)).values.toList(), + assignments: merge(base.assignments, local.assignments, remote.assignments), + channels: merge( + channels(base), + channels(local), + channels(remote), + ).keys.toList(), + ); +} diff --git a/mobile/lib/shared/me/me_search_placement.dart b/mobile/lib/shared/me/me_search_placement.dart new file mode 100644 index 0000000000..e4a6504314 --- /dev/null +++ b/mobile/lib/shared/me/me_search_placement.dart @@ -0,0 +1,19 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'me_sync.dart'; + +/// Optional Me placement must not make ordinary search results inaccessible. +final meSearchPlacementProvider = FutureProvider.autoDispose + .family((ref, channelId) async { + final state = ref.read(meGroupsProvider); + final cached = state.value; + if (cached != null) return cached.channels.contains(channelId); + if (state.hasError) return false; + try { + final groups = await ref + .read(meGroupsProvider.future) + .timeout(const Duration(seconds: 2)); + return groups.channels.contains(channelId); + } catch (_) { + return false; + } + }); diff --git a/mobile/lib/shared/me/me_sync.dart b/mobile/lib/shared/me/me_sync.dart new file mode 100644 index 0000000000..00fc1c7bed --- /dev/null +++ b/mobile/lib/shared/me/me_sync.dart @@ -0,0 +1,516 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; +import '../theme/theme_provider.dart'; + +import '../crypto/nip44.dart'; +import '../relay/relay.dart'; +import 'me_groups.dart'; +import 'me_recovery.dart'; +import 'me_journal.dart'; + +/// A live, account/community-scoped reader for desktop's encrypted Solo record. +final meGroupsProvider = StreamProvider((ref) { + final service = ref.watch(meSyncProvider); + if (service.connectionState != null) { + ref.listen(relaySessionProvider.select((state) => state.status), ( + previous, + next, + ) { + if (previous != next && next == SessionStatus.connected) { + ref.invalidateSelf(); + } + }); + } + final controller = StreamController(); + var disposed = false; + void Function()? unsubscribe; + NostrEvent? latest; + var hasSnapshot = false; + var subscriptionClosed = false; + var syncFailed = false; + Future accept(NostrEvent event) async { + if (disposed || + (latest != null && + event.id != latest!.id && + !meEventIsNewer(event, latest!))) { + return; + } + try { + final value = service.decode(event); + // Reserve the highest verified head before any awaited persistence. + latest = event; + hasSnapshot = true; + controller.add(value); + await service.cache(event); + } catch (error, stack) { + if (!disposed) { + syncFailed = true; + service.syncClosed.value = true; + if (!hasSnapshot) controller.addError(error, stack); + } + } + } + + Future refresh() async { + try { + final remote = await service.head(); + final candidate = service.latestCacheCandidate; + final event = + candidate != null && + (remote == null || meEventIsNewer(candidate, remote)) + ? candidate + : remote; + if (disposed) return; + if (event != null) { + await accept(event); + } else if (latest == null) { + await service.cache(null); + if (disposed || latest != null) return; + hasSnapshot = true; + controller.add(const MeGroups()); + } + } catch (error, stack) { + if (!disposed) { + syncFailed = true; + service.syncClosed.value = true; + if (!hasSnapshot) controller.addError(error, stack); + } + } + } + + Future start() async { + try { + final candidate = service.latestCacheCandidate; + final cached = candidate == null + ? service.cachedSnapshot() + : (event: candidate, groups: service.decode(candidate)); + if (cached != null) { + latest = cached.event; + hasSnapshot = true; + controller.add(cached.groups); + } + } catch (_) { + // A corrupt cache must not prevent a fresh relay read. + } + try { + service.check(); + unsubscribe = await service.session.subscribe( + service.filter, + accept, + onClosed: (_) { + if (disposed) return; + subscriptionClosed = true; + service.syncClosed.value = true; + if (!hasSnapshot) { + controller.addError( + StateError('Couldn’t sync Solo. Reconnect and try again.'), + ); + } + }, + ); + if (disposed) { + unsubscribe?.call(); + return; + } + await refresh(); + if (!disposed && !subscriptionClosed && !syncFailed) { + service.syncClosed.value = false; + } + } catch (error, stack) { + if (!disposed) { + syncFailed = true; + service.syncClosed.value = true; + if (!hasSnapshot) controller.addError(error, stack); + } + } + } + + ref.listen(appLifecycleProvider, (_, next) { + if (next == AppLifecycleState.resumed) unawaited(refresh()); + }); + ref.onDispose(() { + disposed = true; + unsubscribe?.call(); + unawaited(controller.close()); + }); + unawaited(start()); + return controller.stream; +}, retry: (_, _) => null); + +/// Non-disruptive offline-save status, retained across reader retries. +final meOfflineSaveWarningProvider = StreamProvider((ref) { + final service = ref.watch(meSyncProvider); + final controller = StreamController(); + void changed() => controller.add(service.cacheWriteFailed.value); + service.cacheWriteFailed.addListener(changed); + changed(); + ref.onDispose(() { + service.cacheWriteFailed.removeListener(changed); + unawaited(controller.close()); + }); + return controller.stream; +}); + +/// Retryable live-sync interruption without hiding cached organization. +final meSyncWarningProvider = StreamProvider((ref) { + final service = ref.watch(meSyncProvider); + final controller = StreamController(); + void changed() => controller.add(service.syncClosed.value); + service.syncClosed.addListener(changed); + changed(); + ref.onDispose(() { + service.syncClosed.removeListener(changed); + unawaited(controller.close()); + }); + return controller.stream; +}); + +/// NIP-01 replacement ordering: smallest ID wins an equal timestamp. +bool meEventIsNewer(NostrEvent a, NostrEvent b) => + a.createdAt > b.createdAt || + (a.createdAt == b.createdAt && a.id.compareTo(b.id) < 0); + +final Provider meSyncProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final service = MeSync( + ref.read(relaySessionProvider.notifier), + config, + connectionState: () => + ref.read(relaySessionProvider).status == SessionStatus.connected, + prefs: ref.read(savedPrefsProvider), + ); + ref.onDispose(() => service.disposed = true); + return service; +}); + +/// Narrow private-recipe transport; never writes Messages organization. +class MeSync { + final RelaySessionNotifier session; + final RelayConfig config; + late final String community = Uri.parse(config.baseUrl).origin; + late final String privateHex = nostr.Nip19.decode(payload: config.nsec!).data; + late final String pubkey = nostr.Keys(privateHex).public; + late final key = getConversationKey(privateHex, pubkey); + final bool _connected; + + /// Live transport status without replacing account/community-scoped state. + final bool Function()? connectionState; + + /// Whether relay operations can currently be attempted. + bool get connected => connectionState?.call() ?? _connected; + final SharedPreferences? prefs; + String get pendingKey => 'me-pending:$community:$pubkey'; + bool disposed = false; + bool _saving = false; + + MeSync( + this.session, + this.config, { + bool connected = true, + this.connectionState, + this.prefs, + }) : _connected = connected; + + /// Encrypted last verified organization, isolated by account and community. + String get cacheKey => 'me-cache:$community:$pubkey'; + + /// Read while offline. A signed event preserves replacement ordering. + ({NostrEvent? event, MeGroups groups})? cachedSnapshot() { + if (disposed || config.nsec == null) return null; + final saved = prefs?.getString(cacheKey); + if (saved == null) return null; + final value = jsonDecode(nip44Decrypt(key, saved)) as Map; + if (value['community'] != community) { + throw const FormatException('Invalid cached organization'); + } + final json = value['event'] as Map?; + final event = json == null ? null : NostrEvent.fromJson(json); + return ( + event: event, + groups: event == null ? const MeGroups() : decode(event), + ); + } + + Future _cacheQueue = Future.value(); + bool _cacheDirty = false; + NostrEvent? _latestCacheCandidate; + + /// Highest verified candidate survives a reader retry after failed storage. + NostrEvent? get latestCacheCandidate => _latestCacheCandidate; + + /// False only after the newest candidate is durably written. + final cacheWriteFailed = ValueNotifier(false); + + /// A terminal close or invalid relay head requires explicit reader recovery. + final syncClosed = ValueNotifier(false); + + /// Serialize writes and report failures so callers can expose a retry. + Future cache(NostrEvent? event) { + if (event != null) decode(event); + var highest = _latestCacheCandidate; + try { + final persisted = cachedSnapshot()?.event; + if (persisted != null && + (highest == null || meEventIsNewer(persisted, highest))) { + highest = persisted; + } + } catch (_) { + // Verified relay data can replace an unreadable cache. + } + if (highest != null && + (event == null || + (highest.id != event.id && !meEventIsNewer(event, highest)))) { + _latestCacheCandidate = highest; + return Future.value(); + } + _latestCacheCandidate = event; + final operation = _cacheQueue.then((_) => _writeCache(event)); + _cacheQueue = operation.catchError((Object _) {}); + return operation; + } + + Future _writeCache(NostrEvent? event) async { + if (disposed || config.nsec == null || prefs == null) return; + final highest = _latestCacheCandidate; + if (highest != null && highest.id != event?.id) return; + if (event != null) decode(event); + try { + final previous = cachedSnapshot()?.event; + if (!_cacheDirty && + previous != null && + (event == null || !meEventIsNewer(event, previous))) { + return; + } + } catch (_) { + // Replace corrupt local storage with verified relay data. + } + try { + if (!await prefs!.setString( + cacheKey, + nip44Encrypt( + key, + jsonEncode({'community': community, 'event': event?.toJson()}), + ), + )) { + throw const MeCacheException(); + } + _cacheDirty = false; + if (_latestCacheCandidate?.id == event?.id) { + cacheWriteFailed.value = false; + } + } catch (_) { + // SharedPreferences updates its memory cache even if the disk write fails. + // Reload so Retry cannot mistake that optimistic value for durable data. + _cacheDirty = true; + if (_latestCacheCandidate?.id == event?.id) cacheWriteFailed.value = true; + try { + await prefs!.reload(); + } finally { + throw const MeCacheException(); + } + } + } + + NostrFilter get filter => NostrFilter( + kinds: const [30078], + authors: [pubkey], + tags: { + '#d': [MeGroups.coordinate(community)], + }, + limit: 1, + ); + + void check() { + if (disposed) throw StateError('Your community changed. Open Solo again.'); + if (!connected || config.nsec == null) { + throw StateError('Connect to your community to load Solo.'); + } + } + + MeGroups decode(NostrEvent event) { + if (event.kind != 30078 || + event.pubkey != pubkey || + event.tags.where((t) => t.isNotEmpty && t[0] == 'd').length != 1 || + !event.tags.any( + (t) => + t.length == 2 && + t[0] == 'd' && + t[1] == MeGroups.coordinate(community), + ) || + !event.tags.any( + (t) => t.length == 2 && t[0] == 't' && t[1] == MeGroups.tag, + )) { + throw const FormatException('Invalid Solo record'); + } + nostr.Event.fromJson(jsonEncode(event.toJson())); + return MeGroups.decode(nip44Decrypt(key, event.content), community); + } + + Future head() async { + check(); + final events = await session.fetchHistory(filter); + check(); + if (events.isEmpty) return null; + final event = events.reduce((a, b) => meEventIsNewer(a, b) ? a : b); + decode(event); + return event; + } + + /// Discard only the pending snapshot the user explicitly reviewed. + Future discardPending(String expected) async { + check(); + if (_saving || prefs?.getString(pendingKey) != expected) { + throw StateError('The pending save changed. Review it again.'); + } + await clearMeJournal(prefs!, pendingKey, expected, checkScope: check); + } + + Future _clearConfirmedPending(String expected) async { + final storage = prefs; + if (storage == null) return; + await storage.reload(); + check(); + final current = storage.getString(pendingKey); + if (current == null) return; + if (current != expected) { + throw StateError('The pending save changed. Review it again.'); + } + try { + if (!await storage.remove(pendingKey)) { + throw StateError('Local cleanup failed.'); + } + } catch (_) { + // remove() mutates the in-memory preferences even when disk removal fails. + await storage.reload(); + throw StateError( + 'Saved to your community, but cleanup on this phone failed. Retry to finish the pending save.', + ); + } + } + + /// Refresh before applying an intent, then confirm the selected signed event. + Future update(MeGroups Function(MeGroups) change) async { + check(); + if (_saving) throw StateError('A Solo change is still being saved.'); + _saving = true; + try { + MeGroups? recovered; + String? recoveredHead; + final pending = prefs?.getString(pendingKey); + if (pending != null) { + final stored = jsonDecode(pending) as Map; + final event = NostrEvent.fromJson( + (stored['event'] as Map?) ?? stored, + ); + final local = decode(event); + final remote = await head(); + if (remote != null && + remote.id != event.id && + meEventIsNewer(remote, event)) { + if (!stored.containsKey('event')) { + // Older clients did not persist a base, so a safe rebase is unknown. + throw MeRecoveryConflict(); + } + recoveredHead = remote.id; + final baseEvent = stored['base'] as Map?; + recovered = rebaseMeGroups( + baseEvent == null + ? const MeGroups() + : decode(NostrEvent.fromJson(baseEvent)), + local, + decode(remote), + ); + } else { + if (remote?.id != event.id) { + check(); + await session.publish(event); + if ((await head())?.id != event.id) { + throw StateError( + 'A previous Solo save is still unconfirmed. Try again.', + ); + } + } + await _clearConfirmedPending(pending); + throw StateError( + 'Your previous Solo save is confirmed. Review your projects before making another change.', + ); + } + } + final before = await head(); + if (recovered != null && before?.id != recoveredHead) { + throw StateError( + 'Solo changed again. Your pending change is saved; try again.', + ); + } + final value = + recovered ?? + change(before == null ? const MeGroups() : decode(before)); + final String encoded; + try { + encoded = value.encode(community); + } on FormatException { + if (recovered != null) throw MeRecoveryConflict(); + rethrow; + } + final content = nip44Encrypt(key, encoded); + final current = await head(); + if (before?.id != current?.id) { + throw StateError( + 'Solo changed on another device. Refresh and try again.', + ); + } + if (current != null && + current.createdAt >= DateTime.now().millisecondsSinceEpoch ~/ 1000) { + throw StateError('Please wait a second before saving again.'); + } + check(); + final signed = NostrEvent.fromJson( + nostr.Event.from( + kind: 30078, + content: content, + secretKey: privateHex, + verify: false, + tags: [ + ['d', MeGroups.coordinate(community)], + ['t', MeGroups.tag], + ], + ).toMap(), + ); + final pendingRecord = jsonEncode({ + 'event': signed.toJson(), + 'base': before?.toJson(), + }); + if (prefs != null && !await prefs!.setString(pendingKey, pendingRecord)) { + throw StateError('Couldn’t save this change on your phone. Try again.'); + } + check(); + await session.publish(signed); + check(); + if ((await head())?.id != signed.id) { + throw StateError( + 'Another Solo change is selected. Refresh to see the latest projects.', + ); + } + await _clearConfirmedPending(pendingRecord); + if (recovered != null) { + throw StateError( + 'Your previous Solo save was recovered alongside the desktop changes. Review your projects before making another change.', + ); + } + } finally { + _saving = false; + } + } +} + +/// A verified Solo snapshot could not be retained for offline use. +class MeCacheException implements Exception { + const MeCacheException(); + @override + String toString() => 'Could not save Solo for offline use. Please retry.'; +} diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index d4d5b237ae..75a9673a23 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -30,20 +30,19 @@ class AgentDirectoryEntry { factory AgentDirectoryEntry.fromEvent(NostrEvent event) { final content = _tryDecodeJsonMap(event.content); + String? text(String field) => + content?[field] is String ? content![field] as String : null; + List strings(String field) => content?[field] is List + ? (content![field] as List).whereType().toList() + : const []; return AgentDirectoryEntry( pubkey: event.pubkey.toLowerCase(), - displayName: - (content?['display_name'] as String?) ?? - (content?['name'] as String?), - respondTo: content?['respond_to'] as String?, - respondToAllowlist: [ - for (final value in (content?['respond_to_allowlist'] as List?) ?? []) - if (value is String) value.toLowerCase(), - ], - channelIds: [ - for (final value in (content?['channel_ids'] as List?) ?? []) - if (value is String) value, - ], + displayName: text('display_name') ?? text('name'), + respondTo: text('respond_to'), + respondToAllowlist: strings( + 'respond_to_allowlist', + ).map((value) => value.toLowerCase()).toList(), + channelIds: strings('channel_ids'), ); } } diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index 71f393903e..a636977c57 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -47,6 +47,7 @@ abstract final class NostrFilters { String channelId, { int limit = 200, int? until, + String? beforeId, }) => NostrFilter( kinds: EventKind.channelEventKinds, tags: { @@ -54,6 +55,7 @@ abstract final class NostrFilters { }, limit: limit, until: until, + extensions: {'before_id': ?beforeId}, ); /// Huddle lifecycle state for one parent channel, independent of timeline paging. diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 7c47ee879c..5d05459ab6 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -57,7 +57,15 @@ class AvatarImage extends StatelessWidget { final borderRadius = BorderRadius.circular(radius * 0.6); return DecoratedBox( decoration: BoxDecoration(color: color, borderRadius: borderRadius), - child: ClipRRect(borderRadius: borderRadius, child: content), + child: ClipRRect( + borderRadius: borderRadius, + child: !usesInsetAgentArtwork(imageUrl) + ? content + : Transform.scale( + scale: animatedAvatarArtworkScale, + child: content, + ), + ), ); } } diff --git a/mobile/lib/shared/widgets/avatar_image_native.dart b/mobile/lib/shared/widgets/avatar_image_native.dart index 9a1f5f6437..b1cc52ba5e 100644 --- a/mobile/lib/shared/widgets/avatar_image_native.dart +++ b/mobile/lib/shared/widgets/avatar_image_native.dart @@ -5,12 +5,14 @@ Future nativeAvatarImage({ required String? url, required String initial, bool isAgent = false, + bool circular = false, required Color background, required Color foreground, required ImageProvider Function(String) networkImage, }) async { const dimension = 72.0; - final poster = parseAnimatedAvatarUrl(url)?.posterUrl ?? url; + final animatedAvatar = parseAnimatedAvatarUrl(url); + final poster = animatedAvatar?.posterUrl ?? url; final source = _AvatarSource.parse(poster); final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); @@ -18,10 +20,13 @@ Future nativeAvatarImage({ canvas.clipRRect( RRect.fromRectAndRadius( bounds, - Radius.circular(dimension * (isAgent ? 0.3 : 0.5)), + Radius.circular(dimension * (isAgent && !circular ? 0.3 : 0.5)), ), ); canvas.drawColor(background, BlendMode.src); + final imageBounds = isAgent && usesInsetAgentArtwork(url) + ? bounds.deflate(dimension * (1 - animatedAvatarArtworkScale) / 2) + : bounds; var rendered = false; try { switch (source) { @@ -59,7 +64,7 @@ Future nativeAvatarImage({ final frame = await codec.getNextFrame(); paintImage( canvas: canvas, - rect: bounds, + rect: imageBounds, image: frame.image, fit: BoxFit.cover, ); @@ -91,7 +96,7 @@ Future nativeAvatarImage({ ); paintImage( canvas: canvas, - rect: bounds, + rect: imageBounds, image: image.image, fit: BoxFit.cover, ); diff --git a/mobile/lib/shared/widgets/collapsible_section_body.dart b/mobile/lib/shared/widgets/collapsible_section_body.dart new file mode 100644 index 0000000000..bc1a2ae30b --- /dev/null +++ b/mobile/lib/shared/widgets/collapsible_section_body.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Duration of a section opening and its chevron rotation. +const Duration sectionExpandDuration = Duration(milliseconds: 220); + +/// Duration of a section closing. +const Duration sectionCollapseDuration = Duration(milliseconds: 170); + +/// Easing shared by section bodies and chevrons. +const Curve sectionExpandCurve = Cubic(0.23, 1, 0.32, 1); + +/// Easing for the closing section body. +const Curve sectionCollapseCurve = Curves.easeInCubic; + +/// Subtle vertical compression at the collapsed end of the transition. +const double sectionCollapsedScaleY = 0.98; + +/// Shared Channels and Me section expansion, including reduced-motion support. +class CollapsibleSectionBody extends HookWidget { + final bool expanded; + final Widget child; + + const CollapsibleSectionBody({ + super.key, + required this.expanded, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.of(context).disableAnimations; + final controller = useAnimationController( + duration: reducedMotion ? Duration.zero : sectionExpandDuration, + reverseDuration: reducedMotion ? Duration.zero : sectionCollapseDuration, + initialValue: expanded ? 1 : 0, + ); + final curvedAnimation = useMemoized( + () => CurvedAnimation( + parent: controller, + curve: sectionExpandCurve, + reverseCurve: sectionCollapseCurve, + ), + [controller], + ); + final shouldRender = useState(expanded); + + useEffect(() => curvedAnimation.dispose, [curvedAnimation]); + + useEffect(() { + void handleStatus(AnimationStatus status) { + if (status == AnimationStatus.dismissed && !expanded) { + shouldRender.value = false; + } + } + + controller.addStatusListener(handleStatus); + return () => controller.removeStatusListener(handleStatus); + }, [controller, expanded]); + + useEffect(() { + controller.duration = reducedMotion + ? Duration.zero + : sectionExpandDuration; + controller.reverseDuration = reducedMotion + ? Duration.zero + : sectionCollapseDuration; + + if (expanded) { + shouldRender.value = true; + if (reducedMotion) { + controller.value = 1; + } else { + unawaited(controller.forward()); + } + } else if (reducedMotion) { + controller.value = 0; + shouldRender.value = false; + } else { + unawaited(controller.reverse()); + } + + return null; + }, [controller, expanded, reducedMotion]); + + return ClipRect( + child: AnimatedBuilder( + animation: curvedAnimation, + child: shouldRender.value ? child : const SizedBox.shrink(), + builder: (context, child) { + final value = curvedAnimation.value.clamp(0.0, 1.0); + final scaleY = + sectionCollapsedScaleY + ((1 - sectionCollapsedScaleY) * value); + + return Align( + alignment: Alignment.topCenter, + heightFactor: value, + child: Opacity( + opacity: value, + child: Transform.scale( + alignment: Alignment.topCenter, + scaleY: scaleY, + child: ExcludeSemantics( + excluding: !expanded, + child: IgnorePointer(ignoring: !expanded, child: child), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 628267c167..0b4f622c59 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'ios_glass_navigation_button.dart'; import '../theme/theme.dart'; @@ -37,6 +39,9 @@ class FilterChipBar extends StatelessWidget { /// When enabled, every chip shares the width rather than scrolling. final bool expandItems; + /// Uses native glass capsules on iOS for navigation-style filters. + final bool useNativeGlass; + /// Material density applied to each chip. final VisualDensity visualDensity; @@ -52,6 +57,7 @@ class FilterChipBar extends StatelessWidget { required this.selected, required this.onSelected, this.expandItems = false, + this.useNativeGlass = false, this.visualDensity = VisualDensity.compact, this.chipVerticalPadding = Grid.quarter, this.barVerticalPadding = Grid.xxs, @@ -59,6 +65,60 @@ class FilterChipBar extends StatelessWidget { @override Widget build(BuildContext context) { + if (useNativeGlass && defaultTargetPlatform == TargetPlatform.iOS) { + final textStyle = filterChipTextStyle.copyWith( + fontWeight: FontWeight.w600, + ); + final height = (MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) + .clamp(48.0, double.infinity); + final widths = [ + for (final item in items) + (TextPainter( + text: TextSpan(text: item.label, style: textStyle), + textScaler: MediaQuery.textScalerOf(context), + textDirection: Directionality.of(context), + )..layout()).width + + 32, + ]; + return LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxWidth - Grid.gutter * 2; + final required = + widths.fold(0.0, (sum, width) => sum + width) + + Grid.xxs * (items.length - 1); + final extra = expandItems && available > required + ? (available - required) / items.length + : 0.0; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: barVerticalPadding, + ), + child: Row( + children: [ + for (var index = 0; index < items.length; index++) ...[ + if (index > 0) const SizedBox(width: Grid.xxs), + IosGlassNavigationButton( + icon: IosGlassNavigationIcon.more, + label: items[index].label, + semanticLabel: items[index].label, + onPressed: () => onSelected(items[index].id), + isSelected: selected == items[index].id, + width: widths[index] + extra, + height: height, + controlSize: height - 8, + fillWidth: true, + foregroundColor: context.colors.primary, + ), + ], + ], + ), + ); + }, + ); + } final chips = [ for (var index = 0; index < items.length; index++) ...[ if (index > 0) const SizedBox(width: Grid.xxs), diff --git a/mobile/lib/shared/widgets/ios_navigation_bar.dart b/mobile/lib/shared/widgets/ios_navigation_bar.dart index 42cf258d17..2736cc4daf 100644 --- a/mobile/lib/shared/widgets/ios_navigation_bar.dart +++ b/mobile/lib/shared/widgets/ios_navigation_bar.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -8,6 +9,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; +import '../animated_avatar.dart'; import 'avatar_image.dart'; import 'app_route_observer.dart'; import 'ios_navigation_metrics.dart'; @@ -18,6 +20,7 @@ class IosNavigationAction { const IosNavigationAction({ required this.label, this.symbol, + this.icon, this.imageUrl, this.avatarInitial, this.avatarIdentity, @@ -35,6 +38,9 @@ class IosNavigationAction { final String label; final String? symbol; + + /// Custom font artwork rendered as a native template image. + final IconData? icon; final String? imageUrl; final String? avatarInitial; final String? avatarIdentity; @@ -60,6 +66,7 @@ class IosNavigationAction { 'symbol': avatarInitial == null ? symbol : null, 'avatarInitial': avatarInitial, 'avatarIsAgent': avatarIsAgent, + 'avatarArtwork': avatarIsAgent && usesInsetAgentArtwork(imageUrl), 'activityColor': activityColor?.toARGB32(), 'activityLabel': activityLabel, 'imageUrl': imageUrl, @@ -182,7 +189,12 @@ class IosNavigationBar extends HookConsumerWidget { url: entry.value.imageUrl, initial: entry.value.avatarInitial!, isAgent: entry.value.avatarIsAgent, - background: colors.primaryContainer, + circular: true, + background: + entry.value.avatarIsAgent && + usesInsetAgentArtwork(entry.value.imageUrl) + ? Colors.transparent + : colors.primaryContainer, foreground: colors.onPrimaryContainer, networkImage: (url) => MediaImageProvider(url: url, auth: auth, client: client), @@ -213,11 +225,69 @@ class IosNavigationBar extends HookConsumerWidget { colors.onPrimaryContainer, ], ); + final iconImages = useState({}); + final icons = { + if (leading?.icon != null) 'leading': leading!.icon!, + for (var i = 0; i < actions.length; i++) + if (actions[i].icon != null) '$i': actions[i].icon!, + }; + final iconKey = jsonEncode([ + for (final entry in icons.entries) + [ + entry.key, + entry.value.codePoint, + entry.value.fontFamily, + entry.value.fontPackage, + ], + ]); + useEffect(() { + if (icons.isEmpty) return null; + var active = true; + unawaited( + Future.microtask(() async { + final rendered = {}; + for (final entry in icons.entries) { + final recorder = ui.PictureRecorder(); + final painter = TextPainter( + text: TextSpan( + text: String.fromCharCode(entry.value.codePoint), + style: TextStyle( + fontFamily: entry.value.fontFamily, + package: entry.value.fontPackage, + fontSize: 22, + color: const Color(0xff000000), + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + final canvas = Canvas(recorder)..scale(3); + painter.paint( + canvas, + Offset((24 - painter.width) / 2, (24 - painter.height) / 2), + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(72, 72); + final bytes = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + if (bytes != null) { + rendered[entry.key] = base64Encode(bytes.buffer.asUint8List()); + } + image.dispose(); + picture.dispose(); + painter.dispose(); + } + if (active && context.mounted) iconImages.value = rendered; + }), + ); + return () => active = false; + }, [iconKey]); Map encodeAction(IosNavigationAction action, String id) { final key = imageKey(action); final retained = avatarImages.value[id]; return { ...action._encode(id), + if (iconImages.value[id] != null) 'iconData': iconImages.value[id], 'avatarBackground': colors.primaryContainer.toARGB32(), 'avatarForeground': colors.onPrimaryContainer.toARGB32(), if (retained?.key == key) 'imageData': retained!.data, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 613684a9bf..12f1c7966d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1329,7 +1329,7 @@ packages: source: hosted version: "2.4.1" shared_preferences_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences_platform_interface sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2e09552ae8..f96ee6a2bd 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: local_auth: ^3.0.2 dev_dependencies: + shared_preferences_platform_interface: ^2.4.2 flutter_test: sdk: flutter flutter_lints: ^6.0.0 diff --git a/mobile/test/features/channels/agent_reply_handoff_test.dart b/mobile/test/features/channels/agent_reply_handoff_test.dart new file mode 100644 index 0000000000..803b919323 --- /dev/null +++ b/mobile/test/features/channels/agent_reply_handoff_test.dart @@ -0,0 +1,56 @@ +import 'package:buzz/features/channels/agent_reply_handoff.dart'; +import 'package:buzz/features/channels/agent_typing_bubble.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final reduced in [false, true]) { + testWidgets('reply grows from bubble with reduced motion $reduced', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: const Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: AgentReplyHandoff( + animate: true, + child: SizedBox( + key: ValueKey('reply'), + width: 220, + height: 120, + child: Text('Agent reply'), + ), + ), + ), + ), + ), + ), + ); + expect( + find.byType(AgentTypingBubble), + reduced ? findsNothing : findsOneWidget, + ); + if (!reduced) { + final bubble = tester.getSize(find.byType(AnimatedSize)); + expect(bubble.width, lessThan(220)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 70)); + final during = tester.getSize(find.byType(AnimatedSize)); + expect(during.width, greaterThan(bubble.width)); + expect(during.width, lessThan(220)); + } + await tester.pumpAndSettle(); + expect(find.byType(AgentTypingBubble), findsNothing); + expect(find.text('Agent reply'), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('reply'))), + const Size(220, 120), + ); + }); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 8819553c15..4f212b3952 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,3 +1,10 @@ +import 'package:buzz/features/channels/compose_bar.dart'; +import 'package:buzz/features/channels/send_message_provider.dart'; +import 'package:buzz/features/channels/agent_reply_handoff.dart'; +import 'package:buzz/features/channels/me/me_messages_provider.dart'; +import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/me/me_groups.dart'; import 'package:flutter/semantics.dart'; import 'package:buzz/features/channels/message_media_avatar.dart'; import 'dart:async'; @@ -229,6 +236,7 @@ Widget _buildTestable({ ProfileNotifier? profileNotifier, String? signingPubkey, bool darkTheme = false, + bool personalChat = false, required List messages, PresenceCacheNotifier? presenceCache, List typing = const [], @@ -278,6 +286,10 @@ Widget _buildTestable({ http.Client? mediaClient, VideoPreviewFrameLoader? videoPreviewLoader, Widget? home, + Stream? organization, + SendMessage? sender, + SendMessage? soloSender, + ChannelRoutePlacement placement = ChannelRoutePlacement.ordinary, }) { final resolvedChannel = channel ?? _testChannel; final navigatorKey = GlobalKey(); @@ -288,11 +300,42 @@ Widget _buildTestable({ return ProviderScope( retry: providerRetry ?? (disableRetries ? (_, _) => null : null), overrides: [ + if (sender != null) sendMessageProvider.overrideWithValue(sender), + if (soloSender != null) ...[ + meSendMessageProvider.overrideWithValue(soloSender), + meAssistantOwnershipProvider('agent').overrideWith((ref) async => true), + ], + meGroupsProvider.overrideWith( + (ref) => + organization ?? + Stream.value( + MeGroups(channels: personalChat ? [resolvedChannel.id] : []), + ), + ), + if (personalChat) ...[ + meSyncProvider.overrideWithValue( + MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: + '0000000000000000000000000000000000000000000000000000000000000001', + ), + ), + ), + ), + meMessagesProvider(_channelId).overrideWith(() => fakeMessagesNotifier), + meAssistantsProvider.overrideWith((ref) async => []), + ], if (videoPreviewLoader != null) videoPreviewFrameLoaderProvider.overrideWithValue(videoPreviewLoader), - channelMessagesProvider( - _channelId, - ).overrideWith(() => fakeMessagesNotifier), + channelMessagesProvider(_channelId).overrideWith( + () => personalChat + ? _FakeMessagesNotifier(messages) + : fakeMessagesNotifier, + ), channelTypingProvider( _channelId, ).overrideWith(() => typingNotifier ?? _FakeTypingNotifier(typing)), @@ -431,6 +474,8 @@ Widget _buildTestable({ home ?? ChannelDetailPage( channel: resolvedChannel, + personalChat: personalChat, + placement: placement, initialMessageId: initialMessageId, initialThreadRootId: initialThreadRootId, initialThreadRouteBehavior: initialThreadRouteBehavior, @@ -4502,78 +4547,165 @@ void main() { expect(find.byTooltip('Jump to latest message'), findsOneWidget); }); - testWidgets('loads history through the oldest unread boundary', ( - tester, - ) async { - final newestPage = [ - for (var i = 50; i < 100; i++) - _textMsg( - id: 'msg$i', - pubkey: 'alice', - content: 'Message $i', - createdAt: 1000 + i, - ), - ]; - final olderPage = [ - for (var i = 0; i < 50; i++) - _textMsg( - id: 'msg$i', - pubkey: 'alice', - content: 'Message $i', - createdAt: 1000 + i, - ), - ]; - final messagesNotifier = _FakeMessagesNotifier( - newestPage, - olderPages: [olderPage], - ); - final channelsNotifier = _FakeChannelsNotifier( - [_testChannel], - observedUnread: { - _channelId: [ - makeObservedUnreadEvent( - id: 'msg21', - createdAt: 1021, - rootId: null, - highPriority: false, - channelType: 'stream', - isThreadedReply: false, + for (final gesture in ['status bar', 'scroll']) { + testWidgets( + 'Me history uses selected provider for $gesture', + (tester) async { + final notifier = _FakeMessagesNotifier( + [ + for (var i = 50; i < 250; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ], + olderPages: [ + [ + for (var i = 0; i < 50; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ], + ], + ); + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: notifier, + personalChat: true, + disableAnimations: true, ), - ], + ); + await tester.pumpAndSettle(); + if (gesture == 'status bar') { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + } else { + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + list.itemScrollController!.jumpTo(index: 195); + await tester.pumpAndSettle(); + await tester.drag( + find.byType(ScrollablePositionedList), + const Offset(0, 350), + ); + await tester.pumpAndSettle(); + } + expect(notifier.fetchOlderCalls, greaterThan(0)); + if (gesture == 'status bar') { + expect(findRichText('Message 0'), findsOneWidget); + } else { + // Scroll pagination extends the visible provider, even if the user + // has not scrolled through every newly loaded row yet. + expect( + ProviderScope.containerOf( + tester.element(find.byType(ChannelDetailPage)), + ).read(meMessagesProvider(_channelId)).requireValue.first.id, + 'msg0', + ); + } + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + + for (final personal in [false, true]) { + testWidgets( + 'loads history through the oldest unread boundary personal=$personal', + (tester) async { + final newestPage = [ + for (var i = 50; i < 250; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + extraTags: personal + ? [ + ['e', threadIdThreadDashRoot, '', 'reply'], + ] + : const [], + ), + ]; + final olderPage = [ + for (var i = 0; i < 50; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + extraTags: personal + ? [ + ['e', threadIdThreadDashRoot, '', 'reply'], + ] + : const [], + ), + ]; + final messagesNotifier = _FakeMessagesNotifier( + newestPage, + olderPages: [olderPage], + ); + final channelsNotifier = _FakeChannelsNotifier( + [_testChannel], + observedUnread: { + _channelId: [ + makeObservedUnreadEvent( + id: 'msg21', + createdAt: 1021, + rootId: personal ? threadIdThreadDashRoot : null, + highPriority: false, + channelType: 'stream', + isThreadedReply: personal, + ), + ], + }, + ); + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {_channelId: 1020}, + version: 0, + ), + ); + + await tester.pumpWidget( + _buildTestable( + personalChat: personal, + messages: const [], + messagesNotifier: messagesNotifier, + channelsNotifier: channelsNotifier, + readStateNotifier: readState, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('channel-jump-to-oldest-unread')), + ); + await tester.pumpAndSettle(); + + expect(findRichText('Message 21'), findsOneWidget); + expect(findRichText('Message 50'), findsNothing); + expect(messagesNotifier.fetchOlderCalls, 1); }, ); - final readState = _SynchronousReadStateNotifier( - const ReadStateState( - isReady: true, - pubkey: 'self', - contexts: {_channelId: 1020}, - version: 0, - ), - ); - - await tester.pumpWidget( - _buildTestable( - messages: const [], - messagesNotifier: messagesNotifier, - channelsNotifier: channelsNotifier, - readStateNotifier: readState, - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - await tester.tap( - find.byKey(const ValueKey('channel-jump-to-oldest-unread')), - ); - await tester.pumpAndSettle(); - - expect(findRichText('Message 21'), findsOneWidget); - expect(findRichText('Message 50'), findsNothing); - expect(messagesNotifier.fetchOlderCalls, 1); - }); - + } testWidgets('does not load history for threaded-only unread events', ( tester, ) async { @@ -10328,7 +10460,307 @@ void main() { }); }); + for (final visibility in ['open', 'private']) { + testWidgets( + 'ordinary $visibility send survives a Solo offline-cache failure', + (tester) async { + final sender = _RecordingOrdinarySender(); + final channel = Channel( + id: _channelId, + name: 'Ordinary', + channelType: 'stream', + visibility: visibility, + description: '', + createdBy: 'self', + createdAt: DateTime(2026), + memberCount: 2, + isMember: true, + ); + await tester.pumpWidget( + _buildTestable( + messages: [], + disableRetries: true, + sender: sender, + organization: Stream.error(const MeCacheException()), + home: ChannelDetailPage(channel: channel), + ), + ); + await tester.pumpAndSettle(); + await tester + .widget(find.byType(ComposeBar)) + .onSend('Still usable', []); + expect(sender.sent, ['Still usable']); + }, + ); + } + + testWidgets( + 'affirmative ordinary route sends without waiting for Solo lookup', + (tester) async { + final sender = _RecordingOrdinarySender(); + final pending = StreamController(); + addTearDown(pending.close); + await tester.pumpWidget( + _buildTestable( + messages: [], + sender: sender, + organization: pending.stream, + home: ChannelDetailPage( + channel: _testChannel, + placement: ChannelRoutePlacement.ordinary, + ), + ), + ); + await tester.pump(); + expect(find.byType(ComposeBar), findsOneWidget); + await tester + .widget(find.byType(ComposeBar)) + .onSend('Immediate', []); + expect(sender.sent, ['Immediate']); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 3)); + }, + ); + + testWidgets('affirmative Solo route never falls back to ordinary sending', ( + tester, + ) async { + final sender = _RecordingOrdinarySender(); + await tester.pumpWidget( + _buildTestable( + messages: [], + personalChat: true, + disableRetries: true, + sender: sender, + organization: Stream.error(const MeCacheException()), + home: ChannelDetailPage( + channel: _testChannel, + personalChat: true, + placement: ChannelRoutePlacement.ordinary, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('me-assistant-button')), findsOneWidget); + await expectLater( + tester + .widget(find.byType(ComposeBar)) + .onSend('Must not leak', []), + throwsStateError, + ); + expect(sender.sent, isEmpty); + }); + + testWidgets('identified Solo sends through the Solo sender', (tester) async { + final ordinary = _RecordingOrdinarySender(), + solo = _RecordingOrdinarySender(); + await tester.pumpWidget( + _buildTestable( + messages: [], + personalChat: true, + sender: ordinary, + soloSender: solo, + members: [ + ChannelMember(pubkey: 'agent', role: 'bot', joinedAt: DateTime(2026)), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.widget(find.byType(ComposeBar)).onSend( + 'Solo message', + ['agent'], + ); + expect(solo.sent, ['Solo message']); + expect(ordinary.sent, isEmpty); + }); + + testWidgets('unknown Solo placement remains readable but cannot send', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + disableRetries: true, + placement: ChannelRoutePlacement.unresolved, + organization: Stream.error(StateError('offline')), + channel: Channel( + id: _channelId, + name: 'Unresolved private chat', + channelType: 'stream', + visibility: 'private', + description: '', + createdBy: 'self', + createdAt: DateTime(2026), + memberCount: 2, + isMember: true, + ), + messages: [ + _textMsg(id: 'visible', pubkey: 'alice', content: 'Cached message'), + ], + ), + ); + await tester.pumpAndSettle(); + expect(findRichText('Cached message'), findsOneWidget); + final compose = tester.widget(find.byType(ComposeBar)); + await expectLater( + compose.onSend('Do not route without placement', []), + throwsStateError, + ); + }); + + testWidgets( + 'default Activity/deep-link entry resolves Solo and keeps replies inline', + (tester) async { + await tester.pumpWidget( + _buildTestable( + personalChat: true, + messages: [ + _textMsg(id: threadIdRoot, pubkey: 'self', content: 'Question'), + _textMsg( + id: threadIdReplyZero, + pubkey: 'alice', + content: 'Agent answer', + extraTags: [ + ['e', threadIdRoot, '', 'reply'], + ], + ), + ], + home: ChannelDetailPage( + channel: Channel( + id: _channelId, + name: 'Solo chat', + channelType: 'stream', + visibility: 'private', + description: '', + createdBy: 'self', + createdAt: DateTime(2026), + memberCount: 2, + isMember: true, + ), + initialMessageId: threadIdReplyZero, + initialThreadRootId: threadIdRoot, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('me-assistant-button')), findsOneWidget); + expect(findRichText('Agent answer'), findsOneWidget); + expect(find.byType(ThreadDetailPage), findsNothing); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelDetailPage)), + ); + final notifier = + container.read(meMessagesProvider(_channelId).notifier) + as _FakeMessagesNotifier; + expect(notifier.loadedEventIds, contains(threadIdReplyZero)); + }, + ); + group('Typing indicator', () { + testWidgets('Me reply consumes typing and grows its pending bubble', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final prompt = _textMsg( + id: threadIdRoot, + pubkey: 'self', + content: 'Hello', + createdAt: now - 3, + ); + final typing = _FakeTypingNotifier(const []); + await tester.pumpWidget( + _buildTestable( + personalChat: true, + messages: [prompt], + typingNotifier: typing, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + typing.setEntries([ + TypingEntry( + pubkey: 'alice', + threadHeadId: threadIdRoot, + expiresAtMs: (now - 1) * 1000 + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.byKey(const ValueKey('agent-typing-bubble')), findsOneWidget); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelDetailPage)), + ); + final messages = + container.read(meMessagesProvider(_channelId).notifier) + as _FakeMessagesNotifier; + messages.setMessages([ + prompt, + _textMsg( + id: 'reply', + pubkey: 'alice', + content: 'Here is the reply', + createdAt: now, + extraTags: [ + ['e', threadIdRoot, '', 'reply'], + ], + ), + ]); + await tester.pump(); + expect( + find.byKey(const ValueKey('inline-agent-typing-alice')), + findsNothing, + ); + final handoff = tester.widget( + find.byKey(const ValueKey('reply-handoff-reply')), + ); + expect(handoff.animate, isTrue); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('agent-typing-bubble')), findsNothing); + expect(findRichText('Here is the reply'), findsOneWidget); + }); + + for (final personal in [true, false]) { + testWidgets('reply-scoped typing is visible only in flat Me: $personal', ( + tester, + ) async { + final typing = _FakeTypingNotifier(const []); + await tester.pumpWidget( + _buildTestable( + personalChat: personal, + messages: [ + _textMsg(id: 'prompt', pubkey: 'self', content: 'Hello'), + ], + typingNotifier: typing, + disableAnimations: true, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + typing.setEntries(const [ + TypingEntry( + pubkey: 'alice', + threadHeadId: 'prompt', + expiresAtMs: 9999999999999, + ), + ]); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('inline-agent-typing-alice')), + personal ? findsOneWidget : findsNothing, + ); + expect(find.text('Alice is typing…'), findsNothing); + typing.setEntries(const []); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('inline-agent-typing-alice')), + findsNothing, + ); + }); + } + testWidgets('shows single typer', (tester) async { await tester.pumpWidget( _buildTestable( @@ -16247,6 +16679,13 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { final List> _olderPages; final bool failOlderFetch; final Map summaries; + final loadedEventIds = {}; + @override + Future loadEventsById(Iterable eventIds) async { + loadedEventIds.addAll(eventIds); + await super.loadEventsById(eventIds); + } + int fetchOlderCalls = 0; _FakeMessagesNotifier( @@ -17226,3 +17665,19 @@ class _MutableColorProfileNotifier extends ProfileNotifier { void setProfileState(AsyncValue value) => state = value; } + +class _RecordingOrdinarySender implements SendMessage { + final sent = []; + @override + Future call({ + required String channelId, + required String content, + String? parentEventId, + String? rootEventId, + List? mentionPubkeys, + Channel? channel, + List> mediaTags = const [], + }) async { + sent.add(content); + } +} diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index d9c37cea04..f0a7d1a08b 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:buzz/features/channels/me/me_messages_provider.dart'; import 'dart:collection'; import 'dart:convert'; @@ -44,6 +45,65 @@ void test(String description, Future Function() body) { } void main() { + test( + 'Solo notification target does not skip the intervening history', + () async { + final relay = _CursorRelay(); + final container = _buildContainer(relay); + addTearDown(container.dispose); + container.read(meMessagesProvider(_channelId)); + await relay.subscribed; + await _pumpEventQueue(); + final notifier = container.read(meMessagesProvider(_channelId).notifier); + await notifier.loadEventsById(['older']); + expect( + container.read(meMessagesProvider(_channelId)).value!.first.id, + 'older', + ); + expect(await notifier.fetchOlder(), isTrue); + expect(await notifier.fetchOlder(), isTrue); + expect(await notifier.fetchOlder(), isFalse); + expect(container.read(meMessagesProvider(_channelId)).value!.length, 351); + }, + ); + test('Solo paginates through more than 100 equal-second events', () async { + final relay = _CursorRelay(); + final container = _buildContainer(relay); + addTearDown(container.dispose); + container.read(meMessagesProvider(_channelId)); + await relay.subscribed; + await _pumpEventQueue(); + final notifier = container.read(meMessagesProvider(_channelId).notifier); + expect(await notifier.fetchOlder(), isTrue); + expect(await notifier.fetchOlder(), isTrue); + expect(await notifier.fetchOlder(), isFalse); + final events = container.read(meMessagesProvider(_channelId)).value!; + expect(events.length, 351); + expect(events.first.id, 'older'); + expect(events.map((e) => e.id).toSet().length, 351); + }); + test( + 'Me reads channel replies without the top-level window filter', + () async { + final relay = _RecordingRelaySessionNotifier( + historyResults: [ + [_event(id: 'history', createdAt: 10)], + ], + ); + final container = _buildContainer(relay); + addTearDown(container.dispose); + container.read(meMessagesProvider(_channelId)); + await relay.subscribed; + await _pumpEventQueue(); + expect(relay.queryFilters, isEmpty); + expect(relay.historyFilters.single.extensions['top_level'], isNot(true)); + expect( + container.read(meMessagesProvider(_channelId)).value!.map((e) => e.id), + ['history'], + ); + }, + ); + test('live window without deep links does not rescan flattened ids', () async { var historyIdReads = 0; final history = _IdReadTrackingEvent( @@ -5709,3 +5769,31 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { } } } + +class _CursorRelay extends _RecordingRelaySessionNotifier { + final events = [ + for (var i = 0; i < 350; i++) + _event(id: i.toString().padLeft(64, '0'), createdAt: 20), + _event(id: 'older', createdAt: 19), + ]; + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filter.ids != null) { + return events.where((e) => filter.ids!.contains(e.id)).toList(); + } + final before = filter.extensions['before_id'] as String?; + return events + .where( + (e) => + filter.until == null || + e.createdAt < filter.until! || + (e.createdAt == filter.until && + (before == null || e.id.compareTo(before) > 0)), + ) + .take(filter.limit) + .toList(); + } +} diff --git a/mobile/test/features/channels/channel_typing_provider_test.dart b/mobile/test/features/channels/channel_typing_provider_test.dart new file mode 100644 index 0000000000..53eacdbe0a --- /dev/null +++ b/mobile/test/features/channels/channel_typing_provider_test.dart @@ -0,0 +1,209 @@ +import 'package:buzz/features/channels/channel_typing_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../helpers/thread_ids.dart'; + +class _Relay extends RelaySessionNotifier { + late void Function(NostrEvent) receive; + int calls = 0; + bool reject = false; + void Function(String)? close; + int active = 0; + int peak = 0; + void setStatus(SessionStatus status) => state = SessionState(status: status); + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async { + expect(filter.kinds, containsAll([20002, 9, 40002, 40008])); + calls++; + close = onClosed; + if (reject) throw StateError('rejected'); + receive = onEvent; + active++; + if (active > peak) peak = active; + return () { + active--; + }; + } +} + +void main() { + test( + 'initial rejection retries with a bound and no uncaught error', + () async { + final relay = _Relay()..reject = true; + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relay)], + ); + container.listen(channelTypingProvider('chat'), (_, _) {}); + await container.pump(); + expect(relay.calls, 1); + for (final seconds in [1, 2, 4, 8]) { + await Future.delayed(Duration(milliseconds: seconds * 1000 + 20)); + } + expect(relay.calls, 4); + container.dispose(); + }, + ); + test( + 'terminal close clears typing and retries; disposal cancels recovery', + () async { + final relay = _Relay(); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relay)], + ); + final provider = channelTypingProvider('chat'); + container.listen(provider, (_, _) {}); + await container.pump(); + relay.receive( + NostrEvent( + id: 'typing', + pubkey: 'agent', + kind: 20002, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + content: '', + sig: '', + tags: [ + ['h', 'chat'], + ], + ), + ); + expect(container.read(provider), hasLength(1)); + relay.close!('terminal'); + await container.pump(); + expect(container.read(provider), isEmpty); + expect(relay.active, 0); + await Future.delayed(const Duration(milliseconds: 1100)); + expect(relay.active, 1); + expect(relay.calls, 2); + relay.close!('terminal again'); + await container.pump(); + container.dispose(); + await Future.delayed(const Duration(milliseconds: 2100)); + expect(relay.calls, 2); + expect(relay.active, 0); + }, + ); + test( + 'terminal closure loops stay bounded until sustained recovery', + () async { + final relay = _Relay(); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relay)], + ); + addTearDown(container.dispose); + container.listen(channelTypingProvider('chat'), (_, _) {}); + await container.pump(); + for (final delay in [1, 2, 4]) { + final calls = relay.calls; + relay.close!('rejected after admission'); + await container.pump(); + if (delay > 1) { + await Future.delayed(const Duration(milliseconds: 1100)); + expect(relay.calls, calls); + } + await Future.delayed( + Duration(milliseconds: delay * 1000 + 50 - (delay > 1 ? 1100 : 0)), + ); + expect(relay.calls, calls + 1); + } + relay.close!('fourth rejection'); + await container.pump(); + await Future.delayed(const Duration(milliseconds: 1100)); + expect(relay.calls, 4); + relay.setStatus(SessionStatus.disconnected); + await container.pump(); + relay.setStatus(SessionStatus.connected); + await container.pump(); + relay.close!('first rejection after reconnect'); + await container.pump(); + await Future.delayed(const Duration(milliseconds: 1100)); + final recoveredCalls = relay.calls; + await Future.delayed(const Duration(milliseconds: 8100)); + relay.close!('closure after sustained health'); + await container.pump(); + await Future.delayed(const Duration(milliseconds: 1100)); + expect(relay.calls, recoveredCalls + 1); + expect(relay.active, 1); + }, + ); + test('transport rebuilds release each typing subscription', () async { + final relay = _Relay(); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relay)], + ); + container.listen(channelTypingProvider('chat'), (_, _) {}); + await container.pump(); + for (var i = 0; i < 4; i++) { + relay.setStatus(SessionStatus.disconnected); + await container.pump(); + expect(relay.active, 0); + relay.setStatus(SessionStatus.connected); + await container.pump(); + expect(relay.active, 1); + } + expect(relay.peak, 1); + container.dispose(); + await Future.delayed(Duration.zero); + expect(relay.active, 0); + }); + test( + 'reply clears its scope immediately and suppresses late pulses', + () async { + final relay = _Relay(); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relay)], + ); + addTearDown(container.dispose); + final provider = channelTypingProvider('chat'); + container.read(provider); + await Future.value(); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + void emit(int kind, String agent, String root, int time) => relay.receive( + NostrEvent( + id: '$kind-$agent-$root-$time', + pubkey: agent, + kind: kind, + createdAt: time, + content: '', + sig: '', + tags: [ + ['h', 'chat'], + ['e', root, '', 'root'], + ['e', threadIdParent, '', 'reply'], + ], + ), + ); + emit(20002, 'a', threadIdRoot, now - 2); + emit(20002, 'a', threadIdOtherRoot, now - 2); + emit(20002, 'b', threadIdRoot, now - 2); + expect(container.read(provider), hasLength(3)); + emit(9, 'a', threadIdRoot, now - 1); + expect(container.read(provider).map((e) => (e.pubkey, e.threadHeadId)), [ + ('a', threadIdOtherRoot), + ('b', threadIdRoot), + ]); + emit( + 20002, + 'a', + threadIdRoot, + now, + ); // Newer but inside desktop's quiet interval. + emit(20002, 'a', threadIdRoot, now - 2); // Delayed old pulse. + expect(container.read(provider), hasLength(2)); + emit( + 20002, + 'c', + threadIdRoot, + now - 9, + ); // Expired history is not live typing. + expect(container.read(provider), hasLength(2)); + }, + ); +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 88fd266cf5..1d6fa04c24 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -3,6 +3,7 @@ import 'package:buzz/shared/widgets/app_route_observer.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/community/paired_community_landing.dart'; import 'dart:async'; +import 'package:shared_preferences/shared_preferences.dart'; import '../profile/presence_snapshot_test.dart' show PresenceTestRelay, presenceEvent; @@ -667,6 +668,56 @@ void main() { expect(appBar.frostedBlurSigma, 23.12); }); + testWidgets('Home keeps header blur after returning from search', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + tester.view.physicalSize = const Size(320, 320); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + buildTestable( + realHome: true, + topSectionGradient: const LinearGradient( + colors: [Colors.yellow, Colors.blue], + ), + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + final position = tester + .state( + find + .descendant( + of: find.byType(CustomScrollView), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + final header = find.byType(FrostedAppBar).first; + expect( + find.descendant(of: header, matching: find.byType(BackdropFilter)), + findsOneWidget, + ); + final offset = position.pixels; + await tester.tap(find.byTooltip('Search')); + await tester.pumpAndSettle(); + tester.state(find.byType(Navigator).first).pop(); + await tester.pumpAndSettle(); + expect(position.pixels, offset); + expect( + find.descendant(of: header, matching: find.byType(BackdropFilter)), + findsOneWidget, + ); + }); + for (final customRoute in [false, true]) { testWidgets( 'Home replays native scroll state on return: custom=$customRoute', diff --git a/mobile/test/features/channels/inline_agent_typing_test.dart b/mobile/test/features/channels/inline_agent_typing_test.dart new file mode 100644 index 0000000000..2e7e1ea367 --- /dev/null +++ b/mobile/test/features/channels/inline_agent_typing_test.dart @@ -0,0 +1,84 @@ +import 'package:buzz/features/channels/channel_identity_names_provider.dart'; +import 'package:buzz/features/channels/channel_typing_provider.dart'; +import 'package:buzz/features/channels/inline_agent_typing.dart'; +import 'package:buzz/shared/identity_names/identity_names.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const agent = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const profile = UserProfile(pubkey: agent, displayName: 'Test agent'); + +class Profiles extends UserCacheNotifier { + @override + Map build() => {agent: profile}; +} + +Widget surface({bool reduced = false, bool typing = true}) => ProviderScope( + overrides: [ + userCacheProvider.overrideWith(Profiles.new), + channelIdentityNamesProvider('chat').overrideWithValue( + const IdentityNameSources(profiles: {agent: profile}).scope([agent]), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: Scaffold( + body: InlineAgentTyping( + channelId: 'chat', + entries: [ + if (typing) const TypingEntry(pubkey: agent, expiresAtMs: 8000), + ], + ), + ), + ), + ), +); +void main() { + testWidgets( + 'message rail, author and dots replace status text without a timestamp', + (tester) async { + await tester.pumpWidget(surface()); + expect(find.text('Test agent'), findsOneWidget); + expect(find.byType(AvatarImage), findsOneWidget); + expect(find.textContaining('typing'), findsNothing); + expect(find.byType(Text), findsNWidgets(2)); // Name and avatar initial. + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect(find.byKey(const ValueKey('agent-typing-dot-0')), findsOneWidget); + final dot = find.byKey(const ValueKey('agent-typing-dot-0')); + final initial = tester + .widget(dot) + .transform + .getTranslation() + .y; + await tester.pump(const Duration(milliseconds: 240)); + expect( + tester.widget(dot).transform.getTranslation().y, + lessThan(initial), + ); + expect( + tester.getTopLeft(find.text('Test agent')).dx, + messageAvatarSize + messageAvatarContentGap + Grid.xs, + ); + await tester.pumpWidget(surface(typing: false)); + expect(find.byType(AvatarImage), findsNothing); + expect(find.text('Working…'), findsNothing); + }, + ); + testWidgets('Reduce Motion leaves static dots and no active animation', ( + tester, + ) async { + await tester.pumpWidget(surface(reduced: true)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget); + expect(find.byType(ShaderMask), findsNothing); + expect(tester.binding.hasScheduledFrame, isFalse); + }); +} diff --git a/mobile/test/features/channels/me/chat_preflight_test.dart b/mobile/test/features/channels/me/chat_preflight_test.dart new file mode 100644 index 0000000000..2038a0299c --- /dev/null +++ b/mobile/test/features/channels/me/chat_preflight_test.dart @@ -0,0 +1,118 @@ +import 'package:buzz/features/channels/channel.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'; +import 'package:buzz/shared/me/me_chat_creation.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/theme/theme.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Channels extends ChannelsNotifier { + @override + Future> build() async => []; +} + +class _Relay extends RelaySessionNotifier { + VoidCallback? beforeRead; + int reads = 0; + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + reads++; + beforeRead?.call(); + throw StateError('ownership lookup interrupted'); + } +} + +void main() { + for (final disconnect in [true, false]) { + testWidgets( + 'chat accepted before ${disconnect ? "disconnect" : "preflight failure"} is durable', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ); + final relay = _Relay(); + var sync = MeSync(relay, config, prefs: prefs); + relay.beforeRead = () => + expectSync(MeChatCreation.pending(sync)?.name, 'Saved chat'); + late ProviderContainer container; + var forms = 0; + const bridge = MethodChannel('buzz/profile_text_editor'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( + call, + ) async { + forms++; + if (forms > 1) return null; + if (disconnect) { + sync.disposed = true; + sync = MeSync(relay, config, prefs: prefs, connected: false); + container.invalidate(meSyncProvider); + } + return {'name': 'Saved chat', 'assistant': 'agent'}; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + bridge, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWith((ref) => sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meAssistantsProvider.overrideWith((ref) async => []), + channelsProvider.overrideWith(_Channels.new), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + container = ProviderScope.containerOf( + tester.element(find.byType(MePage)), + ); + tester + .widget(find.byType(FrostedAppBar)) + .nativeActions! + .firstWhere((a) => a.label == 'New chat') + .onPressed!(); + await tester.pumpAndSettle(); + final saved = MeChatCreation.pending( + MeSync(relay, config, prefs: prefs), + ); + expect( + saved, + isNotNull, + reason: 'Accepted chat must survive before preflight succeeds', + ); + expect(saved!.name, 'Saved chat'); + expect(saved.agent, 'agent'); + expect( + prefs.getString(MeChatCreation.storageKey(sync)), + isNot(contains('Saved chat')), + ); + expect(relay.reads, disconnect ? 0 : 1); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } +} diff --git a/mobile/test/features/channels/me/me_chat_assistant_test.dart b/mobile/test/features/channels/me/me_chat_assistant_test.dart new file mode 100644 index 0000000000..d279fd757a --- /dev/null +++ b/mobile/test/features/channels/me/me_chat_assistant_test.dart @@ -0,0 +1,423 @@ +import 'dart:async'; +import 'dart:ui' show SemanticsAction; + +import 'package:buzz/features/channels/me/me_chat_assistant.dart'; +import 'package:buzz/features/channels/me/me_assistant_picker.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_agent_change.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Profiles extends UserCacheNotifier { + @override + Map build() => { + 'a': const UserProfile(pubkey: 'a', displayName: 'Alpha'), + 'b': const UserProfile(pubkey: 'b', displayName: 'Beta'), + }; +} + +class _Harness extends HookConsumerWidget { + final ValueChanged> onSend; + const _Harness(this.onSend); + @override + Widget build(BuildContext context, WidgetRef ref) { + final assistant = useMeChatAssistant( + context, + ref, + enabled: true, + channelId: 'chat', + initial: null, + messages: const [], + ); + return Scaffold( + appBar: AppBar(actions: [assistant.action]), + body: Column( + children: [ + ?assistant.pendingNotice, + TextButton( + onPressed: () async => onSend( + await meRecipients(ref, 'chat', assistant.selected, const []), + ), + child: const Text('Send'), + ), + ], + ), + ); + } +} + +void main() { + for (final keyFallback in [false, true]) { + final target = keyFallback ? '0123456789abcdef' * 4 : 'b'; + final targetName = keyFallback ? '01234567…cdef' : 'Beta'; + for (final action in ['Resume', 'Later', 'Discard choice']) { + testWidgets( + 'header restores pending agent choice: $action fallback=$keyFallback', + (tester) async { + final semantics = tester.ensureSemantics(); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ), + prefs: prefs, + ); + await prefs.setString(MeAgentChange.selectionKey(sync, 'chat'), 'a'); + await MeAgentChange.create(sync, 'chat', target).save(sync, 'chat'); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWithValue(sync), + meAssistantsProvider.overrideWith( + (ref) async => [ + AgentDirectoryEntry(pubkey: 'a', displayName: 'Alpha'), + AgentDirectoryEntry( + pubkey: target, + displayName: keyFallback ? null : 'Beta', + ), + ], + ), + channelMembersProvider('chat').overrideWith( + (ref) async => [ + for (final id in ['a', target]) + ChannelMember( + pubkey: id, + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + ), + userCacheProvider.overrideWith(_Profiles.new), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: _Harness((_) {}), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Finish choosing $targetName'), findsOneWidget); + expect( + tester + .getSemantics( + find.widgetWithText( + TextButton, + 'Finish choosing $targetName', + ), + ) + .getSemanticsData() + .hasAction(SemanticsAction.tap), + isTrue, + ); + expect( + find.byTooltip('Pending agent change: $targetName'), + findsOneWidget, + ); + await tester.tap(find.text('Finish choosing $targetName')); + await tester.pumpAndSettle(); + expect( + find.textContaining('Your choice of $targetName'), + findsOneWidget, + ); + expect( + find.textContaining('keeps any access already granted'), + findsOneWidget, + ); + expect(find.text('Finish changing your agent?'), findsOneWidget); + await tester.tap(find.text(action)); + await tester.pumpAndSettle(); + expect( + prefs.getString(MeAgentChange.selectionKey(sync, 'chat')), + action == 'Resume' ? target : 'a', + ); + expect( + MeAgentChange.pending(sync, 'chat') != null, + action == 'Later', + ); + expect( + find.text('Finish choosing $targetName'), + action == 'Later' ? findsOneWidget : findsNothing, + ); + semantics.dispose(); + }, + ); + } + } + for (final scenario in ['valid', 'revoked', 'removed']) { + testWidgets('parallel send checks handle $scenario recipients', ( + tester, + ) async { + final owned = Completer(); + final membership = Completer>(); + var ownershipStarted = false; + var membershipStarted = false; + late WidgetRef widgetRef; + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: + '0000000000000000000000000000000000000000000000000000000000000001', + ), + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWithValue(sync), + meAssistantOwnershipProvider('a').overrideWith((ref) { + ownershipStarted = true; + return owned.future; + }), + channelMembersProvider('chat').overrideWith((ref) { + membershipStarted = true; + return membership.future; + }), + ], + child: Consumer( + builder: (context, ref, child) { + widgetRef = ref; + return const SizedBox.shrink(); + }, + ), + ), + ); + final result = meRecipients(widgetRef, 'chat', 'a', const []); + expect(ownershipStarted, isTrue); + expect(membershipStarted, isTrue); + final assertion = expectLater( + result, + scenario == 'valid' ? completion(['a']) : throwsStateError, + ); + owned.complete(scenario != 'revoked'); + membership.complete( + scenario == 'removed' + ? [] + : [ + ChannelMember( + pubkey: 'a', + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + ); + await assertion; + }); + } + NostrEvent message(String author, List recipients, int time) => + NostrEvent( + id: '$time', + pubkey: author, + kind: EventKind.streamMessage, + createdAt: time, + content: 'hello', + tags: [ + for (final p in recipients) ['p', p], + ], + sig: '', + ); + test('last agent inference uses newest-first equal-second ID ordering', () { + final older = NostrEvent( + id: 'z', + pubkey: 'me', + kind: EventKind.streamMessage, + createdAt: 2, + content: '', + tags: const [ + ['p', 'a'], + ], + sig: '', + ); + final newer = NostrEvent( + id: 'a', + pubkey: 'me', + kind: EventKind.streamMessage, + createdAt: 2, + content: '', + tags: const [ + ['p', 'b'], + ], + sig: '', + ); + for (final input in [ + [older, newer], + [newer, older], + ]) { + expect( + resolveMeAssistant( + selected: null, + available: {'a', 'b'}, + viewer: 'me', + messages: input, + ), + 'b', + ); + } + }); + test( + 'inherits the sole agent and does not reuse a removed explicit choice', + () { + expect( + resolveMeAssistant( + selected: null, + available: {'a'}, + viewer: 'me', + messages: [], + ), + 'a', + ); + expect( + resolveMeAssistant( + selected: 'b', + available: {'a'}, + viewer: 'me', + messages: [], + ), + isNull, + ); + }, + ); + test( + 'inherits last desktop recipient but leaves ambiguous recipients unset', + () { + expect( + resolveMeAssistant( + selected: null, + available: {'a', 'b'}, + viewer: 'me', + messages: [ + message('me', ['a'], 1), + message('other', ['a'], 3), + message('me', ['b'], 2), + ], + ), + 'b', + ); + expect( + resolveMeAssistant( + selected: null, + available: {'a', 'b'}, + viewer: 'me', + messages: [ + message('me', ['a', 'b'], 2), + message('me', ['a'], 1), + ], + ), + isNull, + ); + }, + ); + testWidgets( + 'header avatar inherits agent, routes plain send, and remembers a change', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: + '0000000000000000000000000000000000000000000000000000000000000001', + ), + ), + prefs: prefs, + ); + var members = ['a']; + var directoryReads = 0; + var ownershipReads = 0; + List? sent; + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWithValue(sync), + meAssistantOwnershipProvider.overrideWith((ref, key) async { + ownershipReads++; + return key == 'a' || key == 'b'; + }), + meAssistantsProvider.overrideWith((ref) async { + directoryReads++; + return const [ + AgentDirectoryEntry(pubkey: 'a', displayName: 'Alpha'), + AgentDirectoryEntry(pubkey: 'b', displayName: 'Beta'), + ]; + }), + channelMembersProvider('chat').overrideWith( + (ref) async => [ + for (final id in members) + ChannelMember( + pubkey: id, + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + ), + userCacheProvider.overrideWith(_Profiles.new), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: _Harness((value) => sent = value), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Change agent: Alpha'), findsOneWidget); + expect(find.byType(AvatarImage), findsOneWidget); + expect(find.text('Choose follow-up assistant'), findsNothing); + final readsBeforeSend = directoryReads; + await tester.tap(find.text('Send')); + await tester.pumpAndSettle(); + expect(sent, ['a']); + expect(directoryReads, readsBeforeSend); + expect(ownershipReads, 1); + final readsBeforeOpening = directoryReads; + await tester.tap(find.byTooltip('Change agent: Alpha')); + await tester.pumpAndSettle(); + expect(directoryReads, readsBeforeOpening); + expect(find.text('Agents Β· 2'), findsOneWidget); + // Only the sheet heading says Agent; member rows have no role subtitle. + expect(find.text('Agent'), findsOneWidget); + final list = tester.widget(find.byType(ListView)); + expect( + list.padding, + const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xs), + ); + members = ['a', 'b']; + ProviderScope.containerOf( + tester.element(find.byType(_Harness)), + ).invalidate(channelMembersProvider('chat')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Beta')); + await tester.pumpAndSettle(); + expect(find.byTooltip('Change agent: Beta'), findsOneWidget); + expect( + prefs.getString('me-assistant:${sync.community}:${sync.pubkey}:chat'), + 'b', + ); + await tester.tap(find.text('Send')); + await tester.pumpAndSettle(); + expect(sent, ['b']); + expect(ownershipReads, 2); + }, + ); +} diff --git a/mobile/test/features/channels/me/me_page_test.dart b/mobile/test/features/channels/me/me_page_test.dart new file mode 100644 index 0000000000..fef20fe362 --- /dev/null +++ b/mobile/test/features/channels/me/me_page_test.dart @@ -0,0 +1,340 @@ +import 'dart:async'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:buzz/shared/me/me_chat_creation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/theme/buzz_icons.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/me/me_page.dart'; +import 'package:buzz/shared/me/me_groups.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/theme/theme.dart'; + +class FakeChannels extends ChannelsNotifier { + @override + Future> build() async => [ + for (final (id, name, member) in [ + ('personal', 'Design agent', true), + ('ordinary', 'General', true), + ('inaccessible', 'Hidden chat', false), + ]) + Channel( + id: id, + name: name, + channelType: 'stream', + visibility: 'private', + description: '', + createdBy: 'owner', + createdAt: DateTime(2026), + memberCount: 2, + isMember: member, + ), + ]; +} + +void main() { + testWidgets( + 'live desktop placement displays projects and only accessible Me chats', + (tester) async { + final updates = StreamController(); + addTearDown(updates.close); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meGroupsProvider.overrideWith((ref) => updates.stream), + channelsProvider.overrideWith(FakeChannels.new), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + updates.add( + const MeGroups( + groups: [ + {'id': 'project', 'name': 'App work', 'defaultTemplateId': ''}, + ], + assignments: {'personal': 'project'}, + channels: ['personal', 'inaccessible'], + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Search chats'), findsNothing); + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(BuzzIcons.plus), findsOneWidget); + expect(find.byIcon(BuzzIcons.folderPlus), findsOneWidget); + final header = tester.widget(find.byType(FrostedAppBar)); + expect(header.nativeLargeTitle, isTrue); + expect(header.nativeActions!.map((action) => action.icon), [ + BuzzIcons.folderPlus, + BuzzIcons.plus, + ]); + expect(find.text('App work'), findsOneWidget); + expect(find.text('Design agent'), findsOneWidget); + expect(find.text('General'), findsNothing); + expect(find.text('Hidden chat'), findsNothing); + expect(find.text('Chats'), findsOneWidget); + await tester.tap(find.text('App work')); + await tester.pumpAndSettle(); + expect(find.text('Design agent'), findsNothing); + await tester.tap(find.text('App work')); + await tester.pumpAndSettle(); + expect(find.text('Design agent'), findsOneWidget); + updates.add( + const MeGroups( + groups: [ + {'id': 'project', 'name': 'App work', 'defaultTemplateId': ''}, + ], + channels: ['personal'], + ), + ); + await tester.pumpAndSettle(); + expect(find.text('No chats in this project'), findsOneWidget); + expect(find.text('Design agent'), findsOneWidget); + await tester.tap(find.text('Chats')); + await tester.pumpAndSettle(); + expect(find.text('Design agent'), findsNothing); + expect(find.text('No chats in this project'), findsOneWidget); + await tester.tap(find.text('Chats')); + await tester.pumpAndSettle(); + expect(find.text('Design agent'), findsOneWidget); + + updates.add(const MeGroups()); + await tester.pumpAndSettle(); + expect(find.text('Design agent'), findsNothing); + expect(find.text('A place to work with your agents'), findsOneWidget); + }, + ); + testWidgets('new chat opens both fields in one dismissible sheet', ( + tester, + ) async { + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: + '0000000000000000000000000000000000000000000000000000000000000001', + ), + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meSyncProvider.overrideWithValue(sync), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith((ref) async => []), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('New chat')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect( + find.byKey(const ValueKey('new-session-name')).hitTestable(), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('new-session-assistant')).hitTestable(), + findsOneWidget, + ); + await tester.enterText( + find.byKey(const ValueKey('new-session-name')), + 'Design session', + ); + await tester.tap(find.text('Create chat')); + await tester.pumpAndSettle(); + expect(find.text('Enter a name and choose an agent.'), findsOneWidget); + expect(find.text('Design session'), findsOneWidget); + }); + + testWidgets('iOS new chat uses the native profile form with both fields', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final calls = []; + var directoryLoads = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + calls.add(call); + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: + '0000000000000000000000000000000000000000000000000000000000000001', + ), + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meSyncProvider.overrideWithValue(sync), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith((ref) async { + directoryLoads++; + return []; + }), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + + final bar = tester.widget(find.byType(FrostedAppBar)); + bar.nativeActions! + .firstWhere((action) => action.label == 'New chat') + .onPressed!(); + await tester.pumpAndSettle(); + expect(directoryLoads, 1); + expect(calls, hasLength(1)); + expect(calls.single.method, 'presentChat'); + final args = calls.single.arguments as Map; + expect(args['title'], 'New chat'); + expect(args['placeholder'], 'Chat name'); + expect(args['assistants'], isEmpty); + expect(args['containerCornerRadius'], Radii.container); + expect(find.byType(BottomSheet), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = null; + }); + + for (final resume in [true, false]) { + testWidgets('iOS new chat recovers a durable creation: resume=$resume', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final calls = []; + var directoryLoads = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + calls.add(call); + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: + '0000000000000000000000000000000000000000000000000000000000000001', + ), + ), + prefs: prefs, + ); + await const MeChatCreation( + 'same-id', + 'Unfinished chat', + 'agent', + ).save(sync); + final restarted = MeSync( + RelaySessionNotifier(), + sync.config, + prefs: prefs, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meSyncProvider.overrideWithValue(restarted), + channelsProvider.overrideWith(FakeChannels.new), + meAssistantsProvider.overrideWith((ref) async { + directoryLoads++; + return []; + }), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + + final bar = tester.widget(find.byType(FrostedAppBar)); + bar.nativeActions! + .firstWhere((action) => action.label == 'New chat') + .onPressed!(); + await tester.pumpAndSettle(); + await tester.tap(find.text(resume ? 'Resume' : 'Discard setup')); + await tester.pumpAndSettle(); + expect(directoryLoads, 1); + expect(calls, hasLength(1)); + expect(calls.single.method, 'presentChat'); + final args = calls.single.arguments as Map; + expect(args['title'], 'New chat'); + expect(args['initialValue'], resume ? 'Unfinished chat' : ''); + expect(args['selectedAssistant'], resume ? 'agent' : isNull); + expect(args['fieldsLocked'], resume); + expect(args['error'], resume ? contains('Resume') : isNull); + expect( + MeChatCreation.pending(restarted)?.id, + resume ? 'same-id' : isNull, + ); + expect(args['placeholder'], 'Chat name'); + expect(args['assistants'], isEmpty); + expect(args['containerCornerRadius'], Radii.container); + expect(find.byType(BottomSheet), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + debugDefaultTargetPlatformOverride = null; + }); + } + + testWidgets('unavailable preferences show retry instead of empty state', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + meGroupsProvider.overrideWith( + (ref) => Stream.error(StateError('offline')), + ), + channelsProvider.overrideWith(FakeChannels.new), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Try again'), findsOneWidget); + expect(find.text('A place to work with your agents'), findsNothing); + }); +} diff --git a/mobile/test/features/channels/me/me_unread_test.dart b/mobile/test/features/channels/me/me_unread_test.dart new file mode 100644 index 0000000000..5018bbf274 --- /dev/null +++ b/mobile/test/features/channels/me/me_unread_test.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/me/me_page.dart'; +import 'package:buzz/features/channels/me/me_unread_provider.dart'; +import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; +import 'package:buzz/features/home/home_page.dart'; +import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_groups.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; +import 'package:buzz/shared/theme/theme.dart'; + +Channel chat(String id, {bool member = true, bool archived = false}) => Channel( + id: id, + name: id, + channelType: 'stream', + visibility: 'private', + description: '', + createdBy: 'owner', + createdAt: DateTime(2026), + lastMessageAt: DateTime.fromMillisecondsSinceEpoch(20000), + memberCount: 2, + isMember: member, + archivedAt: archived ? DateTime(2026) : null, +); + +class _Channels extends ChannelsNotifier { + var all = [ + chat('solo'), + chat('ordinary'), + chat('archived', archived: true), + chat('nonmember', member: false), + ]; + int timestamp = 20; + @override + Future> build() async => all; + @override + Map get latestObservedByChannel => { + for (final c in all) c.id: timestamp, + }; + @override + Map> + get observedUnreadEventsByChannel => { + for (final c in all) + c.id: { + 'reply': ObservedUnreadEvent( + id: 'reply', + createdAt: timestamp, + rootId: 'root', + highPriority: false, + countsTowardBadge: true, + countsTowardAppBadge: false, + ), + }, + }; + void reply() { + timestamp++; + all = [ + for (final c in all) + c.copyWith( + lastMessageAt: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + ), + ]; + state = AsyncData([...all]); + } +} + +class _Reads extends ReadStateNotifier { + final bool empty; + final bool ready; + _Reads({this.empty = false, this.ready = true}); + @override + void seedContextRead(String key, int timestamp) => read(key, timestamp); + @override + ReadStateState build() => ReadStateState( + isReady: ready, + pubkey: 'owner', + contexts: empty ? {} : {'solo': 10}, + version: 0, + ); + void finishLoading() => state = ReadStateState( + isReady: true, + pubkey: state.pubkey, + contexts: state.contexts, + version: state.version + 1, + ); + void resetIdentity() => state = const ReadStateState( + isReady: true, + pubkey: 'other-owner', + contexts: {}, + version: 0, + ); + void read(String key, int timestamp) => + state = state.copyWithContext(key, timestamp); + void force() => state = ReadStateState( + isReady: true, + pubkey: 'owner', + contexts: state.contexts, + version: state.version + 1, + forcedUnreadContexts: const {'msg:reply': 'solo'}, + ); +} + +const organization = MeGroups( + groups: [ + {'id': 'project', 'name': 'Project', 'defaultTemplateId': ''}, + ], + assignments: {'solo': 'project'}, + channels: ['solo', 'archived', 'nonmember'], +); + +void main() { + for (final duringSeed in [false, true]) { + test( + 'fresh Solo history is gated; new reply during seeding=$duringSeed badges', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final channels = _Channels(); + final reads = _Reads(empty: true); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + channelsProvider.overrideWith(() => channels), + readStateProvider.overrideWith(() => reads), + meGroupsProvider.overrideWith( + (ref) => + Stream.value(const MeGroups(channels: ['solo', 'ordinary'])), + ), + ], + ); + addTearDown(container.dispose); + container.listen(channelsProvider, (_, _) {}); + container.listen(meGroupsProvider, (_, _) {}); + await container.read(channelsProvider.future); + await container.read(meGroupsProvider.future); + final observed = >[]; + container.listen( + meUnreadChannelIdsProvider, + (_, next) => observed.add(next), + fireImmediately: true, + ); + expect(container.read(meUnreadChannelIdsProvider), isEmpty); + if (duringSeed) { + channels.reply(); + expect(container.read(meUnreadChannelIdsProvider), isEmpty); + } + await container.pump(); + expect( + container.read(readStateProvider).effectiveTimestamp('solo'), + 20, + ); + if (!duringSeed) { + expect(observed.every((ids) => ids.isEmpty), isTrue); + channels.reply(); + } + await container.pump(); + expect(container.read(meUnreadChannelIdsProvider), { + 'solo', + 'ordinary', + }); + expect( + container.read(readStateProvider).effectiveTimestamp('solo'), + 20, + ); + reads.resetIdentity(); + expect(container.read(meUnreadChannelIdsProvider), isEmpty); + await container.pump(); + expect( + container.read(readStateProvider).effectiveTimestamp('solo'), + 21, + ); + expect(container.read(meUnreadChannelIdsProvider), isEmpty); + channels.reply(); + await container.pump(); + expect(container.read(meUnreadChannelIdsProvider), { + 'solo', + 'ordinary', + }); + }, + ); + } + for (final loading in [false, true]) { + testWidgets( + 'Solo retains row, section, and tab unread after loading=$loading', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final channels = _Channels(); + final reads = _Reads(empty: loading, ready: !loading); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + channelsProvider.overrideWith(() => channels), + readStateProvider.overrideWith(() => reads), + meGroupsProvider.overrideWith( + (ref) => Stream.value(organization), + ), + meAssistantsProvider.overrideWith((ref) async => []), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Consumer( + builder: (context, ref, _) => HomePage( + settingsPageBuilder: (_) => const SizedBox(), + mePageBuilder: (_) => const MePage(), + hasUnreadInbox: false, + hasUnreadSolo: ref + .watch(meUnreadChannelIdsProvider) + .isNotEmpty, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(HomePage)), + ); + if (loading) { + expect(container.read(meUnreadChannelIdsProvider), isEmpty); + expect(find.bySemanticsLabel('Solo, unread'), findsNothing); + expect( + container.read(readStateProvider).effectiveTimestamp('solo'), + isNull, + ); + channels.reply(); + await tester.pumpAndSettle(); + expect(container.read(meUnreadChannelIdsProvider), isEmpty); + expect( + container.read(readStateProvider).effectiveTimestamp('solo'), + isNull, + ); + reads.finishLoading(); + await tester.pumpAndSettle(); + expect( + container.read(readStateProvider).effectiveTimestamp('solo'), + 20, + ); + } + expect(container.read(meUnreadChannelIdsProvider), {'solo'}); + expect(find.bySemanticsLabel('Solo, unread'), findsOneWidget); + expect(find.text('solo'), findsNothing); + await tester.tap(find.byTooltip('Solo')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.text('solo')).style?.fontWeight, + FontWeight.w700, + ); + expect(find.bySemanticsLabel('solo, unread'), findsOneWidget); + await tester.tap(find.text('Project')); + await tester.pumpAndSettle(); + expect(find.text('solo'), findsNothing); + expect( + find.byKey(const ValueKey('solo-section-unread-Project')), + findsOneWidget, + ); + reads.read('msg:reply', channels.timestamp); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('solo-section-unread-Project')), + findsNothing, + ); + await tester.tap(find.byTooltip('Home')); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('Solo, unread'), findsNothing); + channels.reply(); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('Solo, unread'), findsOneWidget); + reads.read('thread:root', channels.timestamp); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('Solo, unread'), findsNothing); + reads.force(); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('Solo, unread'), findsOneWidget); + }, + ); + } +} diff --git a/mobile/test/features/channels/me/offline_save_page_test.dart b/mobile/test/features/channels/me/offline_save_page_test.dart new file mode 100644 index 0000000000..d36ea2aa88 --- /dev/null +++ b/mobile/test/features/channels/me/offline_save_page_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/me/me_page.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/me/me_assistants.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/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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 '../../../shared/me/me_sync_test.dart' show FakeRelay; + +const _id = '11111111-1111-4111-8111-111111111111'; + +class _Channels extends ChannelsNotifier { + @override + Future> build() async => [ + Channel( + id: _id, + name: 'Saved chat', + channelType: 'stream', + visibility: 'private', + description: '', + createdBy: 'owner', + createdAt: DateTime(2026), + memberCount: 2, + isMember: true, + ), + ]; +} + +class _Store extends InMemorySharedPreferencesStore { + bool fail = false; + Completer? blocked; + _Store() : super.empty(); + @override + Future setValue(String type, String key, Object value) async { + if (key.startsWith('flutter.me-cache:')) { + if (fail) return false; + if (blocked != null && !await blocked!.future) return false; + } + return super.setValue(type, key, value); + } +} + +class _Navigation extends NavigatorObserver { + int pushes = 0; + @override + void didPush(Route route, Route? previousRoute) { + pushes++; + } +} + +void main() { + testWidgets( + 'offline-save warning keeps Solo rows usable until retry succeeds', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final store = _Store(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + const secret = + '0000000000000000000000000000000000000000000000000000000000000001'; + final service = MeSync( + relay, + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: secret, + ), + ), + prefs: prefs, + ); + NostrEvent head(int time) => NostrEvent.fromJson( + nostr.Event.from( + kind: 30078, + createdAt: time, + secretKey: secret, + content: nip44Encrypt( + service.key, + const MeGroups(channels: [_id]).encode(service.community), + ), + tags: [ + ['d', MeGroups.coordinate(service.community)], + ['t', MeGroups.tag], + ], + ).toMap(), + ); + final old = head(1), fresh = head(2); + await service.cache(old); + relay.events = [fresh]; + store.fail = true; + final navigation = _Navigation(); + final navigator = GlobalKey(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + meSyncProvider.overrideWithValue(service), + meAssistantsProvider.overrideWith((ref) async => []), + channelsProvider.overrideWith(_Channels.new), + ], + child: MaterialApp( + theme: AppTheme.light(), + navigatorKey: navigator, + navigatorObservers: [navigation], + home: const MePage(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Saved chat'), findsOneWidget); + expect( + find.text( + 'Your chats are available, but couldn’t be saved for offline use.', + ), + findsOneWidget, + ); + expect(find.text('Couldn’t load your chats and projects.'), findsNothing); + expect(service.cachedSnapshot()?.event?.id, old.id); + await tester.tap(find.text('Saved chat')); + await tester.pump(); + expect(navigation.pushes, 2); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + store.fail = false; + store.blocked = Completer(); + await tester.tap(find.text('Retry offline save')); + await tester.pumpAndSettle(); + expect(find.text('Saved chat'), findsOneWidget); + expect(find.text('Retry offline save'), findsOneWidget); + store.blocked!.complete(true); + await tester.pumpAndSettle(); + expect(find.text('Retry offline save'), findsNothing); + expect(find.text('Saved chat'), findsOneWidget); + expect(service.cachedSnapshot()?.event?.id, fresh.id); + final invalid = NostrEvent.fromJson({ + ...head(3).toJson(), + 'sig': '0' * 128, + }); + relay.emit!(invalid); + await tester.pumpAndSettle(); + expect(find.text('Saved chat'), findsOneWidget); + expect(find.text('Retry live updates'), findsOneWidget); + expect(service.cachedSnapshot()?.event?.id, fresh.id); + relay.events = [invalid]; + await tester.tap(find.text('Retry live updates')); + await tester.pumpAndSettle(); + expect(find.text('Retry live updates'), findsOneWidget); + expect(find.text('Saved chat'), findsOneWidget); + relay.events = [fresh]; + await tester.tap(find.text('Retry live updates')); + await tester.pumpAndSettle(); + expect(find.text('Retry live updates'), findsNothing); + relay.closeSubscription!('terminal CLOSED'); + await tester.pumpAndSettle(); + expect(find.text('Saved chat'), findsOneWidget); + expect(find.text('Retry live updates'), findsOneWidget); + final pendingRefresh = Completer>(); + relay.pendingRead = pendingRefresh.future; + await tester.tap(find.text('Retry live updates')); + await tester.pumpAndSettle(); + expect(find.text('Saved chat'), findsOneWidget); + expect(find.text('Retry live updates'), findsOneWidget); + pendingRefresh.complete([fresh]); + await tester.pumpAndSettle(); + expect(find.text('Retry live updates'), findsNothing); + relay.closeSubscription!('closed again'); + await tester.pumpAndSettle(); + expect(find.text('Retry live updates'), findsOneWidget); + }, + ); +} diff --git a/mobile/test/features/channels/me/project_creation_test.dart b/mobile/test/features/channels/me/project_creation_test.dart new file mode 100644 index 0000000000..d1387f07dc --- /dev/null +++ b/mobile/test/features/channels/me/project_creation_test.dart @@ -0,0 +1,292 @@ +import 'package:buzz/features/channels/channel.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'; +import 'package:buzz/shared/me/me_groups.dart'; +import 'package:buzz/shared/me/me_project_creation.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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'; + +class _FailingStore extends InMemorySharedPreferencesStore { + final bool throws; + _FailingStore(this.throws) : super.empty(); + @override + Future setValue(String type, String key, Object value) async { + if (throws) throw StateError('disk failure'); + return false; + } +} + +class _Channels extends ChannelsNotifier { + @override + Future> build() async => []; +} + +class _Relay extends RelaySessionNotifier { + List events = []; + VoidCallback? beforeRead; + bool failRead = false; + bool failAfterPublish = false; + int writes = 0; + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + beforeRead?.call(); + if (failRead) throw StateError('interrupted before the first head'); + return events; + } + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + events = [event]; + writes++; + if (failAfterPublish) throw StateError('lost acknowledgement'); + return event; + } +} + +void main() { + final config = RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ); + for (final throws in [false, true]) { + testWidgets( + 'failed project write ($throws) reopens the entered name without claiming recovery', + (tester) async { + SharedPreferences.setMockInitialValues({}); + SharedPreferencesStorePlatform.instance = _FailingStore(throws); + final prefs = await SharedPreferences.getInstance(); + final relay = _Relay(); + final sync = MeSync(relay, config, prefs: prefs); + const channel = MethodChannel('buzz/profile_text_editor'); + var forms = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + forms++; + if (forms == 1) return 'My entered name'; + expectSync( + (call.arguments as Map)['initialValue'], + 'My entered name', + ); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + meSyncProvider.overrideWithValue(sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meAssistantsProvider.overrideWith((ref) async => []), + channelsProvider.overrideWith(_Channels.new), + ], + child: MaterialApp(theme: AppTheme.light(), home: const MePage()), + ), + ); + await tester.pumpAndSettle(); + tester + .widget(find.byType(FrostedAppBar)) + .nativeActions! + .firstWhere((a) => a.label == 'New project') + .onPressed!(); + await tester.pumpAndSettle(); + expect(find.text('Project was not saved'), findsOneWidget); + expect(find.textContaining('Project setup saved'), findsNothing); + expect(MeProjectCreation.pending(sync), isNull); + expect(relay.writes, 0); + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(forms, 2); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + for (final recovery in ['Resume', 'Discard setup']) { + for (final offline in [true, false]) { + testWidgets( + 'accepted project survives ${offline ? "offline" : "interrupted head"} restart with $recovery', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final relay = _Relay()..failRead = !offline; + var sync = MeSync(relay, config, prefs: prefs, connected: !offline); + var forms = 0; + relay.beforeRead = () => expectSync( + MeProjectCreation.pending(sync)?.name, + forms == 1 ? 'Private project' : 'Replacement project', + ); + const channel = MethodChannel('buzz/profile_text_editor'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + expectSync(call.method, 'presentProject'); + forms++; + return forms == 1 ? 'Private project' : 'Replacement project'; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + Future mount() async { + await tester.pumpWidget( + ProviderScope( + key: UniqueKey(), + overrides: [ + meSyncProvider.overrideWithValue(sync), + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups()), + ), + meAssistantsProvider.overrideWith((ref) async => []), + channelsProvider.overrideWith(_Channels.new), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const MePage(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future open() async { + tester + .widget(find.byType(FrostedAppBar)) + .nativeActions! + .firstWhere((a) => a.label == 'New project') + .onPressed!(); + await tester.pumpAndSettle(); + } + + await mount(); + await open(); + final saved = MeProjectCreation.pending(sync); + expect( + saved, + isNotNull, + reason: 'Accepted project intent must be durable before networking', + ); + final pending = saved!; + expect(pending.name, 'Private project'); + expect( + prefs.getString(MeProjectCreation.storageKey(sync)), + isNot(contains('Private project')), + ); + expect(relay.writes, 0); + sync = MeSync(relay, config, prefs: prefs); + relay.failRead = false; + await mount(); + await open(); + expect(find.textContaining('Private project'), findsOneWidget); + await tester.tap(find.text('Later')); + await tester.pumpAndSettle(); + expect(MeProjectCreation.pending(sync)?.id, pending.id); + expect(relay.writes, 0); + await open(); + await tester.tap(find.text(recovery)); + await tester.pumpAndSettle(); + expect(forms, recovery == 'Resume' ? 1 : 2); + expect( + MeProjectCreation.pending(sync), + isNull, + reason: tester + .widgetList(find.byType(Text)) + .map((t) => t.data) + .join(' | '), + ); + expect(relay.writes, 1); + expect( + sync.decode(relay.events.single).groups.single['id'], + recovery == 'Resume' ? pending.id : isNot(pending.id), + ); + expect( + sync.decode(relay.events.single).groups.single['name'], + recovery == 'Resume' ? pending.name : 'Replacement project', + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + } + test( + 'scoped offline discard compares the reviewed intent and allows a different project', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync(_Relay(), config, prefs: prefs, connected: false); + const original = MeProjectCreation('project', 'Private project'); + await original.save(sync); + for (final other in [ + RelayConfig(baseUrl: 'https://other.test', nsec: config.nsec), + RelayConfig( + baseUrl: config.baseUrl, + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '2', + ), + ), + ]) { + expect( + MeProjectCreation.pending(MeSync(_Relay(), other, prefs: prefs)), + isNull, + ); + } + await expectLater( + const MeProjectCreation('different', 'Private project').discard(sync), + throwsStateError, + ); + expect(MeProjectCreation.pending(sync)?.id, original.id); + await original.discard(sync); + await const MeProjectCreation('new', 'Different project').save(sync); + await expectLater(original.discard(sync), throwsStateError); + expect(MeProjectCreation.pending(sync)?.id, 'new'); + }, + ); + test( + 'lost publication acknowledgement retries the same ID without duplicating it', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final relay = _Relay()..failAfterPublish = true; + final sync = MeSync(relay, config, prefs: prefs); + const intent = MeProjectCreation('project', 'Name'); + await expectLater(intent.complete(sync), throwsStateError); + final restarted = MeSync(relay, config, prefs: prefs); + relay.failAfterPublish = false; + await expectLater( + MeProjectCreation.pending(restarted)!.complete(restarted), + throwsStateError, + ); + await MeProjectCreation.pending(restarted)!.complete(restarted); + expect(MeProjectCreation.pending(restarted), isNull); + expect(relay.writes, 1); + expect(restarted.decode(relay.events.single).groups, hasLength(1)); + }, + ); +} diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 7c0d5573a0..48f4d8e4a5 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -811,6 +811,24 @@ void main() { }); group('buildMainTimelineEntries', () { + test( + 'Me keeps agent replies inline without duplicate thread summaries', + () { + final messages = formatTimeline([ + _textMsg(id: 'root', createdAt: 1000), + _replyMsg(id: 'answer', parentId: 'root', createdAt: 2000), + _textMsg(id: 'followup', createdAt: 3000), + ]); + final entries = buildMainTimelineEntries(messages, flat: true); + expect(entries.map((e) => e.message.id), [ + 'root', + 'answer', + 'followup', + ]); + expect(entries.every((e) => e.summary == null), isTrue); + }, + ); + test('returns only root messages', () { final messages = formatTimeline([ _textMsg(id: threadIdA, createdAt: 1000), diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 9b8cc9f78b..1b6db41d0b 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -1,6 +1,6 @@ +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/features/search/search_page.dart'; -import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; @@ -30,6 +30,7 @@ void main() { child: child!, ), home: HomePage( + mePageBuilder: (_) => const Center(child: Text('Personal workspace')), settingsPageBuilder: _buildSettingsPage, hasUnreadInbox: unreadInboxCount > 0, ), @@ -131,71 +132,87 @@ void main() { findsOneWidget, ); expect(find.bySemanticsLabel('Activity, unread'), findsNothing); - final semantics = tester.ensureSemantics(); + await tester.tap(find.byTooltip('Solo')); + await tester.pumpAndSettle(); + expect(find.text('Personal workspace'), findsOneWidget); expect(find.byType(SearchPage), findsNothing); - expect( - tester - .getSemantics(find.bySemanticsLabel('Search')) - .flagsCollection - .isSelected - .toString(), - 'Tristate.isFalse', - ); - await tester.tap(find.byTooltip('Search')); - await tester.pump(const Duration(milliseconds: 250)); - await tester.pump(); + await tester.tap(find.byTooltip('Home')); + await tester.pumpAndSettle(); + tester + .widget(find.byType(FrostedAppBar)) + .nativeActions! + .firstWhere((action) => action.label == 'Search') + .onPressed!(); + await tester.pumpAndSettle(); expect(find.byType(SearchPage), findsOneWidget); + final searchRoute = ModalRoute.of(tester.element(find.byType(SearchPage)))!; + expect(searchRoute.allowSnapshotting, isFalse); + expect(searchRoute.opaque, isTrue); + expect(searchRoute.transitionDuration, const Duration(milliseconds: 500)); expect( - tester - .getSemantics(find.bySemanticsLabel('Search')) - .flagsCollection - .isSelected - .toString(), - 'Tristate.isTrue', + searchRoute.reverseTransitionDuration, + const Duration(milliseconds: 500), ); - semantics.dispose(); + Navigator.of(tester.element(find.byType(SearchPage))).pop(); + await tester.pumpAndSettle(); + expect(find.byType(SearchPage), findsNothing); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); debugDefaultTargetPlatformOverride = null; }); - testWidgets('shows icon-only navigation and an aligned quick action', ( + for (final reducedMotion in [false, true]) { + testWidgets('search slides vertically, reduced motion: $reducedMotion', ( + tester, + ) async { + await tester.pumpWidget( + await buildHome(disableAnimations: reducedMotion), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Search')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + final search = find.byType(SearchPage); + expect(search, findsOneWidget); + expect(tester.getTopLeft(search).dx, 0); + expect(tester.getTopLeft(search).dy, reducedMotion ? 0 : greaterThan(0)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(search).dy, 0); + expect(ModalRoute.of(tester.element(search))!.opaque, isFalse); + Navigator.of(tester.element(search)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + if (reducedMotion) { + expect(search, findsNothing); + } else { + expect(tester.getTopLeft(search).dy, greaterThan(0)); + } + await tester.pumpAndSettle(); + expect(search, findsNothing); + }); + } + + testWidgets('keeps creation beside the tabs and search beside the avatar', ( tester, ) async { await tester.pumpWidget(await buildHome()); await tester.pump(); - - expect(find.byKey(const ValueKey('home-ios-glass-tabs')), findsNothing); - expect(find.text('Home'), findsNothing); - expect(find.text('Activity'), findsNothing); - expect(find.text('Search'), findsNothing); - expect(find.bySemanticsLabel('Home'), findsOneWidget); - expect(find.bySemanticsLabel('Activity'), findsOneWidget); - expect(find.bySemanticsLabel('Search'), findsOneWidget); - - final quickAction = find.byTooltip('Create or start conversation'); - expect(quickAction, findsOneWidget); - final launcherSize = tester.getSize( - find.byType(ChannelQuickActionsLauncher), - ); - expect(launcherSize.width, 800); - expect(launcherSize.height, greaterThan(0)); - final motionRect = tester.getRect( - find.byKey(const Key('channel-quick-actions-motion')), - ); - expect(motionRect.width, const Size.square(56).width); - expect(motionRect.left, greaterThanOrEqualTo(0)); - expect(tester.getSize(quickAction), const Size.square(56)); - final quickActionRect = tester.getRect(quickAction); - expect(quickActionRect.left, greaterThanOrEqualTo(0)); - expect(quickActionRect.top, greaterThanOrEqualTo(0)); - expect(quickActionRect.right, lessThanOrEqualTo(800)); - expect(quickActionRect.bottom, lessThanOrEqualTo(600)); - final homeDestinationRect = tester.getRect(find.bySemanticsLabel('Home')); + final plus = find.byTooltip('Create or start conversation'); + final search = find.byTooltip('Search'); + final avatar = find.byType(ProfileAvatar); + expect(plus, findsOneWidget); + expect(search, findsOneWidget); + expect(tester.getCenter(search).dy, lessThan(tester.getCenter(plus).dy)); + expect(tester.getCenter(search).dy, tester.getCenter(avatar).dy); expect( - quickActionRect.center.dy, - closeTo(homeDestinationRect.center.dy, 0.01), + tester.getCenter(plus).dy, + closeTo(tester.getCenter(find.byTooltip('Home')).dy, 0.01), ); + await tester.tap(plus); + await tester.pumpAndSettle(); + expect(find.text('Create channel'), findsOneWidget); + expect(find.text('New direct message'), findsOneWidget); + expect(find.text('Browse channels'), findsOneWidget); }); testWidgets('keeps the Buzz backdrop behind the scalable Home screen', ( @@ -354,7 +371,7 @@ void main() { await tester.pump(); expect(hapticCalls, hasLength(1)); - await tester.tap(find.byTooltip('Search')); + await tester.tap(find.byTooltip('Solo')); await tester.pump(); expect(hapticCalls, hasLength(2)); }); @@ -518,7 +535,6 @@ void main() { expect(bodyOpacity.opacity, closeTo(1, 0.001)); expect(appBarOpacity.opacity, closeTo(1, 0.001)); }); - testWidgets('scales and fades the quick action as tabs change', ( tester, ) async { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 76970915a8..9b1eb9a5c9 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -12,20 +12,26 @@ import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; -import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/concentric_sheet_surface.dart'; +import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/widget_helpers.dart'; void main() { - for (final reducedMotion in [false, true]) { - testWidgets( - 'iOS search field rises above tappable chips, reduced motion=$reducedMotion', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + for (final scale in [1.0, 2.0]) { + testWidgets('search controls stay fixed on $platform at scale $scale', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = platform; addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); await tester.pumpWidget( WidgetHelpers.testable( overrides: [ @@ -39,8 +45,7 @@ void main() { ], child: MediaQuery( data: MediaQueryData( - disableAnimations: reducedMotion, - textScaler: TextScaler.linear(reducedMotion ? 2 : 1), + textScaler: TextScaler.linear(scale), padding: const EdgeInsets.only(top: 59), ), child: const SearchPage(), @@ -49,206 +54,135 @@ void main() { ); await tester.pumpAndSettle(); final field = find.byKey(const Key('search-field-container')); - final idle = tester.getRect(field); - final input = tester.element(find.byType(TextField)); - await tester.tap(find.byKey(const Key('search-field'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 80)); - final middle = tester.getRect(field); - await tester.pumpAndSettle(); - final active = tester.getRect(field); - final cancel = find.byKey(const Key('search-ios-cancel')); - final cancelRect = tester.getRect(cancel); - expect(cancel.hitTestable(), findsOneWidget); - expect(cancelRect.left - active.right, closeTo(Grid.xxs, 0.01)); - expect( - tester.getRect(find.byType(FrostedAppBar)).right - cancelRect.right, - closeTo(Grid.gutter, 0.01), - ); - expect(active.top, lessThan(idle.top)); - expect(active.width, lessThan(idle.width)); - expect(active.top, greaterThanOrEqualTo(59)); - if (!reducedMotion) { - expect(middle.top, greaterThan(active.top)); - expect(middle.top, lessThan(idle.top)); + final filters = find.byKey(const Key('search-header-filters')); + final close = find.byKey(const Key('search-close')); + final fieldRect = tester.getRect(field); + final filtersRect = tester.getRect(filters); + expect(find.text('Search'), findsNothing); + expect(find.text('Cancel'), findsNothing); + expect(fieldRect.top, greaterThanOrEqualTo(59)); + expect(tester.getRect(close).left, greaterThan(fieldRect.right)); + expect(tester.getSize(close), const Size(48, 48)); + for (final label in ['All', 'Messages', 'Channels', 'People']) { + expect( + platform == TargetPlatform.iOS + ? find.byTooltip(label) + : find.text(label).hitTestable(), + findsOneWidget, + ); } + final input = tester.element(find.byType(TextField)); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(tester.getRect(field), fieldRect); + expect(tester.getRect(filters), filtersRect); expect(tester.element(find.byType(TextField)), same(input)); expect( tester.widget(find.byType(TextField)).focusNode!.hasFocus, isTrue, ); - final filters = tester.getRect( - find.byKey(const Key('search-header-filters')), - ); - expect(filters.top - active.bottom, closeTo(Grid.xxs, 0.01)); - expect( - tester.getRect(find.byType(FrostedAppBar)).bottom, - greaterThanOrEqualTo(filters.bottom), - ); - for (final label in ['All', 'Messages', 'Channels', 'People']) { - expect(find.text(label).hitTestable(), findsOneWidget); - } await tester.enterText(find.byType(TextField), 'design'); - await tester.tap(cancel); + if (platform == TargetPlatform.iOS) { + await _pressNativeFilter(tester, 'Channels'); + } else { + await tester.tap(find.text('Channels')); + } await tester.pumpAndSettle(); - expect(tester.getRect(field).top, closeTo(idle.top, 0.01)); - await tester.pumpWidget(const SizedBox()); + expect(tester.getRect(field), fieldRect); + expect(tester.getRect(filters), filtersRect); + expect( + tester + .widget( + find.byType(ConcentricSheetSurface), + ) + .usesGlass, + isTrue, + ); + expect( + find.byType(IosGlassNavigationButton), + platform == TargetPlatform.iOS ? findsNWidgets(5) : findsNothing, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); debugDefaultTargetPlatformOverride = null; - }, - ); + }); + } } - testWidgets('iOS recent rows move once through title collapse', ( + testWidgets('iOS native filter press selects Channels and filters results', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); + final state = SearchState( + query: 'design', + channelResults: [ + Channel( + id: 'design', + name: 'design', + channelType: 'stream', + visibility: 'open', + description: 'Design discussion', + memberCount: 4, + createdBy: 'test', + createdAt: DateTime(2025), + isMember: true, + ), + ], + userResults: const [DirectoryUser(pubkey: 'maya', displayName: 'Maya')], + ); await tester.pumpWidget( WidgetHelpers.testable( overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier( - List.generate(30, (i) => 'Recent query $i'), - ), + () => _FakeRecentSearchesNotifier(const []), ), profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), ], child: const SearchPage(), ), ); await tester.pumpAndSettle(); - final row = find.text('Recent query 1'); - final list = find.byKey(const Key('recent-searches-list')); - final gesture = await tester.startGesture(tester.getCenter(list)); - await gesture.moveBy(const Offset(0, -20)); - await tester.pump(); - for (var i = 0; i < 5; i++) { - final before = tester.getTopLeft(row).dy; - await gesture.moveBy(const Offset(0, -20)); - await tester.pump(); - expect(before - tester.getTopLeft(row).dy, closeTo(20, 1)); - } - await gesture.up(); - await tester.pumpWidget(const SizedBox()); + final channel = find.byKey(const ValueKey('search-channel-row-design')); + final person = find.byKey(const ValueKey('search-person-row-maya')); + expect(channel, findsOneWidget); + expect(person, findsOneWidget); + expect( + tester.widget(_nativeFilter('All')).isSelected, + isTrue, + ); + expect( + tester + .widget(_nativeFilter('Channels')) + .isSelected, + isFalse, + ); + + await _pressNativeFilter(tester, 'Channels'); + await tester.pumpAndSettle(); + + expect( + tester + .widget(_nativeFilter('Channels')) + .isSelected, + isTrue, + ); + expect( + tester.widget(_nativeFilter('All')).isSelected, + isFalse, + ); + expect(channel, findsOneWidget); + expect(person, findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); debugDefaultTargetPlatformOverride = null; }); - testWidgets('reselecting Search uses the field activation path', ( + testWidgets('X dismisses search while the keyboard is focused', ( tester, ) async { - final tabReselection = ValueNotifier(0); - addTearDown(tabReselection.dispose); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier(const []), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: SearchPage(tabReselection: tabReselection), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('search-cancel')), findsNothing); - tabReselection.value++; - await tester.pump(); - await tester.pump(); - - expect(find.byKey(const Key('search-cancel')), findsOneWidget); - expect( - tester.widget(find.byType(TextField)).focusNode?.hasFocus, - isTrue, - ); - - final focusNode = tester - .widget(find.byType(TextField)) - .focusNode!; - // Reproduce the real tab-tap ordering where the destination callback can - // run immediately before the same pointer gesture dismisses the field. - tabReselection.value++; - focusNode.unfocus(); - await tester.pump(); - await tester.pump(); - - expect(find.byKey(const Key('search-cancel')), findsOneWidget); - expect(focusNode.hasFocus, isTrue); - expect( - tester - .widget( - find.byKey(const Key('search-header-title-opacity')), - ) - .opacity, - 0, - reason: 'The tab gesture must not paint a close-and-reopen flicker.', - ); - }); - - testWidgets('uses the shared frosted navigation surface', (tester) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier(const []), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: const SearchPage(), - ), - ); - await tester.pumpAndSettle(); - - final appBar = tester.widget(find.byType(FrostedAppBar)); - expect(appBar.gradient, isNull); - expect(appBar.frosted, isTrue); - expect(appBar.showBottomDivider, isTrue); - expect(appBar.bottomDividerOpacity, 0.07); - expect(appBar.bottomHeight, 57); - expect(appBar.leading, isNull); - expect(appBar.centerTitle, isFalse); - expect(find.text('Search'), findsOneWidget); - final promptText = find.descendant( - of: find.byKey(const Key('search-field-container')), - matching: find.byType(Text), - ); - expect( - tester.getRect(promptText).left, - closeTo( - tester.getRect(find.byKey(const Key('search-moving-icon'))).right + - Grid.xxs, - 0.01, - ), - ); - expect( - tester.getRect(promptText).center.dy, - closeTo( - tester - .getRect(find.byKey(const Key('search-field-container'))) - .center - .dy, - 0.5, - ), - ); - }); - - testWidgets('empty state preserves large accessible text scaling', ( - tester, - ) async { - tester.view.physicalSize = const Size(320, 640); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( WidgetHelpers.testable( overrides: [ @@ -261,75 +195,31 @@ void main() { profileProvider.overrideWith(() => _FakeProfileNotifier()), ], child: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of( + builder: (context) => TextButton( + onPressed: () => Navigator.of( context, - ).copyWith(textScaler: const TextScaler.linear(2)), - child: const SearchPage(), + ).push(MaterialPageRoute(builder: (_) => const SearchPage())), + child: const Text('Open search'), ), ), ), ); + await tester.tap(find.text('Open search')); await tester.pumpAndSettle(); - - final appBar = tester.widget(find.byType(FrostedAppBar)); - final titleStyle = appBar.titleStyle!; - expect(titleStyle.fontSize, 22); - expect( - tester - .getSize( - find - .descendant( - of: find.byType(FrostedAppBar), - matching: find.byType(ClipRect), - ) - .first, - ) - .height, - closeTo( - frostedAppBarHeight( - tester.element(find.byType(FrostedAppBar)), - titleStyle: titleStyle, - bottomHeight: appBar.bottomHeight, - ), - 0.01, - ), - ); - final emptyState = find.byKey(const Key('search-empty-state')); - final searchField = find.byKey(const Key('search-field-container')); - final searchFieldContext = tester.element(searchField); - final bodyStyle = Theme.of(searchFieldContext).textTheme.bodyMedium!; - final scaledLineHeight = - MediaQuery.textScalerOf(searchFieldContext).scale(bodyStyle.fontSize!) * - bodyStyle.height!; - - expect( - find.descendant(of: emptyState, matching: find.byType(FittedBox)), - findsNothing, - ); - expect( - tester.getSize(searchField).height, - greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2), - ); - final prompt = tester.widget( - find.descendant( - of: find.byKey(const Key('search-field-container')), - matching: find.byType(Text), - ), - ); - expect(prompt.style?.fontSize, 15); - expect(prompt.maxLines, 1); - expect(prompt.overflow, TextOverflow.ellipsis); - expect( - tester - .getSize(find.descendant(of: emptyState, matching: find.byType(Text))) - .height, - greaterThan(32), - ); - expect(tester.takeException(), isNull); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'draft'); + await tester.tap(find.byTooltip('Close search')); + await tester.pumpAndSettle(); + expect(find.byType(SearchPage), findsNothing); + expect(find.text('Open search'), findsOneWidget); }); - testWidgets('search filters grow with accessible text', (tester) async { + testWidgets('reselecting Search restores input focus without moving tabs', ( + tester, + ) async { + final reselection = ValueNotifier(0); + addTearDown(reselection.dispose); await tester.pumpWidget( WidgetHelpers.testable( overrides: [ @@ -341,197 +231,24 @@ void main() { ), profileProvider.overrideWith(() => _FakeProfileNotifier()), ], - child: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: const TextScaler.linear(2)), - child: const SearchPage(), - ), - ), + child: SearchPage(tabReselection: reselection), ), ); await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('search-field'))); - await tester.pumpAndSettle(); - - final filters = find.byKey(const Key('search-header-filters')); - final activeField = find.byKey(const Key('search-field-container')); - final cancel = find.byKey(const Key('search-cancel')); - expect(filters, findsOneWidget); - expect(cancel, findsOneWidget); - expect( - tester.getRect(activeField).right, - lessThanOrEqualTo(tester.getRect(cancel).left), - reason: 'Scaled Cancel must not overlap the active search field.', - ); - expect(tester.getSize(filters).height, greaterThan(Grid.xl)); - expect( - tester.getSize(filters).height, - greaterThanOrEqualTo( - tester.getSize(find.text('Messages')).height + Grid.xs * 2, - ), - ); - expect(tester.takeException(), isNull); - }); - - testWidgets('focus slides Cancel in beside the search field', (tester) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier(const []), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: const SearchPage(), - ), - ); - await tester.pumpAndSettle(); - - final searchField = find.byKey(const Key('search-field')); - final editingField = find.byType(TextField); - final searchFieldContainer = find.byKey( - const Key('search-field-container'), - ); - final unfocusedWidth = tester.getSize(searchFieldContainer).width; - final unfocusedTop = tester.getRect(searchFieldContainer).top; - expect(find.byKey(const Key('search-cancel')), findsNothing); - await tester.tap(searchField); - await tester.pump(); - - final cancel = find.byKey(const Key('search-cancel')); - expect(cancel, findsOneWidget); - expect( - tester.getSize(cancel).height, - greaterThanOrEqualTo(Grid.xl), - reason: 'Cancel must keep a 48dp touch target.', - ); - final input = tester.widget(editingField); - expect(input.decoration?.hintText, isNull); - expect(input.textInputAction, TextInputAction.search); - expect( - input.focusNode?.hasFocus, - isTrue, - reason: 'Tapping the idle search field opens the native keyboard.', - ); - final enteringSlide = tester.widget( - find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first, - ); - expect(enteringSlide.position.value.dx, greaterThan(0)); - - await tester.pump(const Duration(milliseconds: 160)); - final focusedWidth = tester.getSize(searchFieldContainer).width; - final focusedRect = tester.getRect(searchFieldContainer); - expect(focusedWidth, lessThan(unfocusedWidth)); - expect( - focusedRect.top, - lessThan(unfocusedTop), - reason: 'The active field translates upward into the title row.', - ); - expect(find.byKey(const Key('search-header-filters')), findsOneWidget); - final filtersRect = tester.getRect( + final filters = tester.getRect( find.byKey(const Key('search-header-filters')), ); - expect( - filtersRect.top - focusedRect.bottom, - Grid.xxs, - reason: 'Filters keep one compact spacing token below the controls.', - ); - final settledSlide = tester.widget( - find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first, - ); - expect(settledSlide.position.value, Offset.zero); - final movingIcon = find.byKey(const Key('search-moving-icon')); - final iconScale = tester.widget( - find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)), - ); - final movingField = tester.widget( - find.ancestor(of: movingIcon, matching: find.byType(AnimatedPositioned)), - ); - expect(iconScale.scale, lessThan(1)); - expect(movingField.top, Grid.half); - final appBarRect = tester.getRect(find.byType(FrostedAppBar)); - expect( - appBarRect.bottom - filtersRect.bottom, - closeTo(Grid.xxs + 1, 0.01), - reason: - 'The filter row keeps the same spacing below it, plus the divider.', - ); - expect( - appBarRect.contains(focusedRect.center), - isTrue, - reason: 'The translated field remains inside the app bar hit-test box.', - ); - - await tester.enterText(editingField, 'design'); - await tester.tap(cancel); - await tester.pump(); - - final titleOpacity = tester.widget( - find.byKey(const Key('search-header-title-opacity')), - ); - expect( - titleOpacity.opacity, - 1, - reason: - 'The title fades beneath the returning field instead of appearing after it.', - ); - await tester.pump(const Duration(milliseconds: 159)); - expect( - tester - .widget( - find.byKey(const Key('search-header-title-opacity')), - ) - .opacity, - 1, - ); + reselection.value++; await tester.pumpAndSettle(); - - expect(find.byKey(const Key('search-cancel')), findsNothing); - expect( - tester.getSize(searchFieldContainer).width, - closeTo(unfocusedWidth, 0.01), - ); - }); - - testWidgets('keeps the search prompt calm until it is focused', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier(const []), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: const SearchPage(), - ), - ); + final focus = tester.widget(find.byType(TextField)).focusNode!; + expect(focus.hasFocus, isTrue); + reselection.value++; + focus.unfocus(); await tester.pumpAndSettle(); - - final searchField = find.byKey(const Key('search-field')); - final searchFieldContainer = find.byKey( - const Key('search-field-container'), - ); - expect(find.text('Messages'), findsNothing); - expect(tester.getSize(searchFieldContainer).height, greaterThan(36)); - - await tester.tap(searchField); - await tester.pumpAndSettle(); - - expect(find.text('Messages'), findsOneWidget); + expect(focus.hasFocus, isTrue); expect( - tester.getSize(searchFieldContainer).height, - greaterThanOrEqualTo(36), + tester.getRect(find.byKey(const Key('search-header-filters'))), + filters, ); }); @@ -558,7 +275,7 @@ void main() { await tester.tap(searchField); await tester.pumpAndSettle(); await tester.enterText(searchField, 'draft'); - await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.enterText(find.byKey(const Key('search-field')), ''); await tester.pumpAndSettle(); expect(find.byKey(const Key('recent-searches-list')), findsNothing); @@ -566,8 +283,12 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(searchField, 'design systems'); await tester.testTextInput.receiveAction(TextInputAction.search); - await tester.pump(); - await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('search-close')), findsOneWidget); + // Submission dismisses editing immediately. Reopen before clearing. + await tester.tap(searchField); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('search-field')), ''); await tester.pumpAndSettle(); expect(find.byKey(const Key('recent-searches-list')), findsOneWidget); @@ -616,7 +337,7 @@ void main() { expect(input.focusNode?.hasFocus, isTrue); expect(find.text("No results for 'launch plan'"), findsOneWidget); - await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.enterText(find.byKey(const Key('search-field')), ''); await tester.pumpAndSettle(); expect(find.byKey(const Key('recent-search-0')), findsOneWidget); expect(find.text('launch plan'), findsOneWidget); @@ -1126,6 +847,27 @@ void main() { }); } +Finder _nativeFilter(String label) => find.byWidgetPredicate( + (widget) => widget is IosGlassNavigationButton && widget.label == label, +); + +Future _pressNativeFilter(WidgetTester tester, String label) async { + final view = tester.widget( + find.descendant(of: _nativeFilter(label), matching: find.byType(UiKitView)), + ); + // Simulate the engine creating this view, then deliver the native tap through + // the same per-view channel used by the production UIKit control. + const viewId = 42; + view.onPlatformViewCreated!(viewId); + await tester.pump(); + const channel = MethodChannel('buzz/navigation_glass/$viewId'); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall('pressed')), + (_) {}, + ); +} + class _FakeSearchNotifier extends SearchNotifier { _FakeSearchNotifier(this.initialState); diff --git a/mobile/test/features/settings/settings_overlay_test.dart b/mobile/test/features/settings/settings_overlay_test.dart index 7dbae46aa0..2605292197 100644 --- a/mobile/test/features/settings/settings_overlay_test.dart +++ b/mobile/test/features/settings/settings_overlay_test.dart @@ -135,7 +135,9 @@ void main() { final appBar = tester .widgetList(find.byType(FrostedAppBar)) .first; - appBar.nativeActions!.single.onPressed!(); + appBar.nativeActions! + .singleWhere((action) => action.label == 'Settings') + .onPressed!(); } else { await tester.tap(find.byType(ProfileAvatar)); } diff --git a/mobile/test/shared/crypto/nip_oa_test.dart b/mobile/test/shared/crypto/nip_oa_test.dart index 55577fe848..17f5cb7fed 100644 --- a/mobile/test/shared/crypto/nip_oa_test.dart +++ b/mobile/test/shared/crypto/nip_oa_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; import 'package:buzz/shared/crypto/nip_oa.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; String _sha256Hex(String input) { final digest = SHA256Digest().process(Uint8List.fromList(utf8.encode(input))); @@ -25,6 +26,46 @@ void main() { final owner = nostr.Keys.generate(); final agent = nostr.Keys.generate(); + for (final (conditions, expected) in [ + ('', true), + ('kind=0&created_at>99&created_at<101', true), + ('kind=1', false), + ('created_at>100', false), + ('created_at<100', false), + ('kind=0&created_at>100', false), + ]) { + test('profile evaluates signed conditions $conditions', () { + final tag = authTag(owner, agent.public, conditions: conditions); + NostrEvent event(List> tags, {int kind = 0}) => + NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + content: '{}', + secretKey: agent.secret, + createdAt: 100, + tags: tags, + ).toMap(), + ); + expect( + verifiedProfileOaOwnerPubkey(event([tag])), + expected ? owner.public : null, + ); + expect( + verifiedProfileOaOwnerPubkey( + event([ + tag, + ['auth'], + ]), + ), + isNull, + ); + expect(verifiedProfileOaOwnerPubkey(event([tag, tag])), isNull); + expect(verifiedProfileOaOwnerPubkey(event([tag], kind: 1)), isNull); + final forged = event([tag]).toJson()..['sig'] = '0' * 128; + expect(verifiedProfileOaOwnerPubkey(NostrEvent.fromJson(forged)), isNull); + }); + } + test('returns the owner pubkey for a valid auth tag', () { final tag = authTag(owner, agent.public); expect( diff --git a/mobile/test/shared/me/me_agent_change_test.dart b/mobile/test/shared/me/me_agent_change_test.dart new file mode 100644 index 0000000000..34afc528dc --- /dev/null +++ b/mobile/test/shared/me/me_agent_change_test.dart @@ -0,0 +1,120 @@ +import 'package:buzz/shared/me/me_agent_change.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:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; + +class _Store extends InMemorySharedPreferencesStore { + bool failPreference = false; + bool failJournal = false; + _Store() : super.empty(); + @override + Future setValue(String valueType, String key, Object value) async { + if (failJournal && key.endsWith(':pending')) return false; + if (failPreference && + key.startsWith('flutter.me-assistant:') && + !key.endsWith(':pending')) { + return false; + } + return super.setValue(valueType, key, value); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences prefs; + late _Store store; + setUp(() async { + SharedPreferences.setMockInitialValues({}); + store = _Store(); + SharedPreferencesStorePlatform.instance = store; + prefs = await SharedPreferences.getInstance(); + }); + MeSync service({String relay = 'relay.test', String secret = '1'}) => MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://$relay', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + secret, + ), + ), + prefs: prefs, + ); + for (final boundary in ['before add', 'after add', 'preference']) { + test('restart resumes agent change after $boundary failure', () async { + var member = false; + var adds = 0; + var fail = true; + final intent = MeAgentChange.create(service(), 'chat', 'private-agent'); + Future ensure() async { + expect( + MeAgentChange.pending(service(), 'chat')?.agent, + 'private-agent', + ); + if (fail && boundary == 'before add') throw StateError('crash'); + if (!member) { + member = true; + adds++; + } + if (fail && boundary == 'after add') throw StateError('lost ack'); + } + + store.failPreference = boundary == 'preference'; + await expectLater( + intent.complete(service(), 'chat', ensureMembership: ensure), + throwsStateError, + ); + expect( + prefs.getString( + '${MeAgentChange.selectionKey(service(), 'chat')}:pending', + ), + isNot(contains('private-agent')), + ); + final restored = MeAgentChange.pending(service(), 'chat')!; + fail = false; + store.failPreference = false; + await restored.complete(service(), 'chat', ensureMembership: ensure); + expect(adds, 1); + expect( + prefs.getString(MeAgentChange.selectionKey(service(), 'chat')), + 'private-agent', + ); + expect(MeAgentChange.pending(service(), 'chat'), isNull); + }); + } + test('failed durable write never grants access', () async { + store.failJournal = true; + var accessed = false; + await expectLater( + MeAgentChange.create(service(), 'chat', 'agent').complete( + service(), + 'chat', + ensureMembership: () async { + accessed = true; + }, + ), + throwsStateError, + ); + expect(accessed, isFalse); + }); + test('journal isolates scope and compares exact reviewed record', () async { + final sync = service(); + final first = MeAgentChange.create(sync, 'chat', 'agent'); + await first.save(sync, 'chat'); + expect(MeAgentChange.pending(service(relay: 'other.test'), 'chat'), isNull); + expect(MeAgentChange.pending(service(secret: '2'), 'chat'), isNull); + expect(MeAgentChange.pending(sync, 'other-chat'), isNull); + await expectLater( + first.discard(service(relay: 'other.test'), 'chat'), + throwsStateError, + ); + await first.discard(sync, 'chat'); + final second = MeAgentChange.create(sync, 'chat', 'agent'); + await second.save(sync, 'chat'); + await expectLater(first.discard(sync, 'chat'), throwsStateError); + expect(MeAgentChange.pending(sync, 'chat'), isNotNull); + }); +} diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart new file mode 100644 index 0000000000..62aee1b2a5 --- /dev/null +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buzz/shared/me/me_assistants.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:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; + +class _Relay extends RelaySessionNotifier { + final List> pages; + final requestedPages = []; + final requestedTags = >[]; + final requestedAuthors = ?>[]; + _Relay(this.pages); + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + @override + Future> queryRelay( + 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]; + } +} + +void main() { + final owner = nostr.Keys.generate(); + final other = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + NostrEvent profile( + nostr.Keys signer, { + nostr.Keys? ownedBy, + int time = 1, + String conditions = '', + Object? content, + }) { + final digest = SHA256Digest() + .process( + Uint8List.fromList( + utf8.encode('nostr:agent-auth:${signer.public}:$conditions'), + ), + ) + .map((v) => v.toRadixString(16).padLeft(2, '0')) + .join(); + return NostrEvent.fromJson( + nostr.Event.from( + kind: 0, + content: jsonEncode(content ?? {'display_name': 'My assistant'}), + secretKey: signer.secret, + createdAt: time, + tags: [ + if (ownedBy != null) + [ + 'auth', + ownedBy.public, + conditions, + nostr.Schnorr.sign(secretKey: ownedBy.secret, message: digest), + ], + ], + ).toMap(), + ); + } + + ProviderContainer setup(_Relay relay) { + final sync = MeSync( + relay, + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: owner.secret, + ), + ), + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relay), + meSyncProvider.overrideWithValue(sync), + ], + ); + 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'); + }); + for (final malformed in [7, true, 'wrong', {}, []]) { + test( + 'malformed optional profile fields do not poison directory: $malformed', + () async { + final broken = nostr.Keys.generate(); + final container = setup( + _Relay([ + [ + profile( + broken, + ownedBy: owner, + content: { + for (final field in [ + 'display_name', + 'name', + 'respond_to', + 'respond_to_allowlist', + 'channel_ids', + ]) + field: malformed, + }, + ), + profile(agent, ownedBy: owner), + ], + ]), + ); + final choices = await container.read(meAssistantsProvider.future); + expect( + choices.singleWhere((a) => a.pubkey == agent.public).displayName, + 'My assistant', + ); + expect( + await isOwnedMeAssistant( + container.read(meSyncProvider), + agent.public, + ), + isTrue, + ); + }, + ); + } + test('latest profile removes obsolete ownership', () async { + final container = setup( + _Relay([ + [profile(agent, ownedBy: owner), profile(agent, time: 2)], + ]), + ); + 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', + () 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, + ); + expect(relay.requestedPages.length, 10); + for (final filter in relay.requestedTags) { + expect(filter.containsKey('#auth'), isFalse); + expect(filter['limit'], 100); + } + }, + ); + for (final conditions in ['kind=1', 'created_at>1', 'created_at<1']) { + test( + 'discovery and send revalidation reject inapplicable $conditions', + () async { + final relay = _Relay([ + [profile(agent, ownedBy: owner, conditions: conditions)], + ]); + final container = setup(relay); + expect(await container.read(meAssistantsProvider.future), isEmpty); + expect( + await isOwnedMeAssistant( + container.read(meSyncProvider), + agent.public, + ), + isFalse, + ); + }, + ); + } + test('creation revalidates only the chosen assistant', () async { + final relay = _Relay([ + [profile(agent, ownedBy: owner)], + ]); + final container = setup(relay); + expect( + await isOwnedMeAssistant(container.read(meSyncProvider), agent.public), + isTrue, + ); + expect(relay.requestedAuthors, [ + [agent.public], + ]); + }); + test( + 'creation rejects revoked ownership and unrelated returned profiles', + () async { + final relay = _Relay([ + [ + profile(agent, ownedBy: owner), + profile(agent, time: 2), + profile(other, ownedBy: owner), + ], + ]); + final container = setup(relay); + expect( + await isOwnedMeAssistant(container.read(meSyncProvider), agent.public), + isFalse, + ); + }, + ); +} diff --git a/mobile/test/shared/me/me_chat_creation_test.dart b/mobile/test/shared/me/me_chat_creation_test.dart new file mode 100644 index 0000000000..a9752817a0 --- /dev/null +++ b/mobile/test/shared/me/me_chat_creation_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.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('missing durable storage blocks every relay side effect', () async { + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ), + ); + var touchedRelay = false; + await expectLater( + const MeChatCreation('id', 'Name', 'agent').complete( + sync, + findChannel: () async { + touchedRelay = true; + return null; + }, + createChannel: () async => 'id', + hasAgent: () async => false, + addAgent: () async {}, + isPlaced: () async => false, + place: () async {}, + ), + throwsStateError, + ); + expect(touchedRelay, isFalse); + }); + test( + 'discard preserves partial relay state and permits a different intent offline', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ); + final online = MeSync(RelaySessionNotifier(), config, prefs: prefs); + const intent = MeChatCreation('old', 'Old', 'revoked-agent'); + await intent.save(online); + final offline = MeSync( + RelaySessionNotifier(), + config, + prefs: prefs, + connected: false, + ); + await expectLater( + const MeChatCreation('wrong', 'Old', 'revoked-agent').discard(offline), + throwsStateError, + ); + expect(MeChatCreation.pending(offline)?.id, 'old'); + await intent.discard(offline); + expect(MeChatCreation.pending(offline), isNull); + await const MeChatCreation('new', 'New', 'other-agent').save(online); + expect(MeChatCreation.pending(online)?.id, 'new'); + }, + ); + for (final crashAfter in [0, 1, 2, 3]) { + test('restart resumes the same chat after boundary $crashAfter', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ); + MeSync service() => MeSync(RelaySessionNotifier(), config, prefs: prefs); + const intent = MeChatCreation('same-id', 'Private name', 'agent'); + var created = false, member = false, placed = false, crash = true; + final writes = [0, 0, 0]; + void boundary(int point) { + expect(MeChatCreation.pending(service())?.id, intent.id); + if (crash && crashAfter == point) throw StateError('process stopped'); + } + + Future run(MeSync sync, MeChatCreation intent) => + intent.complete( + sync, + findChannel: () async { + boundary(0); + return created ? intent.id : null; + }, + createChannel: () async { + created = true; + writes[0]++; + boundary(1); + return intent.id; + }, + hasAgent: () async => member, + addAgent: () async { + member = true; + writes[1]++; + boundary(2); + }, + isPlaced: () async => placed, + place: () async { + placed = true; + writes[2]++; + boundary(3); + }, + ); + await expectLater(run(service(), intent), throwsStateError); + final restarted = service(); + final pending = MeChatCreation.pending(restarted)!; + expect(pending.name, intent.name); + expect(pending.agent, intent.agent); + expect( + prefs.getString(MeChatCreation.storageKey(restarted)), + isNot(contains(intent.name)), + ); + crash = false; + expect(await run(restarted, pending), intent.id); + expect(writes, [1, 1, 1]); + expect(MeChatCreation.pending(restarted), isNull); + final other = MeSync( + RelaySessionNotifier(), + RelayConfig(baseUrl: 'https://other.test', nsec: config.nsec), + prefs: prefs, + ); + expect(MeChatCreation.pending(other), isNull); + }); + } +} diff --git a/mobile/test/shared/me/me_groups_test.dart b/mobile/test/shared/me/me_groups_test.dart new file mode 100644 index 0000000000..0dce29f48b --- /dev/null +++ b/mobile/test/shared/me/me_groups_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/me/me_groups.dart'; + +void main() { + const origin = 'https://buzz.block.builderlab.xyz'; + const channel = '11111111-1111-4111-8111-111111111111'; + Map record() => { + 'version': 1, + 'community': origin, + 'deleted': false, + 'value': { + 'type': 'groups', + 'id': 'me', + 'groups': [ + { + 'id': 'project-one', + 'name': 'Mobile', + 'defaultTemplateId': 'me-section-one', + }, + ], + 'assignments': {channel: 'project-one'}, + 'channels': [channel], + }, + }; + test('reads desktop groups and preserves defaults when placing a chat', () { + final source = record(); + final groups = MeGroups.decode(jsonEncode(source), origin); + expect(groups.groups.single['defaultTemplateId'], 'me-section-one'); + expect(jsonDecode(groups.place(channel).encode(origin)), source); + expect( + MeGroups.coordinate(origin), + 'buzz-me-kit-v1:https%3A%2F%2Fbuzz.block.builderlab.xyz:groups:me', + ); + }); + test('rejects another community and independent Messages organization', () { + expect( + () => MeGroups.decode(jsonEncode(record()), 'https://other.test'), + throwsFormatException, + ); + final source = record(); + (source['value'] as Map)['id'] = 'messages'; + expect( + () => MeGroups.decode(jsonEncode(source), origin), + throwsFormatException, + ); + }); + test( + 'tombstones clear personal placement without inferring old sessions', + () { + final source = record()..['deleted'] = true; + expect(MeGroups.decode(jsonEncode(source), origin).channels, isEmpty); + }, + ); + test('rejects duplicate placement and missing project references', () { + final source = record(); + (source['value'] as Map)['channels'] = [channel, channel]; + expect( + () => MeGroups.decode(jsonEncode(source), origin), + throwsFormatException, + ); + (source['value'] as Map)['channels'] = [channel]; + (source['value'] as Map)['assignments'] = {channel: 'gone'}; + expect( + () => MeGroups.decode(jsonEncode(source), origin), + throwsFormatException, + ); + }); + test('project names use the editors grapheme limit', () { + for (final character in ['🌳', 'πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦', 'e\u0301']) { + MeGroups groups(int count) => MeGroups( + groups: [ + {'id': 'project', 'name': character * count, 'defaultTemplateId': ''}, + ], + ); + final encoded = groups(120).encode(origin); + expect( + MeGroups.decode(encoded, origin).groups.single['name'], + character * 120, + ); + expect(() => groups(121).encode(origin), throwsFormatException); + } + }); + test('capacity is measured in UTF-8 bytes before publishing', () { + final groups = MeGroups( + groups: List.generate( + 100, + (i) => {'id': 'g$i', 'name': '🌳' * 60, 'defaultTemplateId': ''}, + ), + ); + expect(() => groups.encode(origin), throwsFormatException); + }); +} diff --git a/mobile/test/shared/me/me_journal_cleanup_test.dart b/mobile/test/shared/me/me_journal_cleanup_test.dart new file mode 100644 index 0000000000..4b2060e096 --- /dev/null +++ b/mobile/test/shared/me/me_journal_cleanup_test.dart @@ -0,0 +1,163 @@ +import 'package:buzz/shared/me/me_agent_change.dart'; +import 'package:buzz/shared/me/me_chat_creation.dart'; +import 'package:buzz/shared/me/me_project_creation.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: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'; + +class _Store extends InMemorySharedPreferencesStore { + String? failure; + _Store() : super.empty(); + @override + Future remove(String key) async { + if (failure == 'throw') throw StateError('disk failure'); + if (failure == 'false') return false; + return super.remove(key); + } +} + +class _Sync extends MeSync { + MeGroups groups = const MeGroups(); + _Sync(SharedPreferences prefs) + : super( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '1', + ), + ), + prefs: prefs, + ); + @override + Future head() async => null; + @override + Future update(MeGroups Function(MeGroups) change) async { + groups = change(groups); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final kind in ['chat', 'project', 'agent']) { + for (final action in ['complete', 'discard']) { + for (final failure in ['false', 'throw']) { + test( + '$kind $action retains same-process and restarted recovery after $failure removal', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _Store(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final sync = _Sync(prefs); + const chat = MeChatCreation('chat-id', 'Chat', 'agent'); + const project = MeProjectCreation('project-id', 'Project'); + final agent = MeAgentChange.create(sync, 'chat-id', 'agent'); + var created = false, member = false, placed = false; + var creates = 0, adds = 0, placements = 0; + Future save() async { + if (kind == 'chat') await chat.save(sync); + if (kind == 'project') await project.save(sync); + if (kind == 'agent') await agent.save(sync, 'chat-id'); + } + + Object? pending() => switch (kind) { + 'chat' => MeChatCreation.pending(sync)?.id, + 'project' => MeProjectCreation.pending(sync)?.id, + _ => MeAgentChange.pending(sync, 'chat-id')?.agent, + }; + Future run() async { + if (action == 'discard') { + if (kind == 'chat') await chat.discard(sync); + if (kind == 'project') await project.discard(sync); + if (kind == 'agent') await agent.discard(sync, 'chat-id'); + } else if (kind == 'chat') { + await chat.complete( + sync, + findChannel: () async => created ? 'chat-id' : null, + createChannel: () async { + creates++; + created = true; + return 'chat-id'; + }, + hasAgent: () async => member, + addAgent: () async { + adds++; + member = true; + }, + isPlaced: () async => placed, + place: () async { + placements++; + placed = true; + }, + ); + } else if (kind == 'project') { + await project.complete(sync); + } else { + await agent.complete( + sync, + 'chat-id', + ensureMembership: () async { + if (!member) { + adds++; + member = true; + } + }, + ); + } + } + + await save(); + final original = pending(); + store.failure = failure; + await expectLater(run(), throwsStateError); + expect( + pending(), + original, + ); // Reopening without restarting recovers. + await prefs.reload(); + expect(pending(), original); // Disk agrees after restart. + store.failure = null; + await run(); + expect(pending(), isNull); + if (action == 'complete') { + if (kind == 'chat') { + expect([creates, adds, placements], [1, 1, 1]); + } + if (kind == 'project') expect(sync.groups.groups.length, 1); + if (kind == 'agent') expect(adds, 1); + } + }, + ); + } + } + } + + test('chat completion preserves a replacement journal', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final sync = _Sync(prefs); + const intent = MeChatCreation('first', 'First', 'agent'); + await expectLater( + intent.complete( + sync, + findChannel: () async => 'first', + createChannel: () async => 'first', + hasAgent: () async => true, + addAgent: () async {}, + isPlaced: () async => false, + place: () async { + await prefs.remove(MeChatCreation.storageKey(sync)); + await const MeChatCreation('second', 'Second', 'agent').save(sync); + }, + ), + throwsStateError, + ); + expect(MeChatCreation.pending(sync)?.id, 'second'); + }); +} diff --git a/mobile/test/shared/me/me_search_placement_test.dart b/mobile/test/shared/me/me_search_placement_test.dart new file mode 100644 index 0000000000..8b7a20c71d --- /dev/null +++ b/mobile/test/shared/me/me_search_placement_test.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/me/me_groups.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/me/me_search_placement.dart'; + +void main() { + for (final error in [ + const FormatException('malformed'), + StateError('unavailable'), + ]) { + test('ordinary search remains navigable when Me is $error', () async { + final container = ProviderContainer( + overrides: [ + meGroupsProvider.overrideWith((ref) => Stream.error(error)), + ], + retry: (_, _) => null, + ); + addTearDown(container.dispose); + expect( + await container.read(meSearchPlacementProvider('ordinary').future), + isFalse, + ); + }); + } + testWidgets('unresponsive Me lookup has a bounded ordinary fallback', ( + tester, + ) async { + final stream = StreamController(); + final container = ProviderContainer( + overrides: [meGroupsProvider.overrideWith((ref) => stream.stream)], + ); + addTearDown(() async { + container.dispose(); + await stream.close(); + }); + final result = container.read(meSearchPlacementProvider('ordinary').future); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + expect(await result, isFalse); + }); + test( + 'cached personal placement is used without waiting for refresh', + () async { + final container = ProviderContainer( + overrides: [ + meGroupsProvider.overrideWith( + (ref) => Stream.value(const MeGroups(channels: ['personal'])), + ), + ], + ); + addTearDown(container.dispose); + container.listen(meGroupsProvider, (_, _) {}); + await container.read(meGroupsProvider.future); + expect( + await container.read(meSearchPlacementProvider('personal').future), + isTrue, + ); + expect( + await container.read(meSearchPlacementProvider('ordinary').future), + isFalse, + ); + }, + ); +} diff --git a/mobile/test/shared/me/me_sync_test.dart b/mobile/test/shared/me/me_sync_test.dart new file mode 100644 index 0000000000..5675ec23bb --- /dev/null +++ b/mobile/test/shared/me/me_sync_test.dart @@ -0,0 +1,797 @@ +import 'dart:async'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'dart:convert'; +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/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_sync.dart'; +import 'package:buzz/shared/me/me_recovery.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; + +class _Config extends RelayConfigNotifier { + final RelayConfig config; + _Config(this.config); + @override + RelayConfig build() => config; + void switchTo(RelayConfig next) => state = next; +} + +class FakeRelay extends RelaySessionNotifier { + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + void setStatus(SessionStatus status) => state = SessionState(status: status); + int directoryQueries = 0; + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + directoryQueries++; + return []; + } + + List events = []; + Future>? pendingRead; + void Function(String)? closeSubscription; + void Function(NostrEvent)? emit; + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + closeSubscription = onClosed; + emit = onEvent; + return () {}; + } + + int writes = 0; + bool loseAcknowledgment = false; + void Function()? beforeRead; + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + beforeRead?.call(); + return pendingRead ?? events; + } + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + writes++; + events = [event]; + if (loseAcknowledgment) throw TimeoutException('no acknowledgment'); + return event; + } +} + +class _FailingCacheStore extends InMemorySharedPreferencesStore { + String? failure; + String? removeFailure; + Completer? blockedWrite; + int cacheWrites = 0; + _FailingCacheStore() : super.empty(); + @override + Future setValue(String type, String key, Object value) { + if (key.startsWith('flutter.me-cache:')) { + cacheWrites++; + if (blockedWrite != null) return blockedWrite!.future; + if (failure == 'throw') throw StateError('disk error'); + if (failure == 'false') return Future.value(false); + } + return super.setValue(type, key, value); + } + + @override + Future remove(String key) async { + if (key.startsWith('flutter.me-pending:')) { + if (removeFailure == 'false') return false; + if (removeFailure == 'throw') throw StateError('remove failed'); + } + return super.remove(key); + } +} + +void main() { + const secret = + '0000000000000000000000000000000000000000000000000000000000000001'; + final nsec = nostr.Nip19.encode(prefix: nostr.Nip19Prefix.nsec, data: secret); + const origin = 'https://relay.test'; + final public = nostr.Keys(secret).public; + NostrEvent signed({ + String community = origin, + String signingKey = secret, + int timestamp = 1, + MeGroups groups = const MeGroups(), + }) => NostrEvent.fromJson( + nostr.Event.from( + kind: 30078, + content: nip44Encrypt( + getConversationKey(secret, public), + groups.encode(community), + ), + secretKey: signingKey, + createdAt: timestamp, + tags: [ + ['d', MeGroups.coordinate(community)], + ['t', MeGroups.tag], + ], + verify: false, + ).toMap(), + ); + for (final failure in ['false', 'throw']) { + test( + 'chat recovery reconciles its confirmed Solo journal after $failure cleanup', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingCacheStore()..removeFailure = failure; + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + final sync = MeSync( + relay, + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + const id = '11111111-1111-4111-8111-111111111111'; + const intent = MeChatCreation(id, 'Chat', 'agent'); + Future complete() => intent.complete( + sync, + findChannel: () async => id, + createChannel: () async => id, + hasAgent: () async => true, + addAgent: () async {}, + isPlaced: () async => + relay.events.isNotEmpty && + sync.decode(relay.events.single).channels.contains(id), + place: () => sync.update((current) => current.place(id)), + ); + await expectLater(complete(), throwsStateError); + expect(prefs.getString(sync.pendingKey), isNotNull); + expect(MeChatCreation.pending(sync), isNotNull); + store.removeFailure = null; + await expectLater(complete(), throwsStateError); + expect(prefs.getString(sync.pendingKey), isNull); + expect(MeChatCreation.pending(sync), isNotNull); + expect(await complete(), id); + expect(MeChatCreation.pending(sync), isNull); + expect(relay.writes, 1); + }, + ); + } + for (final failure in ['false', 'throw']) { + test( + 'pending-save discard retains recovery after $failure removal', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingCacheStore(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final service = MeSync( + FakeRelay(), + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + await prefs.setString(service.pendingKey, 'reviewed'); + store.removeFailure = failure; + await expectLater(service.discardPending('reviewed'), throwsStateError); + expect(prefs.getString(service.pendingKey), 'reviewed'); + store.removeFailure = null; + await service.discardPending('reviewed'); + expect(prefs.getString(service.pendingKey), isNull); + }, + ); + } + test( + 'dirty newest snapshot survives transport reconnect and remains scoped', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingCacheStore(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + final config = _Config(RelayConfig(baseUrl: origin, nsec: nsec)); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relay), + savedPrefsProvider.overrideWithValue(prefs), + ], + ); + addTearDown(container.dispose); + final service = container.read(meSyncProvider); + container.listen(meAssistantsProvider, (_, _) {}); + await container.read(meAssistantsProvider.future); + expect(relay.directoryQueries, 1); + final a = signed(timestamp: 1); + final b = signed( + timestamp: 2, + groups: const MeGroups( + groups: [ + {'id': 'new', 'name': 'Newest', 'defaultTemplateId': ''}, + ], + ), + ); + await service.cache(a); + relay.events = [a]; + container.listen(meGroupsProvider, (_, _) {}); + container.listen(meOfflineSaveWarningProvider, (_, _) {}); + await container.read(meGroupsProvider.future); + store.failure = 'false'; + relay.emit!(b); + await Future.delayed(Duration.zero); + expect( + container.read(meGroupsProvider).value!.groups.single['name'], + 'Newest', + ); + expect(service.cacheWriteFailed.value, isTrue); + relay.setStatus(SessionStatus.disconnected); + await container.pump(); + expect(container.read(meSyncProvider), same(service)); + expect(service.connected, isFalse); + expect( + container.read(meGroupsProvider).value!.groups.single['name'], + 'Newest', + ); + expect(container.read(meOfflineSaveWarningProvider).value, isTrue); + relay.setStatus(SessionStatus.connected); + await container.pump(); + await container.read(meGroupsProvider.future); + await Future.delayed(Duration.zero); + expect(container.read(meSyncProvider), same(service)); + expect(service.connected, isTrue); + await container.read(meAssistantsProvider.future); + expect(relay.directoryQueries, 2); + expect( + container.read(meGroupsProvider).value!.groups.single['name'], + 'Newest', + ); + expect(service.cacheWriteFailed.value, isTrue); + store.failure = null; + container.invalidate(meGroupsProvider); + await container.read(meGroupsProvider.future); + await Future.delayed(Duration.zero); + expect(service.cachedSnapshot()?.event?.id, b.id); + expect(service.cacheWriteFailed.value, isFalse); + config.switchTo(RelayConfig(baseUrl: 'https://other.test', nsec: nsec)); + await container.pump(); + final other = container.read(meSyncProvider); + expect(service.disposed, isTrue); + expect(other.latestCacheCandidate, isNull); + expect(other.cacheWriteFailed.value, isFalse); + }, + ); + test( + 'newer failed cache fences older arrivals and Retry retains newest', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingCacheStore(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + final service = MeSync( + relay, + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + final initial = signed(timestamp: 1); + await service.cache(initial); + relay.events = [initial]; + final container = ProviderContainer( + overrides: [meSyncProvider.overrideWithValue(service)], + ); + addTearDown(container.dispose); + final seen = []; + container.listen(meGroupsProvider, (_, value) { + if (value.hasValue) { + seen.add( + value.value!.groups.firstOrNull?['name'] as String? ?? 'initial', + ); + } + }); + container.listen(meOfflineSaveWarningProvider, (_, _) {}); + await container.read(meGroupsProvider.future); + await Future.delayed(Duration.zero); + final newer = signed( + timestamp: 3, + groups: const MeGroups( + groups: [ + {'id': 'new', 'name': 'New', 'defaultTemplateId': ''}, + ], + ), + ); + final older = signed( + timestamp: 2, + groups: const MeGroups( + groups: [ + {'id': 'old', 'name': 'Old', 'defaultTemplateId': ''}, + ], + ), + ); + store.blockedWrite = Completer(); + relay.emit!(newer); + await Future.delayed(Duration.zero); + final writes = store.cacheWrites; + relay.emit!(older); + // Also exercise the persistence fence independently of the reader. + final olderWrite = service.cache(older); + final blockedWrite = store.blockedWrite!; + store.blockedWrite = null; + blockedWrite.complete(false); + await olderWrite; + await Future.delayed(Duration.zero); + expect(store.cacheWrites, writes); + expect(seen, isNot(contains('Old'))); + expect( + container.read(meGroupsProvider).value!.groups.single['name'], + 'New', + ); + expect(service.cacheWriteFailed.value, isTrue); + expect(service.cachedSnapshot()?.event?.id, initial.id); + store.blockedWrite = null; + relay.events = [older]; + container.invalidate(meGroupsProvider); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(service.cacheWriteFailed.value, isFalse); + expect(service.cachedSnapshot()?.event?.id, newer.id); + }, + ); + + for (final absent in [false, true]) { + test( + 'confirmed cleanup preserves ${absent ? 'already absent' : 'replaced'} journal', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + final service = MeSync( + relay, + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + relay.beforeRead = () { + if (relay.writes > 0) { + if (absent) { + unawaited(prefs.remove(service.pendingKey)); + } else { + unawaited( + prefs.setString(service.pendingKey, 'another pending record'), + ); + } + } + }; + if (absent) { + await service.update((v) => v); + expect(prefs.getString(service.pendingKey), isNull); + } else { + await expectLater(service.update((v) => v), throwsStateError); + expect(prefs.getString(service.pendingKey), 'another pending record'); + } + }, + ); + } + for (final recovered in [false, true]) { + for (final failure in ['false', 'throw']) { + test( + 'confirmed ${recovered ? 'recovery' : 'normal'} cleanup $failure remains retryable', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingCacheStore(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + final config = RelayConfig(baseUrl: origin, nsec: nsec); + var service = MeSync(relay, config, prefs: prefs); + if (recovered) { + relay.loseAcknowledgment = true; + await expectLater( + service.update((v) => v), + throwsA(isA()), + ); + relay.loseAcknowledgment = false; + service = MeSync(relay, config, prefs: prefs); + } + store.removeFailure = failure; + await expectLater( + service.update((v) => v), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('cleanup on this phone failed'), + ), + ), + ); + await prefs.reload(); + expect(prefs.getString(service.pendingKey), isNotNull); + expect(relay.writes, 1); + store.removeFailure = null; + await expectLater( + MeSync(relay, config, prefs: prefs).update((v) => v), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('previous Solo save is confirmed'), + ), + ), + ); + expect(prefs.getString(service.pendingKey), isNull); + expect(relay.writes, 1); + }, + ); + } + } + for (final failure in ['false', 'throw']) { + test( + 'snapshot write $failure exposes failure and Retry persists the same event', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingCacheStore(); + SharedPreferencesStorePlatform.instance = store; + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + final config = RelayConfig(baseUrl: origin, nsec: nsec); + final service = MeSync(relay, config, prefs: prefs); + final old = signed(timestamp: 1); + await service.cache(old); + final fresh = signed( + timestamp: 2, + groups: const MeGroups( + channels: ['11111111-1111-4111-8111-111111111111'], + ), + ); + relay.events = [fresh]; + store.failure = failure; + final container = ProviderContainer( + overrides: [meSyncProvider.overrideWithValue(service)], + ); + addTearDown(container.dispose); + final failed = Completer(); + container.listen(meGroupsProvider, (_, _) {}); + container.listen(meOfflineSaveWarningProvider, (_, next) { + if (next.value == true && !failed.isCompleted) failed.complete(); + }); + await failed.future; + expect(container.read(meGroupsProvider).hasError, isFalse); + expect(container.read(meOfflineSaveWarningProvider).value, isTrue); + expect(service.cachedSnapshot()?.event?.id, old.id); + store.failure = null; + container.invalidate(meGroupsProvider); + final restored = Completer(); + final subscription = container.listen(meOfflineSaveWarningProvider, ( + _, + next, + ) { + if (next.value == false && !restored.isCompleted) restored.complete(); + }); + await restored.future; + subscription.close(); + await prefs.reload(); + final restarted = MeSync( + FakeRelay(), + config, + prefs: prefs, + connected: false, + ); + expect(restarted.cachedSnapshot()?.event?.id, fresh.id); + }, + ); + } + test( + 'offline relaunch emits encrypted cached organization and isolates accounts', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = RelayConfig(baseUrl: origin, nsec: nsec); + final online = MeSync(FakeRelay(), config, prefs: prefs); + const groups = MeGroups( + groups: [ + {'id': 'project', 'name': 'Private project', 'defaultTemplateId': ''}, + ], + ); + await online.cache(signed(groups: groups, timestamp: 3)); + await online.cache(signed(timestamp: 2)); + await online.cache(null); + expect( + prefs.getString(online.cacheKey), + isNot(contains('Private project')), + ); + final offline = MeSync( + FakeRelay(), + config, + prefs: prefs, + connected: false, + ); + final container = ProviderContainer( + overrides: [meSyncProvider.overrideWithValue(offline)], + retry: (_, _) => null, + ); + addTearDown(container.dispose); + container.listen(meGroupsProvider, (_, _) {}); + expect( + (await container.read(meGroupsProvider.future)).groups.single['name'], + 'Private project', + ); + await Future.delayed(Duration.zero); + expect(container.read(meGroupsProvider).hasError, isFalse); + for (final other in [ + RelayConfig(baseUrl: 'https://other.test', nsec: nsec), + RelayConfig( + baseUrl: origin, + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '0' * 63 + '2', + ), + ), + ]) { + expect( + MeSync( + FakeRelay(), + other, + prefs: prefs, + connected: false, + ).cachedSnapshot(), + isNull, + ); + } + }, + ); + test( + 'corrupt cache refreshes online and persists the verified replacement', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay()..events = [signed(timestamp: 5)]; + final service = MeSync( + relay, + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + await prefs.setString(service.cacheKey, 'corrupt'); + final container = ProviderContainer( + overrides: [meSyncProvider.overrideWithValue(service)], + retry: (_, _) => null, + ); + addTearDown(container.dispose); + container.listen(meGroupsProvider, (_, _) {}); + expect((await container.read(meGroupsProvider.future)).channels, isEmpty); + await Future.delayed(Duration.zero); + expect(service.cachedSnapshot()?.event?.id, relay.events.single.id); + }, + ); + test( + 'empty organization survives relaunch without relay connectivity', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final config = RelayConfig(baseUrl: origin, nsec: nsec); + await MeSync(FakeRelay(), config, prefs: prefs).cache(null); + final offline = MeSync( + FakeRelay(), + config, + prefs: prefs, + connected: false, + ); + expect(offline.cachedSnapshot()?.groups.channels, isEmpty); + }, + ); + test( + 'cached organization emits before refresh and survives a closed subscription', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final response = Completer>(); + final relay = FakeRelay()..pendingRead = response.future; + final service = MeSync( + relay, + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + final old = signed(timestamp: 1); + await service.cache(old); + final container = ProviderContainer( + overrides: [meSyncProvider.overrideWithValue(service)], + retry: (_, _) => null, + ); + addTearDown(container.dispose); + container.listen(meGroupsProvider, (_, _) {}); + expect((await container.read(meGroupsProvider.future)).groups, isEmpty); + final fresh = signed( + timestamp: 2, + groups: const MeGroups( + groups: [ + {'id': 'new', 'name': 'From desktop', 'defaultTemplateId': ''}, + ], + ), + ); + response.complete([fresh]); + await Future.delayed(Duration.zero); + expect( + container.read(meGroupsProvider).value?.groups.single['name'], + 'From desktop', + ); + expect(service.cachedSnapshot()?.event?.id, fresh.id); + relay.closeSubscription!('offline'); + await Future.delayed(Duration.zero); + expect(service.syncClosed.value, isTrue); + expect(container.read(meGroupsProvider).hasError, isFalse); + expect( + container.read(meGroupsProvider).value?.groups.single['name'], + 'From desktop', + ); + }, + ); + test('verifies signature, account and encrypted community envelope', () { + final sync = MeSync(FakeRelay(), RelayConfig(baseUrl: origin, nsec: nsec)); + expect(sync.decode(signed()).channels, isEmpty); + expect( + () => sync.decode(signed(community: 'https://other.test')), + throwsFormatException, + ); + expect( + () => sync.decode( + signed( + signingKey: + '0000000000000000000000000000000000000000000000000000000000000002', + ), + ), + throwsFormatException, + ); + final tampered = signed().toJson()..['sig'] = '0' * 128; + expect(() => sync.decode(NostrEvent.fromJson(tampered)), throwsA(anything)); + }); + test('equal timestamp selects the lowest event ID', () { + final a = signed(timestamp: 2); + final b = signed(timestamp: 2); + expect(meEventIsNewer(a, b), a.id.compareTo(b.id) < 0); + expect(meEventIsNewer(signed(timestamp: 3), a), isTrue); + }); + test('scope disposal during preparation prevents publication', () async { + final relay = FakeRelay(); + final sync = MeSync(relay, RelayConfig(baseUrl: origin, nsec: nsec)); + relay.beforeRead = () => sync.disposed = true; + await expectLater(sync.update((v) => v), throwsStateError); + expect(relay.writes, 0); + }); + test('concurrent desktop change blocks stale replacement', () async { + final relay = FakeRelay()..events = [signed()]; + final sync = MeSync(relay, RelayConfig(baseUrl: origin, nsec: nsec)); + var reads = 0; + relay.beforeRead = () { + if (++reads == 2) relay.events = [signed(timestamp: 2)]; + }; + await expectLater(sync.update((v) => v), throwsStateError); + expect(relay.writes, 0); + }); + test( + 'unconfirmed save survives service restart and never silently replaces it', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay()..loseAcknowledgment = true; + final config = RelayConfig(baseUrl: origin, nsec: nsec); + final first = MeSync(relay, config, prefs: prefs); + await expectLater( + first.update((v) => v), + throwsA(isA()), + ); + final pending = prefs.getString(first.pendingKey); + expect(pending, isNotNull); + expect( + jsonDecode(pending!)['event']['content'], + isNot(contains('groups')), + ); + final second = MeSync(relay, config, prefs: prefs); + await expectLater(second.update((v) => v), throwsStateError); + expect(relay.writes, 1); + expect(prefs.getString(first.pendingKey), isNull); + }, + ); + test( + 'superseded pending save rebases local additions after restart', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + final config = RelayConfig(baseUrl: origin, nsec: nsec); + final sync = MeSync(relay, config, prefs: prefs); + const base = MeGroups( + groups: [ + {'id': 'old', 'name': 'Original', 'defaultTemplateId': ''}, + ], + ); + const local = MeGroups( + groups: [ + {'id': 'old', 'name': 'Original', 'defaultTemplateId': ''}, + {'id': 'local', 'name': 'Phone project', 'defaultTemplateId': ''}, + ], + ); + const remote = MeGroups( + groups: [ + {'id': 'old', 'name': 'Desktop rename', 'defaultTemplateId': ''}, + {'id': 'remote', 'name': 'Desktop project', 'defaultTemplateId': ''}, + ], + ); + await prefs.setString( + sync.pendingKey, + jsonEncode({ + 'event': signed(groups: local, timestamp: 2).toJson(), + 'base': signed(groups: base, timestamp: 1).toJson(), + }), + ); + relay.events = [signed(groups: remote, timestamp: 3)]; + final restarted = MeSync(relay, config, prefs: prefs); + var newIntentRan = false; + await expectLater( + restarted.update((v) { + newIntentRan = true; + return v; + }), + throwsStateError, + ); + expect(newIntentRan, isFalse); + expect( + restarted.decode(relay.events.single).groups.map((g) => g['name']), + ['Desktop rename', 'Desktop project', 'Phone project'], + ); + expect(prefs.getString(sync.pendingKey), isNull); + expect(relay.writes, 1); + }, + ); + test('conflicting edits remain durable until explicit discard', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final relay = FakeRelay(); + final sync = MeSync( + relay, + RelayConfig(baseUrl: origin, nsec: nsec), + prefs: prefs, + ); + MeGroups group(String name) => MeGroups( + groups: [ + {'id': 'project', 'name': name, 'defaultTemplateId': ''}, + ], + ); + final stored = jsonEncode({ + 'event': signed(groups: group('Phone'), timestamp: 2).toJson(), + 'base': signed(groups: group('Original')).toJson(), + }); + await prefs.setString(sync.pendingKey, stored); + final remote = signed(groups: group('Desktop'), timestamp: 3); + relay.events = [remote]; + await expectLater( + sync.update((v) => v), + throwsA(isA()), + ); + expect(prefs.getString(sync.pendingKey), stored); + expect(relay.writes, 0); + await expectLater(sync.discardPending('stale snapshot'), throwsStateError); + expect(prefs.getString(sync.pendingKey), stored); + await sync.discardPending(stored); + await sync.update( + (v) => MeGroups( + groups: [ + ...v.groups, + {'id': 'new', 'name': 'New', 'defaultTemplateId': ''}, + ], + ), + ); + expect(sync.decode(relay.events.single).groups.map((g) => g['name']), [ + 'Desktop', + 'New', + ]); + expect(prefs.getString(sync.pendingKey), isNull); + }); +} diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 1b911136ff..2eef174204 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -28,6 +28,29 @@ void main() { ), ); + testWidgets('only animated agent artwork shrinks inside its unchanged frame', ( + tester, + ) async { + const poster = 'https://relay.example/poster.png'; + final animated = + '$poster#buzz-anim=${Uri.encodeComponent('https://relay.example/animation.png')}'; + const preset = + 'https://dwwgwmfqqjotj.cloudfront.net/avatars/20260925T154625166Z/poster/pollies/pollies-10.png'; + for (final url in [poster, animated, preset]) { + await tester.pumpWidget(subject(url, isAgent: true)); + final clip = find.byType(ClipRRect); + final scale = find.descendant(of: clip, matching: find.byType(Transform)); + if (url != poster) { + expect(scale, findsOneWidget); + expect(tester.widget(scale).transform.entry(0, 0), 0.85); + final content = find.byType(AvatarImageContent); + expect(tester.getSize(content), tester.getSize(clip)); + } else { + expect(scale, findsNothing); + } + } + }); + testWidgets('clips agents to a 30 percent squircle', (tester) async { await tester.pumpWidget(subject(null, isAgent: true)); diff --git a/scripts/test-mobile-me-agent-accessibility.sh b/scripts/test-mobile-me-agent-accessibility.sh new file mode 100755 index 0000000000..6fd75eb013 --- /dev/null +++ b/scripts/test-mobile-me-agent-accessibility.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Exercise the production UIKit Me agent row on a booted iOS simulator. +set -euo pipefail +repo_root=$(cd "$(dirname "$0")/.." && pwd) +device=${1:?Pass a booted iOS simulator UDID} +flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig") +frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator" +fixture=$(mktemp -d) +trap 'rm -r "$fixture"' EXIT +# Compile in one file to exercise the private production controller directly. +cat "$repo_root/mobile/ios/Runner/MeAvatarLoader.swift" \ + "$repo_root/mobile/ios/Runner/NativeProfileTextEditor.swift" \ + "$repo_root/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift" > "$fixture/test.swift" +xcrun --sdk iphonesimulator swiftc -parse-as-library \ + -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ + -target "$(uname -m)-apple-ios16.0-simulator" \ + -F "$frameworks" -framework Flutter \ + -Xlinker -rpath -Xlinker "$frameworks" \ + "$fixture/test.swift" \ + -o "$fixture/me-agent-tests" +xcrun simctl spawn "$device" "$fixture/me-agent-tests"