Răsfoiți Sursa

Merge remote-tracking branch 'origin/master' into imprv/closable-text-input-autosizing

Yuki Takei 1 an în urmă
părinte
comite
498b229db5

+ 1 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -77,6 +77,7 @@
 
   "create_page_dropdown": {
     "new_page": "Create New Page",
+    "open_page_create_modal": "Open new page create modal",
     "todays": {
       "desc": "Create today's memo",
       "memo": "memo"

+ 1 - 0
apps/app/public/static/locales/fr_FR/commons.json

@@ -77,6 +77,7 @@
 
   "create_page_dropdown": {
     "new_page": "Créer nouvelle page",
+    "open_page_create_modal": "Ouvrir une nouvelle page créer une fenêtre modale",
     "todays": {
       "desc": "Créer le mémo du jour",
       "memo": "mémo"

+ 1 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -79,6 +79,7 @@
 
   "create_page_dropdown": {
     "new_page": "新規ページ作成",
+    "open_page_create_modal": "新規ページ作成モーダルを表示",
     "todays": {
       "desc": "今日のメモを作成",
       "memo": "メモ"

+ 1 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -80,6 +80,7 @@
 
   "create_page_dropdown": {
     "new_page": "新页面",
+    "open_page_create_modal": "打开新页面创建模式",
     "todays": {
       "desc": "Create today's memo",
       "memo": "memo"

+ 33 - 0
apps/app/src/client/services/side-effects/yjs.ts

@@ -0,0 +1,33 @@
+import { useCallback, useEffect } from 'react';
+
+import { useGlobalSocket } from '@growi/core/dist/swr';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { useCurrentPageYjsData } from '~/stores/yjs';
+
+export const useCurrentPageYjsDataEffect = (): void => {
+  const { data: socket } = useGlobalSocket();
+  const { updateHasRevisionBodyDiff, updateAwarenessStateSize } = useCurrentPageYjsData();
+
+  const hasRevisionBodyDiffUpdateHandler = useCallback((hasRevisionBodyDiff: boolean) => {
+    updateHasRevisionBodyDiff(hasRevisionBodyDiff);
+  }, [updateHasRevisionBodyDiff]);
+
+  const awarenessStateSizeUpdateHandler = useCallback(((awarenessStateSize: number) => {
+    updateAwarenessStateSize(awarenessStateSize);
+  }), [updateAwarenessStateSize]);
+
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
+    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+
+    return () => {
+      socket.off(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
+      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+    };
+
+  }, [socket, awarenessStateSizeUpdateHandler, hasRevisionBodyDiffUpdateHandler]);
+};

+ 14 - 2
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,13 +1,13 @@
-import React, { type ReactNode, useCallback } from 'react';
+import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 import { Origin } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
 
@@ -65,6 +65,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const { isCreating, createAndTransit } = useCreatePageAndTransit();
 
@@ -87,6 +88,16 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
+  const circleColor = useMemo(() => {
+    if (currentPageYjsData?.awarenessStateSize != null && currentPageYjsData.awarenessStateSize > 0) {
+      return 'bg-primary';
+    }
+
+    if (currentPageYjsData?.hasRevisionBodyDiff != null && currentPageYjsData.hasRevisionBodyDiff) {
+      return 'bg-secondary';
+    }
+  }, [currentPageYjsData]);
+
   return (
     <>
       <div
@@ -113,6 +124,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             onClick={editButtonClickedHandler}
           >
             <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
+            { circleColor != null && <span className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`} />}
           </PageEditorModeButton>
         )}
       </div>

+ 2 - 0
apps/app/src/components/Page/DisplaySwitcher.tsx

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
+import { useCurrentPageYjsDataEffect } from '~/client/services/side-effects/yjs';
 import { useIsEditable } from '~/stores/context';
 import { useIsLatestRevision } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
@@ -26,6 +27,7 @@ export const DisplaySwitcher = (props: Props): JSX.Element => {
 
   usePageUpdatedEffect();
   useHashChangedEffect();
+  useCurrentPageYjsDataEffect();
 
   return (
     <>

+ 9 - 0
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -8,6 +8,7 @@ import type { LabelType } from '~/interfaces/template';
 
 type DropendMenuProps = {
   onClickCreateNewPage: () => Promise<void>
+  onClickOpenPageCreateModal: () => void
   onClickCreateTodaysMemo: () => Promise<void>
   onClickCreateTemplate?: (label: LabelType) => Promise<void>
   todaysPath: string | null,
@@ -16,6 +17,7 @@ type DropendMenuProps = {
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
   const {
     onClickCreateNewPage,
+    onClickOpenPageCreateModal,
     onClickCreateTodaysMemo,
     onClickCreateTemplate,
     todaysPath,
@@ -34,6 +36,13 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
         {t('create_page_dropdown.new_page')}
       </DropdownItem>
 
+      <DropdownItem
+        onClick={onClickOpenPageCreateModal}
+      >
+        {t('create_page_dropdown.open_page_create_modal')}
+      </DropdownItem>
+
+
       { todaysPath != null && (
         <>
           <DropdownItem divider />

+ 6 - 0
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -4,6 +4,8 @@ import { Dropdown } from 'reactstrap';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
+import { usePageCreateModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
 
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
@@ -16,6 +18,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
+  const { open: openPageCreateModal } = usePageCreateModal();
+  const { data: currentPagePath } = useCurrentPagePath();
+
   const { createNewPage, isCreating: isNewPageCreating } = useCreateNewPage();
   // TODO: https://redmine.weseek.co.jp/issues/138806
   const { createTodaysMemo, isCreating: isTodaysPageCreating, todaysPath } = useCreateTodaysMemo();
@@ -64,6 +69,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
           <DropendToggle />
           <DropendMenu
             onClickCreateNewPage={createNewPageWithToastr}
+            onClickOpenPageCreateModal={() => openPageCreateModal(currentPagePath)}
             onClickCreateTodaysMemo={createTodaysMemoWithToastr}
             onClickCreateTemplate={isTemplatePageCreatable ? createTemplateWithToastr : undefined}
             todaysPath={todaysPath}

+ 17 - 13
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -8,9 +8,6 @@ import {
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'react-i18next';
-import {
-  DropdownItem, DropdownMenu, DropdownToggle, UncontrolledButtonDropdown,
-} from 'reactstrap';
 
 import { useKeywordManager } from '~/client/services/search-operation';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
@@ -182,13 +179,20 @@ export const RecentChangesHeader = ({
     <>
       <SidebarHeaderReloadButton onClick={() => mutate()} />
 
-      <UncontrolledButtonDropdown className="me-1">
-        <DropdownToggle color="transparent" className="p-0 border-0">
+      <div className="me-1">
+        <button
+          color="transparent"
+          className="btn p-0 border-0"
+          type="button"
+          data-bs-toggle="dropdown"
+          data-bs-auto-close="outside"
+          aria-expanded="false"
+        >
           <span className="material-symbols-outlined">more_horiz</span>
-        </DropdownToggle>
+        </button>
 
-        <DropdownMenu container="body">
-          <DropdownItem onClick={changeSizeHandler}>
+        <ul className="dropdown-menu">
+          <li className="dropdown-item" onClick={changeSizeHandler}>
             <div className={`${styles['grw-recent-changes-resize-button']} form-check form-switch mb-0`}>
               <input
                 id="recentChangesResize"
@@ -201,9 +205,9 @@ export const RecentChangesHeader = ({
                 {isSmall ? t('sidebar_header.size_s') : t('sidebar_header.size_l')}
               </label>
             </div>
-          </DropdownItem>
+          </li>
 
-          <DropdownItem onClick={onWipPageShownChange}>
+          <li className="dropdown-item" onClick={onWipPageShownChange}>
             <div className="form-check form-switch mb-0">
               <input
                 id="wipPageVisibility"
@@ -216,9 +220,9 @@ export const RecentChangesHeader = ({
                 {t('sidebar_header.show_wip_page')}
               </label>
             </div>
-          </DropdownItem>
-        </DropdownMenu>
-      </UncontrolledButtonDropdown>
+          </li>
+        </ul>
+      </div>
     </>
   );
 };

+ 3 - 2
apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -4,7 +4,7 @@ import { memo } from 'react';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 
-import { useGrowiCloudUri, useIsAdmin } from '~/stores/context';
+import { useIsGuestUser, useGrowiCloudUri, useIsAdmin } from '~/stores/context';
 
 import { SkeletonItem } from './SkeletonItem';
 
@@ -43,10 +43,11 @@ export const SecondaryItems: FC = memo(() => {
 
   const { data: isAdmin } = useIsAdmin();
   const { data: growiCloudUri } = useGrowiCloudUri();
+  const { data: isGuestUser } = useIsGuestUser();
 
   return (
     <div className={styles['grw-secondary-items']}>
-      <PersonalDropdown />
+      {!isGuestUser && <PersonalDropdown />}
       <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
       {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />

+ 8 - 0
apps/app/src/interfaces/websocket.ts

@@ -40,10 +40,18 @@ export const SocketEventName = {
   // External user group sync
   externalUserGroup: generateGroupSyncEvents(),
 
+  // room per pageId
+  JoinPage: 'join:page',
+  LeavePage: 'leave:page',
+
   // Page Operation
   PageCreated: 'page:create',
   PageUpdated: 'page:update',
   PageDeleted: 'page:delete',
+
+  // Yjs
+  YjsAwarenessStateSizeUpdated: 'yjs:awareness-state-size-update',
+  YjsHasRevisionBodyDiffUpdated: 'yjs:has-revision-body-diff-update',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

+ 4 - 0
apps/app/src/interfaces/yjs.ts

@@ -0,0 +1,4 @@
+export type CurrentPageYjsData = {
+  hasRevisionBodyDiff?: boolean,
+  awarenessStateSize?: number,
+}

+ 18 - 1
apps/app/src/pages/[[...path]].page.tsx

@@ -27,6 +27,7 @@ import { SupportedAction, type SupportedActionType } from '~/interfaces/activity
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import {
@@ -49,6 +50,7 @@ import {
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
+import { useCurrentPageYjsData, useSWRMUTxCurrentPageYjsData } from '~/stores/yjs';
 import loggerFactory from '~/utils/logger';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
@@ -172,6 +174,8 @@ type Props = CommonProps & {
   skipSSR: boolean,
   ssrMaxRevisionBodyLength: number,
 
+  yjsData: CurrentPageYjsData,
+
   rendererConfig: RendererConfig,
 };
 
@@ -232,6 +236,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { data: currentPage } = useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { trigger: mutateCurrentPageYjsDataFromApi } = useSWRMUTxCurrentPageYjsData();
+
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
 
@@ -244,6 +250,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { mutate: mutateTemplateTagData } = useTemplateTagData();
   const { mutate: mutateTemplateBodyData } = useTemplateBodyData();
 
+  const { mutate: mutateCurrentPageYjsData } = useCurrentPageYjsData();
+
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
 
@@ -257,13 +265,14 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       const mutatePageData = async() => {
         const pageData = await mutateCurrentPage();
         mutateEditingMarkdown(pageData?.revision?.body);
+        mutateCurrentPageYjsDataFromApi();
       };
 
       // If skipSSR is true, use the API to retrieve page data.
       // Because pageWIthMeta does not contain revision.body
       mutatePageData();
     }
-  }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
+  }, [currentPageId, mutateCurrentPage, mutateCurrentPageYjsDataFromApi, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
@@ -306,6 +315,10 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     mutateTemplateBodyData(props.templateBodyData);
   }, [props.templateBodyData, mutateTemplateBodyData]);
 
+  useEffect(() => {
+    mutateCurrentPageYjsData(props.yjsData);
+  }, [mutateCurrentPageYjsData, props.yjsData]);
+
   // If the data on the page changes without router.push, pageWithMeta remains old because getServerSideProps() is not executed
   // So preferentially take page data from useSWRxCurrentPage
   const pagePath = currentPage?.path ?? pageWithMeta?.data.path ?? props.currentPathname;
@@ -485,6 +498,10 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
         props.currentPathname = `/${page._id}`;
       }
     }
+
+    if (!props.skipSSR) {
+      props.yjsData = await crowi.pageService.getYjsData(page._id);
+    }
   }
 }
 

+ 57 - 0
apps/app/src/server/routes/apiv3/page/get-yjs-data.ts

@@ -0,0 +1,57 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:get-yjs-data');
+
+type GetYjsDataHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  pageId: string,
+}
+interface Req extends Request<ReqParams, ApiV3Response> {
+  user: IUserHasId,
+}
+export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.params
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+
+      // check whether accessible
+      if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      }
+
+      try {
+        const yjsData = await crowi.pageService.getYjsData(pageId);
+        return res.apiv3({ yjsData });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 3 - 0
apps/app/src/server/routes/apiv3/page/index.ts

@@ -24,6 +24,7 @@ import loggerFactory from '~/utils/logger';
 
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
+import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
@@ -908,5 +909,7 @@ module.exports = (crowi) => {
 
   router.put('/:pageId/unpublish', unpublishPageHandlersFactory(crowi));
 
+  router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
+
   return router;
 };

+ 29 - 0
apps/app/src/server/service/page/index.ts

@@ -33,6 +33,7 @@ import {
 } from '~/interfaces/page-operation';
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { CreateMethod } from '~/server/models/page';
 import {
   type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
@@ -40,6 +41,7 @@ import {
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import type { UserGroupDocument } from '~/server/models/user-group';
+import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
 import loggerFactory from '~/utils/logger';
@@ -4447,6 +4449,33 @@ class PageService implements IPageService {
     });
   }
 
+  async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
+    const yjsConnectionManager = getYjsConnectionManager();
+    const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
+    const yjsDraft = currentYdoc?.getText('codemirror').toString();
+    const hasRevisionBodyDiff = await this.hasRevisionBodyDiff(pageId, yjsDraft);
+
+    return {
+      hasRevisionBodyDiff,
+      awarenessStateSize: currentYdoc?.awareness.states.size,
+    };
+  }
+
+  async hasRevisionBodyDiff(pageId: string, comparisonTarget?: string): Promise<boolean> {
+    if (comparisonTarget == null) {
+      return false;
+    }
+
+    const Revision = mongoose.model<IRevisionHasId>('Revision');
+    const revision = await Revision.findOne({ pageId }).sort({ createdAt: -1 });
+
+    if (revision == null) {
+      return false;
+    }
+
+    return revision.body !== comparisonTarget;
+  }
+
   async createTtlIndex(): Promise<void> {
     const wipPageExpirationSeconds = configManager.getConfig('crowi', 'app:wipPageExpirationSeconds') ?? 172800;
     const collection = mongoose.connection.collection('pages');

+ 2 - 0
apps/app/src/server/service/page/page-service.ts

@@ -8,6 +8,7 @@ import type { ObjectId } from 'mongoose';
 
 import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { PageDocument } from '~/server/models/page';
 
@@ -30,4 +31,5 @@ export interface IPageService {
   canDeleteCompletelyAsMultiGroupGrantedPage(
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
   ): boolean,
+  getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
 }

+ 35 - 2
apps/app/src/server/service/socket-io.js

@@ -1,11 +1,13 @@
 import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
 import { Server } from 'socket.io';
 
+import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
-import { getYjsConnectionManager } from './yjs-connection-manager';
+import { getYjsConnectionManager, extractPageIdFromYdocId } from './yjs-connection-manager';
+
 
 const expressSession = require('express-session');
 const passport = require('passport');
@@ -51,6 +53,7 @@ class SocketIoService {
 
     await this.setupLoginedUserRoomsJoinOnConnection();
     await this.setupDefaultSocketJoinRoomsEventHandler();
+    await this.setupDefaultSocketLeaveRoomsEventHandler();
   }
 
   getDefaultSocket() {
@@ -149,15 +152,45 @@ class SocketIoService {
   setupDefaultSocketJoinRoomsEventHandler() {
     this.io.on('connection', (socket) => {
       // set event handlers for joining rooms
-      socket.on('join:page', ({ pageId }) => {
+      socket.on(SocketEventName.JoinPage, ({ pageId }) => {
         socket.join(getRoomNameWithId(RoomPrefix.PAGE, pageId));
       });
     });
   }
 
+  setupDefaultSocketLeaveRoomsEventHandler() {
+    this.io.on('connection', (socket) => {
+      socket.on(SocketEventName.LeavePage, ({ pageId }) => {
+        socket.leave(getRoomNameWithId(RoomPrefix.PAGE, pageId));
+      });
+    });
+  }
+
   setupYjsConnection() {
     const yjsConnectionManager = getYjsConnectionManager();
+
     this.io.on('connection', (socket) => {
+
+      yjsConnectionManager.ysocketioInstance.on('awareness-update', async(update) => {
+        const pageId = extractPageIdFromYdocId(update.name);
+        const awarenessStateSize = update.awareness.states.size;
+
+        // Triggered when awareness changes
+        this.io
+          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+          .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
+
+        // Triggered when the last user leaves the editor
+        if (awarenessStateSize === 0) {
+          const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
+          const yjsDraft = currentYdoc?.getText('codemirror').toString();
+          const hasRevisionBodyDiff = await this.crowi.pageService.hasRevisionBodyDiff(pageId, yjsDraft);
+          this.io
+            .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+            .emit(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiff);
+        }
+      });
+
       socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
         try {
           await yjsConnectionManager.handleYDocSync(pageId, initialValue);

+ 20 - 7
apps/app/src/server/service/yjs-connection-manager.ts

@@ -1,6 +1,6 @@
 import type { Server } from 'socket.io';
 import { MongodbPersistence } from 'y-mongodb-provider';
-import { YSocketIO } from 'y-socket.io/dist/server';
+import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
 import * as Y from 'yjs';
 
 import { getMongoUri } from '../util/mongoose-utils';
@@ -8,6 +8,11 @@ import { getMongoUri } from '../util/mongoose-utils';
 const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
 const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
 
+export const extractPageIdFromYdocId = (ydocId: string): string | undefined => {
+  const result = ydocId.match(/yjs\/(.*)/);
+  return result?.[1];
+};
+
 class YjsConnectionManager {
 
   private static instance: YjsConnectionManager;
@@ -16,6 +21,10 @@ class YjsConnectionManager {
 
   private mdb: MongodbPersistence;
 
+  get ysocketioInstance(): YSocketIO {
+    return this.ysocketio;
+  }
+
   private constructor(io: Server) {
     this.ysocketio = new YSocketIO(io);
     this.ysocketio.initialize();
@@ -40,13 +49,16 @@ class YjsConnectionManager {
   }
 
   public async handleYDocSync(pageId: string, initialValue: string): Promise<void> {
+    const currentYdoc = this.getCurrentYdoc(pageId);
+    if (currentYdoc == null) {
+      return;
+    }
+
     const persistedYdoc = await this.mdb.getYDoc(pageId);
     const persistedStateVector = Y.encodeStateVector(persistedYdoc);
 
     await this.mdb.flushDocument(pageId);
 
-    const currentYdoc = this.getCurrentYdoc(pageId);
-
     const persistedCodeMirrorText = persistedYdoc.getText('codemirror').toString();
     const currentCodeMirrorText = currentYdoc.getText('codemirror').toString();
 
@@ -77,17 +89,18 @@ class YjsConnectionManager {
     // TODO: https://redmine.weseek.co.jp/issues/132775
     // It's necessary to confirm that the user is not editing the target page in the Editor
     const currentYdoc = this.getCurrentYdoc(pageId);
+    if (currentYdoc == null) {
+      return;
+    }
+
     const currentMarkdownLength = currentYdoc.getText('codemirror').length;
     currentYdoc.getText('codemirror').delete(0, currentMarkdownLength);
     currentYdoc.getText('codemirror').insert(0, newValue);
     Y.encodeStateAsUpdate(currentYdoc);
   }
 
-  private getCurrentYdoc(pageId: string): Y.Doc {
+  public getCurrentYdoc(pageId: string): Ydoc | undefined {
     const currentYdoc = this.ysocketio.documents.get(`yjs/${pageId}`);
-    if (currentYdoc == null) {
-      throw new Error(`currentYdoc for pageId ${pageId} is undefined.`);
-    }
     return currentYdoc;
   }
 

+ 1 - 0
apps/app/src/stores/page.tsx

@@ -23,6 +23,7 @@ import type { AxiosResponse } from '~/utils/axios';
 
 import type { IPageTagsInfo } from '../interfaces/tag';
 
+
 import {
   useCurrentPathname, useShareLinkId, useIsGuestUser, useIsReadOnlyUser,
 } from './context';

+ 7 - 2
apps/app/src/stores/websocket.tsx

@@ -2,8 +2,9 @@ import { useEffect } from 'react';
 
 import { useGlobalSocket, GLOBAL_SOCKET_KEY, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
 import type { Socket } from 'socket.io-client';
-import { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
 
+import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import { useStaticSWR } from './use-static-swr';
@@ -67,6 +68,10 @@ export const useSetupGlobalSocketForPage = (pageId: string | undefined): void =>
   useEffect(() => {
     if (socket == null || pageId == null) { return }
 
-    socket.emit('join:page', { socketId: socket.id, pageId });
+    socket.emit(SocketEventName.JoinPage, { pageId });
+
+    return () => {
+      socket.emit(SocketEventName.LeavePage, { pageId });
+    };
   }, [pageId, socket]);
 };

+ 42 - 0
apps/app/src/stores/yjs.ts

@@ -0,0 +1,42 @@
+import { useCallback } from 'react';
+
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
+
+import { useCurrentPageId } from './page';
+
+type CurrentPageYjsDataUtils = {
+  updateHasRevisionBodyDiff(hasRevisionBodyDiff: boolean): void
+  updateAwarenessStateSize(awarenessStateSize: number): void
+}
+
+export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsData, Error> & CurrentPageYjsDataUtils => {
+  const swrResponse = useSWRStatic<CurrentPageYjsData, Error>('currentPageYjsData', undefined);
+
+  const updateHasRevisionBodyDiff = useCallback((hasRevisionBodyDiff: boolean) => {
+    swrResponse.mutate({ ...swrResponse.data, hasRevisionBodyDiff });
+  }, [swrResponse]);
+
+  const updateAwarenessStateSize = useCallback((awarenessStateSize: number) => {
+    swrResponse.mutate({ ...swrResponse.data, awarenessStateSize });
+  }, [swrResponse]);
+
+  return {
+    ...swrResponse, updateHasRevisionBodyDiff, updateAwarenessStateSize,
+  };
+};
+
+export const useSWRMUTxCurrentPageYjsData = (): SWRMutationResponse<CurrentPageYjsData, Error> => {
+  const key = 'currentPageYjsData';
+  const { data: currentPageId } = useCurrentPageId();
+
+  return useSWRMutation(
+    key,
+    () => apiv3Get<{ yjsData: CurrentPageYjsData }>(`/page/${currentPageId}/yjs-data`).then(result => result.data.yjsData),
+    { populateCache: true, revalidate: false },
+  );
+};