From 511acc5fca633a691a284b73e985aedd886492f7 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Thu, 9 May 2024 12:04:22 +0500 Subject: [PATCH] mobile: handle editor not responding --- .../app/components/sheets/link-note/index.tsx | 13 +- apps/mobile/app/screens/editor/index.tsx | 3 +- .../screens/editor/tiptap/editor-events.ts | 3 +- .../app/screens/editor/tiptap/picker.ts | 4 +- .../mobile/app/screens/editor/tiptap/types.ts | 4 + .../editor/tiptap/use-editor-events.tsx | 45 +++-- .../app/screens/editor/tiptap/use-editor.ts | 95 +++++++-- .../mobile/app/screens/editor/tiptap/utils.ts | 5 +- apps/mobile/app/services/event-manager.ts | 4 +- apps/mobile/app/services/exporter.ts | 186 +----------------- apps/mobile/app/utils/unlock-vault.ts | 91 +++++++++ apps/mobile/native/ios/Podfile.lock | 5 +- apps/mobile/native/package.json | 2 +- apps/mobile/package-lock.json | 12 +- packages/editor-mobile/package-lock.json | 44 ++++- packages/editor-mobile/package.json | 1 + packages/editor-mobile/src/App.tsx | 1 - .../editor-mobile/src/components/editor.tsx | 157 +++++++++++++-- .../src/hooks/useEditorController.ts | 115 ++++++++--- packages/editor-mobile/src/utils/index.ts | 102 ++++++++-- .../editor-mobile/src/utils/pending-saves.ts | 139 +++++++++++++ 21 files changed, 744 insertions(+), 287 deletions(-) create mode 100644 apps/mobile/app/utils/unlock-vault.ts create mode 100644 packages/editor-mobile/src/utils/pending-saves.ts diff --git a/apps/mobile/app/components/sheets/link-note/index.tsx b/apps/mobile/app/components/sheets/link-note/index.tsx index 3e980c7f9..306b506db 100644 --- a/apps/mobile/app/components/sheets/link-note/index.tsx +++ b/apps/mobile/app/components/sheets/link-note/index.tsx @@ -35,7 +35,10 @@ import Input from "../../ui/input"; import { Pressable } from "../../ui/pressable"; import Paragraph from "../../ui/typography/paragraph"; import type { LinkAttributes } from "@notesnook/editor/dist/extensions/link"; -import { editorController } from "../../../screens/editor/tiptap/utils"; +import { + EditorEvents, + editorController +} from "../../../screens/editor/tiptap/utils"; const ListNoteItem = ({ id, @@ -190,13 +193,13 @@ export default function LinkNote(props: { } : undefined ); - editorController.current.commands.createInternalLink( - { + editorController.current?.postMessage(EditorEvents.resolve, { + data: { href: link, title: selectedNote.title }, - props.resolverId - ); + resolverId: props.resolverId + }); }; const onSelectNote = async (note: Note) => { diff --git a/apps/mobile/app/screens/editor/index.tsx b/apps/mobile/app/screens/editor/index.tsx index eac9df275..69d96834a 100755 --- a/apps/mobile/app/screens/editor/index.tsx +++ b/apps/mobile/app/screens/editor/index.tsx @@ -56,6 +56,7 @@ import { editorState, openInternalLink } from "./tiptap/utils"; +import { tabBarRef } from "../../utils/global-refs"; const style: ViewStyle = { height: "100%", @@ -339,7 +340,7 @@ const useLockedNoteHandler = () => { }), eSubscribeEvent(eUnlockWithPassword, onSubmit) ]; - if (tabRef.current?.locked) { + if (tabRef.current?.locked && tabBarRef.current?.page() === 2) { unlock(); } return () => { diff --git a/apps/mobile/app/screens/editor/tiptap/editor-events.ts b/apps/mobile/app/screens/editor/tiptap/editor-events.ts index 2a4152ab9..668c7d7cb 100644 --- a/apps/mobile/app/screens/editor/tiptap/editor-events.ts +++ b/apps/mobile/app/screens/editor/tiptap/editor-events.ts @@ -48,5 +48,6 @@ export const EventTypes = { unlockWithBiometrics: "editor-events:unlock-biometrics", disableReadonlyMode: "editor-events:disable-readonly-mode", readonlyEditorLoaded: "readonlyEditorLoaded", - error: "editorError" + error: "editorError", + dbLogger: "editor-events:dbLogger" }; diff --git a/apps/mobile/app/screens/editor/tiptap/picker.ts b/apps/mobile/app/screens/editor/tiptap/picker.ts index d98feb890..517bd1caf 100644 --- a/apps/mobile/app/screens/editor/tiptap/picker.ts +++ b/apps/mobile/app/screens/editor/tiptap/picker.ts @@ -108,7 +108,6 @@ const file = async (fileOptions: PickerOptions) => { return; } - console.log("file uri: ", uri); uri = Platform.OS === "ios" ? santizeUri(uri) : uri; showEncryptionSheet(file); const hash = await Sodium.hashFile({ @@ -164,13 +163,14 @@ const file = async (fileOptions: PickerOptions) => { eSendEvent(eCloseSheet); }, 1000); } catch (e) { + eSendEvent(eCloseSheet); ToastManager.show({ heading: (e as Error).message, message: "You need internet access to attach a file", type: "error", context: "global" }); - console.log("attachment error: ", e); + DatabaseLogger.error(e); } }; diff --git a/apps/mobile/app/screens/editor/tiptap/types.ts b/apps/mobile/app/screens/editor/tiptap/types.ts index 32de40cfc..112147073 100644 --- a/apps/mobile/app/screens/editor/tiptap/types.ts +++ b/apps/mobile/app/screens/editor/tiptap/types.ts @@ -37,6 +37,7 @@ export type EditorState = { isAwaitingResult: boolean; scrollPosition: number; overlay?: boolean; + initialLoadCalled?: boolean; }; export type Settings = { @@ -74,6 +75,8 @@ export type EditorMessage = { type: string; noteId: string; tabId: number; + resolverId?: string; + hasTimeout?: boolean; }; export type SavePayload = { @@ -84,6 +87,7 @@ export type SavePayload = { sessionHistoryId?: number; ignoreEdit: boolean; tabId: number; + pendingChanges?: boolean; }; 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 1d85b3047..b562e3aa4 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx +++ b/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx @@ -77,7 +77,6 @@ import { EditorMessage, EditorProps, useEditorType } from "./types"; import { useTabStore } from "./use-tab-store"; import { EditorEvents, editorState, openInternalLink } from "./utils"; - const publishNote = async () => { const user = useUserStore.getState().user; if (!user) { @@ -354,8 +353,15 @@ export const useEditorEvents = ( const data = event.nativeEvent.data; const editorMessage = JSON.parse(data) as EditorMessage; + if (editorMessage.hasTimeout && editorMessage.resolverId) { + editor.postMessage(EditorEvents.resolve, { + data: true, + resolverId: editorMessage.resolverId + }); + } + if (editorMessage.type === EventTypes.load) { - console.log("Editor loaded"); + DatabaseLogger.log("Editor is ready"); editor.onLoad(); return; } @@ -383,21 +389,36 @@ export const useEditorEvents = ( content: editorMessage.value.html as string, noteId: noteId, tabId: editorMessage.tabId, - ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit + ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit, + pendingChanges: editorMessage.value?.pendingChanges }); break; case EventTypes.title: DatabaseLogger.log("EventTypes.title"); editor.saveContent({ type: editorMessage.type, - title: editorMessage.value as string, + title: editorMessage.value?.title as string, noteId: noteId, tabId: editorMessage.tabId, - ignoreEdit: false + ignoreEdit: false, + pendingChanges: editorMessage.value?.pendingChanges }); break; case EventTypes.logger: - logger.info("[WEBVIEW LOG]", editorMessage.value); + logger.info("[EDITOR LOG]", editorMessage.value); + break; + case EventTypes.dbLogger: + if (editorMessage.value.error) { + DatabaseLogger.error( + editorMessage.value.error, + editorMessage.value.error, + { + message: "[EDITOR_ERROR]" + editorMessage.value.message + } + ); + } else { + DatabaseLogger.info("[EDITOR_LOG]" + editorMessage.value.message); + } break; case EventTypes.contentchange: editor.onContentChanged(editorMessage.noteId); @@ -485,17 +506,17 @@ export const useEditorEvents = ( console.log( "Got attachment data:", !!data, - (editorMessage.value as any).resolverId + editorMessage.resolverId ); - editor.postMessage(EditorEvents.attachmentData, { - resolverId: (editorMessage.value as any).resolverId, + editor.postMessage(EditorEvents.resolve, { + resolverId: editorMessage.resolverId, data }); }) .catch((e) => { DatabaseLogger.error(e); - editor.postMessage(EditorEvents.attachmentData, { - resolverId: (editorMessage.value as any).resolverId, + editor.postMessage(EditorEvents.resolve, { + resolverId: editorMessage.resolverId, data: undefined }); }); @@ -622,7 +643,7 @@ export const useEditorEvents = ( case EventTypes.createInternalLink: { LinkNote.present( editorMessage.value.attributes, - editorMessage.value.resolverId + editorMessage.resolverId as string ); break; } diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor.ts b/apps/mobile/app/screens/editor/tiptap/use-editor.ts index 0491449b6..56a3a22dd 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor.ts +++ b/apps/mobile/app/screens/editor/tiptap/use-editor.ts @@ -39,6 +39,7 @@ import { DatabaseLogger, db } from "../../../common/database"; import useGlobalSafeAreaInsets from "../../../hooks/use-global-safe-area-insets"; import { DDS } from "../../../services/device-detection"; import { + ToastManager, eSendEvent, eSubscribeEvent, eUnSubscribeEvent @@ -46,6 +47,7 @@ import { import Navigation from "../../../services/navigation"; import Notifications from "../../../services/notifications"; import SettingsService from "../../../services/settings"; +import { useSettingStore } from "../../../stores/use-setting-store"; import { useTagStore } from "../../../stores/use-tag-store"; import { eEditorTabFocused, @@ -54,8 +56,10 @@ import { eUpdateNoteInEditor } from "../../../utils/events"; import { tabBarRef } from "../../../utils/global-refs"; +import { unlockVault } from "../../../utils/unlock-vault"; import { onNoteCreated } from "../../notes/common"; import Commands from "./commands"; +import { EventTypes } from "./editor-events"; import { SessionHistory } from "./session-history"; import { EditorState, SavePayload } from "./types"; import { syncTabs, useTabStore } from "./use-tab-store"; @@ -68,6 +72,7 @@ import { isEditorLoaded, post } from "./utils"; +import { sleep } from "../../../utils/time"; type NoteWithContent = Note & { content?: NoteContent; @@ -166,10 +171,11 @@ export const useEditor = ( useEffect(() => { if (loading) { + overlay(true); state.current.ready = false; setLoading(false); } - }, [loading]); + }, [loading, overlay]); const withTimer = useCallback( (id: string, fn: () => void, duration: number) => { @@ -217,7 +223,8 @@ export const useEditor = ( type, ignoreEdit, sessionHistoryId: currentSessionHistoryId, - tabId + tabId, + pendingChanges }: SavePayload) => { if (currentNotes.current[id as string]?.readonly || readonly) return; try { @@ -278,12 +285,26 @@ export const useEditor = ( ); }, 50); + const saveTimer = setTimeout(() => { + DatabaseLogger.log(`Note save timeout: ${id}...`); + ToastManager.error( + new Error( + "Copy your changes and restart the app to avoid data loss. If the issue persists, please report to us at support@streetwriters.co." + ), + "Saving note is taking too long", + "global", + 15000 + ); + }, 30 * 1000); + if (!locked) { DatabaseLogger.log(`Saving note: ${id}...`); id = await db.notes?.add({ ...noteData }); saved = true; DatabaseLogger.log(`Note saved: ${id}...`); + clearTimeout(saveTimer); + if (!note && id) { editorSessionHistory.newSession(id); if (id) { @@ -317,9 +338,22 @@ export const useEditor = ( Notifications.pinNote(id as string); } } else { + if (!db.vault.unlocked) { + if (pendingChanges) await sleep(3000); + const unlocked = await unlockVault({ + title: "Unlock vault to save note", + paragraph: `This note is locked, unlock to save ${ + pendingChanges ? "some pending" : "" + } changes`, + context: "global" + }); + if (!unlocked) + throw new Error("Could not save note, vault is locked"); + } noteData.contentId = note?.contentId; // eslint-disable-next-line @typescript-eslint/no-explicit-any await db.vault?.save(noteData as any); + clearTimeout(saveTimer); } if (id && useTabStore.getState().getTabForNote(id) === tabId) { @@ -342,6 +376,16 @@ export const useEditor = ( } } + if ( + id && + id === useTabStore.getState().getCurrentNoteId() && + pendingChanges + ) { + postMessage(EditorEvents.title, title || note?.title, tabId); + postMessage(EditorEvents.html, data, tabId); + currentNotes.current[id] = note; + } + saveCount.current++; return id; } catch (e) { @@ -416,7 +460,10 @@ export const useEditor = ( commands.focus(tabId); }); } else { - if (!event.item) return; + if (!event.item) { + overlay(false); + return; + } const item = event.item; const currentTab = useTabStore @@ -444,7 +491,6 @@ export const useEditor = ( useTabStore.getState().focusTab(tabId); setTimeout(() => { if (blockIdRef.current) { - console.log("scrolling to block", blockIdRef.current); commands.scrollIntoViewById(blockIdRef.current); blockIdRef.current = undefined; } @@ -536,11 +582,8 @@ export const useEditor = ( 10000 ); - console.log("blockId", blockIdRef.current); - setTimeout(() => { if (blockIdRef.current) { - console.log("scrolling to block", blockIdRef.current); commands.scrollIntoViewById(blockIdRef.current); blockIdRef.current = undefined; } @@ -724,7 +767,8 @@ export const useEditor = ( type, ignoreEdit, noteId, - tabId + tabId, + pendingChanges }: { noteId?: string; title?: string; @@ -732,8 +776,11 @@ export const useEditor = ( type: string; ignoreEdit: boolean; tabId: number; + pendingChanges?: boolean; }) => { - DatabaseLogger.log(`Saving content...`); + DatabaseLogger.log( + `saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}` + ); if ( lock.current || (currentLoadingNoteId.current && @@ -759,7 +806,7 @@ export const useEditor = ( lastContentChangeTime.current[noteId] = Date.now(); } - if (type === EditorEvents.content && noteId) { + if (type === EventTypes.content && noteId) { currentContents.current[noteId as string] = { data: content, type: "tiptap", @@ -774,7 +821,8 @@ export const useEditor = ( id: noteId, ignoreEdit, sessionHistoryId: noteId ? editorSessionHistory.get(noteId) : undefined, - tabId: tabId + tabId: tabId, + pendingChanges }; withTimer( noteId || "newnote", @@ -786,7 +834,16 @@ export const useEditor = ( onChange(params.data); return; } - saveNote(params); + if (useSettingStore.getState().isAppLoading) { + const sub = useSettingStore.subscribe((state) => { + if (!state.isAppLoading) { + saveNote(params); + sub(); + } + }); + } else { + saveNote(params); + } }, ignoreEdit ? 0 : 150 ); @@ -839,7 +896,6 @@ export const useEditor = ( }, [isDefaultEditor, restoreEditorState]); const onLoad = useCallback(async () => { - if (currentNotes.current) overlay(true); setTimeout(() => { postMessage(EditorEvents.theme, theme); }); @@ -854,12 +910,23 @@ export const useEditor = ( const noteId = useTabStore.getState().getCurrentNoteId(); if (!noteId) { - overlay(false); loadNote({ newNote: true }); if (tabBarRef.current?.page() === 1) { state.current.currentlyEditing = false; } + } else if (state.current?.initialLoadCalled) { + const note = currentNotes.current[noteId]; + if (note) { + console.log("Loading note in onLoad..."); + loadNote({ + item: note + }); + } } + if (!state.current?.initialLoadCalled) { + state.current.initialLoadCalled = true; + } + overlay(false); }, [ postMessage, theme, diff --git a/apps/mobile/app/screens/editor/tiptap/utils.ts b/apps/mobile/app/screens/editor/tiptap/utils.ts index 455b3d70a..a6b241e63 100644 --- a/apps/mobile/app/screens/editor/tiptap/utils.ts +++ b/apps/mobile/app/screens/editor/tiptap/utils.ts @@ -46,7 +46,7 @@ export function editorState() { return editorController.current?.state.current || defaultState; } -export const EditorEvents: { [name: string]: string } = { +export const EditorEvents = { html: "native:html", updatehtml: "native:updatehtml", title: "native:title", @@ -55,7 +55,8 @@ export const EditorEvents: { [name: string]: string } = { logger: "native:logger", status: "native:status", keyboardShown: "native:keyboardShown", - attachmentData: "native:attachment-data" + attachmentData: "native:attachment-data", + resolve: "native:resolve" }; export function randId(prefix: string) { diff --git a/apps/mobile/app/services/event-manager.ts b/apps/mobile/app/services/event-manager.ts index edad58411..b17ea81f4 100644 --- a/apps/mobile/app/services/event-manager.ts +++ b/apps/mobile/app/services/event-manager.ts @@ -162,7 +162,7 @@ export const ToastManager = { }); }, hide: () => eSendEvent(eHideToast), - error: (e: Error, title?: string, context?: any) => { + error: (e: Error, title?: string, context?: any, duration = 5000) => { ToastManager.show({ heading: title, message: e?.message || "", @@ -176,7 +176,7 @@ export const ToastManager = { heading: "Logs copied!", type: "success", context: "global", - duration: 5000 + duration: duration }); } }); diff --git a/apps/mobile/app/services/exporter.ts b/apps/mobile/app/services/exporter.ts index b233d1751..aed89db50 100644 --- a/apps/mobile/app/services/exporter.ts +++ b/apps/mobile/app/services/exporter.ts @@ -17,40 +17,26 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { decode, EntityLevel } from "entities"; import { Platform } from "react-native"; import RNFetchBlob from "react-native-blob-util"; import RNHTMLtoPDF from "react-native-html-to-pdf-lite"; import * as ScopedStorage from "react-native-scoped-storage"; import { zip } from "react-native-zip-archive"; -import { DatabaseLogger, db } from "../common/database/index"; +import { DatabaseLogger } from "../common/database/index"; import Storage from "../common/database/storage"; import { exportNote as _exportNote, ExportableAttachment, ExportableNote, - exportNotes, - sanitizeFilename + exportNotes } from "@notesnook/common"; import { Note } from "@notesnook/core"; -import { NoteContent } from "@notesnook/core/dist/collections/session-content"; import { FilteredSelector } from "@notesnook/core/dist/database/sql-collection"; import { basename, dirname, join } from "pathe"; import downloadAttachment from "../common/filesystem/download-attachment"; -import { presentDialog } from "../components/dialog/functions"; -import { useSettingStore } from "../stores/use-setting-store"; -import BiometricService from "./biometrics"; -import { ToastManager } from "./event-manager"; import { cacheDir } from "../common/filesystem/utils"; - -const MIMETypes = { - txt: "text/plain", - pdf: "application/pdf", - md: "text/markdown", - "md-frontmatter": "text/markdown", - html: "text/html" -}; +import { unlockVault } from "../utils/unlock-vault"; const FolderNames: { [name: string]: string } = { txt: "Text", @@ -59,16 +45,6 @@ const FolderNames: { [name: string]: string } = { html: "Html" }; -async function releasePermissions(path: string) { - if (Platform.OS === "ios") return; - const uris = await ScopedStorage.getPersistedUriPermissions(); - for (const uri of uris) { - if (path.startsWith(uri)) { - await ScopedStorage.releasePersistableUriPermission(uri); - } - } -} - async function getPath(type: string) { let path = Platform.OS === "ios" && @@ -82,153 +58,11 @@ async function getPath(type: string) { return path; } -async function save( - path: string, - data: string, - fileName: string, - extension: "txt" | "pdf" | "md" | "html" | "md-frontmatter" -) { - let uri; - if (Platform.OS === "android") { - uri = await ScopedStorage.writeFile( - path, - data, - `${fileName}.${extension}`, - MIMETypes[extension], - extension === "pdf" ? "base64" : "utf8", - false - ); - await releasePermissions(path); - } else { - path = path + fileName + `.${extension}`; - await RNFetchBlob.fs.writeFile(path, data, "utf8"); - } - return uri || path; -} - -async function makeHtml(note: Note, content?: NoteContent) { - let html = await db.notes.export(note.id, { - format: "html", - contentItem: content - }); - if (!html) return ""; - - html = decode(html, { - level: EntityLevel.HTML - }); - return html; -} - -async function exportAs( - type: string, - note: Note, - bulk?: boolean, - content?: NoteContent -) { - let data; - switch (type) { - case "html": - { - data = await makeHtml(note, content); - } - break; - case "md": - data = await db.notes.export(note.id, { - format: "md", - contentItem: content - }); - break; - case "md-frontmatter": - data = await db.notes.export(note.id, { - format: "md-frontmatter", - contentItem: content - }); - break; - case "pdf": - { - const html = await makeHtml(note, content); - const fileName = sanitizeFilename(note.title + Date.now(), { - replacement: "_" - }); - - const options = { - html: html, - fileName: - Platform.OS === "ios" ? "/exported/PDF/" + fileName : fileName, - width: 595, - height: 852, - bgColor: "#FFFFFF", - padding: 30, - base64: bulk || Platform.OS === "android" - } as { [name: string]: any }; - - if (Platform.OS === "ios") { - options.directory = "Documents"; - } - const res = await RNHTMLtoPDF.convert(options); - data = !bulk && Platform.OS === "ios" ? res.filePath : res.base64; - if (bulk && res.filePath) { - RNFetchBlob.fs.unlink(res.filePath); - } - } - break; - case "txt": - { - data = await db.notes.export(note.id, { - format: "txt", - contentItem: content - }); - } - break; - } - - return data; -} - -async function unlockVault() { - const biometry = await BiometricService.isBiometryAvailable(); - const fingerprint = await BiometricService.hasInternetCredentials(); - if (biometry && fingerprint) { - const credentials = await BiometricService.getCredentials( - "Unlock vault", - "Unlock vault to export locked notes" - ); - if (credentials) { - return db.vault.unlock(credentials.password); - } - } - useSettingStore.getState().setSheetKeyboardHandler(false); - return new Promise((resolve) => { - setImmediate(() => { - presentDialog({ - context: "export-notes", - input: true, - secureTextEntry: true, - positiveText: "Unlock", - title: "Unlock vault", - paragraph: "Some exported notes are locked, Unlock to export them", - inputPlaceholder: "Enter password", - positivePress: async (value) => { - const unlocked = await db.vault.unlock(value); - if (!unlocked) { - ToastManager.show({ - heading: "Invalid password", - message: "Please enter a valid password", - type: "error", - context: "local" - }); - return false; - } - resolve(unlocked); - useSettingStore.getState().setSheetKeyboardHandler(true); - return true; - }, - onClose: () => { - resolve(false); - useSettingStore.getState().setSheetKeyboardHandler(true); - } - }); - }); +async function unlockVaultForNoteExport() { + return await unlockVault({ + title: "Unlock vault", + paragraph: "Some exported notes are locked, Unlock to export them", + context: "export-notes" }); } @@ -388,7 +222,7 @@ async function bulkExport( let currentAttachmentProgress = 0; for await (const item of exportNotes(notes, { format: type, - unlockVault: unlockVault as () => Promise + unlockVault: unlockVaultForNoteExport as () => Promise })) { if (item instanceof Error) { DatabaseLogger.error(item); @@ -432,7 +266,7 @@ async function exportNote( let currentAttachmentProgress = 0; for await (const item of _exportNote(note, { format: type, - unlockVault: unlockVault as () => Promise + unlockVault: unlockVaultForNoteExport as () => Promise })) { if (item instanceof Error) { DatabaseLogger.error(item); diff --git a/apps/mobile/app/utils/unlock-vault.ts b/apps/mobile/app/utils/unlock-vault.ts new file mode 100644 index 000000000..d559dba8d --- /dev/null +++ b/apps/mobile/app/utils/unlock-vault.ts @@ -0,0 +1,91 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +import { db } from "../common/database"; +import { presentDialog } from "../components/dialog/functions"; +import BiometricService from "../services/biometrics"; +import { ToastManager } from "../services/event-manager"; +import { useSettingStore } from "../stores/use-setting-store"; + +let unlockPromise: Promise | undefined = undefined; +export async function unlockVault({ + context, + title, + paragraph +}: { + context?: string; + title: string; + paragraph: string; +}) { + if (unlockPromise) { + console.log("Unlocking.... waiting for unlock promise"); + return unlockPromise; + } + unlockPromise = new Promise(async (resolve) => { + const result = await (async () => { + if (db.vault.unlocked) return true; + const biometry = await BiometricService.isBiometryAvailable(); + const fingerprint = await BiometricService.hasInternetCredentials(); + if (biometry && fingerprint) { + const credentials = await BiometricService.getCredentials( + title, + paragraph + ); + if (credentials) { + return db.vault.unlock(credentials.password); + } + } + useSettingStore.getState().setSheetKeyboardHandler(false); + return new Promise((resolve) => { + setImmediate(() => { + presentDialog({ + context: context, + input: true, + secureTextEntry: true, + positiveText: "Unlock", + title: title, + paragraph: paragraph, + inputPlaceholder: "Enter password", + positivePress: async (value) => { + const unlocked = await db.vault.unlock(value); + if (!unlocked) { + ToastManager.show({ + heading: "Invalid password", + message: "Please enter a valid password", + type: "error", + context: "local" + }); + return false; + } + resolve(unlocked); + useSettingStore.getState().setSheetKeyboardHandler(true); + return true; + }, + onClose: () => { + resolve(false); + useSettingStore.getState().setSheetKeyboardHandler(true); + } + }); + }); + }); + })(); + unlockPromise = undefined; + resolve(result); + }); + return unlockPromise; +} diff --git a/apps/mobile/native/ios/Podfile.lock b/apps/mobile/native/ios/Podfile.lock index 11d655f33..e49b8524d 100644 --- a/apps/mobile/native/ios/Podfile.lock +++ b/apps/mobile/native/ios/Podfile.lock @@ -350,7 +350,8 @@ PODS: - react-native-theme-switch-animation (0.6.0): - RCT-Folly (= 2021.07.22.00) - React-Core - - react-native-webview (11.26.1): + - react-native-webview (13.10.0): + - RCT-Folly (= 2021.07.22.00) - React-Core - React-NativeModulesApple (0.72.0): - React-callinvoker @@ -907,7 +908,7 @@ SPEC CHECKSUMS: react-native-share-extension: faed334b1ddf165f1e576fcabd3dc1c9e748bfa9 react-native-sodium: 955bb0dc3ea05f8ea06d5e96cb89d1be7b5d7681 react-native-theme-switch-animation: 220f883f7be290e79f2ab022093ed1a7a5929e6d - react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1 + react-native-webview: 90153193e679163ec257011fa457d02765120210 React-NativeModulesApple: 1d81d927ef1a67a3545a01e14c2e98500bf9b199 React-perflogger: 684a11499a0589cc42135d6d5cc04d0e4e0e261a React-RCTActionSheet: 00b0a4c382a13b834124fa3f541a7d8d1d56efb9 diff --git a/apps/mobile/native/package.json b/apps/mobile/native/package.json index 47cc0201c..a3ac31448 100644 --- a/apps/mobile/native/package.json +++ b/apps/mobile/native/package.json @@ -60,7 +60,7 @@ "react-native-swiper-flatlist": "3.2.2", "react-native-tooltips": "^1.0.3", "react-native-vector-icons": "9.2.0", - "react-native-webview": "^11.14.1", + "react-native-webview": "^13.10.0", "react-native-zip-archive": "6.0.9", "react-native-quick-sqlite": "^8.0.6", "react-native-theme-switch-animation": "^0.6.0", diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 5fb5c3458..af7ece688 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@notesnook/mobile", - "version": "3.0.0", + "version": "3.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@notesnook/mobile", - "version": "3.0.0", + "version": "3.0.2", "hasInstallScript": true, "license": "GPL-3.0-or-later", "workspaces": [ @@ -3234,6 +3234,7 @@ "@szhsin/react-menu": "^4.1.0", "buffer": "^6.0.3", "framer-motion": "^10.16.8", + "localforage": "^1.10.0", "mdi-react": "9.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -28495,7 +28496,7 @@ "react-native-tooltips": "^1.0.3", "react-native-url-polyfill": "^2.0.0", "react-native-vector-icons": "9.2.0", - "react-native-webview": "^11.14.1", + "react-native-webview": "^13.10.0", "react-native-zip-archive": "6.0.9" }, "devDependencies": { @@ -45372,8 +45373,9 @@ } }, "node_modules/react-native-webview": { - "version": "11.26.1", - "license": "MIT", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.10.0.tgz", + "integrity": "sha512-bntBbc3JHBve17NL5fqBWPiOYYDz1VmYUPA0UbsUPHgZj6t7TXUQd4yn2yL//XFKXPDlvMO+MPB9E1uAYpEp/g==", "dependencies": { "escape-string-regexp": "2.0.0", "invariant": "2.2.4" diff --git a/packages/editor-mobile/package-lock.json b/packages/editor-mobile/package-lock.json index 2962cf4f4..85607ea63 100644 --- a/packages/editor-mobile/package-lock.json +++ b/packages/editor-mobile/package-lock.json @@ -17,6 +17,7 @@ "@szhsin/react-menu": "^4.1.0", "buffer": "^6.0.3", "framer-motion": "^10.16.8", + "localforage": "^1.10.0", "mdi-react": "9.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -4375,7 +4376,7 @@ "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "dev": true + "devOptional": true }, "node_modules/@types/q": { "version": "1.5.8", @@ -4399,7 +4400,7 @@ "version": "18.2.39", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.39.tgz", "integrity": "sha512-Oiw+ppED6IremMInLV4HXGbfbG6GyziY3kqAwJYOR0PNbkYDmLWQA3a95EhdSmamsvbkJN96ZNN+YD+fGjzSBA==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4434,7 +4435,7 @@ "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "dev": true + "devOptional": true }, "node_modules/@types/semver": { "version": "7.5.6", @@ -9726,11 +9727,16 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", - "dev": true, + "devOptional": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -12799,6 +12805,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -12836,6 +12850,14 @@ "node": ">=8.9.0" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -17800,6 +17822,20 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/packages/editor-mobile/package.json b/packages/editor-mobile/package.json index 05a2426df..078f3946c 100644 --- a/packages/editor-mobile/package.json +++ b/packages/editor-mobile/package.json @@ -11,6 +11,7 @@ "@szhsin/react-menu": "^4.1.0", "buffer": "^6.0.3", "framer-motion": "^10.16.8", + "localforage": "^1.10.0", "mdi-react": "9.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/editor-mobile/src/App.tsx b/packages/editor-mobile/src/App.tsx index 80466ca58..22a4877fc 100644 --- a/packages/editor-mobile/src/App.tsx +++ b/packages/editor-mobile/src/App.tsx @@ -31,7 +31,6 @@ import { TabContext, useTabStore } from "./hooks/useTabStore"; import { EmotionEditorTheme } from "./theme-factory"; import { getTheme } from "./utils"; import { ReadonlyEditorProvider } from "./components/readonly-editor"; -import { error } from "console"; const currentTheme = getTheme(); if (currentTheme) { diff --git a/packages/editor-mobile/src/components/editor.tsx b/packages/editor-mobile/src/components/editor.tsx index 750382b3e..00901fb5e 100644 --- a/packages/editor-mobile/src/components/editor.tsx +++ b/packages/editor-mobile/src/components/editor.tsx @@ -47,11 +47,12 @@ import { useTabStore } from "../hooks/useTabStore"; import { EmotionEditorToolbarTheme } from "../theme-factory"; -import { EventTypes, randId, Settings } from "../utils"; +import { EventTypes, postAsyncWithTimeout, randId, Settings } from "../utils"; import Header from "./header"; import StatusBar from "./statusbar"; import Tags from "./tags"; import Title from "./title"; +import { pendingSaveRequests } from "../utils/pending-saves"; globalThis.toBlobURL = toBlobURL as typeof globalThis.toBlobURL; @@ -141,20 +142,8 @@ const Tiptap = ({ ) as Promise; }, createInternalLink(attributes) { - logger("info", "create internal link"); - return new Promise((resolve) => { - const id = randId("createInternalLink"); - - globalThis.pendingResolvers[id] = (value) => { - delete globalThis.pendingResolvers[id]; - resolve(value); - logger("info", "resolved create link request:", id); - }; - - post("editor-events:create-internal-link", { - attributes: attributes, - resolverId: id - }); + return postAsyncWithTimeout(EventTypes.createInternalLink, { + attributes }); }, element: getContentDiv(), @@ -221,6 +210,31 @@ const Tiptap = ({ onCreate() { setTimeout(() => { restoreNoteSelection(); + const noteState = tabRef.current.noteId + ? useTabStore.getState().noteState[tabRef.current.noteId] + : undefined; + const top = noteState?.top; + logger( + "info", + "editor.onCreate", + tabRef.current?.noteId, + noteState?.top, + noteState?.to, + noteState?.from + ); + + if (noteState?.to || noteState?.from) { + editors[tabRef.current.id]?.chain().setTextSelection({ + to: noteState.to, + from: noteState.from + }); + } + + containerRef.current?.scrollTo({ + left: 0, + top: top || 0, + behavior: "auto" + }); }, 32); }, downloadOptions: { @@ -279,6 +293,19 @@ const Tiptap = ({ if (!didCallOnLoad) { didCallOnLoad = true; post("editor-events:load"); + pendingSaveRequests + .getPendingContentIds() + .then(async (result) => { + logger("info", result.length, "PENDING ITEMS"); + + if (result && result.length) { + dbLogger("log", "Pending save requests found... restoring"); + await pendingSaveRequests.postPendingRequests(); + } + }) + .catch(() => { + logger("info", "Error restoring pending contents..."); + }); } const updateScrollPosition = (state: TabStore) => { @@ -289,7 +316,6 @@ const Tiptap = ({ ? state.noteState[tabRef.current.noteId] : undefined; if (noteState) { - if ( containerRef.current && containerRef.current?.scrollHeight < noteState.top @@ -425,6 +451,105 @@ const Tiptap = ({ noHeader={settings.noHeader || false} /> +
+

+ Your changes could not be saved. +

+

+ It seems that your changes could not be saved. What to do next: +

+ +

+

    +
  1. +

    + Tap on "Dismiss" and copy the contents of your note so they + are not lost. +

    +
  2. +
  3. +

    Restart the app.

    +
  4. +
+

+ + +
+
{}, []); - const titleChange = useCallback((title: string) => { + const titleChange = useCallback(async (title: string) => { + const currentSessionId = globalThis.sessionId; post( EventTypes.contentchange, undefined, tabRef.current.id, tabRef.current.noteId ); - post(EventTypes.title, title, tabRef.current.id, tabRef.current.noteId); + const params = [ + { + title + }, + tabRef.current.id, + tabRef.current.noteId, + currentSessionId + ]; + const pendingTitleIds = await pendingSaveRequests.getPendingTitleIds(); + postAsyncWithTimeout(EventTypes.title, ...params, 1000) + .then(() => { + if (pendingTitleIds.length) { + dbLogger( + "log", + `Title saved: ${title}, removing ${pendingTitleIds.length} pending requests ` + ); + } + pendingSaveRequests.removePendingTitlesById(pendingTitleIds); + }) + .catch((e) => { + dbLogger("error", e); + dbLogger( + "log", + `Saving title failed, setting pending request ${pendingTitleIds.length}` + ); + if (params[2]) { + pendingSaveRequests.setTitle(params); + } + const element = document.getElementById("editor-saving-failed-overlay"); + if (element) { + element.style.display = "flex"; + editors[tabRef.current.id]?.commands?.blur(); + element.focus(); + } + }); }, []); const countWords = useCallback((ms = 300) => { @@ -185,10 +227,10 @@ export function useEditorController({ if (typeof timers.current.change === "number") { clearTimeout(timers.current?.change); } - timers.current.change = setTimeout(() => { + timers.current.change = setTimeout(async () => { htmlContentRef.current = editor.getHTML(); - post( - EventTypes.content, + + const params = [ { html: htmlContentRef.current, ignoreEdit: ignoreEdit @@ -196,7 +238,40 @@ export function useEditorController({ tabRef.current.id, tabRef.current.noteId, currentSessionId - ); + ]; + const pendingContentIds = + await pendingSaveRequests.getPendingContentIds(); + postAsyncWithTimeout(EventTypes.content, ...params, 5000) + .then(() => { + if (pendingContentIds.length) { + dbLogger( + "log", + `Content saved, removing ${pendingContentIds.length} pending requests` + ); + } + pendingSaveRequests.removePendingContentsById(pendingContentIds); + }) + .catch((e) => { + dbLogger("error", e); + dbLogger( + "log", + `Saving content failed, setting pending request ${ + pendingContentIds.length + 1 + }` + ); + if (params[2]) { + pendingSaveRequests.setContent(params); + } + + const element = document.getElementById( + "editor-saving-failed-overlay" + ); + if (element) { + element.style.display = "flex"; + element.focus(); + } + }); + logger( "info", "Editor saving content", @@ -305,9 +380,8 @@ export function useEditorController({ scrollIntoView(editor as any); } break; - case "native:attachment-data": + case "native:resolve": if (pendingResolvers[value.resolverId]) { - logger("info", "resolved data for attachment", value.resolverId); pendingResolvers[value.resolverId](value.data); } break; @@ -320,16 +394,9 @@ export function useEditorController({ ); useEffect(() => { - if (!isReactNative()) return; // Subscribe only in react native webview. - const isSafari = navigator.vendor.match(/apple/i); - let root: Document | Window = document; - if (isSafari) { - root = window; - } - root.addEventListener("message", onMessage); - + getRoot()?.addEventListener("message", onMessage); return () => { - root.removeEventListener("message", onMessage); + getRoot()?.removeEventListener("message", onMessage); }; }, [onMessage]); @@ -363,16 +430,8 @@ export function useEditorController({ }; const getAttachmentData = (attachment: Partial) => { - return new Promise((resolve, reject) => { - const resolverId = randId("get_attachment_data"); - pendingResolvers[resolverId] = (data) => { - delete pendingResolvers[resolverId]; - resolve(data); - }; - post(EventTypes.getAttachmentData, { - attachment, - resolverId: resolverId - }); + return postAsyncWithTimeout(EventTypes.getAttachmentData, { + attachment }); }; diff --git a/packages/editor-mobile/src/utils/index.ts b/packages/editor-mobile/src/utils/index.ts index 046391b32..00b0135a2 100644 --- a/packages/editor-mobile/src/utils/index.ts +++ b/packages/editor-mobile/src/utils/index.ts @@ -136,6 +136,7 @@ declare global { >; function logger(type: "info" | "warn" | "error", ...logs: unknown[]): void; + function dbLogger(type: "log" | "error", ...logs: unknown[]): void; /** * Function to post message to react native * @param type @@ -158,6 +159,24 @@ declare global { }; } } + +export function getRoot() { + if (!isReactNative()) return; // Subscribe only in react native webview. + const isSafari = navigator.vendor.match(/apple/i); + let root: Document | Window = document; + if (isSafari) { + root = window; + } + return root; +} + +export function getOnMessageListener(callback: () => void) { + getRoot()?.addEventListener("onMessage", callback); + return { + remove: getRoot()?.removeEventListener("onMessage", callback) + }; +} + /* eslint-enable no-var */ export const EventTypes = { @@ -192,7 +211,8 @@ export const EventTypes = { unlockWithBiometrics: "editor-events:unlock-biometrics", disableReadonlyMode: "editor-events:disable-readonly-mode", readonlyEditorLoaded: "readonlyEditorLoaded", - error: "editorError" + error: "editorError", + dbLogger: "editor-events:dbLogger" } as const; export function randId(prefix: string) { @@ -218,29 +238,81 @@ export function logger( post(EventTypes.logger, `[${type}]: ` + logString); } -export function post( - type: (typeof EventTypes)[T], +export function dbLogger(type: "error" | "log", ...logs: unknown[]): void { + const logString = logs + .map((log) => { + return typeof log !== "string" ? JSON.stringify(log) : log; + }) + .join(" "); + + post(EventTypes.dbLogger, { + message: `[${type}]: ` + logString, + error: logs[0] instanceof Error ? logs[0] : undefined + }); +} + +export function post( + type: string, value?: unknown, tabId?: number, noteId?: string, - sessionId?: string -): void { + sessionId?: string, + hasTimeout?: boolean +): string { + const id = randId(type); if (isReactNative()) { - window.ReactNativeWebView.postMessage( - JSON.stringify({ - type, - value: value, - sessionId: sessionId || globalThis.sessionId, - tabId, - noteId - }) + setTimeout(() => + window.ReactNativeWebView.postMessage( + JSON.stringify({ + type, + value: value, + sessionId: sessionId || globalThis.sessionId, + tabId, + noteId, + resolverId: id, + hasTimeout: hasTimeout + }) + ) ); - } else { - // console.log(type, value); } + return id; +} + +export async function postAsyncWithTimeout( + type: string, + value?: unknown, + tabId?: number, + noteId?: string, + sessionId?: string, + waitFor?: number +): Promise { + return new Promise((resolve, reject) => { + const id = post( + type, + value, + tabId, + noteId, + sessionId, + waitFor !== undefined ? true : false + ); + globalThis.pendingResolvers[id] = (result) => { + delete globalThis.pendingResolvers[id]; + logger("info", `Async post request resolved for ${id}`); + resolve(result); + }; + if (waitFor !== undefined) { + setTimeout(() => { + if (globalThis.pendingResolvers[id]) { + delete globalThis.pendingResolvers[id]; + reject(new Error(`Async post request timed out for ${id}`)); + } + }, waitFor); + } + }); } globalThis.logger = logger; +globalThis.dbLogger = dbLogger; globalThis.post = post; export function saveTheme(theme: ThemeDefinition) { diff --git a/packages/editor-mobile/src/utils/pending-saves.ts b/packages/editor-mobile/src/utils/pending-saves.ts new file mode 100644 index 000000000..2a415b944 --- /dev/null +++ b/packages/editor-mobile/src/utils/pending-saves.ts @@ -0,0 +1,139 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +import { EventTypes, postAsyncWithTimeout, randId } from "."; + +class PendingSaveRequests { + static TITLES = "pendingTitles"; + static CONTENT = "pendingContents"; + + async setTitle(value: any) { + const pendingTitles = JSON.parse( + this.get(PendingSaveRequests.TITLES) || "[]" + ); + + (pendingTitles as any[]).push({ + id: randId("title-pending"), + params: value + }); + return localStorage.setItem( + PendingSaveRequests.TITLES, + JSON.stringify(pendingTitles) + ); + } + + async getPendingTitles() { + const pendingTitles = JSON.parse( + this.get(PendingSaveRequests.TITLES) || "[]" + ); + return pendingTitles; + } + + async setContent(value: any) { + const pendingContents = JSON.parse( + this.get(PendingSaveRequests.CONTENT) || "[]" + ); + + (pendingContents as any[]).push({ + id: randId("content-pending"), + params: value + }); + return localStorage.setItem( + PendingSaveRequests.CONTENT, + JSON.stringify(pendingContents) + ); + } + + async getPendingContent() { + const pendingContents = this.get(PendingSaveRequests.CONTENT); + return JSON.parse(pendingContents || "[]"); + } + + get(key: string) { + return localStorage.getItem(key); + } + + remove(key: string) { + return localStorage.removeItem(key); + } + + clear() { + return localStorage.clear(); + } + + keys() { + return localStorage.keys(); + } + + async getPendingTitleIds() { + const pendingTitles = await this.getPendingTitles(); + return (pendingTitles as any[]).map((pending) => pending.id); + } + + async getPendingContentIds() { + const pendingContents = await this.getPendingContent(); + return (pendingContents as any[]).map((pending) => pending.id); + } + + async removePendingTitlesById(ids: string[]) { + const pendingTitles = await this.getPendingTitles(); + const filtered = (pendingTitles as any[]).filter( + (pending) => !ids.includes(pending.id) + ); + return localStorage.setItem( + PendingSaveRequests.TITLES, + JSON.stringify(filtered) + ); + } + + async removePendingContentsById(ids: string[]) { + const pendingContents = await this.getPendingContent(); + const filtered = (pendingContents as any[]).filter( + (pending) => !ids.includes(pending.id) + ); + return localStorage.setItem( + PendingSaveRequests.CONTENT, + JSON.stringify(filtered) + ); + } + + async postPendingRequests() { + const postPendingTitles = async () => { + const pendingTitles = await this.getPendingTitles(); + this.remove(PendingSaveRequests.TITLES); + for (const pending of pendingTitles) { + if (pending.params[0]) pending.params[0].pendingChanges = true; + await postAsyncWithTimeout(EventTypes.title, ...pending.params, 5000); + } + }; + + const postPendingContent = async () => { + const pendingContents = await this.getPendingContent(); + this.remove(PendingSaveRequests.CONTENT); + for (const pending of pendingContents) { + if (pending.params[0]) pending.params[0].pendingChanges = true; + await postAsyncWithTimeout(EventTypes.content, ...pending.params, 5000); + } + }; + + await postPendingTitles(); + await postPendingContent(); + } +} + +export const pendingSaveRequests = new PendingSaveRequests();