Просмотр исходного кода

Merge branch 'master' into support/cypress-v12

Shun Miyazawa 3 лет назад
Родитель
Сommit
37ec99d5b8

+ 16 - 14
packages/app/_obsolete/src/client/services/PageContainer.js

@@ -277,12 +277,13 @@ export default class PageContainer extends Container {
     });
   }
 
-  // request to server so the client to join a room for each page
-  emitJoinPageRoomRequest() {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const socket = socketIoContainer.getSocket();
-    socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
-  }
+  // replaced accompanied by omiting container: https://github.com/weseek/growi/pull/6968
+  // // request to server so the client to join a room for each page
+  // emitJoinPageRoomRequest() {
+  //   const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
+  //   const socket = socketIoContainer.getSocket();
+  //   socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
+  // }
 
   addWebSocketEventHandlers() {
     // eslint-disable-next-line @typescript-eslint/no-this-alias
@@ -300,15 +301,16 @@ export default class PageContainer extends Container {
       }
     });
 
-    socket.on('page:update', (data) => {
-      logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+    // replaced accompanied by omiting container: https://github.com/weseek/growi/pull/6968
+    // socket.on('page:update', (data) => {
+    //   logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
 
-      // update remote page data
-      const { s2cMessagePageUpdated } = data;
-      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
-        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
-      }
-    });
+    //   // update remote page data
+    //   const { s2cMessagePageUpdated } = data;
+    //   if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+    //     pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
+    //   }
+    // });
 
     socket.on('page:delete', (data) => {
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes

+ 28 - 1
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -6,12 +6,15 @@ import dynamic from 'next/dynamic';
 import { Link } from 'react-scroll';
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
+import { SocketEventName } from '~/interfaces/websocket';
 import {
   useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useRemoteRevisionId, useRemoteRevisionLastUpdatUser } from '~/stores/remote-latest-page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useGlobalSocket } from '~/stores/websocket';
 
 import CountBadge from '../Common/CountBadge';
 import { ContentLinkButtonsProps } from '../ContentLinkButtons';
@@ -44,10 +47,34 @@ const PageView = React.memo((): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+  const { mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
+  const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdatUser();
 
   const isTopPagePath = isTopPage(currentPagePath ?? '');
   const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
 
+  const { data: socket } = useGlobalSocket();
+
+  const setLatestRemotePageData = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    mutateRemoteRevisionId(s2cMessagePageUpdated.revisionId);
+    mutateRemoteRevisionLastUpdateUser(s2cMessagePageUpdated.remoteLastUpdateUser);
+  }, [mutateRemoteRevisionId, mutateRemoteRevisionLastUpdateUser]);
+
+  // listen socket for someone updating this page
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, setLatestRemotePageData);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, setLatestRemotePageData);
+    };
+
+  }, [setLatestRemotePageData, socket]);
+
   return (
     <div className="d-flex flex-column flex-lg-row">
 

+ 45 - 0
packages/app/src/components/PageEditor.tsx

@@ -17,6 +17,7 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { OptionsToSave } from '~/interfaces/page-operation';
+import { SocketEventName } from '~/interfaces/websocket';
 import {
   useCurrentPathname, useCurrentPageId, useIsEnabledAttachTitleHeader, useTemplateBodyData,
   useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsNotFound, useIsIndentSizeForced,
@@ -24,6 +25,7 @@ import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
+  useIsConflict,
   useEditingMarkdown,
 } from '~/stores/editor';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
@@ -32,6 +34,7 @@ import {
   EditorMode,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
+import { useGlobalSocket } from '~/stores/websocket';
 import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
@@ -108,9 +111,51 @@ const PageEditor = React.memo((): JSX.Element => {
 
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 
+  const { data: socket } = useGlobalSocket();
+
+  const { mutate: mutateIsConflict } = useIsConflict();
+
+
   const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
+  const checkIsConflict = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    const isConflict = markdownToPreview !== s2cMessagePageUpdated.revisionBody;
+
+    mutateIsConflict(isConflict);
+
+  }, [markdownToPreview, mutateIsConflict]);
+
+  useEffect(() => {
+    markdownToSave.current = initialValue;
+    setMarkdownToPreview(initialValue);
+  }, [initialValue]);
+
+  useEffect(() => {
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, checkIsConflict);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, checkIsConflict);
+    };
+
+  }, [socket, checkIsConflict]);
+
+  // const optionsToSave = useMemo(() => {
+  //   if (grantData == null) {
+  //     return;
+  //   }
+  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
+  //   const optionsToSave = getOptionsToSave(
+  //     isSlackEnabled ?? false, slackChannels,
+  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
+  //     pageTags || [],
+  //   );
+  //   return optionsToSave;
+  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
   // register to facade
   useEffect(() => {
     // for markdownRenderer

+ 10 - 7
packages/app/src/components/PageEditorByHackmd.tsx

@@ -19,12 +19,13 @@ import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors,
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 import {
-  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
+  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -83,7 +84,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
-  const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime(false);
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
@@ -211,7 +213,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
         throw new Error(res.error);
       }
 
-      setIsHackmdDraftUpdatingInRealtime(false);
+      mutateIsHackmdDraftUpdatingInRealtime(false);
       mutateHasDraftOnHackmd(false);
       mutatePageIdOnHackmd(res.pageIdOnHackmd);
       mutateRemoteRevisionId(res.revisionIdHackmdSynced);
@@ -223,7 +225,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error(err);
       toastError(err.message);
     }
-  }, [pageId, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced]);
+  }, [mutateIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, mutateRemoteRevisionId, pageId]);
 
   /**
    * save and update state of containers
@@ -264,8 +266,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  // eslint-disable-next-line max-len
-  }, [currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced, pageTags, saveOrUpdate, mutatePageData, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced, mutateHasDraftOnHackmd, mutateTagsInfo, t]);
+  }, [
+    currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced, pageTags,
+    saveOrUpdate, mutatePageData, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced, mutateHasDraftOnHackmd, mutateTagsInfo, t]);
 
   /**
    * onChange event of HackmdEditor handler

+ 0 - 177
packages/app/src/components/PageStatusAlert.jsx

@@ -1,177 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-// import AppContainer from '~/client/services/AppContainer';
-// import PageContainer from '~/client/services/PageContainer';
-// import Username from '~/components/User/Username';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageStatusAlert
- * @extends {React.Component}
- */
-
-class PageStatusAlert extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-    };
-
-    this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
-    this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
-    this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
-    this.onClickResolveConflict = this.onClickResolveConflict.bind(this);
-  }
-
-  refreshPage() {
-    window.location.reload();
-  }
-
-  onClickResolveConflict() {
-    this.props.pageContainer.setState({
-      isConflictDiffModalOpen: true,
-    });
-  }
-
-  getContentsForSomeoneEditingAlert() {
-    const { t } = this.props;
-    return [
-      ['bg-success', 'd-hackmd-none'],
-      <>
-        <i className="icon-fw icon-people"></i>
-        {t('hackmd.someone_editing')}
-      </>,
-      <a href="#hackmd" key="btnOpenHackmdSomeoneEditing" className="btn btn-outline-white">
-        <i className="fa fa-fw fa-file-text-o mr-1"></i>
-        Open HackMD Editor
-      </a>,
-    ];
-  }
-
-  getContentsForDraftExistsAlert(isRealtime) {
-    const { t } = this.props;
-    return [
-      ['bg-success', 'd-hackmd-none'],
-      <>
-        <i className="icon-fw icon-pencil"></i>
-        {t('hackmd.this_page_has_draft')}
-      </>,
-      <a href="#hackmd" key="btnOpenHackmdPageHasDraft" className="btn btn-outline-white">
-        <i className="fa fa-fw fa-file-text-o mr-1"></i>
-        Open HackMD Editor
-      </a>,
-    ];
-  }
-
-  getContentsForUpdatedAlert() {
-    const { t } = this.props;
-    // const pageEditor = appContainer.getComponentInstance('PageEditor');
-
-    const isConflictOnEdit = false;
-
-    // if (pageEditor != null) {
-    //   const markdownOnEdit = pageEditor.getMarkdown();
-    //   isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
-    // }
-
-    // TODO: re-impl with Next.js way
-    // const usernameComponentToString = ReactDOMServer.renderToString(<Username user={pageContainer.state.lastUpdateUser} />);
-
-    // const label1 = isConflictOnEdit
-    //   ? 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 label1 = '(TBD -- 2022.09.13)';
-
-    return [
-      ['bg-warning'],
-      <>
-        <i className="icon-fw icon-bulb"></i>
-        {label1}
-      </>,
-      <>
-        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
-          <i className="icon-fw icon-reload mr-1"></i>
-          {t('Load latest')}
-        </button>
-        { isConflictOnEdit && (
-          <button
-            type="button"
-            onClick={this.onClickResolveConflict}
-            className="btn btn-outline-white"
-          >
-            <i className="fa fa-fw fa-file-text-o mr-1"></i>
-            {t('modal_resolve_conflict.resolve_conflict')}
-          </button>
-        )}
-      </>,
-    ];
-  }
-
-  render() {
-    const {
-      revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
-    } = this.props.pageContainer.state;
-
-    const isRevisionOutdated = revisionId !== remoteRevisionId;
-    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
-
-    let getContentsFunc = null;
-
-    // when remote revision is newer than both
-    if (isHackmdDocumentOutdated && isRevisionOutdated) {
-      getContentsFunc = this.getContentsForUpdatedAlert;
-    }
-    // when someone editing with HackMD
-    else if (isHackmdDraftUpdatingInRealtime) {
-      getContentsFunc = this.getContentsForSomeoneEditingAlert;
-    }
-    // when the draft of HackMD is newest
-    else if (hasDraftOnHackmd) {
-      getContentsFunc = this.getContentsForDraftExistsAlert;
-    }
-    // do not render anything
-    else {
-      return null;
-    }
-
-    const [additionalClasses, label, btn] = getContentsFunc();
-
-    return (
-      <div className={`card grw-page-status-alert text-white fixed-bottom animated fadeInUp faster ${additionalClasses.join(' ')}`}>
-        <div className="card-body">
-          <p className="card-text grw-card-label-container">
-            {label}
-          </p>
-          <p className="card-text grw-card-btn-container">
-            {btn}
-          </p>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-PageStatusAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-const PageStatusAlertWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <PageStatusAlert t={t} {...props} />;
-};
-
-export default PageStatusAlertWrapperFC;

+ 40 - 0
packages/app/src/components/PageStatusAlert.module.scss

@@ -0,0 +1,40 @@
+@use '~/styles/variables' as var;
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-page-status-alert :global {
+  $margin-bottom: var.$grw-navbar-bottom-height + 10px;
+
+  box-shadow: 0px 2px 4px #0000004d;
+  opacity: 0.9;
+
+  @include bs.media-breakpoint-down(sm) {
+    margin: 0 10px $margin-bottom;
+
+    .grw-card-label-container {
+      text-align: center;
+    }
+    .grw-card-btn-container {
+      text-align: center;
+
+      .btn {
+        @include bs.button-size(bs.$btn-padding-y-lg, bs.$btn-padding-x-lg, bs.$btn-font-size-lg, bs.$btn-line-height-lg, bs.$btn-border-radius-lg);
+      }
+    }
+  }
+
+  @include bs.media-breakpoint-up(md) {
+    width: 700px;
+    margin: 0 auto $margin-bottom;
+
+    .card-body {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .grw-card-label-container,
+    .grw-card-btn-container {
+      margin: 0;
+    }
+  }
+}

+ 166 - 0
packages/app/src/components/PageStatusAlert.tsx

@@ -0,0 +1,166 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import * as ReactDOMServer from 'react-dom/server';
+
+import { useEditingMarkdown, useIsConflict } from '~/stores/editor';
+import {
+  useHasDraftOnHackmd, useIsHackmdDraftUpdatingInRealtime, useRevisionIdHackmdSynced,
+} from '~/stores/hackmd';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useRemoteRevisionId, useRemoteRevisionLastUpdatUser } from '~/stores/remote-latest-page';
+
+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: isHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
+  const { data: hasDraftOnHackmd } = useHasDraftOnHackmd();
+  const { data: isConflict } = useIsConflict();
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+
+  // store remote latest page data
+  const { data: revisionIdHackmdSynced } = useRevisionIdHackmdSynced();
+  const { data: remoteRevisionId } = useRemoteRevisionId();
+  const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdatUser();
+
+  const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
+  const revision = pageData?.revision;
+
+  const refreshPage = useCallback(async() => {
+    const updatedPageData = await mutatePageData();
+    mutateEditingMarkdown(updatedPageData?.revision.body);
+  }, [mutateEditingMarkdown, mutatePageData]);
+
+  const onClickResolveConflict = useCallback(() => {
+    // this.props.pageContainer.setState({
+    //   isConflictDiffModalOpen: true,
+    // });
+  }, []);
+
+  const getContentsForSomeoneEditingAlert = useCallback((): AlertComponentContents => {
+    return {
+      additionalClasses: ['bg-success', 'd-hackmd-none'],
+      label:
+        <>
+          <i className="icon-fw icon-people"></i>
+          {t('hackmd.someone_editing')}
+        </>,
+      btn:
+        <a href="#hackmd" key="btnOpenHackmdSomeoneEditing" className="btn btn-outline-white">
+          <i className="fa fa-fw fa-file-text-o mr-1"></i>
+          Open HackMD Editor
+        </a>,
+    };
+  }, [t]);
+
+  const getContentsForDraftExistsAlert = useCallback((): AlertComponentContents => {
+    return {
+      additionalClasses: ['bg-success', 'd-hackmd-none'],
+      label:
+        <>
+          <i className="icon-fw icon-pencil"></i>
+          {t('hackmd.this_page_has_draft')}
+        </>,
+      btn:
+        <a href="#hackmd" key="btnOpenHackmdPageHasDraft" className="btn btn-outline-white">
+          <i className="fa fa-fw fa-file-text-o mr-1"></i>
+          Open HackMD Editor
+        </a>,
+    };
+  }, [t]);
+
+  const getContentsForUpdatedAlert = useCallback((): AlertComponentContents => {
+
+    const usernameComponentToString = ReactDOMServer.renderToString(<Username user={remoteRevisionLastUpdateUser} />);
+
+    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')}` }} />;
+
+    return {
+      additionalClasses: ['bg-warning'],
+      label:
+        <>
+          <i className="icon-fw icon-bulb"></i>
+          {label1}
+        </>,
+      btn:
+        <>
+          <button type="button" onClick={() => refreshPage()} className="btn btn-outline-white mr-4">
+            <i className="icon-fw icon-reload mr-1"></i>
+            {t('Load latest')}
+          </button>
+          { isConflict && (
+            <button
+              type="button"
+              onClick={onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <i className="fa fa-fw fa-file-text-o mr-1"></i>
+              {t('modal_resolve_conflict.resolve_conflict')}
+            </button>
+          )}
+        </>,
+    };
+  }, [remoteRevisionLastUpdateUser, isConflict, t, onClickResolveConflict, refreshPage]);
+
+  const alertComponentContents = useMemo(() => {
+    const isRevisionOutdated = revision?._id !== remoteRevisionId;
+    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
+
+    // when remote revision is newer than both
+    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+      return getContentsForUpdatedAlert();
+    }
+
+    // when someone editing with HackMD
+    if (isHackmdDraftUpdatingInRealtime) {
+      return getContentsForSomeoneEditingAlert();
+    }
+
+    // when the draft of HackMD is newest
+    if (hasDraftOnHackmd) {
+      return getContentsForDraftExistsAlert();
+    }
+
+    return null;
+  }, [
+    revision?._id,
+    remoteRevisionId,
+    revisionIdHackmdSynced,
+    isHackmdDraftUpdatingInRealtime,
+    hasDraftOnHackmd,
+    getContentsForUpdatedAlert,
+    getContentsForSomeoneEditingAlert,
+    getContentsForDraftExistsAlert,
+  ]);
+
+  if (alertComponentContents == null) { return <></> }
+
+  const { additionalClasses, label, btn } = alertComponentContents;
+
+  return (
+    <div className={`${styles['grw-page-status-alert']} card text-white fixed-bottom animated fadeInUp faster ${additionalClasses.join(' ')}`}>
+      <div className="card-body">
+        <p className="card-text grw-card-label-container">
+          {label}
+        </p>
+        <p className="card-text grw-card-btn-container">
+          {btn}
+        </p>
+      </div>
+    </div>
+  );
+
+};

+ 5 - 0
packages/app/src/interfaces/websocket.ts

@@ -17,6 +17,11 @@ export const SocketEventName = {
   FinishAddPage: 'finishAddPage',
   RebuildingFailed: 'rebuildingFailed',
 
+  // Page Operation
+  PageCreated: 'page:create',
+  PageUpdated: 'page:update',
+  PageDeleted: 'page:delete',
+
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

+ 6 - 1
packages/app/src/pages/[[...path]].page.tsx

@@ -43,6 +43,7 @@ import {
   useEditorMode, useSelectedGrant,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
+import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
@@ -88,6 +89,7 @@ const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../c
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
+const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -267,6 +269,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   const { getClassNamesByEditorMode } = useEditorMode();
 
+  useSetupGlobalSocket();
+  useSetupGlobalSocketForPage(pageId);
+
   const shouldRenderPutbackPageModal = pageWithMeta != null
     ? _isTrashPage(pageWithMeta.data.path)
     : false;
@@ -336,7 +341,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
                     { props.isNotCreatablePage && <NotCreatablePage />}
                     { !props.isForbidden && !props.isNotCreatablePage && <DisplaySwitcher />}
                     {/* <DisplaySwitcher /> */}
-                    {/* <PageStatusAlert /> */}
+                    <PageStatusAlert />
                   </>
                 ) }
 

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

@@ -122,3 +122,7 @@ 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 });
+};

+ 3 - 2
packages/app/src/stores/hackmd.ts

@@ -1,4 +1,5 @@
 import { SWRResponse } from 'swr';
+
 import { useStaticSWR } from './use-static-swr';
 
 type Nullable<T> = T | null;
@@ -16,6 +17,6 @@ export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRRespo
   return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
 };
 
-export const useRemoteRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('remoteRevisionId', initialData);
+export const useIsHackmdDraftUpdatingInRealtime = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
+  return useStaticSWR<Nullable<boolean>, Error>('isHackmdDraftUpdatingInRealtime', initialData);
 };

+ 1 - 1
packages/app/src/stores/page.tsx

@@ -29,7 +29,7 @@ export const useSWRxPage = (
     revisionId?: string,
     initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
-  const swrResponse = useSWR<IPagePopulatedToShowRevision|null, Error>(
+  const swrResponse = useSWRImmutable<IPagePopulatedToShowRevision|null, Error>(
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
       .then(result => result.data.page)

+ 18 - 0
packages/app/src/stores/remote-latest-page.ts

@@ -0,0 +1,18 @@
+import { SWRResponse } from 'swr';
+
+import { IUser } from '~/interfaces/user';
+
+import { useStaticSWR } from './use-static-swr';
+
+
+export const useRemoteRevisionId = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('remoteRevisionId', initialData);
+};
+
+export const useRemoteRevisionBody = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('remoteRevisionId', initialData);
+};
+
+export const useRemoteRevisionLastUpdatUser = (initialData?: IUser): SWRResponse<IUser, Error> => {
+  return useStaticSWR<IUser, Error>('remoteRevisionLastUpdatUser', initialData);
+};

+ 28 - 9
packages/app/src/stores/websocket.tsx

@@ -1,9 +1,12 @@
-import { SWRResponse } from 'swr';
+import { useEffect } from 'react';
+
 import io, { Socket } from 'socket.io-client';
+import { SWRResponse } from 'swr';
 
-import { useStaticSWR } from './use-static-swr';
 import loggerFactory from '~/utils/logger';
 
+import { useStaticSWR } from './use-static-swr';
+
 const logger = loggerFactory('growi:stores:ui');
 
 export const GLOBAL_SOCKET_NS = '/';
@@ -15,15 +18,21 @@ export const GLOBAL_ADMIN_SOCKET_KEY = 'globalAdminSocket';
 /*
  * Global Socket
  */
-export const useSetupGlobalSocket = (): SWRResponse<Socket, Error> => {
-  const socket = io(GLOBAL_SOCKET_NS, {
-    transports: ['websocket'],
-  });
+export const useSetupGlobalSocket = (): void => {
+
+  const { mutate } = useStaticSWR(GLOBAL_SOCKET_KEY);
 
-  socket.on('error', (err) => { logger.error(err) });
-  socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+  useEffect(() => {
+    const socket = io(GLOBAL_SOCKET_NS, {
+      transports: ['websocket'],
+    });
 
-  return useStaticSWR(GLOBAL_SOCKET_KEY, socket);
+    socket.on('error', (err) => { logger.error(err) });
+    socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+
+    mutate(socket);
+
+  }, [mutate]);
 };
 
 export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
@@ -51,3 +60,13 @@ export const useSetupGlobalAdminSocket = (shouldInit: boolean): SWRResponse<Sock
 export const useGlobalAdminSocket = (): SWRResponse<Socket, Error> => {
   return useStaticSWR(GLOBAL_ADMIN_SOCKET_KEY);
 };
+
+export const useSetupGlobalSocketForPage = (pageId: string | undefined): void => {
+  const { data: socket } = useGlobalSocket();
+
+  useEffect(() => {
+    if (socket == null || pageId == null) { return }
+
+    socket.emit('join:page', { socketId: socket.id, pageId });
+  }, [pageId, socket]);
+};

+ 34 - 31
packages/app/src/styles/_page.scss

@@ -1,40 +1,43 @@
 // // import diff2html styles
 // @import '~/diff2html/bundles/css/diff2html.min.css';
 
-.card.grw-page-status-alert {
-  $margin-bottom: $grw-navbar-bottom-height + 10px;
-
-  box-shadow: 0px 2px 4px #0000004d;
-  opacity: 0.9;
-
-  @include media-breakpoint-down(sm) {
-    margin: 0 10px $margin-bottom;
-
-    .grw-card-label-container {
-      text-align: center;
-    }
-    .grw-card-btn-container {
-      text-align: center;
-
-      .btn {
-        @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-line-height-lg, $btn-border-radius-lg);
-      }
-    }
+/**
+ * for table with handsontable modal button
+ */
+.editable-with-handsontable {
+  position: relative;
+
+  .handsontable-modal-trigger {
+    position: absolute;
+    top: 11px;
+    right: 10px;
+    padding: 0;
+    font-size: 16px;
+    line-height: 1;
+    vertical-align: bottom;
+    background-color: transparent;
+    border: none;
+    opacity: 0;
   }
 
-  @include media-breakpoint-up(md) {
-    width: 700px;
-    margin: 0 auto $margin-bottom;
+  .page-mobile & .handsontable-modal-trigger {
+    opacity: 0.3;
+  }
 
-    .card-body {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-    }
+  &:hover .handsontable-modal-trigger {
+    opacity: 1;
+  }
+}
 
-    .grw-card-label-container,
-    .grw-card-btn-container {
-      margin: 0;
-    }
+/**
+ * for drawio with drawio iframe button
+ */
+.editable-with-drawio {
+  .drawio-iframe-trigger {
+    top: 11px;
+    right: 10px;
+    z-index: 14;
+    font-size: 12px;
+    line-height: 1;
   }
 }