From e5e965415d69f7b75339f6d68dab141ecd6459e9 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed <40239442+ammarahm-ed@users.noreply.github.com> Date: Tue, 20 Sep 2022 18:33:55 +0500 Subject: [PATCH] feat: realtime & auto sync for all users (#944) --- .github/workflows/core.tests.yml | 2 + .github/workflows/web.tests.yml | 2 + apps/mobile/app/hooks/use-app-events.js | 6 +- .../mobile/app/screens/editor/tiptap/types.ts | 1 + .../app/screens/editor/tiptap/use-editor.ts | 93 +++++++++++++++++- .../mobile/app/screens/editor/tiptap/utils.ts | 1 + apps/mobile/app/services/premium.js | 3 - apps/web/__e2e__/models/app.model.ts | 11 ++- apps/web/__e2e__/models/editor.model.ts | 5 + .../__e2e__/models/note-properties.model.ts | 1 - apps/web/__e2e__/notes.issues.test.ts | 1 - apps/web/__e2e__/sync.test.ts | 95 +++++++++++++++++++ apps/web/src/app-effects.js | 13 ++- apps/web/src/components/editor/index.tsx | 62 ++++++------ apps/web/src/components/editor/tiptap.tsx | 20 +++- apps/web/src/components/editor/title-box.tsx | 40 ++++---- apps/web/src/components/editor/types.ts | 1 + apps/web/src/stores/editor-store.js | 18 +++- packages/core/api/sync/auto-sync.js | 13 ++- packages/core/api/sync/index.js | 6 +- packages/core/api/sync/merger.js | 8 +- packages/core/api/vault.js | 7 +- packages/core/common.js | 4 +- .../src/hooks/useEditorController.ts | 12 +++ 24 files changed, 337 insertions(+), 88 deletions(-) create mode 100644 apps/web/__e2e__/sync.test.ts diff --git a/.github/workflows/core.tests.yml b/.github/workflows/core.tests.yml index 4d3d72438..e71a95b22 100644 --- a/.github/workflows/core.tests.yml +++ b/.github/workflows/core.tests.yml @@ -13,6 +13,8 @@ on: types: - "ready_for_review" - "opened" + - "synchronize" + - "reopened" jobs: test: diff --git a/.github/workflows/web.tests.yml b/.github/workflows/web.tests.yml index d09ece1e9..119ed4ca5 100644 --- a/.github/workflows/web.tests.yml +++ b/.github/workflows/web.tests.yml @@ -13,6 +13,8 @@ on: types: - "ready_for_review" - "opened" + - "synchronize" + - "reopened" jobs: test: diff --git a/apps/mobile/app/hooks/use-app-events.js b/apps/mobile/app/hooks/use-app-events.js index 019542b19..879077815 100644 --- a/apps/mobile/app/hooks/use-app-events.js +++ b/apps/mobile/app/hooks/use-app-events.js @@ -213,14 +213,10 @@ export const useAppEvents = () => { }, []); const onSyncComplete = useCallback(async () => { + console.log('Sync complete'); initAfterSync(); setLastSynced(await db.lastSynced()); eSendEvent(eCloseProgressDialog, "sync_progress"); - let id = useEditorStore.getState().currentEditingNote; - let note = id && db.notes.note(id).data; - if (note) { - //await updateNoteInEditor(); - } }, [setLastSynced]); const onUrlRecieved = useCallback( diff --git a/apps/mobile/app/screens/editor/tiptap/types.ts b/apps/mobile/app/screens/editor/tiptap/types.ts index 363e3cb42..638996e8c 100644 --- a/apps/mobile/app/screens/editor/tiptap/types.ts +++ b/apps/mobile/app/screens/editor/tiptap/types.ts @@ -81,6 +81,7 @@ export type Note = { export type Content = { data?: string; type: string; + noteId: string; }; export type SavePayload = { diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor.ts b/apps/mobile/app/screens/editor/tiptap/use-editor.ts index 26bf74b74..3c584ba6f 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor.ts +++ b/apps/mobile/app/screens/editor/tiptap/use-editor.ts @@ -25,7 +25,8 @@ import { DDS } from "../../../services/device-detection"; import { eSendEvent, eSubscribeEvent, - eUnSubscribeEvent + eUnSubscribeEvent, + openVault } from "../../../services/event-manager"; import Navigation from "../../../services/navigation"; import { TipManager } from "../../../services/tip-manager"; @@ -33,7 +34,7 @@ import { useEditorStore } from "../../../stores/use-editor-store"; import { useNoteStore } from "../../../stores/use-notes-store"; import { useTagStore } from "../../../stores/use-tag-store"; import { ThemeStore, useThemeStore } from "../../../stores/use-theme-store"; -import { eOnLoadNote } from "../../../utils/events"; +import { eClearEditor, eOnLoadNote } from "../../../utils/events"; import { tabBarRef } from "../../../utils/global-refs"; import { timeConverter } from "../../../utils/time"; import { NoteType } from "../../../utils/types"; @@ -49,6 +50,7 @@ import { makeSessionId, post } from "./utils"; +import { EVENTS } from "@notesnook/core/common"; export const useEditor = ( editorId = "", @@ -71,6 +73,8 @@ export const useEditor = ( const insets = useGlobalSafeAreaInsets(); const isDefaultEditor = editorId === ""; const saveCount = useRef(0); + const lastSuccessfulSaveTime = useRef(0); + const lock = useRef(false); const postMessage = useCallback( async (type: string, data: T) => @@ -154,6 +158,7 @@ export const useEditor = ( saveCount.current = 0; useEditorStore.getState().setReadonly(false); postMessage(EditorEvents.title, ""); + lastSuccessfulSaveTime.current = 0; await commands.clearContent(); await commands.clearTags(); if (resetState) { @@ -245,6 +250,8 @@ export const useEditor = ( note = db.notes?.note(id)?.data as Note; await commands.setStatus(timeConverter(note.dateEdited), "Saved"); + lastSuccessfulSaveTime.current = note.dateEdited; + if ( saveCount.current < 2 || currentNote.current?.title !== note.title || @@ -259,8 +266,8 @@ export const useEditor = ( ); } } - saveCount.current++; + saveCount.current++; return id; } catch (e) { console.log("Error saving note: ", e); @@ -274,7 +281,8 @@ export const useEditor = ( if (note.locked || note.content) { currentContent.current = { data: note.content?.data, - type: note.content?.type || "tiny" + type: note.content?.type || "tiptap", + noteId: currentNote.current?.id as string }; } else { currentContent.current = await db.content?.raw(note.contentId); @@ -299,6 +307,7 @@ export const useEditor = ( sessionHistoryId.current = Date.now(); await commands.setSessionId(nextSessionId); await commands.focus(); + lastSuccessfulSaveTime.current = 0; useEditorStore.getState().setReadonly(false); } else { if (!item.forced && currentNote.current?.id === item.id) return; @@ -306,6 +315,7 @@ export const useEditor = ( overlay(true, item); currentNote.current && (await reset(false)); await loadContent(item as NoteType); + lastSuccessfulSaveTime.current = item.dateEdited; const nextSessionId = makeSessionId(item as NoteType); sessionHistoryId.current = Date.now(); setSessionId(nextSessionId); @@ -347,6 +357,77 @@ export const useEditor = ( }, 300); }; + const lockNoteWithVault = useCallback((note: NoteType) => { + eSendEvent(eClearEditor); + openVault({ + item: note, + novault: true, + locked: true, + goToEditor: true, + title: "Open note", + description: "Unlock note to open it in editor." + }); + }, []); + + const onSyncComplete = useCallback( + async (data: NoteType | Content) => { + if (!data) return; + const noteId = data.type === "tiptap" ? data.noteId : data.id; + + if (!currentNote.current || noteId !== currentNote.current.id) return; + const isContentEncrypted = typeof (data as Content)?.data === "object"; + const note = db.notes?.note(currentNote.current?.id).data as NoteType; + lock.current = true; + + if (data.type === "tiptap") { + if (!currentNote.current.locked && isContentEncrypted) { + lockNoteWithVault(note); + } else if (currentNote.current.locked && isContentEncrypted) { + const decryptedContent = (await db.vault?.decryptContent( + data + )) as Content; + if (!decryptedContent) { + lockNoteWithVault(note); + } else { + await postMessage(EditorEvents.updatehtml, decryptedContent.data); + currentContent.current = decryptedContent; + } + } else { + const _nextContent = await db.content?.raw(note.contentId); + lastSuccessfulSaveTime.current = note.dateEdited; + if (_nextContent !== currentContent.current?.data) { + await postMessage(EditorEvents.updatehtml, _nextContent.data); + currentContent.current = _nextContent; + } + } + } else { + const note = data as NoteType; + if (note.title !== currentNote.current.title) { + postMessage(EditorEvents.title, note.title); + } + if (note.tags !== currentNote.current.tags) { + await commands.setTags(note); + } + await commands.setStatus(timeConverter(note.dateEdited), "Saved"); + } + + lock.current = false; + }, + [commands, postMessage, lockNoteWithVault] + ); + + useEffect(() => { + const syncCompletedSubscription = db.eventManager?.subscribe( + EVENTS.syncItemMerged, + onSyncComplete + ); + eSubscribeEvent(eOnLoadNote + editorId, loadNote); + return () => { + syncCompletedSubscription?.unsubscribe(); + eUnSubscribeEvent(eOnLoadNote + editorId, loadNote); + }; + }, [editorId, loadNote, onSyncComplete]); + const saveContent = useCallback( ({ title, @@ -357,10 +438,12 @@ export const useEditor = ( content?: string; type: string; }) => { + if (lock.current) return; if (type === EditorEvents.content) { currentContent.current = { data: content, - type: "tiptap" + type: "tiptap", + noteId: currentNote.current?.id as string }; } const params = { diff --git a/apps/mobile/app/screens/editor/tiptap/utils.ts b/apps/mobile/app/screens/editor/tiptap/utils.ts index efc01bbe1..8bb694fcf 100644 --- a/apps/mobile/app/screens/editor/tiptap/utils.ts +++ b/apps/mobile/app/screens/editor/tiptap/utils.ts @@ -44,6 +44,7 @@ export function editorState() { export const EditorEvents: { [name: string]: string } = { html: "native:html", + updatehtml: "native:updatehtml", title: "native:title", theme: "native:theme", titleplaceholder: "native:titleplaceholder", diff --git a/apps/mobile/app/services/premium.js b/apps/mobile/app/services/premium.js index 517b103db..fc9b56120 100644 --- a/apps/mobile/app/services/premium.js +++ b/apps/mobile/app/services/premium.js @@ -159,9 +159,6 @@ const onUserStatusCheck = async (type) => { desc: "With Notesnook Pro you can add notes to your vault and do so much more! Get it now." }; break; - case CHECK_IDS.databaseSync: - message = null; - break; } if (message) { diff --git a/apps/web/__e2e__/models/app.model.ts b/apps/web/__e2e__/models/app.model.ts index 66f74f3e0..b361e9ed0 100644 --- a/apps/web/__e2e__/models/app.model.ts +++ b/apps/web/__e2e__/models/app.model.ts @@ -30,7 +30,7 @@ import { ToastsModel } from "./toasts.model"; import { TrashViewModel } from "./trash-view.model"; export class AppModel { - private readonly page: Page; + readonly page: Page; readonly toasts: ToastsModel; readonly navigation: NavigationMenuModel; readonly auth: AuthModel; @@ -103,4 +103,13 @@ export class AppModel { (await this.page.locator(getTestId("sync-status-synced")).isVisible()) ); } + + async waitForSync( + state: "completed" | "synced" = "completed", + text?: string + ) { + await this.page + .locator(getTestId(`sync-status-${state}`), { hasText: text }) + .waitFor({ state: "visible" }); + } } diff --git a/apps/web/__e2e__/models/editor.model.ts b/apps/web/__e2e__/models/editor.model.ts index 1c4422a1b..6966b1a3b 100644 --- a/apps/web/__e2e__/models/editor.model.ts +++ b/apps/web/__e2e__/models/editor.model.ts @@ -115,6 +115,7 @@ export class EditorModel { async typeTitle(text: string, delay = 0) { await this.editAndWait(async () => { await this.title.focus(); + await this.title.press("End"); await this.title.type(text, { delay }); }); } @@ -160,8 +161,12 @@ export class EditorModel { async setTags(tags: string[]) { for (const tag of tags) { + await this.tagInput.focus(); await this.tagInput.fill(tag); await this.tagInput.press("Enter"); + await this.tags + .locator(":scope", { hasText: new RegExp(`^${tag}$`) }) + .waitFor(); } } diff --git a/apps/web/__e2e__/models/note-properties.model.ts b/apps/web/__e2e__/models/note-properties.model.ts index dd3f37d61..88975ddcf 100644 --- a/apps/web/__e2e__/models/note-properties.model.ts +++ b/apps/web/__e2e__/models/note-properties.model.ts @@ -323,7 +323,6 @@ class SessionHistoryItemModel { async preview(password?: string) { await this.properties.open(); const isLocked = await this.locked.isVisible(); - await this.locator.click(); if (password && isLocked) { await fillPasswordDialog(this.page, password); diff --git a/apps/web/__e2e__/notes.issues.test.ts b/apps/web/__e2e__/notes.issues.test.ts index 56db26573..77a41d590 100644 --- a/apps/web/__e2e__/notes.issues.test.ts +++ b/apps/web/__e2e__/notes.issues.test.ts @@ -34,7 +34,6 @@ test("#1002 Can't add a tag that's a substring of an existing tag", async ({ await notes.createNote(NOTE); await notes.editor.setTags(tags); - await page.waitForTimeout(200); const noteTags = await notes.editor.getTags(); expect(noteTags).toHaveLength(tags.length); diff --git a/apps/web/__e2e__/sync.test.ts b/apps/web/__e2e__/sync.test.ts new file mode 100644 index 000000000..46990c6a5 --- /dev/null +++ b/apps/web/__e2e__/sync.test.ts @@ -0,0 +1,95 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2022 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 { test, Browser, expect } from "@playwright/test"; +import { AppModel } from "./models/app.model"; +import { USER } from "./utils"; + +async function createDevice(browser: Browser) { + // Create two isolated browser contexts + const context = await browser.newContext(); + const page = await context.newPage(); + + const app = new AppModel(page); + await app.auth.goto(); + await app.auth.login(USER.CURRENT); + await app.waitForSync(); + + return app; +} + +async function actAndSync( + devices: AppModel[], + ...actions: (Promise | undefined)[] +) { + const results = await Promise.all([ + ...actions.filter((a) => !!a), + ...devices.map((d) => d.waitForSync("synced", "now")), + ...devices.map((d) => d.page.waitForTimeout(1000)) + ]); + return results.slice(0, actions.length) as T[]; +} + +const NOTE = { + title: "Real-time sync test note 1" +}; + +test("edits in a note opened on 2 devices should sync in real-time", async ({ + browser +}, info) => { + info.setTimeout(30 * 1000); + const newContent = makeid(24).repeat(2); + + const [deviceA, deviceB] = await Promise.all([ + createDevice(browser), + createDevice(browser) + ]); + const [notesA, notesB] = await Promise.all( + [deviceA, deviceB].map((d) => d.goToNotes()) + ); + const noteB = + (await notesB.findNote(NOTE)) || + (await actAndSync([deviceA, deviceB], notesB.createNote(NOTE)))[0]; + const noteA = await notesA.findNote(NOTE); + await Promise.all([noteA, noteB].map((note) => note?.openNote())); + + const [beforeContentA, beforeContentB] = await Promise.all( + [notesA, notesB].map((notes) => notes?.editor.getContent("text")) + ); + await actAndSync([deviceA, deviceB], notesB.editor.setContent(newContent)); + const [afterContentA, afterContentB] = await Promise.all( + [notesA, notesB].map((notes) => notes?.editor.getContent("text")) + ); + + expect(noteA).toBeDefined(); + expect(noteB).toBeDefined(); + expect(beforeContentA).toBe(beforeContentB); + expect(afterContentA).toBe(`${newContent}${beforeContentA}`); + expect(afterContentB).toBe(`${newContent}${beforeContentB}`); +}); + +function makeid(length: number) { + let result = ""; + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} diff --git a/apps/web/src/app-effects.js b/apps/web/src/app-effects.js index cc640e9ce..87379241b 100644 --- a/apps/web/src/app-effects.js +++ b/apps/web/src/app-effects.js @@ -28,7 +28,7 @@ import { resetReminders } from "./common/reminders"; import { introduceFeatures, showUpgradeReminderDialogs } from "./common"; import { AppEventManager, AppEvents } from "./common/app-events"; import { db } from "./common/db"; -import { CHECK_IDS, EV, EVENTS } from "@notesnook/core/common"; +import { EV, EVENTS } from "@notesnook/core/common"; import { registerKeyMap } from "./common/key-map"; import { isUserPremium } from "./hooks/use-is-user-premium"; import useAnnouncements from "./hooks/use-announcements"; @@ -70,12 +70,11 @@ export default function AppEffects({ setShow }) { if (isUserPremium()) { return { type, result: true }; } else { - if (type !== CHECK_IDS.databaseSync) - showToast( - "error", - "Please upgrade your account to Pro to use this feature.", - [{ text: "Upgrade now", onClick: () => showBuyDialog() }] - ); + showToast( + "error", + "Please upgrade your account to Pro to use this feature.", + [{ text: "Upgrade now", onClick: () => showBuyDialog() }] + ); return { type, result: false }; } } diff --git a/apps/web/src/components/editor/index.tsx b/apps/web/src/components/editor/index.tsx index 1ad15372c..ba0886c1f 100644 --- a/apps/web/src/components/editor/index.tsx +++ b/apps/web/src/components/editor/index.tsx @@ -32,7 +32,6 @@ import Toolbar from "./toolbar"; import { AppEventManager, AppEvents } from "../../common/app-events"; import { FlexScrollContainer } from "../scroll-container"; import { formatDate } from "@notesnook/core/utils/date"; -import { debounceWithId } from "../../utils/debounce"; import Tiptap from "./tiptap"; import Header from "./header"; import { Attachment } from "../icons"; @@ -59,14 +58,6 @@ function onEditorChange(noteId: string, sessionId: string, content: string) { }); } -function onTitleChange(noteId: string, title: string) { - if (!title) return; - - editorstore.get().setTitle(noteId, title); -} - -const debouncedOnTitleChange = debounceWithId(onTitleChange, 100); - export default function EditorManager({ noteId, nonce @@ -83,9 +74,9 @@ export default function EditorManager({ const [timestamp, setTimestamp] = useState(0); const content = useRef(""); - const title = useRef(""); const previewSession = useRef(); const [dropRef, overlayRef] = useDragOverlay(); + const editorInstance = useEditorInstance(); const arePropertiesVisible = useStore((store) => store.arePropertiesVisible); const toggleProperties = useStore((store) => store.toggleProperties); @@ -93,14 +84,43 @@ export default function EditorManager({ const isFocusMode = useAppStore((store) => store.isFocusMode); const isPreviewSession = !!previewSession.current; + useEffect(() => { + const event = db.eventManager.subscribe( + EVENTS.syncItemMerged, + async (item?: Record) => { + if (!item) return; + + const { id, contentId, locked } = editorstore.get().session; + const isContent = item.type === "tiptap" && item.id === contentId; + const isNote = item.type === "note" && item.id === id; + + if (isContent) { + if (locked) { + const result = await db.vault?.decryptContent(item).catch(() => {}); + if (result) item.data = result.data; + else EV.publish(EVENTS.vaultLocked); + } + + editorInstance.current?.updateContent(item.data); + } else if (isNote) { + if (!locked && item.locked) return EV.publish(EVENTS.vaultLocked); + + editorstore.get().updateSession(item); + } + } + ); + return () => { + event.unsubscribe(); + }; + }, [editorInstance]); + const openSession = useCallback(async (noteId: string | number) => { await editorstore.get().openSession(noteId); - const { getSessionContent, session } = editorstore.get(); + const { getSessionContent } = editorstore.get(); const sessionContent = await getSessionContent(); previewSession.current = undefined; - title.current = session.title; content.current = sessionContent?.data; setTimestamp(Date.now()); }, []); @@ -123,7 +143,6 @@ export default function EditorManager({ (async function () { await editorstore.newSession(nonce); - title.current = ""; content.current = ""; setTimestamp(Date.now()); })(); @@ -147,6 +166,7 @@ export default function EditorManager({ flexDirection: "column" }} > + {/* */} {previewSession.current && ( ) { - const { title, nonce, options, children } = props; + const { options, children } = props; const { readonly, focusMode, headless, onRequestFocus } = options || { headless: false, readonly: false, @@ -313,17 +331,7 @@ function EditorChrome(props: PropsWithChildren) { }} /> )} - {title !== undefined ? ( - { - const { sessionId, id } = editorstore.get().session; - debouncedOnTitleChange(sessionId, id, title); - }} - title={title} - /> - ) : null} +
{children} diff --git a/apps/web/src/components/editor/tiptap.tsx b/apps/web/src/components/editor/tiptap.tsx index 7f3733194..ef0b0057b 100644 --- a/apps/web/src/components/editor/tiptap.tsx +++ b/apps/web/src/components/editor/tiptap.tsx @@ -153,13 +153,14 @@ function TipTap(props: TipTapProps) { }); if (onLoad) onLoad(); }, - onUpdate: ({ editor }) => { - if (!editor.isEditable) return; + onUpdate: ({ editor, transaction }) => { + const preventSave = transaction?.getMeta("preventSave") as boolean; const { id, sessionId } = editorstore.get().session; const content = editor.state.doc.content; const text = editor.view.dom.innerText; deferredSave(sessionId, text, () => { + if (!editor.isEditable || preventSave) return; const html = getHTMLFromFragment(content, editor.schema); onChange?.(id, sessionId, html); }); @@ -315,6 +316,21 @@ function toIEditor(editor: Editor): IEditor { focus: () => editor.current?.commands.focus("start"), undo: () => editor.current?.commands.undo(), redo: () => editor.current?.commands.redo(), + updateContent: (content) => { + const { from, to } = editor.state.selection; + editor.current + ?.chain() + .command(({ tr }) => { + tr.setMeta("preventSave", true); + return true; + }) + .setContent(content, true) + .setTextSelection({ + from, + to + }) + .run(); + }, attachFile: (file: Attachment) => { if (file.dataurl) { editor.current?.commands.insertImage({ ...file, src: file.dataurl }); diff --git a/apps/web/src/components/editor/title-box.tsx b/apps/web/src/components/editor/title-box.tsx index 798858030..64ff8cdfd 100644 --- a/apps/web/src/components/editor/title-box.tsx +++ b/apps/web/src/components/editor/title-box.tsx @@ -17,31 +17,27 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef } from "react"; import { Input } from "@theme-ui/components"; +import { useStore, store } from "../../stores/editor-store"; +import { debounceWithId } from "../../utils/debounce"; type TitleBoxProps = { - nonce?: number; readonly: boolean; - title: string; - setTitle: (title: string) => void; }; function TitleBox(props: TitleBoxProps) { - const { readonly, setTitle, title, nonce } = props; - const [currentTitle, setCurrentTitle] = useState(""); + const { readonly } = props; + const inputRef = useRef(null); + const title = useStore((store) => store.session.title); - useEffect( - () => { - setCurrentTitle(title); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [nonce] - ); + useEffect(() => { + if (inputRef.current) inputRef.current.value = title; + }, [title]); return ( { - setCurrentTitle(e.target.value); - setTitle(e.target.value); + const { sessionId, id } = store.get().session; + debouncedOnTitleChange(sessionId, id, e.target.value); }} /> ); } export default React.memo(TitleBox, (prevProps, nextProps) => { - return ( - prevProps.readonly === nextProps.readonly && - prevProps.nonce === nextProps.nonce - ); + return prevProps.readonly === nextProps.readonly; }); + +function onTitleChange(noteId: string, title: string) { + if (!title) return; + store.get().setTitle(noteId, title); +} + +const debouncedOnTitleChange = debounceWithId(onTitleChange, 100); diff --git a/apps/web/src/components/editor/types.ts b/apps/web/src/components/editor/types.ts index 306b6e098..1bfd3d27f 100644 --- a/apps/web/src/components/editor/types.ts +++ b/apps/web/src/components/editor/types.ts @@ -30,6 +30,7 @@ export interface IEditor { focus: () => void; undo: () => void; redo: () => void; + updateContent: (content: string) => void; attachFile: (file: Attachment) => void; loadImage: (hash: string, src: string) => void; sendAttachmentProgress: ( diff --git a/apps/web/src/stores/editor-store.js b/apps/web/src/stores/editor-store.js index 349d8613e..b467f97eb 100644 --- a/apps/web/src/stores/editor-store.js +++ b/apps/web/src/stores/editor-store.js @@ -74,17 +74,25 @@ class EditorStore extends BaseStore { }); }; - refresh = async () => { - const { id } = this.get().session; - await this.openSession(id, true); - }; - refreshTags = () => { this.set((state) => { state.session.tags = state.session.tags.slice(); }); }; + updateSession = async (item) => { + this.set((state) => { + state.session.title = item.title; + state.session.tags = item.tags; + state.session.pinned = item.pinned; + state.session.favorite = item.favorite; + state.session.readonly = item.readonly; + state.session.dateEdited = item.dateEdited; + state.session.dateCreated = item.dateCreated; + state.session.locked = item.locked; + }); + }; + openLockedSession = async (note) => { this.set((state) => { state.session = { diff --git a/packages/core/api/sync/auto-sync.js b/packages/core/api/sync/auto-sync.js index ba484a363..8aeaf38ac 100644 --- a/packages/core/api/sync/auto-sync.js +++ b/packages/core/api/sync/auto-sync.js @@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { checkIsUserPremium, CHECK_IDS, EVENTS } from "../../common"; +import { EVENTS } from "../../common"; import { logger } from "../../logger"; export class AutoSync { @@ -36,8 +36,6 @@ export class AutoSync { async start() { this.logger.info(`Auto sync requested`); - - if (!(await checkIsUserPremium(CHECK_IDS.databaseSync))) return; if (this.isAutoSyncing) return; this.isAutoSyncing = true; @@ -64,9 +62,16 @@ export class AutoSync { if (item && (item.remote || item.localOnly || item.failed)) return; clearTimeout(this.timeout); + // auto sync interval must not be 0 to avoid issues + // during data collection which works based on Date.now(). + // It is required that the dateModified of an item should + // be a few milliseconds less than Date.now(). Setting sync + // interval to 0 causes a conflict where Date.now() & dateModified + // are equal causing the item to not be synced. + const interval = item && item.type === "tiptap" ? 100 : this.interval; this.timeout = setTimeout(() => { this.logger.info(`Sync requested by: ${id}`); this.db.eventManager.publish(EVENTS.databaseSyncRequested, false, false); - }, this.interval); + }, interval); } } diff --git a/packages/core/api/sync/index.js b/packages/core/api/sync/index.js index cd223e70e..2564b3ae7 100644 --- a/packages/core/api/sync/index.js +++ b/packages/core/api/sync/index.js @@ -381,11 +381,13 @@ class Sync { * @param {SyncTransferItem} syncStatus * @private */ - onSyncItem(syncStatus) { + async onSyncItem(syncStatus) { const { item: itemJSON, itemType } = syncStatus; const item = JSON.parse(itemJSON); - return this.merger.mergeItem(itemType, item); + const remoteItem = await this.merger.mergeItem(itemType, item); + if (remoteItem) + this.db.eventManager.publish(EVENTS.syncItemMerged, remoteItem); } /** diff --git a/packages/core/api/sync/merger.js b/packages/core/api/sync/merger.js index ce9bb6646..55bb6024e 100644 --- a/packages/core/api/sync/merger.js +++ b/packages/core/api/sync/merger.js @@ -165,6 +165,7 @@ class Merger { let localItem = await get(remoteItem.id); if (!localItem || remoteItem.dateModified > localItem.dateModified) { await add(remoteItem); + return remoteItem; } } @@ -180,6 +181,7 @@ class Merger { if (!localItem) { await add(remoteItem); + return remoteItem; } else { const isResolved = localItem.dateResolved === remoteItem.dateModified; const isModified = localItem.dateModified > this._lastSynced; @@ -194,6 +196,7 @@ class Merger { if (timeDiff < threshold) { if (remoteItem.dateModified > localItem.dateModified) { await add(remoteItem); + return remoteItem; } return; } @@ -210,6 +213,7 @@ class Merger { await markAsConflicted(localItem, remoteItem); } else if (!isResolved) { await add(remoteItem); + return remoteItem; } } } @@ -227,7 +231,7 @@ class Merger { } if (definition.conflict) { - await this._mergeItemWithConflicts( + return await this._mergeItemWithConflicts( item, definition.get, definition.set, @@ -235,7 +239,7 @@ class Merger { definition.threshold ); } else if (definition.get && definition.set) { - await this._mergeItem(item, definition.get, definition.set); + return await this._mergeItem(item, definition.get, definition.set); } else if (!definition.get && definition.set) { await definition.set(item); } diff --git a/packages/core/api/vault.js b/packages/core/api/vault.js index 420cccf49..9000685c1 100644 --- a/packages/core/api/vault.js +++ b/packages/core/api/vault.js @@ -242,7 +242,12 @@ export default class Vault { }); } - async decryptContent(encryptedContent, password) { + async decryptContent(encryptedContent, password = null) { + if (!password) { + await this._check(); + password = this._password; + } + let decryptedContent = await this._storage.decrypt( { password }, encryptedContent.data diff --git a/packages/core/common.js b/packages/core/common.js index c8a86252d..16d4535d1 100644 --- a/packages/core/common.js +++ b/packages/core/common.js @@ -54,8 +54,7 @@ export const CHECK_IDS = { noteExport: "note:export", vaultAdd: "vault:add", notebookAdd: "notebook:add", - backupEncrypt: "backup:encrypt", - databaseSync: "database:sync" + backupEncrypt: "backup:encrypt" }; export const EVENTS = { @@ -70,6 +69,7 @@ export const EVENTS = { databaseSyncRequested: "db:syncRequested", syncProgress: "sync:progress", syncCompleted: "sync:completed", + syncItemMerged: "sync:itemMerged", databaseUpdated: "db:updated", databaseCollectionInitiated: "db:collectionInitiated", appRefreshRequested: "app:refreshRequested", diff --git a/packages/editor-mobile/src/hooks/useEditorController.ts b/packages/editor-mobile/src/hooks/useEditorController.ts index 486b6fc75..36225250a 100644 --- a/packages/editor-mobile/src/hooks/useEditorController.ts +++ b/packages/editor-mobile/src/hooks/useEditorController.ts @@ -109,6 +109,18 @@ export function useEditorController(update: () => void): EditorController { const value = message.value; global.sessionId = message.sessionId; switch (type) { + case "native:updatehtml": { + htmlContentRef.current = value; + if (!editor) break; + const { from, to } = editor.state.selection; + editor?.commands.setContent(htmlContentRef.current, false); + editor.commands.setTextSelection({ + from, + to + }); + + break; + } case "native:html": htmlContentRef.current = value; update();