Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/application.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
]
Expand Down Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions app/journal_header.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ())
])
Expand Down
2 changes: 2 additions & 0 deletions app/journal_native_collection.ml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ let view
~rows
~scroll_target
?scroll_target_signal
?on_sync
~on_scroll_completed
~actions_enabled
~on_visible_range
Expand Down Expand Up @@ -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 ()
Expand Down
1 change: 1 addition & 0 deletions app/journal_native_collection.mli
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/journal_timeline.ml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ let view
?status_name
?current_day
?store
?on_sync
?(on_region = fun _ -> ())
~render_source
?render_title
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/journal_timeline.mli
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions app/journal_view.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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 =
Expand All @@ -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
;;
Expand Down Expand Up @@ -1697,6 +1698,7 @@ module View = struct
?scroll_request_signal
?on_scroll_completed
?on_visible_range
?on_sync
sections
=
let payload, contents =
Expand All @@ -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. *)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/journal_view.mli
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apple/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<key>NSCameraUsageDescription</key>
<string>Take a photo to attach to a journal entry.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>将你选择的图片保存到相册。Save the image you select to your photo library.</string>
<string>Save the image you select to your photo library.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 28 additions & 8 deletions logseq_sync/lib/pure_reducer/core.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 } ->
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading