Compare commits

...

5 Commits

Author SHA1 Message Date
Ammar Ahmed
d0ab01e356 mobile: fix errors 2024-05-10 09:21:21 +05:00
Abdullah Atta
4cf7ef7af9 web: show error if saving takes more than 30 seconds 2024-05-10 09:01:28 +05:00
Ammar Ahmed
3868cac926 mobile: fix editor loading 2024-05-10 09:01:28 +05:00
Ammar Ahmed
85e60cb575 mobile: handle editor not responding 2024-05-10 09:01:28 +05:00
Ammar Ahmed
57a8d241a3 mobile: add editor lock safety 2024-05-10 08:59:27 +05:00
26 changed files with 943 additions and 451 deletions

View File

@@ -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) => {

View File

@@ -86,6 +86,7 @@ import { updateStatusBarColor } from "../utils/colors";
import { BETA } from "../utils/constants";
import {
eCloseSheet,
eEditorReset,
eLoginSessionExpired,
eOnLoadNote,
eOpenAnnouncementDialog,
@@ -235,7 +236,7 @@ async function checkForShareExtensionLaunchedInBackground() {
if (notesAddedFromIntent || shareExtensionOpened) {
const id = useTabStore.getState().getCurrentNoteId();
const note = id && (await db.notes.note(id));
eSendEvent("webview_reset");
eSendEvent(eEditorReset);
if (note) setTimeout(() => eSendEvent("loadingNote", note), 1);
MMKV.removeItem("shareExtensionOpened");
}
@@ -532,6 +533,14 @@ export const useAppEvents = () => {
}
//@ts-ignore
globalThis["IS_SHARE_EXTENSION"] = false;
if (
SettingsService.getBackgroundEnterTime() + 60 * 1000 * 30 <
Date.now()
) {
// Reset the editor if the app has been in background for more than 10 minutes.
eSendEvent(eEditorReset);
}
} else {
await saveEditorState();
if (

View File

@@ -39,6 +39,7 @@ import {
eSubscribeEvent
} from "../../services/event-manager";
import {
eEditorReset,
eOnLoadNote,
eUnlockNote,
eUnlockWithBiometrics,
@@ -54,8 +55,10 @@ import { syncTabs, useTabStore } from "./tiptap/use-tab-store";
import {
editorController,
editorState,
openInternalLink
openInternalLink,
randId
} from "./tiptap/utils";
import { tabBarRef } from "../../utils/global-refs";
const style: ViewStyle = {
height: "100%",
@@ -102,24 +105,20 @@ const Editor = React.memo(
noToolbar,
noHeader
});
const renderKey = useRef(`editor-0` + editorId);
const renderKey = useRef(randId("editor-id") + editorId);
useImperativeHandle(ref, () => ({
get: () => editor
}));
useLockedNoteHandler();
const onError = useCallback(() => {
renderKey.current =
renderKey.current === `editor-0`
? `editor-1` + editorId
: `editor-0` + editorId;
renderKey.current = randId("editor-id") + editorId;
editor.state.current.ready = false;
editor.setLoading(true);
}, [editor, editorId]);
useEffect(() => {
const sub = [eSubscribeEvent("webview_reset", onError)];
const sub = [eSubscribeEvent(eEditorReset, onError)];
return () => {
sub.forEach((s) => s?.unsubscribe());
};
@@ -146,6 +145,7 @@ const Editor = React.memo(
nestedScrollEnabled
onError={onError}
injectedJavaScriptBeforeContentLoaded={`
globalThis.__DEV__ = ${__DEV__}
globalThis.readonly=${readonly};
globalThis.noToolbar=${noToolbar};
globalThis.noHeader=${noHeader};
@@ -339,7 +339,7 @@ const useLockedNoteHandler = () => {
}),
eSubscribeEvent(eUnlockWithPassword, onSubmit)
];
if (tabRef.current?.locked) {
if (tabRef.current?.locked && tabBarRef.current?.page() === 2) {
unlock();
}
return () => {

View File

@@ -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"
};

View File

@@ -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);
}
};

View File

@@ -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<T> = {
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 = {

View File

@@ -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<any>;
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;
}

View File

@@ -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,16 +47,20 @@ 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 {
eEditorReset,
eEditorTabFocused,
eOnLoadNote,
eShowMergeDialog,
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 +73,7 @@ import {
isEditorLoaded,
post
} from "./utils";
import { sleep } from "../../../utils/time";
type NoteWithContent = Note & {
content?: NoteContent<false>;
@@ -145,8 +151,9 @@ export const useEditor = (
useEffect(() => {
const event = eSubscribeEvent(eEditorTabFocused, (tabId) => {
console.log("Editot tab focus changed", lastTabFocused.current, tabId);
if (lastTabFocused.current !== tabId) lock.current = false;
lastTabFocused.current = tabId as number;
console.log(tabId);
});
return () => {
event?.unsubscribe();
@@ -165,10 +172,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) => {
@@ -216,7 +224,8 @@ export const useEditor = (
type,
ignoreEdit,
sessionHistoryId: currentSessionHistoryId,
tabId
tabId,
pendingChanges
}: SavePayload) => {
if (currentNotes.current[id as string]?.readonly || readonly) return;
try {
@@ -277,12 +286,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) {
@@ -316,9 +339,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) {
@@ -341,6 +377,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) {
@@ -415,7 +461,11 @@ export const useEditor = (
commands.focus(tabId);
});
} else {
if (!event.item) return;
if (!event.item) {
overlay(false);
return;
}
console.log("LOADING NOTE", event.item.id);
const item = event.item;
const currentTab = useTabStore
@@ -443,7 +493,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;
}
@@ -502,6 +551,7 @@ export const useEditor = (
loadingState.current === currentContents.current[item.id]?.data
) {
// If note is already loading, return.
console.log("Note is already loading...");
return;
}
@@ -535,11 +585,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;
}
@@ -573,124 +620,131 @@ export const useEditor = (
data: Note | ContentItem | TrashItem | DeletedItem,
isLocal?: boolean
) => {
if (SettingsService.get().disableRealtimeSync && !isLocal) return;
if (!data) return;
try {
await (async () => {
if (SettingsService.get().disableRealtimeSync && !isLocal) return;
if (!data) return;
if (isDeleted(data) || isTrashItem(data)) {
const tabId = useTabStore.getState().getTabForNote(data.id);
if (tabId !== undefined) {
console.log("Removing tab");
await commands.clearContent(tabId);
useTabStore.getState().removeTab(tabId);
}
return;
}
const noteId =
(data as ContentItem).type === "tiptap"
? (data as ContentItem).noteId
: data.id;
if (!useTabStore.getState().hasTabForNote(noteId)) return;
const tabId = useTabStore.getState().getTabForNote(noteId) as number;
const tab = useTabStore.getState().getTab(tabId);
const note = data.type === "note" ? data : await db.notes?.note(noteId);
lock.current = true;
// Handle this case where note was locked on another device and synced.
const locked = await db.vaults.itemExists(
currentNotes.current[noteId] as ItemReference
);
if (note) {
if (!locked && tab?.noteLocked) {
// Note lock removed.
if (tab.locked) {
if (useTabStore.getState().currentTab === tabId) {
eSendEvent(eOnLoadNote, {
item: note,
forced: true
});
} else {
useTabStore.getState().updateTab(tabId, {
locked: false,
noteLocked: false
});
commands.setLoading(true, tabId);
if (isDeleted(data) || isTrashItem(data)) {
const tabId = useTabStore.getState().getTabForNote(data.id);
if (tabId !== undefined) {
console.log("Removing tab");
await commands.clearContent(tabId);
useTabStore.getState().removeTab(tabId);
}
}
} else if (!tab?.noteLocked && locked) {
// Note lock added.
useTabStore.getState().updateTab(tabId, {
locked: true,
noteLocked: true
});
if (useTabStore.getState().currentTab !== tabId) {
commands.clearContent(tabId);
commands.setLoading(true, tabId);
}
}
if (currentNotes.current[noteId]?.title !== note.title) {
postMessage(EditorEvents.title, note.title, tabId);
}
commands.setTags(note);
if (currentNotes.current[noteId]?.dateEdited !== note.dateEdited) {
commands.setStatus(
getFormattedDate(note.dateEdited, "date-time"),
"Saved",
tabId as number
);
}
console.log("readonly state changed...", note.readonly);
useTabStore.getState().updateTab(tabId, {
readonly: note.readonly
});
}
if (data.type === "tiptap" && note && !isLocal) {
if (lastContentChangeTime.current[noteId] >= data.dateEdited) {
lock.current = false;
return;
}
if (locked && isEncryptedContent(data)) {
const decryptedContent = await db.vault?.decryptContent(data);
if (!decryptedContent) {
useTabStore.getState().updateTab(tabId, {
locked: true,
noteLocked: true
});
if (useTabStore.getState().currentTab !== tabId) {
commands.clearContent(tabId);
commands.setLoading(true, tabId);
}
} else {
await postMessage(
EditorEvents.updatehtml,
decryptedContent.data,
tabId
);
currentContents.current[note.id] = decryptedContent;
}
} else {
const _nextContent = data.data;
if (_nextContent === currentContents.current?.data) {
lock.current = false;
return;
}
lastContentChangeTime.current[note.id] = note.dateEdited;
await postMessage(EditorEvents.updatehtml, _nextContent, tabId);
if (!isEncryptedContent(data)) {
currentContents.current[note.id] = data as UnencryptedContentItem;
const noteId =
(data as ContentItem).type === "tiptap"
? (data as ContentItem).noteId
: data.id;
if (!useTabStore.getState().hasTabForNote(noteId)) return;
const tabId = useTabStore.getState().getTabForNote(noteId) as number;
const tab = useTabStore.getState().getTab(tabId);
const note =
data.type === "note" ? data : await db.notes?.note(noteId);
lock.current = true;
// Handle this case where note was locked on another device and synced.
const locked = await db.vaults.itemExists(
currentNotes.current[noteId] as ItemReference
);
if (note) {
if (!locked && tab?.noteLocked) {
// Note lock removed.
if (tab.locked) {
if (useTabStore.getState().currentTab === tabId) {
eSendEvent(eOnLoadNote, {
item: note,
forced: true
});
} else {
useTabStore.getState().updateTab(tabId, {
locked: false,
noteLocked: false
});
commands.setLoading(true, tabId);
}
}
} else if (!tab?.noteLocked && locked) {
// Note lock added.
useTabStore.getState().updateTab(tabId, {
locked: true,
noteLocked: true
});
if (useTabStore.getState().currentTab !== tabId) {
commands.clearContent(tabId);
commands.setLoading(true, tabId);
}
}
if (currentNotes.current[noteId]?.title !== note.title) {
postMessage(EditorEvents.title, note.title, tabId);
}
commands.setTags(note);
if (currentNotes.current[noteId]?.dateEdited !== note.dateEdited) {
commands.setStatus(
getFormattedDate(note.dateEdited, "date-time"),
"Saved",
tabId as number
);
}
console.log("readonly state changed...", note.readonly);
useTabStore.getState().updateTab(tabId, {
readonly: note.readonly
});
}
}
if (data.type === "tiptap" && note && !isLocal) {
if (lastContentChangeTime.current[noteId] >= data.dateEdited) {
return;
}
if (locked && isEncryptedContent(data)) {
const decryptedContent = await db.vault?.decryptContent(data);
if (!decryptedContent) {
useTabStore.getState().updateTab(tabId, {
locked: true,
noteLocked: true
});
if (useTabStore.getState().currentTab !== tabId) {
commands.clearContent(tabId);
commands.setLoading(true, tabId);
}
} else {
await postMessage(
EditorEvents.updatehtml,
decryptedContent.data,
tabId
);
currentContents.current[note.id] = decryptedContent;
}
} else {
const _nextContent = data.data;
if (_nextContent === currentContents.current?.data) {
return;
}
lastContentChangeTime.current[note.id] = note.dateEdited;
await postMessage(EditorEvents.updatehtml, _nextContent, tabId);
if (!isEncryptedContent(data)) {
currentContents.current[note.id] =
data as UnencryptedContentItem;
}
}
}
})();
} catch (e) {
DatabaseLogger.error(e as Error, "Error when applying sync changes");
} finally {
lock.current = false;
}
lock.current = false;
},
[postMessage, commands]
);
@@ -713,7 +767,8 @@ export const useEditor = (
type,
ignoreEdit,
noteId,
tabId
tabId,
pendingChanges
}: {
noteId?: string;
title?: string;
@@ -721,18 +776,29 @@ 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 &&
currentLoadingNoteId.current === noteId)
) {
DatabaseLogger.log(`Skipped saving conent:
DatabaseLogger.log(`Skipped saving content:
lock.current: ${lock.current}
currentLoadingNoteId.current: ${currentLoadingNoteId.current}
`);
if (lock.current) {
setTimeout(() => {
if (lock.current) {
DatabaseLogger.warn("Editor force removed lock after 5 seconds");
lock.current = false;
}
}, 5000);
}
return;
}
@@ -740,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",
@@ -755,7 +821,8 @@ export const useEditor = (
id: noteId,
ignoreEdit,
sessionHistoryId: noteId ? editorSessionHistory.get(noteId) : undefined,
tabId: tabId
tabId: tabId,
pendingChanges
};
withTimer(
noteId || "newnote",
@@ -767,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
);
@@ -810,7 +886,7 @@ export const useEditor = (
useTabStore.getState().currentTab
))
) {
eSendEvent("webview_reset", "onReady");
eSendEvent(eEditorReset, "onReady");
return false;
} else {
syncTabs();
@@ -820,7 +896,6 @@ export const useEditor = (
}, [isDefaultEditor, restoreEditorState]);
const onLoad = useCallback(async () => {
if (currentNotes.current) overlay(true);
setTimeout(() => {
postMessage(EditorEvents.theme, theme);
});
@@ -835,12 +910,22 @@ 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) {
loadNote({
item: note
});
}
}
if (!state.current?.initialLoadCalled) {
state.current.initialLoadCalled = true;
}
overlay(false);
}, [
postMessage,
theme,

View File

@@ -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) {

View File

@@ -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
});
}
});

View File

@@ -17,40 +17,26 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<false>) {
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<false>
) {
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<boolean>
unlockVault: unlockVaultForNoteExport as () => Promise<boolean>
})) {
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<boolean>
unlockVault: unlockVaultForNoteExport as () => Promise<boolean>
})) {
if (item instanceof Error) {
DatabaseLogger.error(item);

View File

@@ -191,11 +191,11 @@ function canLockAppInBackground() {
}
let backgroundEnterTime = 0;
function appEnteredBackground() {
if (canLockAppInBackground()) {
backgroundEnterTime = Date.now();
}
backgroundEnterTime = Date.now();
}
const getBackgroundEnterTime = () => backgroundEnterTime;
function shouldLockAppOnEnterForeground() {
if (
useUserStore.getState().disableAppLockRequests ||
@@ -227,7 +227,8 @@ export const SettingsService = {
shouldLockAppOnEnterForeground,
canLockAppInBackground,
appEnteredBackground,
setPrivacyScreen
setPrivacyScreen,
getBackgroundEnterTime
};
init();

View File

@@ -174,3 +174,4 @@ export const eUnlockWithPassword = "619";
export const eUpdateNoteInEditor = "620";
export const eOnEnterEditor = "621";
export const eOnExitEditor = "622";
export const eEditorReset = "623";

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<any> | 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;
}

View File

@@ -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
@@ -958,4 +959,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 2b8b28a341b202bf3ca5f231b75bb05893486ed8
COCOAPODS: 1.14.2
COCOAPODS: 1.12.1

View File

@@ -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",

View File

@@ -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"

View File

@@ -73,16 +73,38 @@ import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
const PDFPreview = React.lazy(() => import("../pdf-preview"));
function saveContent(noteId: string, ignoreEdit: boolean, content: string) {
async function saveContent(
noteId: string,
ignoreEdit: boolean,
content: string
) {
logger.debug("saving content", {
noteId,
ignoreEdit,
length: content.length
});
useEditorStore.getState().saveSessionContent(noteId, ignoreEdit, {
type: "tiptap",
data: content
});
await Promise.race([
useEditorStore.getState().saveSessionContent(noteId, ignoreEdit, {
type: "tiptap",
data: content
}),
new Promise((resolve) =>
setTimeout(() => {
const { hide } = showToast(
"error",
"Saving this note is taking too long. Copy your changes and restart the app to prevent data loss. If the problem persists, please report it to us at support@streetwriters.co.",
[
{
text: "Dismiss",
onClick: () => hide()
}
],
0
);
resolve(undefined);
}, 30 * 1000)
)
]);
}
const deferredSave = debounceWithId(saveContent, 100);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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;
@@ -79,30 +80,34 @@ const Tiptap = ({
tabRef.current = tab;
function restoreNoteSelection(state?: NoteState) {
if (!tabRef.current.noteId) return;
const noteState =
state || useTabStore.getState().noteState[tabRef.current.noteId];
try {
if (!tabRef.current.noteId) return;
const noteState =
state || useTabStore.getState().noteState[tabRef.current.noteId];
if (noteState.to || noteState.from) {
const size = editors[tabRef.current.id]?.state.doc.content.size || 0;
if (
noteState.to > 0 &&
noteState.to <= size &&
noteState.from > 0 &&
noteState.from <= size
) {
editors[tabRef.current.id]?.chain().setTextSelection({
to: noteState.to,
from: noteState.from
});
if (noteState && (noteState.to || noteState.from)) {
const size = editors[tabRef.current.id]?.state.doc.content.size || 0;
if (
noteState.to > 0 &&
noteState.to <= size &&
noteState.from > 0 &&
noteState.from <= size
) {
editors[tabRef.current.id]?.chain().setTextSelection({
to: noteState.to,
from: noteState.from
});
}
}
}
containerRef.current?.scrollTo({
left: 0,
top: noteState.top || 0,
behavior: "auto"
});
containerRef.current?.scrollTo({
left: 0,
top: noteState?.top || 0,
behavior: "auto"
});
} catch (e) {
logger("error", (e as Error).message, (e as Error).stack);
}
}
usePermissionHandler({
@@ -141,20 +146,8 @@ const Tiptap = ({
) as Promise<string | undefined>;
},
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(),
@@ -279,6 +272,17 @@ const Tiptap = ({
if (!didCallOnLoad) {
didCallOnLoad = true;
post("editor-events:load");
pendingSaveRequests
.getPendingContentIds()
.then(async (result) => {
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) => {
@@ -288,8 +292,17 @@ const Tiptap = ({
const noteState = tabRef.current.noteId
? state.noteState[tabRef.current.noteId]
: undefined;
post(
EventTypes.tabFocused,
!!globalThis.editorControllers[tabRef.current.id]?.content.current &&
!editorControllers[tabRef.current.id]?.loading,
tabRef.current.id,
state.getCurrentNoteId()
);
editorControllers[tabRef.current.id]?.updateTab();
if (noteState) {
if (
containerRef.current &&
containerRef.current?.scrollHeight < noteState.top
@@ -313,14 +326,6 @@ const Tiptap = ({
) {
editorControllers[tabRef.current.id]?.setLoading(true);
}
post(
EventTypes.tabFocused,
!!globalThis.editorControllers[tabRef.current.id]?.content.current &&
!editorControllers[tabRef.current.id]?.loading,
tabRef.current.id,
state.getCurrentNoteId()
);
editorControllers[tabRef.current.id]?.updateTab();
} else {
isFocusedRef.current = false;
}
@@ -425,6 +430,105 @@ const Tiptap = ({
noHeader={settings.noHeader || false}
/>
<div
id="editor-saving-failed-overlay"
style={{
display: "none",
position: "absolute",
zIndex: 999,
width: "100%",
height: "100%",
backgroundColor: colors.primary.background,
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
rowGap: 10
}}
>
<p
style={{
color: colors.primary.paragraph,
fontSize: 18,
fontWeight: "600",
textAlign: "center",
padding: "0px 20px",
marginBottom: 0,
userSelect: "none"
}}
>
Your changes could not be saved.
</p>
<p
style={{
color: colors.primary.paragraph,
marginTop: 0,
marginBottom: 0,
userSelect: "none",
textAlign: "center",
maxWidth: "90%",
fontSize: "0.9rem"
}}
>
It seems that your changes could not be saved. What to do next:
</p>
<p
style={{
width: "90%",
fontSize: "0.9rem"
}}
>
<ol>
<li>
<p>
Tap on "Dismiss" and copy the contents of your note so they
are not lost.
</p>
</li>
<li>
<p>Restart the app.</p>
</li>
</ol>
</p>
<button
style={{
backgroundColor: colors.primary.accent,
borderRadius: 5,
boxSizing: "border-box",
border: "none",
color: colors.static.white,
width: 250,
fontSize: "1em",
height: 45,
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
onMouseDown={(e) => {
if (globalThis.keyboardShown) {
e.preventDefault();
}
}}
onClick={() => {
const element = document.getElementById(
"editor-saving-failed-overlay"
);
if (element) {
element.style.display = "none";
}
}}
>
<p
style={{
userSelect: "none"
}}
>
Dismiss
</p>
</button>
</div>
<div
onScroll={controller.scroll}
ref={containerRef}

View File

@@ -30,8 +30,15 @@ import {
useRef,
useState
} from "react";
import { EventTypes, isReactNative, post, randId, saveTheme } from "../utils";
import {
EventTypes,
getRoot,
post,
postAsyncWithTimeout,
saveTheme
} from "../utils";
import { injectCss, transform } from "../utils/css";
import { pendingSaveRequests } from "../utils/pending-saves";
import { useTabContext, useTabStore } from "./useTabStore";
type Attachment = {
@@ -146,14 +153,49 @@ export function useEditorController({
const selectionChange = useCallback((_editor: Editor) => {}, []);
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",
@@ -219,7 +294,6 @@ export function useEditorController({
tabRef.current.noteId &&
tabRef.current.noteId === useTabStore.getState().getCurrentNoteId()
) {
logger("info", tabRef.current.noteId, value);
useTabStore.getState().setNoteState(tabRef.current.noteId, {
top: value
});
@@ -306,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;
@@ -321,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]);
@@ -364,16 +430,8 @@ export function useEditorController({
};
const getAttachmentData = (attachment: Partial<Attachment>) => {
return new Promise<string>((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
});
};

View File

@@ -89,6 +89,8 @@ export const useTabStore = create(
currentTab: 0,
scrollPosition: {},
setNoteState: (noteId: string, state: Partial<NoteState>) => {
if (editorControllers[get().currentTab]?.loading) return;
const noteState = {
...get().noteState
};
@@ -96,6 +98,7 @@ export const useTabStore = create(
...get().noteState[noteId],
...state
};
set({
noteState
});

View File

@@ -135,7 +135,10 @@ declare global {
| undefined
>;
var __DEV__: boolean;
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 +161,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 +213,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) {
@@ -209,6 +231,8 @@ export function logger(
type: "info" | "warn" | "error",
...logs: unknown[]
): void {
if (typeof globalThis.__DEV__ !== "undefined" && !globalThis.__DEV__) return;
const logString = logs
.map((log) => {
return typeof log !== "string" ? JSON.stringify(log) : log;
@@ -218,29 +242,81 @@ export function logger(
post(EventTypes.logger, `[${type}]: ` + logString);
}
export function post<T extends keyof typeof EventTypes>(
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<R = any>(
type: string,
value?: unknown,
tabId?: number,
noteId?: string,
sessionId?: string,
waitFor?: number
): Promise<R> {
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) {

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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();