feat: realtime & auto sync for all users (#944)

This commit is contained in:
Ammar Ahmed
2022-09-20 18:33:55 +05:00
committed by GitHub
parent 0774326941
commit e5e965415d
24 changed files with 337 additions and 88 deletions

View File

@@ -13,6 +13,8 @@ on:
types: types:
- "ready_for_review" - "ready_for_review"
- "opened" - "opened"
- "synchronize"
- "reopened"
jobs: jobs:
test: test:

View File

@@ -13,6 +13,8 @@ on:
types: types:
- "ready_for_review" - "ready_for_review"
- "opened" - "opened"
- "synchronize"
- "reopened"
jobs: jobs:
test: test:

View File

@@ -213,14 +213,10 @@ export const useAppEvents = () => {
}, []); }, []);
const onSyncComplete = useCallback(async () => { const onSyncComplete = useCallback(async () => {
console.log('Sync complete');
initAfterSync(); initAfterSync();
setLastSynced(await db.lastSynced()); setLastSynced(await db.lastSynced());
eSendEvent(eCloseProgressDialog, "sync_progress"); eSendEvent(eCloseProgressDialog, "sync_progress");
let id = useEditorStore.getState().currentEditingNote;
let note = id && db.notes.note(id).data;
if (note) {
//await updateNoteInEditor();
}
}, [setLastSynced]); }, [setLastSynced]);
const onUrlRecieved = useCallback( const onUrlRecieved = useCallback(

View File

@@ -81,6 +81,7 @@ export type Note = {
export type Content = { export type Content = {
data?: string; data?: string;
type: string; type: string;
noteId: string;
}; };
export type SavePayload = { export type SavePayload = {

View File

@@ -25,7 +25,8 @@ import { DDS } from "../../../services/device-detection";
import { import {
eSendEvent, eSendEvent,
eSubscribeEvent, eSubscribeEvent,
eUnSubscribeEvent eUnSubscribeEvent,
openVault
} from "../../../services/event-manager"; } from "../../../services/event-manager";
import Navigation from "../../../services/navigation"; import Navigation from "../../../services/navigation";
import { TipManager } from "../../../services/tip-manager"; 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 { useNoteStore } from "../../../stores/use-notes-store";
import { useTagStore } from "../../../stores/use-tag-store"; import { useTagStore } from "../../../stores/use-tag-store";
import { ThemeStore, useThemeStore } from "../../../stores/use-theme-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 { tabBarRef } from "../../../utils/global-refs";
import { timeConverter } from "../../../utils/time"; import { timeConverter } from "../../../utils/time";
import { NoteType } from "../../../utils/types"; import { NoteType } from "../../../utils/types";
@@ -49,6 +50,7 @@ import {
makeSessionId, makeSessionId,
post post
} from "./utils"; } from "./utils";
import { EVENTS } from "@notesnook/core/common";
export const useEditor = ( export const useEditor = (
editorId = "", editorId = "",
@@ -71,6 +73,8 @@ export const useEditor = (
const insets = useGlobalSafeAreaInsets(); const insets = useGlobalSafeAreaInsets();
const isDefaultEditor = editorId === ""; const isDefaultEditor = editorId === "";
const saveCount = useRef(0); const saveCount = useRef(0);
const lastSuccessfulSaveTime = useRef<number>(0);
const lock = useRef(false);
const postMessage = useCallback( const postMessage = useCallback(
async <T>(type: string, data: T) => async <T>(type: string, data: T) =>
@@ -154,6 +158,7 @@ export const useEditor = (
saveCount.current = 0; saveCount.current = 0;
useEditorStore.getState().setReadonly(false); useEditorStore.getState().setReadonly(false);
postMessage(EditorEvents.title, ""); postMessage(EditorEvents.title, "");
lastSuccessfulSaveTime.current = 0;
await commands.clearContent(); await commands.clearContent();
await commands.clearTags(); await commands.clearTags();
if (resetState) { if (resetState) {
@@ -245,6 +250,8 @@ export const useEditor = (
note = db.notes?.note(id)?.data as Note; note = db.notes?.note(id)?.data as Note;
await commands.setStatus(timeConverter(note.dateEdited), "Saved"); await commands.setStatus(timeConverter(note.dateEdited), "Saved");
lastSuccessfulSaveTime.current = note.dateEdited;
if ( if (
saveCount.current < 2 || saveCount.current < 2 ||
currentNote.current?.title !== note.title || currentNote.current?.title !== note.title ||
@@ -259,8 +266,8 @@ export const useEditor = (
); );
} }
} }
saveCount.current++;
saveCount.current++;
return id; return id;
} catch (e) { } catch (e) {
console.log("Error saving note: ", e); console.log("Error saving note: ", e);
@@ -274,7 +281,8 @@ export const useEditor = (
if (note.locked || note.content) { if (note.locked || note.content) {
currentContent.current = { currentContent.current = {
data: note.content?.data, data: note.content?.data,
type: note.content?.type || "tiny" type: note.content?.type || "tiptap",
noteId: currentNote.current?.id as string
}; };
} else { } else {
currentContent.current = await db.content?.raw(note.contentId); currentContent.current = await db.content?.raw(note.contentId);
@@ -299,6 +307,7 @@ export const useEditor = (
sessionHistoryId.current = Date.now(); sessionHistoryId.current = Date.now();
await commands.setSessionId(nextSessionId); await commands.setSessionId(nextSessionId);
await commands.focus(); await commands.focus();
lastSuccessfulSaveTime.current = 0;
useEditorStore.getState().setReadonly(false); useEditorStore.getState().setReadonly(false);
} else { } else {
if (!item.forced && currentNote.current?.id === item.id) return; if (!item.forced && currentNote.current?.id === item.id) return;
@@ -306,6 +315,7 @@ export const useEditor = (
overlay(true, item); overlay(true, item);
currentNote.current && (await reset(false)); currentNote.current && (await reset(false));
await loadContent(item as NoteType); await loadContent(item as NoteType);
lastSuccessfulSaveTime.current = item.dateEdited;
const nextSessionId = makeSessionId(item as NoteType); const nextSessionId = makeSessionId(item as NoteType);
sessionHistoryId.current = Date.now(); sessionHistoryId.current = Date.now();
setSessionId(nextSessionId); setSessionId(nextSessionId);
@@ -347,6 +357,77 @@ export const useEditor = (
}, 300); }, 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( const saveContent = useCallback(
({ ({
title, title,
@@ -357,10 +438,12 @@ export const useEditor = (
content?: string; content?: string;
type: string; type: string;
}) => { }) => {
if (lock.current) return;
if (type === EditorEvents.content) { if (type === EditorEvents.content) {
currentContent.current = { currentContent.current = {
data: content, data: content,
type: "tiptap" type: "tiptap",
noteId: currentNote.current?.id as string
}; };
} }
const params = { const params = {

View File

@@ -44,6 +44,7 @@ export function editorState() {
export const EditorEvents: { [name: string]: string } = { export const EditorEvents: { [name: string]: string } = {
html: "native:html", html: "native:html",
updatehtml: "native:updatehtml",
title: "native:title", title: "native:title",
theme: "native:theme", theme: "native:theme",
titleplaceholder: "native:titleplaceholder", titleplaceholder: "native:titleplaceholder",

View File

@@ -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." desc: "With Notesnook Pro you can add notes to your vault and do so much more! Get it now."
}; };
break; break;
case CHECK_IDS.databaseSync:
message = null;
break;
} }
if (message) { if (message) {

View File

@@ -30,7 +30,7 @@ import { ToastsModel } from "./toasts.model";
import { TrashViewModel } from "./trash-view.model"; import { TrashViewModel } from "./trash-view.model";
export class AppModel { export class AppModel {
private readonly page: Page; readonly page: Page;
readonly toasts: ToastsModel; readonly toasts: ToastsModel;
readonly navigation: NavigationMenuModel; readonly navigation: NavigationMenuModel;
readonly auth: AuthModel; readonly auth: AuthModel;
@@ -103,4 +103,13 @@ export class AppModel {
(await this.page.locator(getTestId("sync-status-synced")).isVisible()) (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" });
}
} }

View File

@@ -115,6 +115,7 @@ export class EditorModel {
async typeTitle(text: string, delay = 0) { async typeTitle(text: string, delay = 0) {
await this.editAndWait(async () => { await this.editAndWait(async () => {
await this.title.focus(); await this.title.focus();
await this.title.press("End");
await this.title.type(text, { delay }); await this.title.type(text, { delay });
}); });
} }
@@ -160,8 +161,12 @@ export class EditorModel {
async setTags(tags: string[]) { async setTags(tags: string[]) {
for (const tag of tags) { for (const tag of tags) {
await this.tagInput.focus();
await this.tagInput.fill(tag); await this.tagInput.fill(tag);
await this.tagInput.press("Enter"); await this.tagInput.press("Enter");
await this.tags
.locator(":scope", { hasText: new RegExp(`^${tag}$`) })
.waitFor();
} }
} }

View File

@@ -323,7 +323,6 @@ class SessionHistoryItemModel {
async preview(password?: string) { async preview(password?: string) {
await this.properties.open(); await this.properties.open();
const isLocked = await this.locked.isVisible(); const isLocked = await this.locked.isVisible();
await this.locator.click(); await this.locator.click();
if (password && isLocked) { if (password && isLocked) {
await fillPasswordDialog(this.page, password); await fillPasswordDialog(this.page, password);

View File

@@ -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.createNote(NOTE);
await notes.editor.setTags(tags); await notes.editor.setTags(tags);
await page.waitForTimeout(200);
const noteTags = await notes.editor.getTags(); const noteTags = await notes.editor.getTags();
expect(noteTags).toHaveLength(tags.length); expect(noteTags).toHaveLength(tags.length);

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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<T>(
devices: AppModel[],
...actions: (Promise<T> | 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;
}

View File

@@ -28,7 +28,7 @@ import { resetReminders } from "./common/reminders";
import { introduceFeatures, showUpgradeReminderDialogs } from "./common"; import { introduceFeatures, showUpgradeReminderDialogs } from "./common";
import { AppEventManager, AppEvents } from "./common/app-events"; import { AppEventManager, AppEvents } from "./common/app-events";
import { db } from "./common/db"; 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 { registerKeyMap } from "./common/key-map";
import { isUserPremium } from "./hooks/use-is-user-premium"; import { isUserPremium } from "./hooks/use-is-user-premium";
import useAnnouncements from "./hooks/use-announcements"; import useAnnouncements from "./hooks/use-announcements";
@@ -70,12 +70,11 @@ export default function AppEffects({ setShow }) {
if (isUserPremium()) { if (isUserPremium()) {
return { type, result: true }; return { type, result: true };
} else { } else {
if (type !== CHECK_IDS.databaseSync) showToast(
showToast( "error",
"error", "Please upgrade your account to Pro to use this feature.",
"Please upgrade your account to Pro to use this feature.", [{ text: "Upgrade now", onClick: () => showBuyDialog() }]
[{ text: "Upgrade now", onClick: () => showBuyDialog() }] );
);
return { type, result: false }; return { type, result: false };
} }
} }

View File

@@ -32,7 +32,6 @@ import Toolbar from "./toolbar";
import { AppEventManager, AppEvents } from "../../common/app-events"; import { AppEventManager, AppEvents } from "../../common/app-events";
import { FlexScrollContainer } from "../scroll-container"; import { FlexScrollContainer } from "../scroll-container";
import { formatDate } from "@notesnook/core/utils/date"; import { formatDate } from "@notesnook/core/utils/date";
import { debounceWithId } from "../../utils/debounce";
import Tiptap from "./tiptap"; import Tiptap from "./tiptap";
import Header from "./header"; import Header from "./header";
import { Attachment } from "../icons"; 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({ export default function EditorManager({
noteId, noteId,
nonce nonce
@@ -83,9 +74,9 @@ export default function EditorManager({
const [timestamp, setTimestamp] = useState<number>(0); const [timestamp, setTimestamp] = useState<number>(0);
const content = useRef<string>(""); const content = useRef<string>("");
const title = useRef<string>("");
const previewSession = useRef<PreviewSession>(); const previewSession = useRef<PreviewSession>();
const [dropRef, overlayRef] = useDragOverlay(); const [dropRef, overlayRef] = useDragOverlay();
const editorInstance = useEditorInstance();
const arePropertiesVisible = useStore((store) => store.arePropertiesVisible); const arePropertiesVisible = useStore((store) => store.arePropertiesVisible);
const toggleProperties = useStore((store) => store.toggleProperties); const toggleProperties = useStore((store) => store.toggleProperties);
@@ -93,14 +84,43 @@ export default function EditorManager({
const isFocusMode = useAppStore((store) => store.isFocusMode); const isFocusMode = useAppStore((store) => store.isFocusMode);
const isPreviewSession = !!previewSession.current; const isPreviewSession = !!previewSession.current;
useEffect(() => {
const event = db.eventManager.subscribe(
EVENTS.syncItemMerged,
async (item?: Record<string, string>) => {
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) => { const openSession = useCallback(async (noteId: string | number) => {
await editorstore.get().openSession(noteId); await editorstore.get().openSession(noteId);
const { getSessionContent, session } = editorstore.get(); const { getSessionContent } = editorstore.get();
const sessionContent = await getSessionContent(); const sessionContent = await getSessionContent();
previewSession.current = undefined; previewSession.current = undefined;
title.current = session.title;
content.current = sessionContent?.data; content.current = sessionContent?.data;
setTimestamp(Date.now()); setTimestamp(Date.now());
}, []); }, []);
@@ -123,7 +143,6 @@ export default function EditorManager({
(async function () { (async function () {
await editorstore.newSession(nonce); await editorstore.newSession(nonce);
title.current = "";
content.current = ""; content.current = "";
setTimestamp(Date.now()); setTimestamp(Date.now());
})(); })();
@@ -147,6 +166,7 @@ export default function EditorManager({
flexDirection: "column" flexDirection: "column"
}} }}
> >
{/* <UpdatesPendingNotice /> */}
{previewSession.current && ( {previewSession.current && (
<PreviewModeNotice <PreviewModeNotice
{...previewSession.current} {...previewSession.current}
@@ -155,7 +175,6 @@ export default function EditorManager({
)} )}
<Editor <Editor
nonce={timestamp} nonce={timestamp}
title={title.current}
content={content.current} content={content.current}
options={{ options={{
readonly: isReadonly || isPreviewSession, readonly: isReadonly || isPreviewSession,
@@ -188,7 +207,6 @@ type EditorOptions = {
}; };
type EditorProps = { type EditorProps = {
content: string; content: string;
title?: string;
nonce?: number; nonce?: number;
options?: EditorOptions; options?: EditorOptions;
}; };
@@ -268,7 +286,7 @@ export function Editor(props: EditorProps) {
} }
function EditorChrome(props: PropsWithChildren<EditorProps>) { function EditorChrome(props: PropsWithChildren<EditorProps>) {
const { title, nonce, options, children } = props; const { options, children } = props;
const { readonly, focusMode, headless, onRequestFocus } = options || { const { readonly, focusMode, headless, onRequestFocus } = options || {
headless: false, headless: false,
readonly: false, readonly: false,
@@ -313,17 +331,7 @@ function EditorChrome(props: PropsWithChildren<EditorProps>) {
}} }}
/> />
)} )}
{title !== undefined ? ( <Titlebox readonly={readonly || false} />
<Titlebox
nonce={nonce}
readonly={readonly || false}
setTitle={(title) => {
const { sessionId, id } = editorstore.get().session;
debouncedOnTitleChange(sessionId, id, title);
}}
title={title}
/>
) : null}
<Header readonly={readonly} /> <Header readonly={readonly} />
{children} {children}
</Flex> </Flex>

View File

@@ -153,13 +153,14 @@ function TipTap(props: TipTapProps) {
}); });
if (onLoad) onLoad(); if (onLoad) onLoad();
}, },
onUpdate: ({ editor }) => { onUpdate: ({ editor, transaction }) => {
if (!editor.isEditable) return; const preventSave = transaction?.getMeta("preventSave") as boolean;
const { id, sessionId } = editorstore.get().session; const { id, sessionId } = editorstore.get().session;
const content = editor.state.doc.content; const content = editor.state.doc.content;
const text = editor.view.dom.innerText; const text = editor.view.dom.innerText;
deferredSave(sessionId, text, () => { deferredSave(sessionId, text, () => {
if (!editor.isEditable || preventSave) return;
const html = getHTMLFromFragment(content, editor.schema); const html = getHTMLFromFragment(content, editor.schema);
onChange?.(id, sessionId, html); onChange?.(id, sessionId, html);
}); });
@@ -315,6 +316,21 @@ function toIEditor(editor: Editor): IEditor {
focus: () => editor.current?.commands.focus("start"), focus: () => editor.current?.commands.focus("start"),
undo: () => editor.current?.commands.undo(), undo: () => editor.current?.commands.undo(),
redo: () => editor.current?.commands.redo(), 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) => { attachFile: (file: Attachment) => {
if (file.dataurl) { if (file.dataurl) {
editor.current?.commands.insertImage({ ...file, src: file.dataurl }); editor.current?.commands.insertImage({ ...file, src: file.dataurl });

View File

@@ -17,31 +17,27 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef } from "react";
import { Input } from "@theme-ui/components"; import { Input } from "@theme-ui/components";
import { useStore, store } from "../../stores/editor-store";
import { debounceWithId } from "../../utils/debounce";
type TitleBoxProps = { type TitleBoxProps = {
nonce?: number;
readonly: boolean; readonly: boolean;
title: string;
setTitle: (title: string) => void;
}; };
function TitleBox(props: TitleBoxProps) { function TitleBox(props: TitleBoxProps) {
const { readonly, setTitle, title, nonce } = props; const { readonly } = props;
const [currentTitle, setCurrentTitle] = useState<string>(""); const inputRef = useRef<HTMLInputElement>(null);
const title = useStore((store) => store.session.title);
useEffect( useEffect(() => {
() => { if (inputRef.current) inputRef.current.value = title;
setCurrentTitle(title); }, [title]);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[nonce]
);
return ( return (
<Input <Input
value={currentTitle} ref={inputRef}
variant="clean" variant="clean"
data-test-id="editor-title" data-test-id="editor-title"
className="editorTitle" className="editorTitle"
@@ -55,16 +51,20 @@ function TitleBox(props: TitleBoxProps) {
width: "100%" width: "100%"
}} }}
onChange={(e) => { onChange={(e) => {
setCurrentTitle(e.target.value); const { sessionId, id } = store.get().session;
setTitle(e.target.value); debouncedOnTitleChange(sessionId, id, e.target.value);
}} }}
/> />
); );
} }
export default React.memo(TitleBox, (prevProps, nextProps) => { export default React.memo(TitleBox, (prevProps, nextProps) => {
return ( return prevProps.readonly === nextProps.readonly;
prevProps.readonly === nextProps.readonly &&
prevProps.nonce === nextProps.nonce
);
}); });
function onTitleChange(noteId: string, title: string) {
if (!title) return;
store.get().setTitle(noteId, title);
}
const debouncedOnTitleChange = debounceWithId(onTitleChange, 100);

View File

@@ -30,6 +30,7 @@ export interface IEditor {
focus: () => void; focus: () => void;
undo: () => void; undo: () => void;
redo: () => void; redo: () => void;
updateContent: (content: string) => void;
attachFile: (file: Attachment) => void; attachFile: (file: Attachment) => void;
loadImage: (hash: string, src: string) => void; loadImage: (hash: string, src: string) => void;
sendAttachmentProgress: ( sendAttachmentProgress: (

View File

@@ -74,17 +74,25 @@ class EditorStore extends BaseStore {
}); });
}; };
refresh = async () => {
const { id } = this.get().session;
await this.openSession(id, true);
};
refreshTags = () => { refreshTags = () => {
this.set((state) => { this.set((state) => {
state.session.tags = state.session.tags.slice(); 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) => { openLockedSession = async (note) => {
this.set((state) => { this.set((state) => {
state.session = { state.session = {

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { checkIsUserPremium, CHECK_IDS, EVENTS } from "../../common"; import { EVENTS } from "../../common";
import { logger } from "../../logger"; import { logger } from "../../logger";
export class AutoSync { export class AutoSync {
@@ -36,8 +36,6 @@ export class AutoSync {
async start() { async start() {
this.logger.info(`Auto sync requested`); this.logger.info(`Auto sync requested`);
if (!(await checkIsUserPremium(CHECK_IDS.databaseSync))) return;
if (this.isAutoSyncing) return; if (this.isAutoSyncing) return;
this.isAutoSyncing = true; this.isAutoSyncing = true;
@@ -64,9 +62,16 @@ export class AutoSync {
if (item && (item.remote || item.localOnly || item.failed)) return; if (item && (item.remote || item.localOnly || item.failed)) return;
clearTimeout(this.timeout); 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.timeout = setTimeout(() => {
this.logger.info(`Sync requested by: ${id}`); this.logger.info(`Sync requested by: ${id}`);
this.db.eventManager.publish(EVENTS.databaseSyncRequested, false, false); this.db.eventManager.publish(EVENTS.databaseSyncRequested, false, false);
}, this.interval); }, interval);
} }
} }

View File

@@ -381,11 +381,13 @@ class Sync {
* @param {SyncTransferItem} syncStatus * @param {SyncTransferItem} syncStatus
* @private * @private
*/ */
onSyncItem(syncStatus) { async onSyncItem(syncStatus) {
const { item: itemJSON, itemType } = syncStatus; const { item: itemJSON, itemType } = syncStatus;
const item = JSON.parse(itemJSON); 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);
} }
/** /**

View File

@@ -165,6 +165,7 @@ class Merger {
let localItem = await get(remoteItem.id); let localItem = await get(remoteItem.id);
if (!localItem || remoteItem.dateModified > localItem.dateModified) { if (!localItem || remoteItem.dateModified > localItem.dateModified) {
await add(remoteItem); await add(remoteItem);
return remoteItem;
} }
} }
@@ -180,6 +181,7 @@ class Merger {
if (!localItem) { if (!localItem) {
await add(remoteItem); await add(remoteItem);
return remoteItem;
} else { } else {
const isResolved = localItem.dateResolved === remoteItem.dateModified; const isResolved = localItem.dateResolved === remoteItem.dateModified;
const isModified = localItem.dateModified > this._lastSynced; const isModified = localItem.dateModified > this._lastSynced;
@@ -194,6 +196,7 @@ class Merger {
if (timeDiff < threshold) { if (timeDiff < threshold) {
if (remoteItem.dateModified > localItem.dateModified) { if (remoteItem.dateModified > localItem.dateModified) {
await add(remoteItem); await add(remoteItem);
return remoteItem;
} }
return; return;
} }
@@ -210,6 +213,7 @@ class Merger {
await markAsConflicted(localItem, remoteItem); await markAsConflicted(localItem, remoteItem);
} else if (!isResolved) { } else if (!isResolved) {
await add(remoteItem); await add(remoteItem);
return remoteItem;
} }
} }
} }
@@ -227,7 +231,7 @@ class Merger {
} }
if (definition.conflict) { if (definition.conflict) {
await this._mergeItemWithConflicts( return await this._mergeItemWithConflicts(
item, item,
definition.get, definition.get,
definition.set, definition.set,
@@ -235,7 +239,7 @@ class Merger {
definition.threshold definition.threshold
); );
} else if (definition.get && definition.set) { } 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) { } else if (!definition.get && definition.set) {
await definition.set(item); await definition.set(item);
} }

View File

@@ -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( let decryptedContent = await this._storage.decrypt(
{ password }, { password },
encryptedContent.data encryptedContent.data

View File

@@ -54,8 +54,7 @@ export const CHECK_IDS = {
noteExport: "note:export", noteExport: "note:export",
vaultAdd: "vault:add", vaultAdd: "vault:add",
notebookAdd: "notebook:add", notebookAdd: "notebook:add",
backupEncrypt: "backup:encrypt", backupEncrypt: "backup:encrypt"
databaseSync: "database:sync"
}; };
export const EVENTS = { export const EVENTS = {
@@ -70,6 +69,7 @@ export const EVENTS = {
databaseSyncRequested: "db:syncRequested", databaseSyncRequested: "db:syncRequested",
syncProgress: "sync:progress", syncProgress: "sync:progress",
syncCompleted: "sync:completed", syncCompleted: "sync:completed",
syncItemMerged: "sync:itemMerged",
databaseUpdated: "db:updated", databaseUpdated: "db:updated",
databaseCollectionInitiated: "db:collectionInitiated", databaseCollectionInitiated: "db:collectionInitiated",
appRefreshRequested: "app:refreshRequested", appRefreshRequested: "app:refreshRequested",

View File

@@ -109,6 +109,18 @@ export function useEditorController(update: () => void): EditorController {
const value = message.value; const value = message.value;
global.sessionId = message.sessionId; global.sessionId = message.sessionId;
switch (type) { 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": case "native:html":
htmlContentRef.current = value; htmlContentRef.current = value;
update(); update();