diff --git a/apps/mobile/src/hooks/useMobileAutomaticSync.ts b/apps/mobile/src/hooks/useMobileAutomaticSync.ts index db0dc494..87d81830 100644 --- a/apps/mobile/src/hooks/useMobileAutomaticSync.ts +++ b/apps/mobile/src/hooks/useMobileAutomaticSync.ts @@ -29,8 +29,9 @@ export const useMobileAutomaticSync = ({ const [syncQueueItems, setSyncQueueItems] = useState([]); const runningRef = useRef(false); const requestedRef = useRef(false); + const requestedForceRef = useRef(false); const retryTimerRef = useRef | null>(null); - const runRef = useRef<() => Promise>(async () => undefined); + const runRef = useRef<(force?: boolean) => Promise>(async () => undefined); const refreshSyncQueueItems = useCallback(async () => { const items = await listMobileSyncQueueItems(syncQueueScope); @@ -47,13 +48,14 @@ export const useMobileAutomaticSync = ({ ]); }, [queryClient]); - const runAutomaticSync = useCallback(async () => { + const runAutomaticSync = useCallback(async (force = false) => { if (!client) { return; } if (runningRef.current) { requestedRef.current = true; + requestedForceRef.current = requestedForceRef.current || force; return; } @@ -63,29 +65,41 @@ export const useMobileAutomaticSync = ({ } runningRef.current = true; + let shouldForce = force; try { - const summary = await loadMobileSyncQueueSummary(syncQueueScope); - if (summary.pending + summary.error + summary.syncing === 0) { - return; - } + for (;;) { + const summary = await loadMobileSyncQueueSummary(syncQueueScope); + const retryable = summary.pending + summary.error + summary.syncing; - await syncMobileQueuedChanges(client, syncQueueScope, { - onSynced: async (memo, item) => { - if (item.kind === "memo.create") { - await replaceLocalMemoId(dataScope, item.memoId, memo); - onMemoIdRemapped(item.memoId, memo); - } else { - await upsertLocalMemo(dataScope, memo); + if (retryable > 0) { + await syncMobileQueuedChanges(client, syncQueueScope, { + force: shouldForce, + onSynced: async (memo, item) => { + if (item.kind === "memo.create") { + await replaceLocalMemoId(dataScope, item.memoId, memo); + onMemoIdRemapped(item.memoId, memo); + } else { + await upsertLocalMemo(dataScope, memo); + } + queryClient.setQueryData(["mobile", "memo", "notebook", memo.id], { memo }); + queryClient.setQueryData(["mobile", "memo", "trash", memo.id], { memo }); + }, + }); + + const nextSummary = await loadMobileSyncQueueSummary(syncQueueScope); + if (nextSummary.total === 0) { + await invalidateSyncedWorkspace(); } - queryClient.setQueryData(["mobile", "memo", "notebook", memo.id], { memo }); - queryClient.setQueryData(["mobile", "memo", "trash", memo.id], { memo }); - }, - }); + } - const nextSummary = await loadMobileSyncQueueSummary(syncQueueScope); - if (nextSummary.total === 0) { - await invalidateSyncedWorkspace(); + if (!requestedRef.current) { + break; + } + + shouldForce = requestedForceRef.current; + requestedRef.current = false; + requestedForceRef.current = false; } } catch { // Queue metadata remains durable; the scheduled pass resumes it. @@ -94,8 +108,10 @@ export const useMobileAutomaticSync = ({ runningRef.current = false; if (requestedRef.current) { + const nextForce = requestedForceRef.current; requestedRef.current = false; - void runRef.current(); + requestedForceRef.current = false; + void runRef.current(nextForce); return; } @@ -103,12 +119,16 @@ export const useMobileAutomaticSync = ({ if (retryDelay !== null) { retryTimerRef.current = setTimeout(() => { retryTimerRef.current = null; - void runRef.current(); + void runRef.current(false); }, retryDelay); } } }, [client, dataScope, invalidateSyncedWorkspace, onMemoIdRemapped, queryClient, refreshSyncQueueItems, syncQueueScope]); + const runForcedSync = useCallback(async () => { + await runAutomaticSync(true); + }, [runAutomaticSync]); + runRef.current = runAutomaticSync; useEffect(() => { @@ -116,11 +136,12 @@ export const useMobileAutomaticSync = ({ return; } - void runRef.current(); + void runRef.current(false); const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => { if (nextState === "active") { - void runRef.current(); + // Returning to the app should immediately retry any deferred failures. + void runRef.current(true); } }); @@ -136,6 +157,7 @@ export const useMobileAutomaticSync = ({ return { refreshSyncQueueItems, runAutomaticSync, + runForcedSync, syncQueueItems, }; }; diff --git a/apps/mobile/src/lib/mobile-locale.tsx b/apps/mobile/src/lib/mobile-locale.tsx index 67abe597..98c099bf 100644 --- a/apps/mobile/src/lib/mobile-locale.tsx +++ b/apps/mobile/src/lib/mobile-locale.tsx @@ -39,6 +39,12 @@ const mobileOnlyTranslations = new Map([ ["同步失败", "Sync failed"], ["待同步", "Pending sync"], ["已同步", "Synced"], + ["立即同步", "Sync now"], + ["查看同步状态并立即重试", "View sync status and retry now"], + ["本地改动还在等待上传到云端。可立即重试同步。", "Local changes are waiting to upload. You can retry sync now."], + ["本地改动未能上传到云端。可立即重试同步。", "Local changes could not upload. You can retry sync now."], + ["本地改动未能上传到云端。内容仍保存在本机,可立即重试。", "Local changes could not upload. They remain on this device and you can retry now."], + ["本地改动待上传。下拉刷新或点此可立即同步。", "Local changes are pending upload. Pull to refresh or tap to sync now."], ["查看并处理同步冲突", "Review and resolve the sync conflict"], ["云端笔记已在其他标签页、设备,或离线期间被更新,本地草稿无法直接覆盖。可先复制本地草稿,再采用云端版本后继续编辑。", "The cloud note was updated in another tab, on another device, or while you were offline. Copy your local draft, then use the cloud version before editing again."], ["云端笔记已在其他标签页、设备,或离线期间被更新。可先复制本地草稿,再采用云端版本后继续编辑。", "The cloud note was updated in another tab, on another device, or while you were offline. Copy your local draft, then use the cloud version before editing again."], diff --git a/apps/mobile/src/lib/sync-queue.test.ts b/apps/mobile/src/lib/sync-queue.test.ts index 6fcf9d1d..66a65b36 100644 --- a/apps/mobile/src/lib/sync-queue.test.ts +++ b/apps/mobile/src/lib/sync-queue.test.ts @@ -17,10 +17,13 @@ mock.module("@react-native-async-storage/async-storage", () => ({ })); const { + armMobileSyncQueueImmediateRetry, cancelMobileMemoQueueItems, + clearMobileMemoUpdateQueueItem, discardMobileMemoConflict, getMobileConflictDraftClipboardText, listMobileSyncQueueItems, + markMobileMemoUpdateError, queueMobileMemoCreate, queueMobileMemoUpdate, syncMobileQueuedChanges, @@ -236,6 +239,61 @@ test("cancels queue items for a memo", async () => { expect(remaining[0]?.memoId).toBe("memo-2"); }); +test("force sync ignores backoff and retries errored items immediately", async () => { + const scope = "https://one.example"; + await queueMobileMemoUpdate(scope, basePayload); + await markMobileMemoUpdateError(scope, "memo-1", "temporary outage"); + + // Simulate exponential backoff far in the future. + const items = await listMobileSyncQueueItems(scope); + expect(items[0]?.status).toBe("error"); + + let updateCalled = false; + const client = { + createMemoEditSession: async () => ({ + editSession: { id: "edit-1", baseRevision: 1, baseContentHash: "hash-1" }, + }), + updateMemo: async () => { + updateCalled = true; + return { memo: createMemo({ revision: 2, contentHash: "hash-2", contentMarkdown: "first" }) }; + }, + }; + + // Without force, a far-future nextAttemptAt would skip the item. Arm + force clears it. + await armMobileSyncQueueImmediateRetry(scope); + const result = await syncMobileQueuedChanges(client as never, scope, { force: true }); + + expect(result.synced).toBe(1); + expect(updateCalled).toBe(true); + expect(await listMobileSyncQueueItems(scope)).toHaveLength(0); +}); + +test("clearMobileMemoUpdateQueueItem drops a stale outbox update after online save", async () => { + const scope = "https://one.example"; + await queueMobileMemoUpdate(scope, basePayload); + await clearMobileMemoUpdateQueueItem(scope, "memo-1"); + expect(await listMobileSyncQueueItems(scope)).toHaveLength(0); +}); + +test("content_conflict is classified as a conflict instead of a retryable error", async () => { + const scope = "https://one.example"; + await queueMobileMemoUpdate(scope, basePayload); + const result = await syncMobileQueuedChanges({ + createMemoEditSession: async () => ({ + editSession: { id: "edit-1", baseRevision: 1, baseContentHash: "hash-1" }, + }), + updateMemo: async () => { + throw new ApiRequestError("Note content changed after this edit session started.", 409, "content_conflict"); + }, + } as never, scope); + + const queued = (await listMobileSyncQueueItems(scope))[0]; + expect(result.conflicted).toBe(1); + expect(result.failed).toBe(0); + expect(queued?.status).toBe("conflict"); + expect(queued?.lastError).toContain("content changed"); +}); + test("soft-deletes the remote orphan when an offline create is cancelled mid-sync", async () => { const scope = "https://one.example"; await queueMobileMemoCreate(scope, { diff --git a/apps/mobile/src/lib/sync-queue.ts b/apps/mobile/src/lib/sync-queue.ts index 95b396d8..c4b4d317 100644 --- a/apps/mobile/src/lib/sync-queue.ts +++ b/apps/mobile/src/lib/sync-queue.ts @@ -147,6 +147,91 @@ export const deleteMobileSyncQueueItem = async (scope: string, id: string) => { return loadMobileSyncQueueSummary(scope); }; +/** Drop a successful online update so a stale outbox entry cannot re-apply older content. */ +export const clearMobileMemoUpdateQueueItem = async (scope: string, memoId: string) => { + await removeMobileSyncQueueItem(scope, getMobileMemoUpdateQueueId(memoId)); + return loadMobileSyncQueueSummary(scope); +}; + +/** + * Clear backoff so the next sync pass retries immediately. + * Conflict items stay conflicted until the user resolves them. + */ +export const armMobileSyncQueueImmediateRetry = async (scope: string) => { + const now = new Date().toISOString(); + await mutateMobileSyncQueue(scope, (items) => + items.map((item) => { + if (item.status === "conflict") { + return item; + } + if (item.status === "pending" || item.status === "error" || item.status === "syncing") { + return { + ...item, + status: "pending", + nextAttemptAt: null, + updatedAt: now, + }; + } + return item; + }) + ); + return loadMobileSyncQueueSummary(scope); +}; + +export const markMobileMemoUpdateConflict = async (scope: string, memoId: string, lastError: string) => { + const id = getMobileMemoUpdateQueueId(memoId); + const now = new Date().toISOString(); + await mutateMobileSyncQueue(scope, (items) => + items.map((item) => { + if (item.id !== id && !(item.kind === "memo.create" && item.memoId === memoId)) { + return item; + } + return { + ...item, + status: "conflict", + lastError, + nextAttemptAt: null, + updatedAt: now, + }; + }) + ); + return loadMobileSyncQueueSummary(scope); +}; + +export const markMobileMemoUpdateError = async (scope: string, memoId: string, lastError: string) => { + const id = getMobileMemoUpdateQueueId(memoId); + const now = new Date().toISOString(); + await mutateMobileSyncQueue(scope, (items) => + items.map((item) => { + if (item.id !== id && !(item.kind === "memo.create" && item.memoId === memoId)) { + return item; + } + return { + ...item, + status: "error", + lastError, + nextAttemptAt: null, + updatedAt: now, + }; + }) + ); + return loadMobileSyncQueueSummary(scope); +}; + +export const getMobileSyncErrorMessage = (error: unknown) => + error instanceof Error ? error.message : "Sync failed"; + +export const isMobileSyncConflictError = (error: unknown) => { + if (!(error instanceof ApiRequestError)) { + return false; + } + if (error.status === 409) { + return true; + } + const code = (error.code ?? "").toLowerCase(); + return code.includes("conflict"); +}; + /** Drop every create/update queue entry for a memo (used when discarding local-only notes). */ export const cancelMobileMemoQueueItems = async (scope: string, memoId: string) => { await mutateMobileSyncQueue(scope, (items) => items.filter((item) => item.memoId !== memoId)); @@ -213,14 +298,19 @@ export const syncMobileQueuedChanges = async ( client: ReturnType, scope: string, options: { + force?: boolean; onSynced?: (memo: MemoDetail, item: MobileSyncQueueItem) => void | Promise; } = {} ): Promise => { + if (options.force) { + await armMobileSyncQueueImmediateRetry(scope); + } + const result = createEmptySyncRunResult(); const now = new Date(); const items = (await readMobileSyncQueue(scope)) .filter((item) => item.status === "pending" || item.status === "error" || item.status === "syncing") - .filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now) + .filter((item) => options.force || !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); for (const item of items) { @@ -256,13 +346,13 @@ export const syncMobileQueuedChanges = async ( } result.synced += 1; } catch (error) { - const status = isRevisionConflict(error) ? "conflict" : "error"; + const status = isMobileSyncConflictError(error) ? "conflict" : "error"; const attemptCount = item.attemptCount + 1; await updateMobileSyncQueueItem(scope, item.id, { status, attemptCount, - lastError: getErrorMessage(error), + lastError: getMobileSyncErrorMessage(error), nextAttemptAt: status === "error" ? getSyncRetryAt(attemptCount) : null, updatedAt: new Date().toISOString(), }, itemVersion); @@ -280,10 +370,15 @@ export const syncMobileQueuedChanges = async ( export const shouldQueueMobileMemoSaveError = (error: unknown) => { if (error instanceof ApiRequestError) { - return error.status === 408 || error.status === 429 || error.status >= 500; + return error.status === 0 || error.status === 408 || error.status === 429 || error.status >= 500; } - return error instanceof TypeError || getErrorMessage(error).toLowerCase().includes("network"); + const message = getMobileSyncErrorMessage(error).toLowerCase(); + return error instanceof TypeError + || message.includes("network") + || message.includes("failed to fetch") + || message.includes("network request failed") + || message.includes("timeout"); }; const syncMobileQueueItem = async (client: ReturnType, item: MobileSyncQueueItem) => { @@ -466,6 +561,4 @@ const isMobileSyncQueueItem = (value: unknown): value is MobileSyncQueueItem => return (item.kind === "memo.update" || item.kind === "memo.create") && typeof item.id === "string" && typeof item.memoId === "string" && Boolean(item.payload); }; -const isRevisionConflict = (error: unknown) => error instanceof ApiRequestError && error.code === "revision_conflict"; -const getErrorMessage = (error: unknown) => (error instanceof Error ? error.message : "Sync failed"); diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index dd30018b..def5b59f 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -304,8 +304,10 @@ export const MemoDetailModal = ({ onOpenRevisions, onRenameResource, onResolveSyncConflict, + onRetrySync, onRestore, onShare, + syncError, syncStatus, visible, }: { @@ -325,8 +327,10 @@ export const MemoDetailModal = ({ onOpenRevisions: (memo: MemoDetail) => void; onRenameResource: (memo: MemoDetail, target: MobileResourceTarget, filename: string) => Promise; onResolveSyncConflict: (memo: MemoDetail) => void; + onRetrySync: () => void; onRestore: (memo: MemoDetail) => void; onShare: (memo: MemoDetail) => void; + syncError: string | null; syncStatus: MobileSyncQueueItem["status"] | null; visible: boolean; }) => { @@ -421,6 +425,26 @@ export const MemoDetailModal = ({ : syncStatus === "pending" ? "待同步" : "已同步"; + const syncStatusInteractive = syncStatus === "conflict" || syncStatus === "error" || syncStatus === "pending"; + const handleSyncStatusPress = () => { + if (!memo) { + return; + } + if (syncStatus === "conflict") { + onResolveSyncConflict(memo); + return; + } + if (syncStatus === "error" || syncStatus === "pending") { + const detail = syncError?.trim() + || (syncStatus === "pending" + ? "本地改动还在等待上传到云端。可立即重试同步。" + : "本地改动未能上传到云端。可立即重试同步。"); + Alert.alert(syncStatusLabel, detail, [ + { text: "取消", style: "cancel" }, + { text: "立即同步", onPress: onRetrySync }, + ]); + } + }; const editFabBottom = Math.max( safeAreaInsets.bottom, Platform.OS === "android" ? ANDROID_SYSTEM_NAVIGATION_FALLBACK : 0 @@ -465,15 +489,26 @@ export const MemoDetailModal = ({ memo && onResolveSyncConflict(memo)} + accessibilityRole={syncStatusInteractive ? "button" : "text"} + disabled={!syncStatusInteractive || !memo} + onPress={handleSyncStatusPress} > {syncStatusLabel} @@ -522,6 +557,7 @@ export const MemoDetailModal = ({ 云端笔记已在其他标签页、设备,或离线期间被更新。可先复制本地草稿,再采用云端版本后继续编辑。 + {syncError ? {syncError} : null} ) : null} + {(syncStatus === "error" || syncStatus === "pending") && memo ? ( + + + {syncStatus === "error" + ? (syncError?.trim() || "本地改动未能上传到云端。内容仍保存在本机,可立即重试。") + : (syncError?.trim() || "本地改动待上传。下拉刷新或点此可立即同步。")} + + + + 立即同步 + + {syncStatus === "error" ? ( + onCopyLocalDraft(memo)} + style={styles.syncErrorBannerSecondaryButton} + > + 复制本地草稿 + + ) : null} + + + ) : null} + {isLoading ? ( diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index a4b851c8..64d34108 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -68,6 +68,7 @@ import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context" import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText"; import Markdown, { type ASTNode, type RenderRules } from "react-native-markdown-display"; import { SvgXml } from "react-native-svg"; +import { ApiRequestError } from "@edgeever/client"; import { buildGitHubFeedbackUrl, createExcerpt, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, resolveMemoContentDoc, type AuthUser, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type TiptapDoc } from "@edgeever/shared"; import { MOBILE_UI_METRICS, getMobileCenteredScrollOffset, getMobileNotebookSearchVisibleIds, toggleMobileMemoFilterMode, toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui"; import { clearMobileMemoDraft, clearMobileNewMemoDraft, readMobileMemoDraft, readMobileNewMemoDraft, writeMobileMemoDraft, writeMobileNewMemoDraft, type MobileMemoDraft } from "../lib/mobile-drafts"; @@ -82,12 +83,18 @@ import { import { useMobileLocale } from "../lib/mobile-locale"; import { useSession } from "../lib/session"; import { + clearMobileMemoUpdateQueueItem, deleteMobileSyncQueueItem, discardMobileMemoConflict, getMobileConflictDraftClipboardText, + getMobileSyncErrorMessage, + isMobileSyncConflictError, listMobileSyncQueueItems, + markMobileMemoUpdateConflict, + markMobileMemoUpdateError, queueMobileMemoCreate, queueMobileMemoUpdate, + shouldQueueMobileMemoSaveError, type MobileSyncQueueItem, } from "../lib/sync-queue"; import { deleteMobileMemos } from "../lib/mobile-memo-delete"; @@ -237,7 +244,7 @@ export const WorkspaceScreen = ({ }, []); const { refreshSyncQueueItems, - runAutomaticSync, + runForcedSync, syncQueueItems, } = useMobileAutomaticSync({ client, @@ -401,7 +408,10 @@ export const WorkspaceScreen = ({ }, [memosQuery.data, notebooksQuery.data]); const refresh = async () => { + // Pull-to-refresh must push the outbox first; previously it only pulled + // the server mirror, so pending local edits stayed stuck on "待同步". if (client) { + await runForcedSync(); await syncMobileLocalMirror(client, dataScope); } await Promise.all([ @@ -409,6 +419,7 @@ export const WorkspaceScreen = ({ queryClient.invalidateQueries({ queryKey: ["mobile", "memos"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "search"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "memo"] }), + refreshSyncQueueItems(), ]); }; @@ -540,9 +551,11 @@ export const WorkspaceScreen = ({ const searchActive = searchText.trim().length > 0; const visibleMemos = searchActive ? searchResults : memos; const selectedMemo = memoDetailQuery.data?.memo ?? null; - const selectedMemoSyncStatus = selectedMemo - ? syncQueueItems.find((item) => item.memoId === selectedMemo.id)?.status ?? null + const selectedMemoSyncItem = selectedMemo + ? syncQueueItems.find((item) => item.memoId === selectedMemo.id) ?? null : null; + const selectedMemoSyncStatus = selectedMemoSyncItem?.status ?? null; + const selectedMemoSyncError = selectedMemoSyncItem?.lastError ?? null; const isRefreshing = notebooksQuery.isFetching || memosQuery.isFetching || searchQuery.isFetching || memoDetailQuery.isFetching; const selectedMemoIdList = Array.from(selectedMemoIds); const selectedMemos = visibleMemos.filter((memo) => selectedMemoIds.has(memo.id)); @@ -781,8 +794,7 @@ export const WorkspaceScreen = ({ mutationFn: async ({ memo, payload }: { memo: MemoDetail; payload: MobileMemoUpdatePayload }) => { const syncBaseMemo = await resolveLocalMemo(dataScope, memo.id) ?? memo; const optimisticMemo = createOptimisticMemo(syncBaseMemo, payload); - - await queueMobileMemoUpdate(syncQueueScope, { + const queuePayload = { memoId: syncBaseMemo.id, expectedRevision: syncBaseMemo.revision, expectedContentHash: syncBaseMemo.contentHash, @@ -790,15 +802,69 @@ export const WorkspaceScreen = ({ contentMarkdown: optimisticMemo.contentMarkdown, notebookId: optimisticMemo.notebookId, tags: optimisticMemo.tags, - }); - await refreshSyncQueueItems(); + }; + // Online-first: push immediately when the instance is reachable so common + // edits never sit in "待同步". Fall back to the durable outbox only when + // the network or server cannot accept the write right now. + if (client && !syncBaseMemo.id.startsWith("local:")) { + try { + const editSessionResponse = payload.contentMarkdown !== undefined + ? await client.createMemoEditSession(syncBaseMemo.id) + : null; + + if ( + editSessionResponse + && ( + editSessionResponse.editSession.baseRevision !== syncBaseMemo.revision + || editSessionResponse.editSession.baseContentHash !== syncBaseMemo.contentHash + ) + ) { + throw new ApiRequestError("Note changed before the offline draft could sync.", 409, "revision_conflict"); + } + + const response = await client.updateMemo(syncBaseMemo.id, { + expectedRevision: syncBaseMemo.revision, + ...(editSessionResponse + ? { + expectedContentHash: syncBaseMemo.contentHash, + editSessionId: editSessionResponse.editSession.id, + } + : {}), + ...payload, + }); + + await clearMobileMemoUpdateQueueItem(syncQueueScope, syncBaseMemo.id); + await refreshSyncQueueItems(); + return response.memo; + } catch (error) { + const message = getMobileSyncErrorMessage(error); + + if (isMobileSyncConflictError(error)) { + await queueMobileMemoUpdate(syncQueueScope, queuePayload); + await markMobileMemoUpdateConflict(syncQueueScope, syncBaseMemo.id, message); + await refreshSyncQueueItems(); + return optimisticMemo; + } + + await queueMobileMemoUpdate(syncQueueScope, queuePayload); + if (!shouldQueueMobileMemoSaveError(error)) { + await markMobileMemoUpdateError(syncQueueScope, syncBaseMemo.id, message); + } + await refreshSyncQueueItems(); + // Durable local save succeeded; outbox will retry. Do not fail the editor. + return optimisticMemo; + } + } + + await queueMobileMemoUpdate(syncQueueScope, queuePayload); + await refreshSyncQueueItems(); return optimisticMemo; }, onSuccess: async (memo, variables) => { await upsertLocalMemo(dataScope, memo); applyOptimisticMemoToCache(queryClient, variables.memo, memo); - void runAutomaticSync(); + void runForcedSync(); }, }); @@ -1126,8 +1192,12 @@ export const WorkspaceScreen = ({ onAdoptCloudVersion={(memo) => void handleAdoptCloudVersion(memo)} onCopyLocalDraft={(memo) => void handleCopyConflictDraft(memo)} onResolveSyncConflict={handleMemoSyncConflict} + onRetrySync={() => { + void runForcedSync(); + }} onRestore={(memo) => restoreMemoMutation.mutate(memo)} onShare={(memo) => shareMemoMutation.mutate(memo)} + syncError={selectedMemoSyncError} syncStatus={selectedMemoSyncStatus} visible={Boolean(selectedMemoId)} /> @@ -1176,7 +1246,7 @@ export const WorkspaceScreen = ({ setMemoView("notebook"); setSelectedMemoId(null); }} - onQueued={runAutomaticSync} + onQueued={runForcedSync} syncQueueScope={syncQueueScope} visible={createOpen} /> @@ -2281,6 +2351,7 @@ const RichEditorModal = ({ const flushResolverRef = useRef<(() => void) | null>(null); const savingRef = useRef(false); const uploadingRef = useRef(false); + const memoBaseRef = useRef(memo); const [title, setTitle] = useState(resolveEditableMemoTitle(restoredDraft?.title ?? memo?.title)); const [tagsText, setTagsText] = useState(restoredDraft?.tagsText ?? memo?.tags.join(", ") ?? ""); const [notebookId, setNotebookId] = useState(restoredDraft?.notebookId ?? memo?.notebookId ?? ""); @@ -2310,7 +2381,8 @@ const RichEditorModal = ({ }, []); const persistDraft = async (contentJson: TiptapDoc) => { - if (!memo) { + const currentMemo = memoBaseRef.current; + if (!currentMemo) { return; } const contentSnapshot = JSON.stringify(contentJson); @@ -2328,8 +2400,8 @@ const RichEditorModal = ({ flushResolverRef.current?.(); flushResolverRef.current = null; await writeMobileMemoDraft({ - memoId: memo.id, - expectedRevision: memo.revision, + memoId: currentMemo.id, + expectedRevision: currentMemo.revision, title: titleRef.current.trim(), contentMarkdown: contentMarkdownRef.current, notebookId: notebookIdRef.current, @@ -2339,19 +2411,23 @@ const RichEditorModal = ({ }; const save = async () => { - if (!memo || savingRef.current || !notebookIdRef.current) { + const currentMemo = memoBaseRef.current; + if (!currentMemo || savingRef.current || !notebookIdRef.current) { return null; } if (!dirtyRef.current) { - return memo; + return currentMemo; } + // Capture whether the user kept typing while this save is in flight so we + // do not clear the dirty flag and drop their next autosave. + const dirtyGenerationAtStart = contentSnapshotRef.current; savingRef.current = true; setSaving(true); setError(null); try { const savedMemo = await updateMutation.mutateAsync({ - memo, + memo: currentMemo, payload: { title: titleRef.current.trim() || DEFAULT_MEMO_TITLE, contentJson: contentJsonRef.current, @@ -2360,9 +2436,16 @@ const RichEditorModal = ({ tags: parseTags(tagsTextRef.current), }, }); - await clearMobileMemoDraft(memo.id); - dirtyRef.current = false; - setDirty(false); + memoBaseRef.current = savedMemo; + await clearMobileMemoDraft(currentMemo.id); + if (contentSnapshotRef.current === dirtyGenerationAtStart) { + dirtyRef.current = false; + setDirty(false); + } else { + // Newer local edits arrived during the save; keep dirty so the next pass uploads them. + dirtyRef.current = true; + setDirty(true); + } setDraftRestored(false); return savedMemo; } catch (saveError) { @@ -2556,13 +2639,14 @@ const RichEditorModal = ({ ); useEffect(() => { - if (!memo || !dirty) { + const currentMemo = memoBaseRef.current; + if (!currentMemo || !dirty) { return; } const timeout = setTimeout(() => { void writeMobileMemoDraft({ - memoId: memo.id, - expectedRevision: memo.revision, + memoId: currentMemo.id, + expectedRevision: currentMemo.revision, title: titleRef.current.trim(), contentMarkdown: contentMarkdownRef.current, notebookId: notebookIdRef.current, @@ -2574,7 +2658,7 @@ const RichEditorModal = ({ }, [dirty, memo, notebookId, tagsText, title]); useEffect(() => { - if (!memo || !dirty || !ready || savingRef.current || uploadingRef.current) { + if (!memoBaseRef.current || !dirty || !ready || savingRef.current || uploadingRef.current) { return; } const timeout = setTimeout(() => { diff --git a/apps/mobile/src/screens/workspace-styles.ts b/apps/mobile/src/screens/workspace-styles.ts index f332e707..ef15081f 100644 --- a/apps/mobile/src/screens/workspace-styles.ts +++ b/apps/mobile/src/screens/workspace-styles.ts @@ -1499,6 +1499,67 @@ const baseWorkspaceStyles = StyleSheet.create({ color: "#c2410c", fontWeight: "700", }, + detailSyncStatusError: { + backgroundColor: "#fef2f2", + color: "#b91c1c", + fontWeight: "700", + }, + detailSyncStatusPending: { + backgroundColor: "#eff6ff", + color: "#1d4ed8", + fontWeight: "600", + }, + syncErrorBanner: { + backgroundColor: "#fef2f2", + borderBottomColor: "#fecaca", + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 10, + paddingHorizontal: 14, + paddingVertical: 12, + }, + syncErrorBannerText: { + color: "#991b1b", + fontSize: 12, + lineHeight: 18, + }, + syncErrorBannerPrimaryButton: { + backgroundColor: "#b91c1c", + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + }, + syncErrorBannerSecondaryButton: { + backgroundColor: "#fff", + borderColor: "#fecaca", + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 8, + }, + syncErrorBannerSecondaryButtonText: { + color: "#991b1b", + fontSize: 12, + fontWeight: "600", + }, + syncPendingBanner: { + backgroundColor: "#eff6ff", + borderBottomColor: "#bfdbfe", + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 10, + paddingHorizontal: 14, + paddingVertical: 12, + }, + syncPendingBannerText: { + color: "#1e3a8a", + fontSize: 12, + lineHeight: 18, + }, + syncPendingBannerPrimaryButton: { + backgroundColor: "#1d4ed8", + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + }, conflictBanner: { backgroundColor: "#fff1f2", borderBottomColor: "#fecdd3",