fix(mobile): push note edits online-first and unstick sync queue
Android edits could stay pending after pull-to-refresh because content always queued and refresh only pulled. Save online when reachable, force-flush the outbox on refresh and resume, and surface retry/error details so Web sees Android changes again. Related Issue: #185
This commit is contained in:
1 parent
95db3ee5a3
commit
b4e8be688b
7 files changed
+448
-58
No files matched your search
@@ -29,8 +29,9 @@ export const useMobileAutomaticSync = ({
|
||||
const [syncQueueItems, setSyncQueueItems] = useState<MobileSyncQueueItem[]>([]);
|
||||
const runningRef = useRef(false);
|
||||
const requestedRef = useRef(false);
|
||||
const requestedForceRef = useRef(false);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const runRef = useRef<() => Promise<void>>(async () => undefined);
|
||||
const runRef = useRef<(force?: boolean) => Promise<void>>(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,
|
||||
};
|
||||
};
|
||||
@@ -39,6 +39,12 @@ const mobileOnlyTranslations = new Map<string, string>([
|
||||
["同步失败", "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."],
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<typeof createEdgeEverClient>,
|
||||
scope: string,
|
||||
options: {
|
||||
force?: boolean;
|
||||
onSynced?: (memo: MemoDetail, item: MobileSyncQueueItem) => void | Promise<void>;
|
||||
} = {}
|
||||
): Promise<MobileSyncRunResult> => {
|
||||
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<typeof createEdgeEverClient>, 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");
|
||||
@@ -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<void>;
|
||||
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 = ({
|
||||
</Pressable>
|
||||
<View style={styles.detailHeaderActions}>
|
||||
<Pressable
|
||||
accessibilityHint={syncStatus === "conflict" ? "查看并处理同步冲突" : undefined}
|
||||
accessibilityHint={
|
||||
syncStatus === "conflict"
|
||||
? "查看并处理同步冲突"
|
||||
: syncStatusInteractive
|
||||
? "查看同步状态并立即重试"
|
||||
: undefined
|
||||
}
|
||||
accessibilityLabel={syncStatusLabel}
|
||||
accessibilityRole={syncStatus === "conflict" ? "button" : "text"}
|
||||
disabled={syncStatus !== "conflict" || !memo}
|
||||
onPress={() => memo && onResolveSyncConflict(memo)}
|
||||
accessibilityRole={syncStatusInteractive ? "button" : "text"}
|
||||
disabled={!syncStatusInteractive || !memo}
|
||||
onPress={handleSyncStatusPress}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[styles.detailSyncStatus, syncStatus === "conflict" && styles.detailSyncStatusConflict]}
|
||||
style={[
|
||||
styles.detailSyncStatus,
|
||||
syncStatus === "conflict" && styles.detailSyncStatusConflict,
|
||||
syncStatus === "error" && styles.detailSyncStatusError,
|
||||
syncStatus === "pending" && styles.detailSyncStatusPending,
|
||||
]}
|
||||
>
|
||||
{syncStatusLabel}
|
||||
</Text>
|
||||
@@ -522,6 +557,7 @@ export const MemoDetailModal = ({
|
||||
<Text style={styles.conflictBannerText}>
|
||||
云端笔记已在其他标签页、设备,或离线期间被更新。可先复制本地草稿,再采用云端版本后继续编辑。
|
||||
</Text>
|
||||
{syncError ? <Text style={styles.conflictBannerText}>{syncError}</Text> : null}
|
||||
<View style={styles.conflictBannerActions}>
|
||||
<Pressable
|
||||
accessibilityLabel="采用云端并重新加载"
|
||||
@@ -551,6 +587,36 @@ export const MemoDetailModal = ({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{(syncStatus === "error" || syncStatus === "pending") && memo ? (
|
||||
<View style={syncStatus === "error" ? styles.syncErrorBanner : styles.syncPendingBanner}>
|
||||
<Text style={syncStatus === "error" ? styles.syncErrorBannerText : styles.syncPendingBannerText}>
|
||||
{syncStatus === "error"
|
||||
? (syncError?.trim() || "本地改动未能上传到云端。内容仍保存在本机,可立即重试。")
|
||||
: (syncError?.trim() || "本地改动待上传。下拉刷新或点此可立即同步。")}
|
||||
</Text>
|
||||
<View style={styles.conflictBannerActions}>
|
||||
<Pressable
|
||||
accessibilityLabel="立即同步"
|
||||
accessibilityRole="button"
|
||||
onPress={onRetrySync}
|
||||
style={syncStatus === "error" ? styles.syncErrorBannerPrimaryButton : styles.syncPendingBannerPrimaryButton}
|
||||
>
|
||||
<Text style={styles.conflictBannerPrimaryButtonText}>立即同步</Text>
|
||||
</Pressable>
|
||||
{syncStatus === "error" ? (
|
||||
<Pressable
|
||||
accessibilityLabel="复制本地草稿"
|
||||
accessibilityRole="button"
|
||||
onPress={() => onCopyLocalDraft(memo)}
|
||||
style={styles.syncErrorBannerSecondaryButton}
|
||||
>
|
||||
<Text style={styles.syncErrorBannerSecondaryButtonText}>复制本地草稿</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator color="#0f172a" />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user