diff --git a/live/src/core/hocuspocus-server.ts b/live/src/core/hocuspocus-server.ts index b34a8fbb221..51896c23bce 100644 --- a/live/src/core/hocuspocus-server.ts +++ b/live/src/core/hocuspocus-server.ts @@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid"; import { handleAuthentication } from "@/core/lib/authentication.js"; // extensions import { getExtensions } from "@/core/extensions/index.js"; +import { + DocumentCollaborativeEvents, + TDocumentEventsServer, +} from "@plane/editor/lib"; // editor types import { TUserDetails } from "@plane/editor"; // types @@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => { throw Error("Authentication unsuccessful!"); } }, + async onStateless({ payload, document }) { + // broadcast the client event (derived from the server event) to all the clients so that they can update their state + const response = + DocumentCollaborativeEvents[payload as TDocumentEventsServer].client; + if (response) { + document.broadcastStateless(response); + } + }, extensions, debounce: 10000, }); diff --git a/packages/editor/src/core/constants/document-collaborative-events.ts b/packages/editor/src/core/constants/document-collaborative-events.ts new file mode 100644 index 00000000000..5e79efc7a71 --- /dev/null +++ b/packages/editor/src/core/constants/document-collaborative-events.ts @@ -0,0 +1,6 @@ +export const DocumentCollaborativeEvents = { + lock: { client: "locked", server: "lock" }, + unlock: { client: "unlocked", server: "unlock" }, + archive: { client: "archived", server: "archive" }, + unarchive: { client: "unarchived", server: "unarchive" }, +} as const; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index f1a85ab1ba9..b5b27e27185 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -118,7 +118,6 @@ export const CustomImageBlock: React.FC = (props) => { height: `${Math.round(initialHeight)}px` satisfies Pixel, aspectRatio: aspectRatioCalculated, }; - setSize(initialComputedSize); updateAttributesSafely( initialComputedSize, diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index 78caa87b301..58b60b306d6 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -29,12 +29,9 @@ export const CustomImageNode = (props: CustomImageNodeProps) => { useEffect(() => { const closestEditorContainer = imageComponentRef.current?.closest(".editor-container"); - if (!closestEditorContainer) { - console.error("Editor container not found"); - return; + if (closestEditorContainer) { + setEditorContainer(closestEditorContainer as HTMLDivElement); } - - setEditorContainer(closestEditorContainer as HTMLDivElement); }, []); // the image is already uploaded if the image-component node has src attribute @@ -55,7 +52,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => { setResolvedSrc(url as string); }; getImageSource(); - }, [imageFromFileSystem, node.attrs.src]); + }, [imgNodeSrc]); return ( diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx index d6148b69aef..93b0ce2ea82 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -41,7 +41,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { if (nextItem < 0) { nextSection = currentSection - 1; if (nextSection < 0) nextSection = sections.length - 1; - nextItem = sections[nextSection].items.length - 1; + nextItem = sections[nextSection]?.items.length - 1; } } if (e.key === "ArrowDown") { diff --git a/packages/editor/src/core/helpers/get-document-server-event.ts b/packages/editor/src/core/helpers/get-document-server-event.ts new file mode 100644 index 00000000000..1ba7646b291 --- /dev/null +++ b/packages/editor/src/core/helpers/get-document-server-event.ts @@ -0,0 +1,11 @@ +import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events"; +import { TDocumentEventKey, TDocumentEventsClient, TDocumentEventsServer } from "@/types/document-collaborative-events"; + +export const getServerEventName = (clientEvent: TDocumentEventsClient): TDocumentEventsServer | undefined => { + for (const key in DocumentCollaborativeEvents) { + if (DocumentCollaborativeEvents[key as TDocumentEventKey].client === clientEvent) { + return DocumentCollaborativeEvents[key as TDocumentEventKey].server; + } + } + return undefined; +}; diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index f32d7f4cca2..b3c7d6cfc2e 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import Collaboration from "@tiptap/extension-collaboration"; import { IndexeddbPersistence } from "y-indexeddb"; @@ -58,21 +58,19 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { [id, realtimeConfig, serverHandler, user] ); - // destroy and disconnect connection on unmount + const localProvider = useMemo( + () => (id ? new IndexeddbPersistence(id, provider.document) : undefined), + [id, provider] + ); + + // destroy and disconnect all providers connection on unmount useEffect( () => () => { - provider.destroy(); - provider.disconnect(); + provider?.destroy(); + localProvider?.destroy(); }, - [provider] + [provider, localProvider] ); - // indexed db integration for offline support - useLayoutEffect(() => { - const localProvider = new IndexeddbPersistence(id, provider.document); - return () => { - localProvider?.destroy(); - }; - }, [provider, id]); const editor = useEditor({ disabledExtensions, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 5cddc79e566..15fbd19d5c8 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -16,13 +16,14 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper // props import { CoreEditorProps } from "@/props"; // types -import { +import type { + TDocumentEventsServer, EditorRefApi, IMentionHighlight, IMentionSuggestion, TEditorCommands, - TExtensions, TFileHandler, + TExtensions, } from "@/types"; export interface CustomEditorProps { @@ -67,9 +68,9 @@ export const useEditor = (props: CustomEditorProps) => { onChange, onTransaction, placeholder, - provider, tabIndex, value, + provider, autofocus = false, } = props; // states @@ -257,7 +258,7 @@ export const useEditor = (props: CustomEditorProps) => { if (empty) return null; const nodesArray: string[] = []; - state.doc.nodesBetween(from, to, (node, pos, parent) => { + state.doc.nodesBetween(from, to, (node, _pos, parent) => { if (parent === state.doc && editorRef.current) { const serializer = DOMSerializer.fromSchema(editorRef.current?.schema); const dom = serializer.serializeNode(node); @@ -298,6 +299,8 @@ export const useEditor = (props: CustomEditorProps) => { if (!document) return; Y.applyUpdate(document, value); }, + emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message), + listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) }, }), [editorRef, savedSelection] ); diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts index 62e08e5d32b..01ca19b8148 100644 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import Collaboration from "@tiptap/extension-collaboration"; import { IndexeddbPersistence } from "y-indexeddb"; @@ -31,8 +31,8 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit const provider = useMemo( () => new HocuspocusProvider({ - url: realtimeConfig.url, name: id, + url: realtimeConfig.url, token: JSON.stringify(user), parameters: realtimeConfig.queryParams, onAuthenticationFailed: () => { @@ -48,23 +48,23 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit }, onSynced: () => setHasServerSynced(true), }), - [id, realtimeConfig, user] + [id, realtimeConfig, serverHandler, user] + ); + + // indexed db integration for offline support + const localProvider = useMemo( + () => (id ? new IndexeddbPersistence(id, provider.document) : undefined), + [id, provider] ); + // destroy and disconnect connection on unmount useEffect( () => () => { provider.destroy(); - provider.disconnect(); + localProvider?.destroy(); }, - [provider] + [provider, localProvider] ); - // indexed db integration for offline support - useLayoutEffect(() => { - const localProvider = new IndexeddbPersistence(id, provider.document); - return () => { - localProvider?.destroy(); - }; - }, [provider, id]); const editor = useReadOnlyEditor({ disabledExtensions, diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index f75fa72685d..5fb49be5f72 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -11,7 +11,13 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TExtensions, TFileHandler } from "@/types"; +import type { + EditorReadOnlyRefApi, + IMentionHighlight, + TExtensions, + TDocumentEventsServer, + TFileHandler, +} from "@/types"; interface CustomReadOnlyEditorProps { disabledExtensions: TExtensions[]; @@ -120,6 +126,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { editorRef.current?.off("update"); }; }, + emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message), + listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) }, getHeadings: () => editorRef?.current?.storage.headingList.headings, })); diff --git a/packages/editor/src/core/types/document-collaborative-events.ts b/packages/editor/src/core/types/document-collaborative-events.ts new file mode 100644 index 00000000000..99936a5ad73 --- /dev/null +++ b/packages/editor/src/core/types/document-collaborative-events.ts @@ -0,0 +1,10 @@ +import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events"; + +export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents; +export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"]; +export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"]; + +export type TDocumentEventEmitter = { + on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void; + off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void; +}; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 4b134a854c0..cfb33f59977 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -8,6 +8,8 @@ import { IMentionSuggestion, TAIHandler, TDisplayConfig, + TDocumentEventEmitter, + TDocumentEventsServer, TEmbedConfig, TExtensions, TFileHandler, @@ -83,6 +85,8 @@ export type EditorReadOnlyRefApi = { }; onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void; getHeadings: () => IMarking[]; + emitRealTimeUpdate: (action: TDocumentEventsServer) => void; + listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined; }; export interface EditorRefApi extends EditorReadOnlyRefApi { diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index 8da9ed276e5..527264d396e 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -8,3 +8,4 @@ export * from "./image"; export * from "./mention-suggestion"; export * from "./slash-commands-suggestion"; export * from "@/plane-editor/types"; +export * from "./document-collaborative-events"; diff --git a/packages/editor/src/lib.ts b/packages/editor/src/lib.ts index e14c40127fb..e32fa078508 100644 --- a/packages/editor/src/lib.ts +++ b/packages/editor/src/lib.ts @@ -1 +1,4 @@ export * from "@/extensions/core-without-props"; +export * from "@/constants/document-collaborative-events"; +export * from "@/helpers/get-document-server-event"; +export * from "@/types/document-collaborative-events"; diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 1402dedb004..f8a2b1c849b 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -1,3 +1,4 @@ +export type { ISvgIcons } from "./type"; export * from "./cycle"; export * from "./module"; export * from "./state"; diff --git a/packages/ui/src/icons/type.d.ts b/packages/ui/src/icons/type.ts similarity index 100% rename from packages/ui/src/icons/type.d.ts rename to packages/ui/src/icons/type.ts diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index e9debb2bcf4..4d3f395ea00 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -31,9 +31,9 @@ const PageDetailsPage = observer(() => { ? () => getPageById(workspaceSlug?.toString(), projectId?.toString(), pageId.toString()) : null, { - revalidateIfStale: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, + revalidateIfStale: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, } ); diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index ad27f9d7ded..6f88445ede9 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -123,7 +123,7 @@ export const PageEditorBody: React.FC = observer((props) => { onConnect: handleServerConnect, onServerError: handleServerError, }), - [] + [handleServerConnect, handleServerError] ); const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => { diff --git a/web/core/components/pages/editor/header/options-dropdown.tsx b/web/core/components/pages/editor/header/options-dropdown.tsx index c7cf53a5f50..ff0987a9dc2 100644 --- a/web/core/components/pages/editor/header/options-dropdown.tsx +++ b/web/core/components/pages/editor/header/options-dropdown.tsx @@ -3,16 +3,27 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useParams, useRouter } from "next/navigation"; -import { ArchiveRestoreIcon, ArrowUpToLine, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react"; +import { + ArchiveRestoreIcon, + ArrowUpToLine, + Clipboard, + Copy, + History, + Link, + Lock, + LockOpen, + LucideIcon, +} from "lucide-react"; // document editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // ui -import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; // components import { ExportPageModal } from "@/components/pages"; // helpers import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper"; // hooks +import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions"; import { usePageFilters } from "@/hooks/use-page-filters"; import { useQueryParams } from "@/hooks/use-query-params"; // store @@ -34,13 +45,9 @@ export const PageOptionsDropdown: React.FC = observer((props) => { archived_at, is_locked, id, - archive, - lock, - unlock, canCurrentUserArchivePage, canCurrentUserDuplicatePage, canCurrentUserLockPage, - restore, } = page; // states const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -50,49 +57,15 @@ export const PageOptionsDropdown: React.FC = observer((props) => { const { isFullWidth, handleFullWidth } = usePageFilters(); // update query params const { updateQueryParams } = useQueryParams(); - - const handleArchivePage = async () => - await archive().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be archived. Please try again later.", - }) - ); - - const handleRestorePage = async () => - await restore().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be restored. Please try again later.", - }) - ); - - const handleLockPage = async () => - await lock().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be locked. Please try again later.", - }) - ); - - const handleUnlockPage = async () => - await unlock().catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Page could not be unlocked. Please try again later.", - }) - ); + // collaborative actions + const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page); // menu items list const MENU_ITEMS: { key: string; action: () => void; label: string; - icon: React.FC; + icon: LucideIcon | React.FC; shouldRender: boolean; }[] = [ { @@ -138,14 +111,18 @@ export const PageOptionsDropdown: React.FC = observer((props) => { }, { key: "lock-unlock-page", - action: is_locked ? handleUnlockPage : handleLockPage, + action: is_locked + ? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" }) + : () => executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" }), label: is_locked ? "Unlock page" : "Lock page", icon: is_locked ? LockOpen : Lock, shouldRender: canCurrentUserLockPage, }, { key: "archive-restore-page", - action: archived_at ? handleRestorePage : handleArchivePage, + action: archived_at + ? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" }) + : () => executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" }), label: archived_at ? "Restore page" : "Archive page", icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, shouldRender: canCurrentUserArchivePage, diff --git a/web/core/hooks/use-collaborative-page-actions.tsx b/web/core/hooks/use-collaborative-page-actions.tsx new file mode 100644 index 00000000000..6ec9f799050 --- /dev/null +++ b/web/core/hooks/use-collaborative-page-actions.tsx @@ -0,0 +1,100 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { EditorReadOnlyRefApi, EditorRefApi, TDocumentEventsServer } from "@plane/editor"; +import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { IPage } from "@/store/pages/page"; + +// Better type naming and structure +type CollaborativeAction = { + execute: (shouldSync?: boolean) => Promise; + errorMessage: string; +}; + +type CollaborativeActionEvent = + | { type: "sendMessageToServer"; message: TDocumentEventsServer } + | { type: "receivedMessageFromServer"; message: TDocumentEventsClient }; + +export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorReadOnlyRefApi | null, page: IPage) => { + // currentUserAction local state to track if the current action is being processed, a + // local action is basically the action performed by the current user to avoid double operations + const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState(null); + + const actionHandlerMap: Record = useMemo( + () => ({ + [DocumentCollaborativeEvents.lock.client]: { + execute: (shouldSync) => page.lock(shouldSync), + errorMessage: "Page could not be locked. Please try again later.", + }, + [DocumentCollaborativeEvents.unlock.client]: { + execute: (shouldSync) => page.unlock(shouldSync), + errorMessage: "Page could not be unlocked. Please try again later.", + }, + [DocumentCollaborativeEvents.archive.client]: { + execute: (shouldSync) => page.archive(shouldSync), + errorMessage: "Page could not be archived. Please try again later.", + }, + [DocumentCollaborativeEvents.unarchive.client]: { + execute: (shouldSync) => page.restore(shouldSync), + errorMessage: "Page could not be restored. Please try again later.", + }, + }), + [page] + ); + + const executeCollaborativeAction = useCallback( + async (event: CollaborativeActionEvent) => { + const isPerformedByCurrentUser = event.type === "sendMessageToServer"; + const clientAction = isPerformedByCurrentUser ? DocumentCollaborativeEvents[event.message].client : event.message; + const actionDetails = actionHandlerMap[clientAction]; + + try { + await actionDetails.execute(isPerformedByCurrentUser); + if (isPerformedByCurrentUser) { + setCurrentActionBeingProcessed(clientAction); + } + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: actionDetails.errorMessage, + }); + } + }, + [actionHandlerMap] + ); + + useEffect(() => { + if (currentActionBeingProcessed) { + const serverEventName = getServerEventName(currentActionBeingProcessed); + if (serverEventName) { + editorRef?.emitRealTimeUpdate(serverEventName); + } + } + }, [currentActionBeingProcessed, editorRef]); + + useEffect(() => { + const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate(); + + const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => { + if (currentActionBeingProcessed === message.payload) { + setCurrentActionBeingProcessed(null); + return; + } + + if (message.payload) { + executeCollaborativeAction({ type: "receivedMessageFromServer", message: message.payload }); + } + }; + + realTimeStatelessMessageListener?.on("stateless", handleStatelessMessage); + + return () => { + realTimeStatelessMessageListener?.off("stateless", handleStatelessMessage); + }; + }, [editorRef, currentActionBeingProcessed, executeCollaborativeAction]); + + return { + executeCollaborativeAction, + EVENT_ACTION_DETAILS_MAP: actionHandlerMap, + }; +}; diff --git a/web/core/store/pages/page.ts b/web/core/store/pages/page.ts index 13c86dd5397..edf136455b2 100644 --- a/web/core/store/pages/page.ts +++ b/web/core/store/pages/page.ts @@ -36,10 +36,10 @@ export interface IPage extends TPage { updateDescription: (document: TDocumentPayload) => Promise; makePublic: () => Promise; makePrivate: () => Promise; - lock: () => Promise; - unlock: () => Promise; - archive: () => Promise; - restore: () => Promise; + lock: (shouldSync?: boolean) => Promise; + unlock: (shouldSync?: boolean) => Promise; + archive: (shouldSync?: boolean) => Promise; + restore: (shouldSync?: boolean) => Promise; updatePageLogo: (logo_props: TLogoProps) => Promise; addToFavorites: () => Promise; removePageFromFavorites: () => Promise; @@ -444,62 +444,94 @@ export class Page implements IPage { /** * @description lock the page */ - lock = async () => { + lock = async (shouldSync: boolean = true) => { const { workspaceSlug, projectId } = this.store.router; if (!workspaceSlug || !projectId || !this.id) return undefined; const pageIsLocked = this.is_locked; runInAction(() => (this.is_locked = true)); - await this.pageService.lock(workspaceSlug, projectId, this.id).catch((error) => { - runInAction(() => { - this.is_locked = pageIsLocked; + if (shouldSync) { + await this.pageService.lock(workspaceSlug, projectId, this.id).catch((error) => { + runInAction(() => { + this.is_locked = pageIsLocked; + }); + throw error; }); - throw error; - }); + } }; /** * @description unlock the page */ - unlock = async () => { + unlock = async (shouldSync: boolean = true) => { const { workspaceSlug, projectId } = this.store.router; if (!workspaceSlug || !projectId || !this.id) return undefined; const pageIsLocked = this.is_locked; runInAction(() => (this.is_locked = false)); - await this.pageService.unlock(workspaceSlug, projectId, this.id).catch((error) => { - runInAction(() => { - this.is_locked = pageIsLocked; + if (shouldSync) { + await this.pageService.unlock(workspaceSlug, projectId, this.id).catch((error) => { + runInAction(() => { + this.is_locked = pageIsLocked; + }); + throw error; }); - throw error; - }); + } }; /** * @description archive the page */ - archive = async () => { + archive = async (shouldSync: boolean = true) => { const { workspaceSlug, projectId } = this.store.router; if (!workspaceSlug || !projectId || !this.id) return undefined; - const response = await this.pageService.archive(workspaceSlug, projectId, this.id); - runInAction(() => { - this.archived_at = response.archived_at; - }); - if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id); + + try { + runInAction(() => { + this.archived_at = new Date().toISOString(); + }); + + if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id); + + if (shouldSync) { + const response = await this.pageService.archive(workspaceSlug, projectId, this.id); + runInAction(() => { + this.archived_at = response.archived_at; + }); + } + } catch (error) { + console.error(error); + runInAction(() => { + this.archived_at = null; + }); + } }; /** * @description restore the page */ - restore = async () => { + restore = async (shouldSync: boolean = true) => { const { workspaceSlug, projectId } = this.store.router; if (!workspaceSlug || !projectId || !this.id) return undefined; - await this.pageService.restore(workspaceSlug, projectId, this.id); - runInAction(() => { - this.archived_at = null; - }); + + const archivedAtBeforeRestore = this.archived_at; + + try { + runInAction(() => { + this.archived_at = null; + }); + + if (shouldSync) { + await this.pageService.restore(workspaceSlug, projectId, this.id); + } + } catch (error) { + console.error(error); + runInAction(() => { + this.archived_at = archivedAtBeforeRestore; + }); + } }; updatePageLogo = async (logo_props: TLogoProps) => {