perf: make native memo editing offline first

This commit is contained in:
tianma-if committed 2026-07-18 03:52:38 +08:00
1 parent c4d03c2e8d
commit ec6cdabc6e
8 files changed
+508 -104

No files matched your search

+2 -2
View File
@@ -3,7 +3,7 @@
"name": "EdgeEver",
"slug": "edgeever",
"scheme": "edgeever",
"version": "0.3.1",
"version": "0.3.2",
"orientation": "portrait",
"userInterfaceStyle": "light",
"newArchEnabled": true,
@@ -13,7 +13,7 @@
},
"android": {
"package": "org.edgeever.mobile",
"versionCode": 4,
"versionCode": 5,
"adaptiveIcon": {
"backgroundColor": "#f8fafc"
}
+26 -8
View File
@@ -15,6 +15,9 @@ import { SessionProvider } from "../src/lib/session";
void SplashScreen.preventAutoHideAsync();
const MOBILE_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
const MOBILE_CACHE_STALE_TIME = 5 * 60 * 1000;
const MAX_PERSISTED_MEMO_LISTS = 12;
const MAX_PERSISTED_MEMO_DETAILS = 20;
export default function RootLayout() {
const [queryClient] = useState(
@@ -26,7 +29,7 @@ export default function RootLayout() {
networkMode: "offlineFirst",
refetchOnReconnect: true,
retry: 1,
staleTime: 30_000,
staleTime: MOBILE_CACHE_STALE_TIME,
},
},
})
@@ -36,6 +39,26 @@ export default function RootLayout() {
key: "edgeever.mobile.query-cache.v1",
storage: AsyncStorage,
throttleTime: 1_000,
serialize: (persistedClient) => {
const queries = persistedClient.clientState.queries;
const notebooks = queries.filter((query) => query.queryKey[1] === "notebooks");
const memoLists = queries
.filter((query) => query.queryKey[1] === "memos")
.sort((left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt)
.slice(0, MAX_PERSISTED_MEMO_LISTS);
const memoDetails = queries
.filter((query) => query.queryKey[1] === "memo")
.sort((left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt)
.slice(0, MAX_PERSISTED_MEMO_DETAILS);
return JSON.stringify({
...persistedClient,
clientState: {
...persistedClient.clientState,
queries: [...notebooks, ...memoLists, ...memoDetails],
},
});
},
})
);
@@ -49,14 +72,9 @@ export default function RootLayout() {
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
const section = query.queryKey[1];
const isDefaultMemoList =
section === "memos" &&
query.queryKey[2] === "notebook" &&
query.queryKey[3] === "all" &&
query.queryKey[4] === "all" &&
query.queryKey[5] === "updated-desc";
const isOfflineReadableData = section === "notebooks" || section === "memos" || section === "memo";
return query.state.status === "success" && (section === "notebooks" || isDefaultMemoList);
return query.state.status === "success" && isOfflineReadableData;
},
},
maxAge: MOBILE_CACHE_MAX_AGE,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@edgeever/mobile",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"main": "expo-router/entry",
+99
View File
@@ -0,0 +1,99 @@
import { beforeEach, expect, mock, test } from "bun:test";
import type { MemoDetail } from "@edgeever/shared";
const storage = new Map<string, string>();
mock.module("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async (key: string) => storage.get(key) ?? null,
removeItem: async (key: string) => {
storage.delete(key);
},
setItem: async (key: string, value: string) => {
storage.set(key, value);
},
},
}));
const { listMobileSyncQueueItems, queueMobileMemoUpdate, syncMobileQueuedChanges } = await import("./sync-queue");
const basePayload = {
memoId: "memo-1",
expectedRevision: 1,
expectedContentHash: "hash-1",
title: "First",
contentMarkdown: "first",
notebookId: "notebook-1",
tags: [],
};
beforeEach(() => {
storage.clear();
});
test("keeps pending updates isolated by instance", async () => {
await queueMobileMemoUpdate("https://one.example", basePayload);
await queueMobileMemoUpdate("https://two.example", { ...basePayload, title: "Second instance" });
expect((await listMobileSyncQueueItems("https://one.example"))[0]?.payload.title).toBe("First");
expect((await listMobileSyncQueueItems("https://two.example"))[0]?.payload.title).toBe("Second instance");
});
test("rebases a newer local save when an older save finishes syncing", async () => {
await queueMobileMemoUpdate("https://one.example", basePayload);
let markUpdateStarted: (() => void) | undefined;
const updateStarted = new Promise<void>((resolve) => {
markUpdateStarted = resolve;
});
let resolveUpdate: ((memo: MemoDetail) => void) | undefined;
const updateResponse = new Promise<MemoDetail>((resolve) => {
resolveUpdate = resolve;
});
const syncedMemo = createMemo({ revision: 2, contentHash: "hash-2", contentMarkdown: "first" });
const client = {
createMemoEditSession: async () => ({ editSession: { id: "edit-1", baseRevision: 1, baseContentHash: "hash-1" } }),
updateMemo: async () => {
markUpdateStarted?.();
return updateResponse.then((memo) => ({ memo }));
},
};
const firstSync = syncMobileQueuedChanges(client as never, "https://one.example");
await updateStarted;
await queueMobileMemoUpdate("https://one.example", {
...basePayload,
title: "Newest",
contentMarkdown: "newest",
});
resolveUpdate?.(syncedMemo);
await firstSync;
const queued = (await listMobileSyncQueueItems("https://one.example"))[0];
expect(queued?.payload.title).toBe("Newest");
expect(queued?.payload.expectedRevision).toBe(2);
expect(queued?.payload.expectedContentHash).toBe("hash-2");
});
const createMemo = (overrides: Partial<MemoDetail> = {}): MemoDetail => ({
id: "memo-1",
notebookId: "notebook-1",
title: "First",
excerpt: "first",
tags: [],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: "first",
contentText: "first",
contentHash: "hash-1",
sourceMemoIds: [],
mergeSourceCount: 0,
mergedIntoMemoId: null,
...overrides,
});
+133 -40
View File
@@ -2,7 +2,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { ApiRequestError, type createEdgeEverClient } from "@edgeever/client";
import type { MemoDetail } from "@edgeever/shared";
const SYNC_QUEUE_KEY = "edgeever.mobile.syncQueue.v1";
const LEGACY_SYNC_QUEUE_KEY = "edgeever.mobile.syncQueue.v1";
const SYNC_QUEUE_KEY_PREFIX = "edgeever.mobile.syncQueue.v2";
export type MobileMemoUpdateSyncPayload = {
memoId: string;
@@ -25,8 +26,11 @@ export type MobileSyncQueueItem = {
nextAttemptAt: string | null;
createdAt: string;
updatedAt: string;
version: number;
};
let syncQueueWriteChain: Promise<void> = Promise.resolve();
export type MobileSyncQueueSummary = {
total: number;
pending: number;
@@ -52,40 +56,57 @@ export const emptyMobileSyncQueueSummary = (): MobileSyncQueueSummary => ({
export const getMobileMemoUpdateQueueId = (memoId: string) => `memo.update:${memoId}`;
export const queueMobileMemoUpdate = async (payload: MobileMemoUpdateSyncPayload) => {
export const queueMobileMemoUpdate = async (scope: string, payload: MobileMemoUpdateSyncPayload) => {
const now = new Date().toISOString();
const items = await readMobileSyncQueue();
const id = getMobileMemoUpdateQueueId(payload.memoId);
const existing = items.find((item) => item.id === id);
const nextItem: MobileSyncQueueItem = {
id,
kind: "memo.update",
memoId: payload.memoId,
status: "pending",
payload,
attemptCount: existing?.attemptCount ?? 0,
lastError: null,
nextAttemptAt: null,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
await mutateMobileSyncQueue(scope, (items) => {
const existing = items.find((item) => item.id === id);
const nextItem: MobileSyncQueueItem = {
id,
kind: "memo.update",
memoId: payload.memoId,
status: "pending",
payload,
attemptCount: existing?.attemptCount ?? 0,
lastError: null,
nextAttemptAt: null,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
version: (existing?.version ?? 0) + 1,
};
await writeMobileSyncQueue([nextItem, ...items.filter((item) => item.id !== id)]);
return summarizeMobileSyncQueue(await readMobileSyncQueue());
return [nextItem, ...items.filter((item) => item.id !== id)];
});
return summarizeMobileSyncQueue(await readMobileSyncQueue(scope));
};
export const loadMobileSyncQueueSummary = async () => summarizeMobileSyncQueue(await readMobileSyncQueue());
export const loadMobileSyncQueueSummary = async (scope: string) => summarizeMobileSyncQueue(await readMobileSyncQueue(scope));
export const listMobileSyncQueueItems = async () =>
(await readMobileSyncQueue()).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
export const getMobileSyncRetryDelay = async (scope: string) => {
const now = Date.now();
const retryTimes = (await readMobileSyncQueue(scope))
.filter((item) => item.status === "pending" || item.status === "error" || item.status === "syncing")
.map((item) => (item.nextAttemptAt ? Date.parse(item.nextAttemptAt) : now))
.filter(Number.isFinite);
export const deleteMobileSyncQueueItem = async (id: string) => {
await removeMobileSyncQueueItem(id);
return loadMobileSyncQueueSummary();
if (retryTimes.length === 0) {
return null;
}
return Math.max(250, Math.min(...retryTimes) - now);
};
export const listMobileSyncQueueItems = async (scope: string) =>
(await readMobileSyncQueue(scope)).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
export const deleteMobileSyncQueueItem = async (scope: string, id: string) => {
await removeMobileSyncQueueItem(scope, id);
return loadMobileSyncQueueSummary(scope);
};
export const syncMobileQueuedChanges = async (
client: ReturnType<typeof createEdgeEverClient>,
scope: string,
options: {
onSynced?: (memo: MemoDetail) => void | Promise<void>;
} = {}
@@ -97,34 +118,40 @@ export const syncMobileQueuedChanges = async (
conflicted: 0,
};
const now = new Date();
const items = (await readMobileSyncQueue())
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)
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
for (const item of items) {
const itemVersion = item.version ?? 1;
result.attempted += 1;
await updateMobileSyncQueueItem(item.id, {
await updateMobileSyncQueueItem(scope, item.id, {
status: "syncing",
updatedAt: new Date().toISOString(),
});
}, itemVersion);
try {
const memo = await syncMobileQueueItem(client, item);
await removeMobileSyncQueueItem(item.id);
await options.onSynced?.(memo);
const removed = await removeMobileSyncQueueItem(scope, item.id, itemVersion);
if (removed) {
await options.onSynced?.(memo);
} else {
await rebaseQueuedMemoUpdate(scope, item.id, itemVersion, memo);
}
result.synced += 1;
} catch (error) {
const status = isRevisionConflict(error) ? "conflict" : "error";
const attemptCount = item.attemptCount + 1;
await updateMobileSyncQueueItem(item.id, {
await updateMobileSyncQueueItem(scope, item.id, {
status,
attemptCount,
lastError: getErrorMessage(error),
nextAttemptAt: status === "error" ? nextRetryAt(attemptCount) : null,
updatedAt: new Date().toISOString(),
});
}, itemVersion);
if (status === "conflict") {
result.conflicted += 1;
@@ -168,8 +195,18 @@ const syncMobileQueueItem = async (client: ReturnType<typeof createEdgeEverClien
return response.memo;
};
const readMobileSyncQueue = async (): Promise<MobileSyncQueueItem[]> => {
const rawValue = await AsyncStorage.getItem(SYNC_QUEUE_KEY);
const readMobileSyncQueue = async (scope: string): Promise<MobileSyncQueueItem[]> => {
const storageKey = getSyncQueueStorageKey(scope);
let rawValue = await AsyncStorage.getItem(storageKey);
if (!rawValue) {
const legacyValue = await AsyncStorage.getItem(LEGACY_SYNC_QUEUE_KEY);
if (legacyValue) {
rawValue = legacyValue;
await AsyncStorage.setItem(storageKey, legacyValue);
await AsyncStorage.removeItem(LEGACY_SYNC_QUEUE_KEY);
}
}
if (!rawValue) {
return [];
@@ -183,18 +220,74 @@ const readMobileSyncQueue = async (): Promise<MobileSyncQueueItem[]> => {
}
};
const writeMobileSyncQueue = (items: MobileSyncQueueItem[]) => AsyncStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(items));
const writeMobileSyncQueue = (scope: string, items: MobileSyncQueueItem[]) =>
AsyncStorage.setItem(getSyncQueueStorageKey(scope), JSON.stringify(items));
const updateMobileSyncQueueItem = async (id: string, patch: Partial<MobileSyncQueueItem>) => {
const items = await readMobileSyncQueue();
await writeMobileSyncQueue(items.map((item) => (item.id === id ? { ...item, ...patch } : item)));
const updateMobileSyncQueueItem = async (scope: string, id: string, patch: Partial<MobileSyncQueueItem>, expectedVersion?: number) => {
let updated = false;
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.id !== id || (expectedVersion !== undefined && (item.version ?? 1) !== expectedVersion)) {
return item;
}
updated = true;
return { ...item, ...patch };
})
);
return updated;
};
const removeMobileSyncQueueItem = async (id: string) => {
const items = await readMobileSyncQueue();
await writeMobileSyncQueue(items.filter((item) => item.id !== id));
const removeMobileSyncQueueItem = async (scope: string, id: string, expectedVersion?: number) => {
let removed = false;
await mutateMobileSyncQueue(scope, (items) =>
items.filter((item) => {
if (item.id !== id || (expectedVersion !== undefined && (item.version ?? 1) !== expectedVersion)) {
return true;
}
removed = true;
return false;
})
);
return removed;
};
const rebaseQueuedMemoUpdate = async (scope: string, id: string, syncedVersion: number, memo: MemoDetail) => {
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.id !== id || (item.version ?? 1) <= syncedVersion) {
return item;
}
return {
...item,
payload: {
...item.payload,
expectedRevision: memo.revision,
expectedContentHash: memo.contentHash,
},
status: "pending",
lastError: null,
nextAttemptAt: null,
updatedAt: new Date().toISOString(),
};
})
);
};
const mutateMobileSyncQueue = async (scope: string, mutate: (items: MobileSyncQueueItem[]) => MobileSyncQueueItem[]) => {
const operation = syncQueueWriteChain.then(async () => {
const items = await readMobileSyncQueue(scope);
await writeMobileSyncQueue(scope, mutate(items));
});
syncQueueWriteChain = operation.catch(() => undefined);
await operation;
};
const getSyncQueueStorageKey = (scope: string) => `${SYNC_QUEUE_KEY_PREFIX}:${encodeURIComponent(scope.trim().toLowerCase())}`;
const summarizeMobileSyncQueue = (items: MobileSyncQueueItem[]) =>
items.reduce((summary, item) => {
summary.total += 1;
+243 -52
View File
@@ -1,5 +1,5 @@
import { createContext, lazy, memo, Suspense, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery, useQueryClient, type QueryClient, type UseMutationResult } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import Constants from "expo-constants";
import type { DocumentPickerAsset } from "expo-document-picker";
@@ -95,10 +95,10 @@ import { useSession } from "../lib/session";
import {
deleteMobileSyncQueueItem,
emptyMobileSyncQueueSummary,
getMobileSyncRetryDelay,
listMobileSyncQueueItems,
loadMobileSyncQueueSummary,
queueMobileMemoUpdate,
shouldQueueMobileMemoSaveError,
syncMobileQueuedChanges,
type MobileSyncQueueItem,
type MobileSyncQueueSummary,
@@ -297,6 +297,7 @@ type MarkdownAction = "heading" | "bold" | "italic" | "bullet" | "checklist" | "
export const WorkspaceScreen = () => {
const { client, session, signOut } = useSession();
const queryClient = useQueryClient();
const syncQueueScope = session?.baseUrl ?? "";
const [activeView, setActiveView] = useState<MobileView>("notes");
const [activeNotebookId, setActiveNotebookId] = useState<string>(ALL_NOTES_ID);
const [memoView, setMemoView] = useState<MemoView>("notebook");
@@ -331,8 +332,9 @@ export const WorkspaceScreen = () => {
const [syncQueueSummary, setSyncQueueSummary] = useState<MobileSyncQueueSummary>(() => emptyMobileSyncQueueSummary());
const [syncQueueMessage, setSyncQueueMessage] = useState("");
const [isSyncingQueue, setIsSyncingQueue] = useState(false);
const [lastAutoSyncAt, setLastAutoSyncAt] = useState<string | null>(null);
const autoSyncRunningRef = useRef(false);
const autoSyncRequestedRef = useRef(false);
const autoSyncRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedSearchText = useDebouncedValue(searchText.trim(), 250);
const notebooksQuery = useQuery({
@@ -417,7 +419,7 @@ export const WorkspaceScreen = () => {
queryClient.invalidateQueries({ queryKey: ["mobile", "search"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "memo"] }),
]);
setSyncQueueSummary(await loadMobileSyncQueueSummary());
setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope));
};
const handleMemoPress = (memoId: string) => {
@@ -522,16 +524,25 @@ export const WorkspaceScreen = () => {
useEffect(() => {
let mounted = true;
loadMobileSyncQueueSummary().then((summary) => {
if (mounted) {
setSyncQueueSummary(summary);
Promise.all([loadMobileSyncQueueSummary(syncQueueScope), listMobileSyncQueueItems(syncQueueScope)]).then(([summary, items]) => {
if (!mounted) {
return;
}
setSyncQueueSummary(summary);
for (const item of items) {
const cachedMemo = findCachedMemoDetail(queryClient, item.memoId);
if (cachedMemo) {
const optimisticMemo = createOptimisticMemo(cachedMemo, item.payload);
applyOptimisticMemoToCache(queryClient, cachedMemo, { ...optimisticMemo, updatedAt: item.updatedAt });
}
}
});
return () => {
mounted = false;
};
}, []);
}, [queryClient, syncQueueScope]);
useEffect(() => {
let mounted = true;
@@ -646,6 +657,30 @@ export const WorkspaceScreen = () => {
},
});
const localUpdateMemoMutation = useMutation({
mutationFn: async ({ memo, payload }: { memo: MemoDetail; payload: { title?: string; contentMarkdown?: string; isPinned?: boolean; notebookId?: string; tags?: string[] } }) => {
const optimisticMemo = createOptimisticMemo(memo, payload);
await queueMobileMemoUpdate(syncQueueScope, {
memoId: memo.id,
expectedRevision: memo.revision,
expectedContentHash: memo.contentHash,
title: optimisticMemo.title?.trim() || DEFAULT_MEMO_TITLE,
contentMarkdown: optimisticMemo.contentMarkdown,
notebookId: optimisticMemo.notebookId,
tags: optimisticMemo.tags,
});
return optimisticMemo;
},
onSuccess: async (memo, variables) => {
applyOptimisticMemoToCache(queryClient, variables.memo, memo);
setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope));
setSyncQueueMessage("已保存到本地,正在后台同步");
void runAutomaticSync();
},
});
const deleteMemoMutation = useMutation({
mutationFn: async ({ memo, permanent }: { memo: MemoDetail; permanent: boolean }) => {
if (!client) {
@@ -852,17 +887,20 @@ export const WorkspaceScreen = () => {
setSyncQueueMessage("");
try {
const result = await syncMobileQueuedChanges(client, {
const result = await syncMobileQueuedChanges(client, syncQueueScope, {
onSynced: async (memo) => {
queryClient.setQueryData(["mobile", "memo", "notebook", memo.id], { memo });
queryClient.setQueryData(["mobile", "memo", "trash", memo.id], { memo });
},
});
await invalidateWorkspace();
setSyncQueueSummary(await loadMobileSyncQueueSummary());
const nextSummary = await loadMobileSyncQueueSummary(syncQueueScope);
if (nextSummary.total === 0) {
await invalidateWorkspace();
}
setSyncQueueSummary(nextSummary);
setSyncQueueMessage(result.attempted === 0 ? "没有可同步的变更" : `已同步 ${result.synced} 条,失败 ${result.failed + result.conflicted} 条`);
} catch (error) {
setSyncQueueSummary(await loadMobileSyncQueueSummary());
setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope));
setSyncQueueMessage(error instanceof Error ? error.message : "同步失败");
} finally {
setIsSyncingQueue(false);
@@ -870,13 +908,23 @@ export const WorkspaceScreen = () => {
};
const runAutomaticSync = async () => {
if (!client || autoSyncRunningRef.current) {
if (!client) {
return;
}
if (autoSyncRunningRef.current) {
autoSyncRequestedRef.current = true;
return;
}
if (autoSyncRetryTimerRef.current) {
clearTimeout(autoSyncRetryTimerRef.current);
autoSyncRetryTimerRef.current = null;
}
autoSyncRunningRef.current = true;
const summary = await loadMobileSyncQueueSummary();
const summary = await loadMobileSyncQueueSummary(syncQueueScope);
setSyncQueueSummary(summary);
if (summary.pending + summary.error + summary.syncing === 0) {
@@ -888,22 +936,36 @@ export const WorkspaceScreen = () => {
setSyncQueueMessage("正在自动同步本地变更");
try {
const result = await syncMobileQueuedChanges(client, {
const result = await syncMobileQueuedChanges(client, syncQueueScope, {
onSynced: async (memo) => {
queryClient.setQueryData(["mobile", "memo", "notebook", memo.id], { memo });
queryClient.setQueryData(["mobile", "memo", "trash", memo.id], { memo });
},
});
await invalidateWorkspace();
setSyncQueueSummary(await loadMobileSyncQueueSummary());
setLastAutoSyncAt(new Date().toISOString());
const nextSummary = await loadMobileSyncQueueSummary(syncQueueScope);
if (nextSummary.total === 0) {
await invalidateWorkspace();
}
setSyncQueueSummary(nextSummary);
setSyncQueueMessage(result.attempted === 0 ? "" : `自动同步完成:成功 ${result.synced} 条,失败 ${result.failed + result.conflicted} 条`);
} catch (error) {
setSyncQueueSummary(await loadMobileSyncQueueSummary());
setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope));
setSyncQueueMessage(error instanceof Error ? error.message : "自动同步失败");
} finally {
setIsSyncingQueue(false);
autoSyncRunningRef.current = false;
if (autoSyncRequestedRef.current) {
autoSyncRequestedRef.current = false;
void runAutomaticSync();
} else {
const retryDelay = await getMobileSyncRetryDelay(syncQueueScope);
if (retryDelay !== null) {
autoSyncRetryTimerRef.current = setTimeout(() => {
autoSyncRetryTimerRef.current = null;
void runAutomaticSync();
}, retryDelay);
}
}
}
};
@@ -920,8 +982,14 @@ export const WorkspaceScreen = () => {
}
});
return () => subscription.remove();
}, [client, lastAutoSyncAt]);
return () => {
subscription.remove();
if (autoSyncRetryTimerRef.current) {
clearTimeout(autoSyncRetryTimerRef.current);
autoSyncRetryTimerRef.current = null;
}
};
}, [client, syncQueueScope]);
return (
<MobileLocaleContext.Provider value={localePreference}>
@@ -1035,16 +1103,11 @@ export const WorkspaceScreen = () => {
imageCompressionEnabled={imageCompressionEnabled}
notebooks={notebooks}
onClose={() => setEditingMemo(null)}
onQueued={async () => {
setEditingMemo(null);
setSyncQueueSummary(await loadMobileSyncQueueSummary());
setSyncQueueMessage("变更已保存到本地队列");
}}
onSaved={(memo) => {
setEditingMemo(null);
setSelectedMemoId(memo.id);
}}
updateMutation={updateMemoMutation}
updateMutation={localUpdateMemoMutation}
/> : null}
{richEditingMemo ? <RichEditorModal baseUrl={session?.baseUrl ?? ""} memo={richEditingMemo} notebooks={notebooks} onClose={closeRichEditor} token={session?.token ?? ""} /> : null}
{notebookPickerOpen ? <NotebookPickerModal
@@ -1074,8 +1137,9 @@ export const WorkspaceScreen = () => {
{advancedPlayOpen ? <AdvancedPlayModal onClose={() => setAdvancedPlayOpen(false)} visible /> : null}
{syncQueueOpen ? <SyncQueueModal
onClose={() => setSyncQueueOpen(false)}
onChanged={async () => setSyncQueueSummary(await loadMobileSyncQueueSummary())}
onChanged={async () => setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope))}
onSync={handleSyncQueuedChanges}
scope={syncQueueScope}
visible
/> : null}
{systemInfoOpen ? <SystemInfoModal baseUrl={session?.baseUrl ?? ""} memoCount={memoCount} notebookCount={notebooks.length} onClose={() => setSystemInfoOpen(false)} visible /> : null}
@@ -3018,11 +3082,13 @@ const SyncQueueModal = ({
onChanged,
onClose,
onSync,
scope,
visible,
}: {
onChanged: () => void | Promise<void>;
onClose: () => void;
onSync: () => void | Promise<void>;
scope: string;
visible: boolean;
}) => {
const [items, setItems] = useState<MobileSyncQueueItem[]>([]);
@@ -3032,7 +3098,7 @@ const SyncQueueModal = ({
const refreshItems = async () => {
setLoading(true);
try {
setItems(await listMobileSyncQueueItems());
setItems(await listMobileSyncQueueItems(scope));
} finally {
setLoading(false);
}
@@ -3042,7 +3108,7 @@ const SyncQueueModal = ({
if (visible) {
void refreshItems();
}
}, [visible]);
}, [scope, visible]);
const discardItem = (item: MobileSyncQueueItem) => {
Alert.alert("丢弃本地变更?", "此操作会移除这条待同步记录,不会修改服务端笔记。", [
@@ -3051,7 +3117,7 @@ const SyncQueueModal = ({
text: "丢弃",
style: "destructive",
onPress: async () => {
await deleteMobileSyncQueueItem(item.id);
await deleteMobileSyncQueueItem(scope, item.id);
await onChanged();
await refreshItems();
},
@@ -4128,7 +4194,6 @@ const EditMemoModal = ({
memo,
notebooks,
onClose,
onQueued,
onSaved,
updateMutation,
}: {
@@ -4136,7 +4201,6 @@ const EditMemoModal = ({
memo: MemoDetail | null;
notebooks: Notebook[];
onClose: () => void;
onQueued: () => void | Promise<void>;
onSaved: (memo: MemoDetail) => void;
updateMutation: UseMutationResult<
MemoDetail,
@@ -4334,25 +4398,6 @@ const EditMemoModal = ({
setLoadedDraftUpdatedAt(null);
onSaved(savedMemo);
},
onError: async (error) => {
if (!shouldQueueMobileMemoSaveError(error)) {
return;
}
await queueMobileMemoUpdate({
memoId: memo.id,
expectedRevision: memo.revision,
expectedContentHash: memo.contentHash,
title: title.trim() || DEFAULT_MEMO_TITLE,
contentMarkdown,
notebookId,
tags: parseTags(tagsText),
});
await clearMobileMemoDraft(memo.id);
await onQueued();
updateMutation.reset();
Alert.alert("已保存到本地队列", "网络恢复后可在设置页手动同步。");
},
}
);
};
@@ -5574,6 +5619,152 @@ const prepareUploadAsset = async (
}
};
const createOptimisticMemo = (
memo: MemoDetail,
payload: {
title?: string;
contentMarkdown?: string;
isPinned?: boolean;
notebookId?: string;
tags?: string[];
}
): MemoDetail => {
const contentMarkdown = payload.contentMarkdown ?? memo.contentMarkdown;
const contentText = markdownToLocalText(contentMarkdown);
return {
...memo,
...(payload.title !== undefined ? { title: payload.title } : {}),
...(payload.isPinned !== undefined ? { isPinned: payload.isPinned } : {}),
...(payload.notebookId !== undefined ? { notebookId: payload.notebookId } : {}),
...(payload.tags !== undefined ? { tags: payload.tags } : {}),
...(payload.contentMarkdown !== undefined
? {
contentMarkdown,
contentText,
excerpt: contentText.slice(0, 180),
}
: {}),
updatedAt: new Date().toISOString(),
};
};
const applyOptimisticMemoToCache = (queryClient: QueryClient, previousMemo: MemoDetail, nextMemo: MemoDetail) => {
const detailQueries = queryClient.getQueryCache().findAll({ queryKey: ["mobile", "memo"] });
for (const query of detailQueries) {
const data = query.state.data as { memo?: MemoDetail } | undefined;
if (data?.memo?.id === nextMemo.id) {
queryClient.setQueryData(query.queryKey, { ...data, memo: nextMemo });
}
}
const listQueries = queryClient.getQueryCache().findAll({ queryKey: ["mobile", "memos"] });
for (const query of listQueries) {
const data = query.state.data as { memos?: MemoSummary[]; totalCount?: number; nextCursor?: string | null } | undefined;
if (!data?.memos) {
continue;
}
const previouslyMatched = memoMatchesListQuery(previousMemo, query.queryKey);
const nextMatches = memoMatchesListQuery(nextMemo, query.queryKey);
const withoutMemo = data.memos.filter((memo) => memo.id !== nextMemo.id);
const memos = nextMatches ? sortMemoSummaries([nextMemo, ...withoutMemo], query.queryKey[5]).slice(0, 50) : withoutMemo;
const totalCount = Math.max(0, (data.totalCount ?? data.memos.length) + (nextMatches ? 1 : 0) - (previouslyMatched ? 1 : 0));
queryClient.setQueryData(query.queryKey, { ...data, memos, totalCount });
}
const searchQueries = queryClient.getQueryCache().findAll({ queryKey: ["mobile", "search"] });
for (const query of searchQueries) {
const data = query.state.data as { memos?: MemoSummary[] } | undefined;
if (data?.memos?.some((memo) => memo.id === nextMemo.id)) {
queryClient.setQueryData(query.queryKey, {
...data,
memos: data.memos.map((memo) => (memo.id === nextMemo.id ? nextMemo : memo)),
});
}
}
if (previousMemo.notebookId !== nextMemo.notebookId) {
queryClient.setQueriesData<{ notebooks: Notebook[] }>({ queryKey: ["mobile", "notebooks"] }, (data) => {
if (!data) {
return data;
}
return {
...data,
notebooks: data.notebooks.map((notebook) => {
if (notebook.id === previousMemo.notebookId) {
return { ...notebook, memoCount: Math.max(0, notebook.memoCount - 1) };
}
if (notebook.id === nextMemo.notebookId) {
return { ...notebook, memoCount: notebook.memoCount + 1, lastMemoUpdatedAt: nextMemo.updatedAt };
}
return notebook;
}),
};
});
}
};
const findCachedMemoDetail = (queryClient: QueryClient, memoId: string) => {
const detailQueries = queryClient.getQueryCache().findAll({ queryKey: ["mobile", "memo"] });
for (const query of detailQueries) {
const data = query.state.data as { memo?: MemoDetail } | undefined;
if (data?.memo?.id === memoId) {
return data.memo;
}
}
return null;
};
const memoMatchesListQuery = (memo: MemoSummary, queryKey: readonly unknown[]) => {
const view = queryKey[2];
const notebookId = queryKey[3];
const filter = queryKey[4];
if ((view === "trash") !== memo.isDeleted) {
return false;
}
if (notebookId !== ALL_NOTES_ID && notebookId !== memo.notebookId) {
return false;
}
if (filter === "tagged" && memo.tags.length === 0) {
return false;
}
if (filter === "untagged" && memo.tags.length > 0) {
return false;
}
if (filter === "pinned" && !memo.isPinned) {
return false;
}
return true;
};
const sortMemoSummaries = (memos: MemoSummary[], sortMode: unknown) =>
[...memos].sort((left, right) => {
if (sortMode === "title-asc") {
return (left.title || DEFAULT_MEMO_TITLE).localeCompare(right.title || DEFAULT_MEMO_TITLE);
}
if (sortMode === "created-desc") {
return right.createdAt.localeCompare(left.createdAt);
}
return right.updatedAt.localeCompare(left.updatedAt);
});
const markdownToLocalText = (markdown: string) =>
markdown
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[`*_>#~-]/g, " ")
.replace(/\s+/g, " ")
.trim();
const getCompressedImageSize = (width: number, height: number) => {
if (width >= height) {
return { width: MAX_COMPRESSED_IMAGE_EDGE };
+3
View File
@@ -17,5 +17,8 @@
"src/**/*.tsx",
"expo-env.d.ts",
".expo/types/**/*.ts"
],
"exclude": [
"src/**/*.test.ts"
]
}
+1 -1
View File
@@ -79,7 +79,7 @@
},
"apps/mobile": {
"name": "@edgeever/mobile",
"version": "0.3.1",
"version": "0.3.2",
"dependencies": {
"@edgeever/client": "workspace:*",
"@edgeever/shared": "workspace:*",