diff --git a/app/application.ml b/app/application.ml index 04ed980..13358b6 100644 --- a/app/application.ml +++ b/app/application.ml @@ -2501,10 +2501,10 @@ let composer_content [ V.of_lui (Lui_elements.button_group ~gap:0 ~height:44 ~background:"glass" ~corner_radius:999 - ([ action ~label:"文件" ~icon:"doc" Journal_asset_import.Files - ; action ~label:"照片" ~icon:"photo" Journal_asset_import.Photos ] + ([ action ~label:"Files" ~icon:"doc" Journal_asset_import.Files + ; action ~label:"Photos" ~icon:"photo" Journal_asset_import.Photos ] @ if assets.camera then - [ action ~label:"相机" ~icon:"camera" Journal_asset_import.Camera ] + [ action ~label:"Camera" ~icon:"camera" Journal_asset_import.Camera ] else [])) |> V.with_test_id (Ui.Test_id.string (scope ^ "-attach")) ] @@ -2718,6 +2718,7 @@ let timeline_page ~status_name ?current_day ?store:timeline_store + ?on_sync:(if interaction_enabled then Some (bind_action on_account_action Begin_online_recovery) else None) ~on_region ~render_source ~render_title diff --git a/app/journal_header.ml b/app/journal_header.ml index e973b17..658bff5 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -190,6 +190,11 @@ type presentation = ; capture_enabled : bool } +let sync_active = function + | Some (Graph_service.Connecting | Pulling | Submitting) -> true + | Some (Offline | Current | Paused | Failed) | None -> false +;; + let view_impl ~presentation_signal ~key @@ -429,23 +434,21 @@ let view_impl ~key ~props_for:(fun presentation -> props ~mode:"page" ?title:(match context with Journals -> None | Favorites -> Some "Favorites") - ~connecting:(presentation.sync_phase = Some Graph_service.Connecting) + ~connecting:(sync_active presentation.sync_phase) ~controls:(controls presentation) ()) (* Keep absent slots mounted so the native child indexes stay stable. *) ~children: [ V.Body.Private.to_widget body ; cluster ; dynamic ~equal:(fun left right -> - left.sync_phase - = Some Graph_service.Connecting - = (right.sync_phase = Some Graph_service.Connecting)) + sync_active left.sync_phase = sync_active right.sync_phase) (fun presentation -> V.column - [ (if presentation.sync_phase = Some Graph_service.Connecting + [ (if sync_active presentation.sync_phase then V.progress ~style:Circular () |> V.semantics - ~properties:(Ui.Semantics.create ~label:"Connecting" ()) + ~properties:(Ui.Semantics.create ~label:"Syncing" ()) |> test_id "journal-header-sync-progress" else V.empty ()) ]) diff --git a/app/journal_native_collection.ml b/app/journal_native_collection.ml index e52c9fa..c472f8c 100644 --- a/app/journal_native_collection.ml +++ b/app/journal_native_collection.ml @@ -20,6 +20,7 @@ let view ~rows ~scroll_target ?scroll_target_signal + ?on_sync ~on_scroll_completed ~actions_enabled ~on_visible_range @@ -161,6 +162,7 @@ let view ?scroll_request_signal ~on_scroll_completed ~on_visible_range + ?on_sync sections in (match scroll_target_signal with | None -> list () diff --git a/app/journal_native_collection.mli b/app/journal_native_collection.mli index e59466a..fd08435 100644 --- a/app/journal_native_collection.mli +++ b/app/journal_native_collection.mli @@ -14,6 +14,7 @@ val view -> rows:row list -> scroll_target:(int64 * string * string) option -> ?scroll_target_signal:(int64 * string * string) option Signal.signal + -> ?on_sync:Ui.Event.Handler.t -> on_scroll_completed:Ui.Event.Handler.t -> actions_enabled:bool -> on_visible_range:Ui.Event.Handler.t diff --git a/app/journal_timeline.ml b/app/journal_timeline.ml index ff4d5cb..2ebc885 100644 --- a/app/journal_timeline.ml +++ b/app/journal_timeline.ml @@ -208,6 +208,7 @@ let view ?status_name ?current_day ?store + ?on_sync ?(on_region = fun _ -> ()) ~render_source ?render_title @@ -366,6 +367,7 @@ let view ~rows ~scroll_target:target ?scroll_target_signal + ?on_sync ~on_scroll_completed ~actions_enabled:(delete_enabled && actions_enabled) ~on_visible_range diff --git a/app/journal_timeline.mli b/app/journal_timeline.mli index 3398b19..8b828de 100644 --- a/app/journal_timeline.mli +++ b/app/journal_timeline.mli @@ -18,6 +18,7 @@ val view : ?status_name:(Journal_model.task_state -> string) -> ?current_day:int -> ?store:Store.t + -> ?on_sync:Ui.Event.Handler.t -> ?on_region:(string -> unit) -> render_source:(string -> string) -> ?render_title:(string -> Journal_model.Title.t) diff --git a/app/journal_view.ml b/app/journal_view.ml index 9ddd331..aebbfc1 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -1615,7 +1615,8 @@ module View = struct [@@deriving yojson] type props = { style : string; sections : section list - ; track_visible_range : bool; track_scroll_completion : bool } + ; track_visible_range : bool; track_scroll_completion : bool + ; sync_enabled : bool } [@@deriving yojson] type row_event = { row : string; key : string } [@@deriving yojson] type event = @@ -1633,7 +1634,7 @@ module View = struct ;; (* Content indexes retain the native child ownership order. *) - let build sections ~style ~track_visible ~track_scroll = + let build sections ~style ~track_visible ~track_scroll ~sync_enabled = let contents = ref Rrbvec.empty in let next_content_index = ref 0 in let push element = @@ -1660,7 +1661,7 @@ module View = struct in let wire : Json.props = { style = style_name style; sections = List.map section_wire sections - ; track_visible_range = track_visible; track_scroll_completion = track_scroll } + ; track_visible_range = track_visible; track_scroll_completion = track_scroll; sync_enabled } in Json.props_to_yojson wire |> Yojson.Safe.to_string, Rrbvec.to_list !contents ;; @@ -1697,6 +1698,7 @@ module View = struct ?scroll_request_signal ?on_scroll_completed ?on_visible_range + ?on_sync sections = let payload, contents = @@ -1705,6 +1707,7 @@ module View = struct ~style ~track_visible:(Option.is_some on_visible_range) ~track_scroll:(Option.is_some on_scroll_completed) + ~sync_enabled:(Option.is_some on_sync) in (* Expansion state arrives as {"type":"expanded","key":..,"expanded":bool}; the owning row's handler receives Bool like the old disclosure callback. *) @@ -1746,6 +1749,7 @@ module View = struct | Error source -> report_event_error "decodeListEvent" source | Ok wire -> match wire.event_type with + | "sync" -> Option.iter (fun handler -> invoke handler Event.Payload.Unit) on_sync | "visible_range" -> (match wire.first, wire.last with | Some first_index, Some last_exclusive diff --git a/app/journal_view.mli b/app/journal_view.mli index 91a6402..eb5aa76 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -705,6 +705,7 @@ module View : sig -> ?scroll_request_signal:scroll_request option Signal.signal -> ?on_scroll_completed:Event.handler -> ?on_visible_range:Event.handler + -> ?on_sync:Event.handler -> section list -> t end diff --git a/apple/Info.plist b/apple/Info.plist index 1c7386e..a84608e 100644 --- a/apple/Info.plist +++ b/apple/Info.plist @@ -29,7 +29,7 @@ NSCameraUsageDescription Take a photo to attach to a journal entry. NSPhotoLibraryAddUsageDescription - 将你选择的图片保存到相册。Save the image you select to your photo library. + Save the image you select to your photo library. UILaunchScreen UISupportedInterfaceOrientations diff --git a/docs/agent-guide/implemented/feature/2026-10-24-timeline-pull-sync-icon.md b/docs/agent-guide/implemented/feature/2026-10-24-timeline-pull-sync-icon.md new file mode 100644 index 0000000..037091a --- /dev/null +++ b/docs/agent-guide/implemented/feature/2026-10-24-timeline-pull-sync-icon.md @@ -0,0 +1,98 @@ +# Timeline pull sync through the shared owner + +## Problem + +Timeline has no deliberate pull-at-top entry into the existing sync owner. +Online recovery currently inspects local sync state without guaranteeing a fresh +pull, and busy force intent is not retained. The existing header spinner only +covers Connecting. + +## Proposal + +The user approved design v3 and implementation plus isolated A135 acceptance on +2026-10-24. The later English-default requirement sets the top drag hints to +Pull to sync, then Release to sync at the threshold; +hide it at release/cancel. Reuse only the existing Options-left loading component. +Extend the existing Online_recovery_requested path and private pull-owner facts: +online forces a check, offline uses existing admission/connection recovery, busy +intent coalesces until the shared owner can send. Existing response/authoritative +commit and sync phases own completion. Do not add a refresh state machine or +per-refresh public contract. No spec/ or Dune changes are needed or authorized. +Use the native List's existing container geometry/phase observers for the small +trigger and text overlay; retain native scrolling, delegate, rows and anchors. + +## Decision + +Implemented the optional Timeline callback through Application's existing +Begin_online_recovery action and Online_recovery_requested event. The shared Core +retains one private forced intent until its existing pull owner can send; the +existing response and authoritative commit own completion. The existing header +progress node now spans Connecting, Pulling and Submitting without replacement. +No protected spec/ or Dune change, independent refresh state or top spinner. + +The default native accessibility action is Sync. The English-default audit also +replaces unconditional Capture attachment labels with Files, Photos and Camera, +and the photo-add permission description with English. Existing locale-selected +Chinese image-preview translations and graph/test contents remain intact. + +The existing public Core contract first reproduced the missing force behavior, +then passed with online, busy, commit, offline/scope, cancel/shutdown and submission +barrier cases. Header identity and optional native callback checks pass in their +existing targets. Full dune build @all and dune runtest --force pass. + +A135 iPhone 13/iOS26.1 ran the actual production List, Timeline, header and Core +in a Git-external synthetic IO fixture. Public XCTest touch drags verified short +pull cancellation, armed release, both hints in actual recorded frames, +online/no-change, offline/reconnect/apply, busy repeats, page exit/return, deep +scroll non-trigger and stable first-row position. Actual system Reduce Motion +was enabled for short/long physical drags and restored to its original off value. +The complete flow and Reduce Motion tests both pass. CUA coordinate dragging was +ineffective, so the explicitly authorized official XCTest path was used. + +## Alternatives considered + +### Default refreshable and a separate per-check contract + +Rejected by the updated user design: a second top spinner and independent refresh +state are unwanted. The original spec extension proposal remains external evidence +only and is not applied. Shared sync phase is sufficient for the reused activity +indicator when the actual shared owner retains force and owns completion. + +## Acceptance criteria + +- Below-threshold release/cancel creates no sync request; armed release creates + one shared trigger and hides the hint immediately. +- Online pulls, offline reconnect/open/pull, and busy/repeated coalescing use the + existing owner and transport; no parallel connections or dropped forced intent. +- Loading is one existing header component, derived from active shared sync phases. + Queue admission, old Current and socket open never imply completion; response + and required authoritative apply retain Pulling until the shared work settles. +- Graph/lifecycle exit retires or fences intent; no refresh continuation or endless + independent spinner is introduced. Existing errors/Diagnostics remain shared. +- Stable List/row identity, anchors, return-top cancellation and Reduce Motion remain. +- Write meaningful failing public Core checks first, then minimal implementation, + passing checks and full applicable local validation in existing test targets. +- Use A135 only with isolated synthetic fixture, no accounts/real graphs, no new + device, 1446, reset/deletion or OS permission changes. Record actual implemented + native UI, with accurate coverage limits. No push/PR/merge/TestFlight. + +## Consequences + +- Native List overscroll remains system-owned. Only semantic hint changes update + view state; the overlay reserves no layout space and no per-row geometry exists. +- Keep the unrelated error-icon branch separate; main remains d37ee9c as fetched. +- Reuse prepared build inputs without changing another owner's switches or caches. +- Simulator host-restamping has cross_abi_validated=false; this is execution + evidence, not a validated cross-compiled iOS release artifact. Synthetic auth, + transport and apply completions do not establish live service/account acceptance. +- No performance measurement, live graph mutation or dedicated native blocking + error scenario is claimed. Existing shared error/Diagnostics behavior remains. +- Evidence, temporary XCTest, original videos, result bundles and build manifests + are outside the repository in the task's implementation-evidence directory. + The final actual-gesture recording supersedes the earlier partial AX recording; + neither it nor the earlier design movie claims production network verification. + +## Questions + +None. The latest user approval answers the implementation/isolated A135 scope. +The obsolete per-check protected-spec proposal is not part of this design. diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml index b13f3cd..5140bcf 100644 --- a/logseq_sync/lib/pure_reducer/core.ml +++ b/logseq_sync/lib/pure_reducer/core.ml @@ -891,6 +891,7 @@ type t = ; submission_owner : submission_owner option ; pull_owner : pull_owner option ; pull_wanted : (graph_scope * Server_cursor.t) option + ; force_pull_pending : bool ; diagnostic_remote_cursor : (graph_scope * Server_cursor.t) option ; pull_retry_delay : float ; active_authoritative_batch : authoritative_batch option @@ -967,7 +968,7 @@ let initial config = ; pending_sync_inspection = None ; pending_outbox_transition = None ; pull_owner = None - ; pull_wanted = None + ; pull_wanted = None; force_pull_pending = false ; diagnostic_remote_cursor = None ; pull_retry_delay = 1. ; submission_owner = None @@ -1081,7 +1082,7 @@ let fail core stage message = { core with pending_effects = [] ; pull_owner = None - ; pull_wanted = None + ; pull_wanted = None; force_pull_pending = false ; pull_retry_delay = 1. ; pending_mirror_inspection = None ; pending_selected_download = None @@ -1281,7 +1282,7 @@ let reset_graph_operations core = ; pending_outbox_transition = None ; submission_owner = None ; pull_owner = None - ; pull_wanted = None + ; pull_wanted = None; force_pull_pending = false ; pull_retry_delay = 1. ; active_authoritative_batch = None ; queued_authoritative_batch = None @@ -1652,7 +1653,8 @@ let pull_target_pending core graph cursor = let pull_in_progress core = match core.current_graph_scope, core.public_state.snapshot.applied_server_t with | Some graph, Some cursor -> - Option.is_some core.pull_owner + core.force_pull_pending + || Option.is_some core.pull_owner || Option.is_some core.active_authoritative_batch || pull_target_pending core graph cursor | _ -> false @@ -1843,7 +1845,11 @@ let request_pull ?through ?(force = false) core = if occupied || Option.is_some core.active_authoritative_batch - || not (force || pull_target_pending core graph cursor) + || (core.force_pull_pending && not force + && not (pull_target_pending core graph cursor) + && (Option.is_some core.pending_outbox_transition + || Option.is_some core.submission_owner)) + || not (force || core.force_pull_pending || pull_target_pending core graph cursor) then unchanged core else ( let connection = { graph; connection_generation = core.connection_generation } in @@ -1852,6 +1858,7 @@ let request_pull ?through ?(force = false) core = set_snapshot { core with next_effect_id = timer + 1 + ; force_pull_pending = false ; pull_owner = Some { pull_connection = connection @@ -3036,7 +3043,7 @@ let step core event = pending_selected_download = None; pending_effects = local_pending; websocket_live = false; deferred_catalog_reconciliation = false; unlock_preparation = No_unlock_preparation; - bootstrap = (if bootstrap_downloading next.bootstrap then No_bootstrap else next.bootstrap); pull_wanted = None; + bootstrap = (if bootstrap_downloading next.bootstrap then No_bootstrap else next.bootstrap); pull_wanted = None; force_pull_pending = false; pull_owner = (match next.pull_owner with | Some {pull_stage=Applying_pull; _} as owner -> owner | _ -> None) } { snapshot with timeline_presentation_pending = false; startup; sync_phase = Offline @@ -3170,7 +3177,12 @@ let step core event = in let requested = begin_e2ee_key_access core scope in { requested with effects = publish requested.next :: requested.effects } - | Some scope, _ -> inspect_sync core scope + | Some scope, _ -> + (* The existing explicit-online entry owns manual freshness demand. + Busy work keeps a single intent until its response/commit retires. *) + let inspected = inspect_sync { core with force_pull_pending = true } scope in + let pulled = request_pull inspected.next in + { pulled with effects = inspected.effects @ pulled.effects } | None, _ -> unchanged core) | Local_cache_deletion_requested graph_id -> begin_local_deletion core graph_id | Foreground_changed { foreground = false; lifecycle_generation } -> @@ -3309,7 +3321,7 @@ let step core event = ; pending_outbox_transition = None ; submission_owner = None ; pull_owner = None - ; pull_wanted = None + ; pull_wanted = None; force_pull_pending = false ; pull_retry_delay = 1. ; active_authoritative_batch = None ; queued_authoritative_batch = None @@ -3984,6 +3996,14 @@ let step_sync = step let step core event = let transition = step_sync core event in + let transition = + if transition.next.force_pull_pending && transition.next.foreground + && not transition.next.closed && network_allowed transition.next + then + let pulled = request_pull transition.next in + { pulled with effects = transition.effects @ pulled.effects } + else transition + in let public_state = with_sync_cursor_diagnostics transition.next transition.next.public_state in let next = { transition.next with public_state } in let effects = List.map (function diff --git a/logseq_sync/test/core_contract.ml b/logseq_sync/test/core_contract.ml index 1f49f5d..8aaaef6 100644 --- a/logseq_sync/test/core_contract.ml +++ b/logseq_sync/test/core_contract.ml @@ -3799,8 +3799,129 @@ let pull_shutdown_and_graph_retirement_cancel_demand () = [ Core.Shutdown; Core.Graph_picker_requested ] ;; +(* The existing explicit-online event is the shared manual-sync entry. *) +let manual_sync core = + Core.step core (Core.Online_recovery_requested (timeline_scope core)) +;; + +let manual_sync_online_forces_a_fresh_pull () = + let opened, connection, _ = pull_fixture () in + let current = pull_response opened.next connection ~after:0 0 in + let requested = manual_sync current.next in + check_pull_count "manual online sync sends a fresh pull" 1 requested.effects; + Alcotest.(check bool) "request is not prematurely Current" true + ((Core.state requested.next).snapshot.sync_phase = Core.Pulling); + let completed = pull_response requested.next connection ~after:0 0 in + check_pull_count "one confirmed empty response settles" 0 completed.effects; + Alcotest.(check bool) "confirmed empty pull is Current" true + ((Core.state completed.next).snapshot.sync_phase = Core.Current) +;; + +let manual_sync_busy_force_is_coalesced () = + let opened, connection, _ = pull_fixture () in + let pending = ref opened.next in + for _ = 1 to 50 do + let requested = manual_sync !pending in + check_pull_count "busy gestures never send concurrently" 0 requested.effects; + pending := requested.next + done; + let received = pull_response !pending connection ~after:0 0 in + check_pull_count "force survives old empty response" 1 received.effects; + Alcotest.(check bool) "post-gesture probe remains Pulling" true + ((Core.state received.next).snapshot.sync_phase = Core.Pulling); + let confirmed = pull_response received.next connection ~after:0 0 in + check_pull_count "fifty gestures merge into one probe" 0 confirmed.effects; + Alcotest.(check bool) "new probe confirmation settles" true + ((Core.state confirmed.next).snapshot.sync_phase = Core.Current) +;; + +let manual_sync_waits_for_authoritative_commit () = + let opened, connection, sync = pull_fixture () in + let received = pull_response opened.next connection ~after:0 1 in + let requested = manual_sync received.next in + check_pull_count "applying authoritative data keeps its owner" 0 requested.effects; + let committed = recovery_apply requested.next connection + (recovery_sync sync ~checkpoint:1 1 []) in + Alcotest.(check bool) "probe starts from committed cursor" true + (pull_sends committed.effects = [connection, Some (server_cursor_of_int 1)]); + Alcotest.(check bool) "no intermediate Current publication" false + (List.exists (function Core.Publish (Core.State_changed state) -> + state.snapshot.sync_phase = Core.Current | _ -> false) committed.effects); + let confirmed = pull_response committed.next connection ~after:1 1 in + Alcotest.(check bool) "commit plus fresh response settles" true + ((Core.state confirmed.next).snapshot.sync_phase = Core.Current) +;; + +let manual_sync_offline_uses_existing_admission_and_socket_owner () = + let opened, connection, _ = pull_fixture () in + let current = pull_response opened.next connection ~after:0 0 in + let closed = Core.step current.next + (Core.Websocket_closed (connection, Core.Recoverable_close (Some "synthetic offline"))) in + let requested = manual_sync closed.next in + check_pull_count "offline never sends through old socket" 0 requested.effects; + (* Application's existing online request re-verifies the native identity. *) + let admitted = Core.step requested.next + (Core.Account_authenticated {user_id=Some "user"}) in + Alcotest.(check bool) "replacement waits for physical retirement" false + (List.exists (function Core.Run (Core.Start_websocket _) -> true | _ -> false) admitted.effects); + let retired = Core.step admitted.next (Core.Websocket_retired connection) in + let fresh = recovery_connection retired.effects in + let connected = Core.step retired.next (Core.Websocket_opened fresh) in + check_pull_count "recovered connection owns the single baseline pull" 1 connected.effects; + let late = pull_response connected.next connection ~after:0 5 in + check_pull_count "old socket response is fenced" 0 late.effects; + let completed = pull_response late.next fresh ~after:0 0 in + check_pull_count "recovery does not queue an extra manual pull" 0 completed.effects; + Alcotest.(check bool) "reconnected confirmation settles" true + ((Core.state completed.next).snapshot.sync_phase = Core.Current) +;; + +let manual_sync_scope_and_retirement_fence_force () = + let opened, connection, _ = pull_fixture () in + let current = pull_response opened.next connection ~after:0 0 in + let scope = timeline_scope current.next in + let stale = {scope with startup={scope.startup with + presentation_generation=scope.startup.presentation_generation+1}} in + let ignored = Core.step current.next (Core.Online_recovery_requested stale) in + check_instructions "stale manual intent is inert" [] ignored.effects; + let requested = manual_sync opened.next in + List.iter (fun event -> + let retired = Core.step requested.next event in + let late = pull_response retired.next connection ~after:0 1 in + check_pull_count "retired force cannot restart pull" 0 late.effects) + [Core.Graph_picker_requested; Core.Shutdown; + Core.Online_cancelled (startup_scope requested.next)] +;; + +let manual_sync_does_not_bypass_submission_owner () = + let submitted, connection, sync, batch = submitted_delete_recovery_fixture () in + let requested = manual_sync submitted.next in + check_pull_count "manual force waits for shared submission response" 0 requested.effects; + Alcotest.(check bool) "submission remains active" true + ((Core.state requested.next).snapshot.sync_phase = Core.Submitting); + let accepted = pull_message requested.next connection + (Protocol.Server.Tx_batch_ok {t=server_cursor_of_int 1;checksum=Some "0123456789abcdef"}) in + let request = recovery_request accepted.effects in + let accepted_sync = recovery_sync sync 3 + (List.map (fun (item : Overlay.submission_descriptor) -> + {item with state=Overlay.Accepted_pending_authoritative (Overlay.submission_batch_id batch)}) + (Overlay.sync_view_submissions sync)) in + let applied = Core.step accepted.next (recovery_commit request accepted_sync None) in + check_pull_count "acceptance joins the still-outstanding baseline pull" 0 applied.effects; + let received = pull_response applied.next connection ~after:0 1 in + let committed = recovery_apply received.next connection + (recovery_sync sync ~checkpoint:1 4 []) in + check_pull_count "submission and manual force drain through shared commit" 1 committed.effects +;; + let pull_singleflight_scenarios = - [ Alcotest.test_case + [ Alcotest.test_case "manual sync online fresh pull" `Quick manual_sync_online_forces_a_fresh_pull + ; Alcotest.test_case "manual sync busy force coalesces" `Quick manual_sync_busy_force_is_coalesced + ; Alcotest.test_case "manual sync waits for commit" `Quick manual_sync_waits_for_authoritative_commit + ; Alcotest.test_case "manual sync offline admission and owner" `Quick manual_sync_offline_uses_existing_admission_and_socket_owner + ; Alcotest.test_case "manual sync stale and retired scope" `Quick manual_sync_scope_and_retirement_fence_force + ; Alcotest.test_case "manual sync preserves submission owner" `Quick manual_sync_does_not_bypass_submission_owner + ; Alcotest.test_case "F5 opening and notification burst share one pull" `Quick pull_open_and_notifications_are_singleflight diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 0698d40..235815c 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -79,6 +79,7 @@ import UIKit let sections: [SectionModel] let track_visible_range: Bool? let track_scroll_completion: Bool? + let sync_enabled: Bool? } /// One payload observation belongs to one mounted native list. Child-model @@ -344,7 +345,7 @@ import UIKit var active = false var handledToken: Int64 = 0 private var userInteracting = false - private var atTop = false + private(set) var atTop = false #if os(iOS) weak var scrollView: UIScrollView? weak var cover: UIView? @@ -539,6 +540,8 @@ import UIKit @State private var scrollProxy: ScrollViewProxy? @State private var pendingScroll: (request: ScrollRequest, id: String, anchor: UnitPoint)? @StateObject private var topScroll = TopScroll() + @State private var pullEligible = false + @State private var pullHint = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion init(context: LUIAppleExtensionViewContext) { @@ -750,6 +753,34 @@ import UIKit } } + private func cancelPull() { + pullEligible = false + pullHint = 0 + } + + private func scrollPhaseChanged(from previous: ScrollPhase, to phase: ScrollPhase) { + let wasTracking = previous == .tracking || previous == .interacting + let tracking = phase == .tracking || phase == .interacting + if tracking && !wasTracking { + pullEligible = properties?.sync_enabled == true && topScroll.atTop + pullHint = 0 + } + if wasTracking && !tracking { + var cancelled = false + #if os(iOS) + let nativePhase = topScroll.scrollView?.panGestureRecognizer.state + cancelled = nativePhase == .cancelled || nativePhase == .failed + #endif + let trigger = pullEligible && pullHint == 2 && !cancelled + && (phase == .idle || phase == .decelerating) + && properties?.sync_enabled == true + cancelPull() + if trigger { emit(Event(type: "sync")) } + } + // The existing top-scroll owner retains interaction cancellation. + topScroll.interactionChanged(tracking) + } + var body: some SwiftUI.View { ScrollViewReader { proxy in List { @@ -775,11 +806,34 @@ import UIKit #if os(iOS) .overlay(TopScrollAccess(owner: topScroll).ignoresSafeArea().allowsHitTesting(false).accessibilityHidden(true)) #endif - .onScrollGeometryChange(for: Bool.self) { geometry in - geometry.contentOffset.y + geometry.contentInsets.top <= 1 - } action: { _, top in topScroll.geometryChanged(top) } - .onScrollPhaseChange { _, phase in - topScroll.interactionChanged(phase == .tracking || phase == .interacting) + .onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top + } action: { _, offset in + topScroll.geometryChanged(offset <= 1) + if pullEligible { + // Only the three semantic hint states invalidate the view. + let hint = offset <= -72 ? 2 : offset <= -20 ? 1 : 0 + if pullHint != hint { pullHint = hint } + } + } + .onScrollPhaseChange { previous, phase in + scrollPhaseChanged(from: previous, to: phase) + } + .overlay(alignment: .top) { + if pullEligible && pullHint > 0 { + Text(pullHint == 2 ? "Release to sync" : "Pull to sync") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 12) + .allowsHitTesting(false) + .accessibilityIdentifier("journal-pull-sync-hint") + .transaction { $0.animation = nil } + } + } + .accessibilityActions { + if properties?.sync_enabled == true { + Button("Sync") { emit(Event(type: "sync")) } + } } .onAppear { topScroll.active = true @@ -790,6 +844,7 @@ import UIKit } } .onDisappear { + cancelPull() topScroll.active = false topScroll.interactionChanged(false) topScroll.cancel() @@ -799,6 +854,7 @@ import UIKit topScroll.geometryChanged(false) } .onChange(of: context.nodeID) { _, _ in + cancelPull() topScroll.interactionChanged(false) topScroll.cancel() pendingScroll = nil diff --git a/test/application_view_test.ml b/test/application_view_test.ml index f8f29f7..2480dff 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -2068,7 +2068,7 @@ let run_favorites_native_visibility scenario [ "attachments"; "attachment-only"; "offscreen-attachments"; "selection-error"; "picker-owner"; "retry-imports"; "attachment-limit"; "interrupted-imports" ] then ( - dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "文件"))); + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Files"))); let adapter, request = Hashtbl.fold (fun id values found -> @@ -2157,7 +2157,7 @@ let run_favorites_native_visibility Alcotest.(check (option bool)) (label ^ " enabled matches capacity") (Some enabled) (Option.map Yojson.Safe.Util.to_bool - (List.assoc_opt "enabled" (Hashtbl.find props node)))) ["文件";"照片";"相机"] in + (List.assoc_opt "enabled" (Hashtbl.find props node)))) ["Files";"Photos";"Camera"] in check false; Alcotest.(check bool) "cap explains how to add another attachment" true (Option.is_some (find "text" "9 attachments maximum. Remove one to add another.")); @@ -3642,7 +3642,7 @@ let run_favorites_native_visibility Two Worker lanes make accidental parallel submission observable. *) dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Capture"))); - dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "文件"))); + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Files"))); let adapter = Hashtbl.fold (fun id values found -> @@ -5131,6 +5131,22 @@ let test_reactive_header_current_controls () = "cleared error capability removes error action" true (Option.is_none (find AccessibilityLabel "Error info")); + let spinner = ref None in + List.iter (fun phase -> + ignore (Lui_app.send app {initial with sync_phase=Some phase}); + ignore (Lui_app.flush app); + let current = find AccessibilityIdentifier "journal-header-sync-progress" in + (match !spinner with + | None -> spinner := current + | Some id -> Alcotest.(check (option int)) + "all active phases retain the same existing spinner" (Some id) current); + Alcotest.(check bool) "shared active phase shows header progress" true + (Option.is_some current)) + [Service.Connecting; Pulling; Submitting]; + ignore (Lui_app.send app {initial with sync_phase=Some Service.Current}); + ignore (Lui_app.flush app); + Alcotest.(check bool) "settled shared owner removes progress" true + (Option.is_none (find AccessibilityIdentifier "journal-header-sync-progress")); Alcotest.(check int) "control changes preserve body node identity" body diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index 0edd12a..5ccd971 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -3077,8 +3077,31 @@ let test_status_property_identity_preserved () = [ G.Uuid.to_string (uuid 99); G.Uuid.to_string (uuid 100) ] ;; +let test_native_sync_entry_is_bound_and_scoped () = + let calls = ref 0 in + let no_op = Ui.Event.Handler.create (fun _ -> ()) in + List.iter (fun enabled -> + let view = Journal_native_collection.view + ~key:(Ui.Key.string "sync-list") ~test_id:(Ui.Test_id.string "sync-list") + ~rows:[] ~scroll_target:None + ?on_sync:(if enabled then Some (Ui.Event.Handler.create (fun _ -> incr calls)) else None) + ~on_scroll_completed:no_op ~actions_enabled:true ~on_visible_range:no_op + ~on_open:no_op ~on_status:no_op ~on_delete:no_op ~on_copy:no_op ~children:[] () + |> V.Body.Private.to_widget in + with_mounted view (fun app ops -> + let node = native_list_node (ops ()) in + let before = !calls in + dispatch_list_payload app node {|{"type":"sync"}|}; + require (!calls = before + (if enabled then 1 else 0)) + "native sync trigger did not use the mounted optional callback"; + dispatch_list_payload app node {|{"type":"unknown-sync"}|}; + require (!calls = before + (if enabled then 1 else 0)) + "unknown native event triggered sync")) [false; true] +;; + let tests = - [ ( "Database status identity preserves complete detail" + [ "native sync entry binding", test_native_sync_entry_is_bound_and_scoped + ; ( "Database status identity preserves complete detail" , test_status_property_identity_preserved ) ; ( "Typed properties omitted from Timeline" , test_property_values_are_not_timeline_children )