diff --git a/apps/mobile/app/screens/editor/tiptap/types.ts b/apps/mobile/app/screens/editor/tiptap/types.ts index 803020a79..6778cc7b8 100644 --- a/apps/mobile/app/screens/editor/tiptap/types.ts +++ b/apps/mobile/app/screens/editor/tiptap/types.ts @@ -93,6 +93,8 @@ export type SavePayload = { ignoreEdit: boolean; tabId: string; pendingChanges?: boolean; + sourceNoteId?: string; + pendingChangesAt?: number; }; export type AppState = { diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx b/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx index 276d2c0a5..716963271 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx +++ b/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx @@ -411,16 +411,20 @@ export const useEditorEvents = ( .getState() .getNoteIdForTab(editorMessage.tabId); + const saveNoteId = editorMessage.noteId || noteId; + switch (editorMessage.type) { case EditorEvents.content: DatabaseLogger.log("EditorEvents.content"); editor.saveContent({ type: editorMessage.type, content: editorMessage.value.html as string, - noteId: noteId, + noteId: saveNoteId, + sourceNoteId: editorMessage.noteId, tabId: editorMessage.tabId, ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit, - pendingChanges: editorMessage.value?.pendingChanges + pendingChanges: editorMessage.value?.pendingChanges, + pendingChangesAt: editorMessage.value?.pendingChangesAt }); break; case EditorEvents.title: @@ -428,10 +432,12 @@ export const useEditorEvents = ( editor.saveContent({ type: editorMessage.type, title: editorMessage.value?.title as string, - noteId: noteId, + noteId: saveNoteId, + sourceNoteId: editorMessage.noteId, tabId: editorMessage.tabId, ignoreEdit: false, - pendingChanges: editorMessage.value?.pendingChanges + pendingChanges: editorMessage.value?.pendingChanges, + pendingChangesAt: editorMessage.value?.pendingChangesAt }); break; case EditorEvents.logger: diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor.ts b/apps/mobile/app/screens/editor/tiptap/use-editor.ts index 830331751..3e24ee06a 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor.ts +++ b/apps/mobile/app/screens/editor/tiptap/use-editor.ts @@ -263,26 +263,63 @@ export const useEditor = ( ignoreEdit, sessionHistoryId: currentSessionHistoryId, tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }: SavePayload) => { if (currentNotes.current[id as string]?.readonly || readonly) return; + + if (sourceNoteId && id && sourceNoteId !== id) { + DatabaseLogger.error( + new Error( + `Refused to save content of note ${sourceNoteId} into note ${id}` + ) + ); + return; + } + try { if (id && !(await db.notes?.note(id))) { - await reset(tabId); - useTabStore.getState().updateTab(tabId, { - session: { - noteId: undefined, - noteLocked: undefined, - locked: undefined, - readonly: undefined, - scrollTop: undefined, - selection: undefined, - spellCheckDisabled: false - } - }); + if (useTabStore.getState().getNoteIdForTab(tabId) === id) { + await reset(tabId); + useTabStore.getState().updateTab(tabId, { + session: { + noteId: undefined, + noteLocked: undefined, + locked: undefined, + readonly: undefined, + scrollTop: undefined, + selection: undefined, + spellCheckDisabled: false + } + }); + } return; } let note = id ? await db.notes?.note(id) : undefined; + + // A restored pending change can be older than what is already in the + // db (it was saved on another device, or the save actually went + // through and only the acknowledgement was lost). Applying it would + // roll the note back, so verify it is still the newest edit. Content + // and title are compared separately so that a newer title doesn't + // discard pending content, and vice versa. + if (pendingChanges && pendingChangesAt && note) { + const dateEdited = data + ? note.contentId + ? (await db.content?.get(note.contentId))?.dateEdited + : undefined + : note.dateEdited; + + if (dateEdited && dateEdited > pendingChangesAt) { + DatabaseLogger.log( + `Discarding stale pending ${ + data ? "content" : "title" + } for note ${id}: edited at ${dateEdited}, change captured at ${pendingChangesAt}` + ); + return id; + } + } const locked = note && (await db.vaults.itemExists(note)); if (note?.conflicted) { @@ -321,6 +358,9 @@ export const useEditor = ( let saved = false; setTimeout(() => { if (saved) return; + // Don't report progress on a tab that has moved on to another note. + if (id && useTabStore.getState().getNoteIdForTab(tabId) !== id) + return; commands.setStatus( getFormattedDate(note ? note.dateEdited : Date.now(), "date-time"), strings.saving(), @@ -436,14 +476,26 @@ export const useEditor = ( } } - if ( - id && - id === useTabStore.getState().getCurrentNoteId() && - pendingChanges - ) { - postMessage(NativeEvents.title, title || note?.title, tabId); - postMessage(NativeEvents.html, data, tabId); - currentNotes.current[id] = note; + if (id && pendingChanges) { + if (data) { + currentContents.current[id] = { + data: data, + type: "tiptap", + noteId: id + }; + } + lastContentChangeTime.current[id] = Date.now(); + + // Push the restored change into the editor only if the note is + // actually open in a tab, and only into that tab. + const noteTabId = useTabStore.getState().getTabForNote(id); + if (noteTabId !== undefined) { + postMessage(NativeEvents.title, title || note?.title, noteTabId); + if (data) { + postMessage(NativeEvents.html, { data: data }, noteTabId); + } + currentNotes.current[id] = note; + } } if (!saveCount.current[tabId]) { @@ -935,7 +987,9 @@ export const useEditor = ( ignoreEdit, noteId, tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }: { noteId?: string; title?: string; @@ -944,6 +998,8 @@ export const useEditor = ( ignoreEdit: boolean; tabId: string; pendingChanges?: boolean; + sourceNoteId?: string; + pendingChangesAt?: number; }) => { DatabaseLogger.log( `saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}` @@ -971,7 +1027,10 @@ export const useEditor = ( return; } - if (noteId) { + // A restored pending change is not a live edit: it may still be + // discarded as stale by saveNote, so it must not claim to be the newest + // content until it is actually written. + if (noteId && !pendingChanges) { lastContentChangeTime.current[noteId] = Date.now(); localTabState.current?.setEditTime(noteId, Date.now()); localTabState?.current?.set(tabId, { @@ -979,7 +1038,7 @@ export const useEditor = ( }); } - if (type === EditorEvents.content && noteId) { + if (type === EditorEvents.content && noteId && !pendingChanges) { currentContents.current[noteId as string] = { data: content, type: "tiptap", @@ -995,12 +1054,15 @@ export const useEditor = ( ignoreEdit, sessionHistoryId: noteId ? editorSessionHistory.get(noteId) : undefined, tabId: tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }; + withTimer( - noteId || "newnote", + `${noteId || tabId}:${type}`, () => { - if (!params.id) { + if (!params.id && !params.sourceNoteId) { params.id = useTabStore.getState().getNoteIdForTab(tabId); } if (onChange && params.data) { diff --git a/packages/editor-mobile/src/hooks/useEditorController.ts b/packages/editor-mobile/src/hooks/useEditorController.ts index 169b5d6fc..5755b94da 100644 --- a/packages/editor-mobile/src/hooks/useEditorController.ts +++ b/packages/editor-mobile/src/hooks/useEditorController.ts @@ -153,18 +153,17 @@ export function useEditorController({ const titleChange = useCallback(async (title: string) => { if (!isReactNative()) return; const currentSessionId = globalThis.sessionId; - post( - EditorEvents.contentchange, - undefined, - tabRef.current.id, - tabRef.current.session?.noteId - ); + const editedAt = Date.now(); + + const tabId = tabRef.current.id; + const noteId = tabRef.current.session?.noteId; + post(EditorEvents.contentchange, undefined, tabId, noteId); const params = [ { title }, - tabRef.current.id, - tabRef.current.session?.noteId, + tabId, + noteId, currentSessionId, 1000 ]; @@ -186,12 +185,12 @@ export function useEditorController({ `Saving title failed, setting pending request ${pendingTitleIds.length}` ); if (params[2]) { - pendingSaveRequests.setTitle(params); + pendingSaveRequests.setTitle(params, editedAt); } const element = document.getElementById("editor-saving-failed-overlay"); if (element) { element.style.display = "flex"; - editors[tabRef.current.id]?.commands?.blur(); + editors[tabId]?.commands?.blur(); element.focus(); } }); @@ -217,26 +216,36 @@ export function useEditorController({ return; } const currentSessionId = globalThis.sessionId; - post( - EditorEvents.contentchange, - undefined, - tabRef.current.id, - tabRef.current.session?.noteId - ); + const tabId = tabRef.current.id; + const noteId = tabRef.current.session?.noteId; + post(EditorEvents.contentchange, undefined, tabId, noteId); if (!editor) return; if (typeof timers.current.change === "number") { clearTimeout(timers.current?.change); } timers.current.change = setTimeout(async () => { + if (tabRef.current.session?.noteId !== noteId) { + logger( + "info", + `Edit discarded, tab ${tabId} moved from note ${noteId} to ${tabRef.current.session?.noteId}` + ); + return; + } + if (editorControllers[tabId]?.loading) { + logger("info", "Edit discarded, tab is in loading state"); + return; + } + + const editedAt = Date.now(); htmlContentRef.current = editor.getHTML(); const params = [ { html: htmlContentRef.current, ignoreEdit: ignoreEdit }, - tabRef.current.id, - tabRef.current.session?.noteId, + tabId, + noteId, currentSessionId, 5000 ]; @@ -262,7 +271,7 @@ export function useEditorController({ }` ); if (params[2]) { - pendingSaveRequests.setContent(params); + pendingSaveRequests.setContent(params, editedAt); } const element = document.getElementById( diff --git a/packages/editor-mobile/src/utils/pending-saves.ts b/packages/editor-mobile/src/utils/pending-saves.ts index b81703aa1..71c7a9fb4 100644 --- a/packages/editor-mobile/src/utils/pending-saves.ts +++ b/packages/editor-mobile/src/utils/pending-saves.ts @@ -23,13 +23,14 @@ class PendingSaveRequests { static TITLES = "pendingTitles"; static CONTENT = "pendingContents"; - async setTitle(value: any) { + async setTitle(value: any, editedAt: number) { const pendingTitles = JSON.parse( this.get(PendingSaveRequests.TITLES) || "[]" ); (pendingTitles as any[]).push({ id: randId("title-pending"), + editedAt, params: value }); return localStorage.setItem( @@ -45,13 +46,14 @@ class PendingSaveRequests { return pendingTitles; } - async setContent(value: any) { + async setContent(value: any, editedAt: number) { const pendingContents = JSON.parse( this.get(PendingSaveRequests.CONTENT) || "[]" ); (pendingContents as any[]).push({ id: randId("content-pending"), + editedAt, params: value }); return localStorage.setItem( @@ -118,7 +120,10 @@ class PendingSaveRequests { const pendingTitles = await this.getPendingTitles(); this.remove(PendingSaveRequests.TITLES); for (const pending of pendingTitles) { - if (pending.params[0]) pending.params[0].pendingChanges = true; + if (pending.params[0]) { + pending.params[0].pendingChanges = true; + pending.params[0].pendingChangesAt = pending.editedAt; + } await postAsyncWithTimeout(EditorEvents.title, ...pending.params); } }; @@ -127,7 +132,10 @@ class PendingSaveRequests { const pendingContents = await this.getPendingContent(); this.remove(PendingSaveRequests.CONTENT); for (const pending of pendingContents) { - if (pending.params[0]) pending.params[0].pendingChanges = true; + if (pending.params[0]) { + pending.params[0].pendingChanges = true; + pending.params[0].pendingChangesAt = pending.editedAt; + } await postAsyncWithTimeout(EditorEvents.content, ...pending.params); } };