Explorar o código

Merge pull request #8571 from weseek/feat/142112-resolve-conflict-from-editor

feat: Resolve conflict from editor
Shun Miyazawa %!s(int64=2) %!d(string=hai) anos
pai
achega
21db28df17

+ 30 - 9
apps/app/src/client/services/side-effects/page-updated.ts

@@ -3,17 +3,24 @@ import { useCallback, useEffect } from 'react';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 
 import { SocketEventName } from '~/interfaces/websocket';
-import { useCurrentPageId } from '~/stores/page';
+import { usePageStatusAlert } from '~/stores/alert';
+import { useSWRxCurrentPage, useSWRMUTxCurrentPage } from '~/stores/page';
 import { useSetRemoteLatestPageData, type RemoteRevisionData } from '~/stores/remote-latest-page';
+import { useEditorMode, EditorMode } from '~/stores/ui';
+
 
 export const usePageUpdatedEffect = (): void => {
 
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
 
   const { data: socket } = useGlobalSocket();
-  const { data: currentPageId } = useCurrentPageId();
+  const { data: editorMode } = useEditorMode();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { open: openPageStatusAlert, close: closePageStatusAlert } = usePageStatusAlert();
 
-  const setLatestRemotePageData = useCallback((data) => {
+  const remotePageDataUpdateHandler = useCallback((data) => {
+    // Set remote page data
     const { s2cMessagePageUpdated } = data;
 
     const remoteData: RemoteRevisionData = {
@@ -23,22 +30,36 @@ export const usePageUpdatedEffect = (): void => {
       remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
     };
 
-    if (currentPageId != null && currentPageId === s2cMessagePageUpdated.pageId) {
+    if (currentPage?._id != null && currentPage._id === s2cMessagePageUpdated.pageId) {
       setRemoteLatestPageData(remoteData);
-    }
 
-  }, [currentPageId, setRemoteLatestPageData]);
+      // Open PageStatusAlert
+      const currentRevisionId = currentPage?.revision?._id;
+      const remoteRevisionId = s2cMessagePageUpdated.revisionId;
+      const isRevisionOutdated = (currentRevisionId != null || remoteRevisionId != null) && currentRevisionId !== remoteRevisionId;
+
+      // !!CAUTION!! Timing of calling openPageStatusAlert may clash with components/PageEditor/conflict.tsx
+      if (isRevisionOutdated && editorMode === EditorMode.View) {
+        openPageStatusAlert({ hideEditorMode: EditorMode.Editor, onRefleshPage: mutateCurrentPage });
+      }
+
+      // Clear cache
+      if (!isRevisionOutdated) {
+        closePageStatusAlert();
+      }
+    }
+  }, [currentPage?._id, currentPage?.revision?._id, editorMode, mutateCurrentPage, openPageStatusAlert, closePageStatusAlert, setRemoteLatestPageData]);
 
   // listen socket for someone updating this page
   useEffect(() => {
 
     if (socket == null) { return }
 
-    socket.on(SocketEventName.PageUpdated, setLatestRemotePageData);
+    socket.on(SocketEventName.PageUpdated, remotePageDataUpdateHandler);
 
     return () => {
-      socket.off(SocketEventName.PageUpdated, setLatestRemotePageData);
+      socket.off(SocketEventName.PageUpdated, remotePageDataUpdateHandler);
     };
 
-  }, [setLatestRemotePageData, socket]);
+  }, [remotePageDataUpdateHandler, socket]);
 };

+ 0 - 0
apps/app/src/client/services/update-page/conflict.ts → apps/app/src/client/services/update-page/conflict.tsx


+ 45 - 85
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -3,11 +3,11 @@ import React, {
   useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
 } from 'react';
 
+
 import type EventEmitter from 'events';
 import nodePath from 'path';
 
 import { type IPageHasId, Origin } from '@growi/core';
-import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
   CodeMirrorEditorMain, GlobalCodeMirrorEditorKey,
@@ -20,10 +20,9 @@ import { throttle, debounce } from 'throttle-debounce';
 
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
-import { updatePage } from '~/client/services/update-page';
+import { updatePage, extractRemoteRevisionDataFromErrorObj } from '~/client/services/update-page';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
-import { toastError, toastSuccess } from '~/client/util/toastr';
-import { SocketEventName } from '~/interfaces/websocket';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import {
   useDefaultIndentSize, useCurrentUser,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
@@ -32,22 +31,14 @@ import {
 } from '~/stores/context';
 import {
   useEditorSettings,
-  useCurrentIndentSize, usePageTagsForEditors,
-  useIsConflict,
+  useCurrentIndentSize,
   useEditingMarkdown,
   useWaitingSaveProcessing,
 } from '~/stores/editor';
-import { useConflictDiffModal } from '~/stores/modal';
 import {
-  useCurrentPagePath, useSWRMUTxCurrentPage, useSWRxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useIsLatestRevision, useTemplateBodyData,
+  useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId, useIsNotFound, useTemplateBodyData,
 } from '~/stores/page';
 import { mutatePageTree } from '~/stores/page-listing';
-import {
-  useRemoteRevisionId,
-  useRemoteRevisionBody,
-  useRemoteRevisionLastUpdatedAt,
-  useRemoteRevisionLastUpdateUser,
-} from '~/stores/remote-latest-page';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,
@@ -61,6 +52,7 @@ import { EditorNavbar } from './EditorNavbar';
 import EditorNavbarBottom from './EditorNavbarBottom';
 import Preview from './Preview';
 import { scrollEditor, scrollPreview } from './ScrollSyncHelper';
+import { useConflictResolver, useConflictEffect, type ConflictHandler } from './conflict';
 
 import '@growi/editor/dist/style.css';
 
@@ -77,6 +69,17 @@ declare global {
 let isOriginOfScrollSyncEditor = false;
 let isOriginOfScrollSyncPreview = false;
 
+export type SaveOptions = {
+  slackChannels: string,
+  overwriteScopesOfDescendants?: boolean
+}
+export type Save = (
+  revisionId?: string,
+  requestMarkdown?: string,
+  opts?: SaveOptions,
+  onConflict?: ConflictHandler
+) => Promise<IPageHasId | null>
+
 type Props = {
   visibility?: boolean,
 }
@@ -93,11 +96,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { data: grantData } = useSelectedGrant();
-  const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
-  const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
-  const { data: editingMarkdown, mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { data: templateBodyData } = useTemplateBodyData();
   const { data: isEditable } = useIsEditable();
@@ -107,20 +107,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
   const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
-  const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
   const { data: editorSettings } = useEditorSettings();
-  const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
-  const { mutate: mutateRemotePageId } = useRemoteRevisionId();
-  const { mutate: mutateRemoteRevisionId } = useRemoteRevisionBody();
-  const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
-  const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
   const { data: user } = useCurrentUser();
   const { onEditorsUpdated } = useEditingUsers();
-
-  const { data: socket } = useGlobalSocket();
+  const onConflict = useConflictResolver();
 
   const { data: rendererOptions } = usePreviewOptions();
-  const { mutate: mutateIsConflict } = useIsConflict();
 
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
 
@@ -128,10 +120,13 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
+  useConflictEffect();
+
   const { resolvedTheme } = useNextThemes();
   mutateResolvedTheme({ themeData: resolvedTheme });
 
   const currentRevisionId = currentPage?.revision?._id;
+  const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;
 
   const initialValueRef = useRef('');
   const initialValue = useMemo(() => {
@@ -168,43 +163,21 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
-
-  const checkIsConflict = useCallback((data) => {
-    const { s2cMessagePageUpdated } = data;
-
-    const isConflict = markdownToPreview !== s2cMessagePageUpdated.revisionBody;
-
-    mutateIsConflict(isConflict);
-
-  }, [markdownToPreview, mutateIsConflict]);
-
-  useEffect(() => {
-    if (socket == null) { return }
-
-    socket.on(SocketEventName.PageUpdated, checkIsConflict);
-
-    return () => {
-      socket.off(SocketEventName.PageUpdated, checkIsConflict);
-    };
-
-  }, [socket, checkIsConflict]);
-
-  const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
-    if (pageId == null || currentRevisionId == null || grantData == null) {
+  const save: Save = useCallback(async(revisionId, markdown, opts, onConflict) => {
+    if (pageId == null || grantData == null) {
       logger.error('Some materials to save are invalid', {
-        pageId, currentRevisionId, grantData,
+        pageId, grantData,
       });
       throw new Error('Some materials to save are invalid');
     }
 
     try {
       mutateWaitingSaveProcessing(true);
-      const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;
 
       const { page } = await updatePage({
         pageId,
-        revisionId: isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined,
-        body: codeMirrorEditor?.getDoc() ?? '',
+        revisionId,
+        body: markdown ?? '',
         grant: grantData?.grant,
         origin: Origin.Editor,
         userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
@@ -220,41 +193,45 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
     catch (error) {
       logger.error('failed to save', error);
-      toastError(error);
-      if (error.code === 'conflict') {
-        mutateRemotePageId(error.data.revisionId);
-        mutateRemoteRevisionId(error.data.revisionBody);
-        mutateRemoteRevisionLastUpdatedAt(error.data.createdAt);
-        mutateRemoteRevisionLastUpdateUser(error.data.user);
+
+      const remoteRevisionData = extractRemoteRevisionDataFromErrorObj(error);
+      if (remoteRevisionData != null) {
+        onConflict?.(remoteRevisionData, markdown ?? '', save, opts);
+        toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
+        return null;
       }
+
+      toastError(error);
       return null;
     }
     finally {
       mutateWaitingSaveProcessing(false);
     }
+  }, [pageId, grantData, mutateWaitingSaveProcessing, t]);
 
-  // eslint-disable-next-line max-len
-  }, [codeMirrorEditor, grantData, pageId, currentRevisionId, mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser]);
-
-  const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
-    const page = await save(opts);
+  const saveAndReturnToViewHandler = useCallback(async(opts: SaveOptions) => {
+    const markdown = codeMirrorEditor?.getDoc();
+    const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
+    const page = await save(revisionId, markdown, opts, onConflict);
     if (page == null) {
       return;
     }
 
     mutateEditorMode(EditorMode.View);
     updateStateAfterSave?.();
-  }, [mutateEditorMode, save, updateStateAfterSave]);
+  }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, mutateEditorMode, onConflict, save, updateStateAfterSave]);
 
   const saveWithShortcut = useCallback(async() => {
-    const page = await save();
+    const markdown = codeMirrorEditor?.getDoc();
+    const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
+    const page = await save(revisionId, markdown, undefined, onConflict);
     if (page == null) {
       return;
     }
 
     toastSuccess(t('toaster.save_succeeded'));
     updateStateAfterSave?.();
-  }, [save, t, updateStateAfterSave]);
+  }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, onConflict, save, t, updateStateAfterSave]);
 
 
   // the upload event handler
@@ -327,23 +304,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const scrollPreviewHandlerThrottle = useMemo(() => throttle(25, scrollPreviewHandler), [scrollPreviewHandler]);
 
-  const afterResolvedHandler = useCallback(async() => {
-    // get page data from db
-    const pageData = await mutateCurrentPage();
-
-    // update tag
-    await mutateTagsInfo(); // get from DB
-    syncTagsInfoForEditor(); // sync global state for client
-
-    // clear isConflict
-    mutateIsConflict(false);
-
-    // set resolved markdown in editing markdown
-    const markdown = pageData?.revision?.body ?? '';
-    mutateEditingMarkdown(markdown);
-
-  }, [mutateCurrentPage, mutateEditingMarkdown, mutateIsConflict, mutateTagsInfo, syncTagsInfoForEditor]);
-
   // initial caret line
   useEffect(() => {
     codeMirrorEditor?.setCaretLine();

+ 135 - 0
apps/app/src/components/PageEditor/conflict.tsx

@@ -0,0 +1,135 @@
+import { useCallback, useEffect } from 'react';
+
+import { Origin } from '@growi/core';
+import { useGlobalSocket } from '@growi/core/dist/swr';
+import { GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated } from '@growi/editor';
+import { useTranslation } from 'react-i18next';
+
+import { useUpdateStateAfterSave } from '~/client/services/page-operation';
+import { toastSuccess } from '~/client/util/toastr';
+import type { Save, SaveOptions } from '~/components/PageEditor/PageEditor';
+import { SocketEventName } from '~/interfaces/websocket';
+import { usePageStatusAlert } from '~/stores/alert';
+import { useConflictDiffModal } from '~/stores/modal';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
+import { type RemoteRevisionData, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
+
+export type ConflictHandler = (
+  remoteRevisionData: RemoteRevisionData,
+  requestMarkdown: string,
+  save: Save,
+  saveOptions?: SaveOptions,
+) => void;
+
+type GenerateResolveConflicthandler = () => (
+  revisionId: string,
+  save: Save,
+  saveOptions?: SaveOptions,
+  onConflict?: () => void
+) => (newMarkdown: string) => Promise<void>
+
+const useGenerateResolveConflictHandler: GenerateResolveConflicthandler = () => {
+  const { t } = useTranslation();
+
+  const { data: pageId } = useCurrentPageId();
+  const { close: closePageStatusAlert } = usePageStatusAlert();
+  const { close: closeConflictDiffModal } = useConflictDiffModal();
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
+
+  return useCallback((revisionId, save, saveOptions, onConflict) => {
+    return async(newMarkdown) => {
+      const page = await save(revisionId, newMarkdown, saveOptions, onConflict);
+      if (page == null) {
+        return;
+      }
+
+      // Reflect conflict resolution results in CodeMirrorEditor
+      codeMirrorEditor?.initDoc(newMarkdown);
+
+      closePageStatusAlert();
+      closeConflictDiffModal();
+
+      toastSuccess(t('toaster.save_succeeded'));
+      updateStateAfterSave?.();
+    };
+  }, [closeConflictDiffModal, closePageStatusAlert, codeMirrorEditor, t, updateStateAfterSave]);
+};
+
+
+type ConflictResolver = () => ConflictHandler;
+
+export const useConflictResolver: ConflictResolver = () => {
+  const { open: openPageStatusAlert } = usePageStatusAlert();
+  const { open: openConflictDiffModal } = useConflictDiffModal();
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+  const generateResolveConflictHandler = useGenerateResolveConflictHandler();
+
+  return useCallback((remoteRevidsionData, requestMarkdown, save, saveOptions) => {
+    const conflictHandler = () => {
+      const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, save, saveOptions, conflictHandler);
+      openPageStatusAlert({ onResolveConflict: () => openConflictDiffModal(requestMarkdown, resolveConflictHandler) });
+      setRemoteLatestPageData(remoteRevidsionData);
+    };
+
+    conflictHandler();
+  }, [generateResolveConflictHandler, openConflictDiffModal, openPageStatusAlert, setRemoteLatestPageData]);
+};
+
+export const useConflictEffect = (): void => {
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { close: closePageStatusAlert } = usePageStatusAlert();
+  const { close: closeConflictDiffModal } = useConflictDiffModal();
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const { open: openPageStatusAlert } = usePageStatusAlert();
+  const { open: openConflictDiffModal } = useConflictDiffModal();
+  const { data: socket } = useGlobalSocket();
+  const { data: editorMode } = useEditorMode();
+
+  const conflictHandler = useCallback(() => {
+    const onResolveConflict = () => {
+      const resolveConflictHandler = (newMarkdown: string) => {
+        codeMirrorEditor?.initDoc(newMarkdown);
+        closeConflictDiffModal();
+        closePageStatusAlert();
+      };
+
+      const markdown = codeMirrorEditor?.getDoc();
+      openConflictDiffModal(markdown ?? '', resolveConflictHandler);
+    };
+
+    openPageStatusAlert({ onResolveConflict });
+  }, [closeConflictDiffModal, closePageStatusAlert, codeMirrorEditor, openConflictDiffModal, openPageStatusAlert]);
+
+  const updateRemotePageDataHandler = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    const remoteRevisionId = s2cMessagePageUpdated.revisionId;
+    const remoteRevisionOrigin = s2cMessagePageUpdated.revisionOrigin;
+    const currentRevisionId = currentPage?.revision?._id;
+    const isRevisionOutdated = (currentRevisionId != null || remoteRevisionId != null) && currentRevisionId !== remoteRevisionId;
+
+    // !!CAUTION!! Timing of calling openPageStatusAlert may clash with client/services/side-effects/page-updated.ts
+    if (isRevisionOutdated && editorMode === EditorMode.Editor && (remoteRevisionOrigin === Origin.View || remoteRevisionOrigin === undefined)) {
+      conflictHandler();
+    }
+
+    // Clear cache
+    if (!isRevisionOutdated) {
+      closePageStatusAlert();
+    }
+  }, [closePageStatusAlert, currentPage?.revision?._id, editorMode, conflictHandler]);
+
+  useEffect(() => {
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, updateRemotePageDataHandler);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, updateRemotePageDataHandler);
+    };
+
+  }, [socket, updateRemotePageDataHandler]);
+};

+ 37 - 98
apps/app/src/components/PageStatusAlert.tsx

@@ -1,133 +1,72 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import * as ReactDOMServer from 'react-dom/server';
 
+import { usePageStatusAlert } from '~/stores/alert';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
-import { useEditingMarkdown, useIsConflict } from '~/stores/editor';
-import { useConflictDiffModal } from '~/stores/modal';
-import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { useRemoteRevisionId, useRemoteRevisionLastUpdateUser } from '~/stores/remote-latest-page';
+import { useEditorMode } from '~/stores/ui';
 
 import { Username } from './User/Username';
 
 import styles from './PageStatusAlert.module.scss';
 
-type AlertComponentContents = {
-  additionalClasses: string[],
-  label: JSX.Element,
-  btn: JSX.Element
-}
-
 export const PageStatusAlert = (): JSX.Element => {
-
   const { t } = useTranslation();
-  const { data: isConflict } = useIsConflict();
-  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { open: openConflictDiffModal } = useConflictDiffModal();
+
+  const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
-
-  // store remote latest page data
+  const { data: pageStatusAlertData } = usePageStatusAlert();
   const { data: remoteRevisionId } = useRemoteRevisionId();
   const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
-
   const { data: pageData } = useSWRxCurrentPage();
-  const { trigger: mutatePageData } = useSWRMUTxCurrentPage();
-  const revision = pageData?.revision;
 
-  const refreshPage = useCallback(async() => {
-    const updatedPageData = await mutatePageData();
-    mutateEditingMarkdown(updatedPageData?.revision?.body);
-  }, [mutateEditingMarkdown, mutatePageData]);
+  const onClickRefreshPage = useCallback(() => {
+    pageStatusAlertData?.onRefleshPage?.();
+  }, [pageStatusAlertData]);
 
   const onClickResolveConflict = useCallback(() => {
-    // openConflictDiffModal();
-  }, [openConflictDiffModal]);
-
-  // TODO: re-impl for builtin editor
-  //
-  // const getContentsForSomeoneEditingAlert = useCallback((): AlertComponentContents => {
-  //   return {
-  //     additionalClasses: ['bg-success', 'd-hackmd-none'],
-  //     label:
-  // <>
-  //   <span className="material-symbols-outlined">person</span>
-  //   {t('hackmd.someone_editing')}
-  // </>,
-  //     btn:
-  // <a href="#hackmd" key="btnOpenHackmdSomeoneEditing" className="btn btn-outline-white">
-  //   <span class="material-symbols-outlined">description</span>
-  //   Open HackMD Editor
-  // </a>,
-  //   };
-  // }, [t]);
-
-  const getContentsForUpdatedAlert = useCallback((): AlertComponentContents => {
+    pageStatusAlertData?.onResolveConflict?.();
+  }, [pageStatusAlertData]);
 
-    const usernameComponentToString = ReactDOMServer.renderToString(<Username user={remoteRevisionLastUpdateUser} />);
+  const hasResolveConflictHandler = pageStatusAlertData?.onResolveConflict != null;
+  const hasRefreshPageHandler = pageStatusAlertData?.onRefleshPage != null;
 
-    const label1 = isConflict
-      ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
-      // eslint-disable-next-line react/no-danger
-      : <span dangerouslySetInnerHTML={{ __html: `${usernameComponentToString} ${t('edited this page')}` }} />;
+  const currentRevisionId = pageData?.revision?._id;
+  const isRevisionOutdated = (currentRevisionId != null || remoteRevisionId != null) && currentRevisionId !== remoteRevisionId;
 
-    return {
-      additionalClasses: ['bg-warning text-dark'],
-      label:
-  <>
-    <span className="material-symbols-outlined">lightbulb</span>
-    {label1}
-  </>,
-      btn:
-  <>
-    <button type="button" onClick={() => refreshPage()} className="btn btn-outline-white me-4">
-      <span className="material-symbols-outlined">refresh</span>
-      {t('Load latest')}
-    </button>
-    { isConflict && (
-      <button
-        type="button"
-        onClick={onClickResolveConflict}
-        className="btn btn-outline-white"
-      >
-        <span className="material-symbols-outlined">description</span>
-        {t('modal_resolve_conflict.resolve_conflict')}
-      </button>
-    )}
-  </>,
-    };
-  }, [remoteRevisionLastUpdateUser, isConflict, t, onClickResolveConflict, refreshPage]);
+  if (!pageStatusAlertData?.isOpen || !!isGuestUser || !!isReadOnlyUser || !isRevisionOutdated) {
+    return <></>;
+  }
 
-  const alertComponentContents = useMemo(() => {
-    const isRevisionOutdated = revision?._id !== remoteRevisionId;
-
-    // 'revision?._id' and 'remoteRevisionId' are can not be undefined
-    if (revision?._id == null || remoteRevisionId == null) { return }
-
-    // when remote revision is newer than both
-    if (isRevisionOutdated) {
-      return getContentsForUpdatedAlert();
-    }
-
-    return null;
-  }, [revision?._id, remoteRevisionId, getContentsForUpdatedAlert]);
-
-  if (!!isGuestUser || !!isReadOnlyUser || alertComponentContents == null) { return <></> }
-
-  const { additionalClasses, label, btn } = alertComponentContents;
+  if (editorMode === pageStatusAlertData?.hideEditorMode) {
+    return <></>;
+  }
 
   return (
-    <div className={`${styles['grw-page-status-alert']} card text-white fixed-bottom animated fadeInUp faster ${additionalClasses.join(' ')}`}>
+    <div className={`${styles['grw-page-status-alert']} card fixed-bottom animated fadeInUp faster text-bg-warning`}>
       <div className="card-body">
         <p className="card-text grw-card-label-container">
-          {label}
+          { hasResolveConflictHandler
+            ? <>{t('modal_resolve_conflict.file_conflicting_with_newer_remote')}</>
+            : <><Username user={remoteRevisionLastUpdateUser} /> {t('edited this page')}</>
+          }
         </p>
         <p className="card-text grw-card-btn-container">
-          {btn}
+          {hasRefreshPageHandler && (
+            <button type="button" onClick={onClickRefreshPage} className="btn btn-outline-white">
+              <span className="material-symbols-outlined">refresh</span>{t('Load latest')}
+            </button>
+          )}
+          {hasResolveConflictHandler && (
+            <button type="button" onClick={onClickResolveConflict} className="btn btn-outline-white">
+              <span className="material-symbols-outlined">description</span>{t('modal_resolve_conflict.resolve_conflict')}
+            </button>
+          )}
         </p>
       </div>
     </div>
   );
-
 };

+ 1 - 0
apps/app/src/server/models/vo/s2c-message.js

@@ -17,6 +17,7 @@ class S2cMessagePageUpdated {
     this.revisionId = revision;
     this.revisionBody = page.revision.body;
     this.revisionUpdateAt = updatedAt;
+    this.revisionOrigin = page.revision.origin;
 
     if (user != null) {
       this.remoteLastUpdateUser = user;

+ 39 - 0
apps/app/src/stores/alert.tsx

@@ -0,0 +1,39 @@
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+
+import type { EditorMode } from './ui';
+
+/*
+* PageStatusAlert
+*/
+type OpenPageStatusAlertOptions = {
+  hideEditorMode?: EditorMode
+  onRefleshPage?: () => void
+  onResolveConflict?: () => void
+}
+
+type PageStatusAlertStatus = {
+  isOpen: boolean
+  hideEditorMode?: EditorMode,
+  onRefleshPage?: () => void
+  onResolveConflict?: () => void
+}
+
+type PageStatusAlertUtils = {
+  open: (openPageStatusAlert: OpenPageStatusAlertOptions) => void,
+  close: () => void,
+}
+export const usePageStatusAlert = (): SWRResponse<PageStatusAlertStatus, Error> & PageStatusAlertUtils => {
+  const initialData: PageStatusAlertStatus = { isOpen: false };
+  const swrResponse = useSWRStatic<PageStatusAlertStatus, Error>('pageStatusAlert', undefined, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open({ ...options }) {
+      swrResponse.mutate({ isOpen: true, ...options });
+    },
+    close() {
+      swrResponse.mutate({ isOpen: false });
+    },
+  };
+};

+ 0 - 4
apps/app/src/stores/editor.tsx

@@ -120,7 +120,3 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning');
 };
-
-export const useIsConflict = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isConflict', undefined, { fallbackData: false });
-};