perf: make native memo editing offline first
This commit is contained in:
1 parent
c4d03c2e8d
commit
ec6cdabc6e
8 files changed
+508
-104
No files matched your search
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@edgeever/mobile",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "expo-router/entry",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -17,5 +17,8 @@
|
||||
"src/**/*.tsx",
|
||||
"expo-env.d.ts",
|
||||
".expo/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user