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

Merge pull request #8486 from weseek/feat/132774-140637-able-to-see-editing-user

feat: Add editing user list on page header
Yuki Takei 2 лет назад
Родитель
Сommit
59009b70f3

+ 2 - 3
apps/app/src/components/PageControls/BookmarkButtons.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useState, useCallback,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import DropdownToggle from 'reactstrap/esm/DropdownToggle';
 import DropdownToggle from 'reactstrap/esm/DropdownToggle';

+ 4 - 2
apps/app/src/components/PageControls/LikeButtons.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -8,6 +9,7 @@ import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import UserPictureList from '../Common/UserPictureList';
 import UserPictureList from '../Common/UserPictureList';
 
 
 import styles from './LikeButtons.module.scss';
 import styles from './LikeButtons.module.scss';
+import popoverStyles from './user-list-popover.module.scss';
 
 
 type LikeButtonsProps = {
 type LikeButtonsProps = {
 
 
@@ -65,7 +67,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         {sumOfLikers}
         {sumOfLikers}
       </button>
       </button>
       <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
       <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
-        <PopoverBody className="user-list-popover">
+        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
           <div className="px-2 text-end user-list-content text-truncate text-muted">
           <div className="px-2 text-end user-list-content text-truncate text-muted">
             {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
             {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
           </div>
           </div>

+ 2 - 1
apps/app/src/components/PageControls/SeenUserInfo.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState } from 'react';
+import type { FC } from 'react';
+import React, { useState } from 'react';
 
 
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import { FootstampIcon } from '@growi/ui/dist/components';
 import { FootstampIcon } from '@growi/ui/dist/components';

+ 3 - 10
apps/app/src/components/PageControls/user-list-popover.module.scss

@@ -1,12 +1,5 @@
-.user-list-popover :global {
-  --bs-popover-max-width: 200px;
-  --bs-popover-body-padding-x: .5rem;
-  --bs-popover-body-padding-y: .5rem;
+@use '@growi/ui/scss/molecules/user-list-popover';
 
 
-  .user-list-content {
-    direction: rtl;
-  }
-  .cls-1 {
-    isolation: isolate;
-  }
+.user-list-popover :global {
+  @extend %user-list-popover
 }
 }

+ 5 - 4
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -6,7 +6,7 @@ import React, {
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 import nodePath from 'path';
 import nodePath from 'path';
 
 
-import type { IPageHasId } from '@growi/core';
+import type { IPageHasId, IUserHasId } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
 import {
@@ -53,6 +53,7 @@ import {
   EditorMode,
   EditorMode,
   useEditorMode, useSelectedGrant,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { useEditingUsers } from '~/stores/use-editing-users';
 import { useNextThemes } from '~/stores/use-next-themes';
 import { useNextThemes } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -117,6 +118,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
   const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
   const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
   const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
   const { data: user } = useCurrentUser();
   const { data: user } = useCurrentUser();
+  const { onEditorsUpdated } = useEditingUsers();
 
 
   const { data: socket } = useGlobalSocket();
   const { data: socket } = useGlobalSocket();
 
 
@@ -157,8 +159,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     // set to ref
     // set to ref
     initialValueRef.current = initialValue;
     initialValueRef.current = initialValue;
   }, [initialValue]);
   }, [initialValue]);
-
-
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
     setMarkdownToPreview(value);
     setMarkdownToPreview(value);
@@ -445,10 +445,11 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             acceptedUploadFileType={acceptedUploadFileType}
             acceptedUploadFileType={acceptedUploadFileType}
             onScroll={scrollEditorHandlerThrottle}
             onScroll={scrollEditorHandlerThrottle}
             indentSize={currentIndentSize ?? defaultIndentSize}
             indentSize={currentIndentSize ?? defaultIndentSize}
-            userName={user?.name}
+            user={user ?? undefined}
             pageId={pageId ?? undefined}
             pageId={pageId ?? undefined}
             initialValue={initialValue}
             initialValue={initialValue}
             onOpenEditor={markdown => setMarkdownToPreview(markdown)}
             onOpenEditor={markdown => setMarkdownToPreview(markdown)}
+            onEditorsUpdated={onEditorsUpdated}
             editorTheme={editorSettings?.theme}
             editorTheme={editorSettings?.theme}
             editorKeymap={editorSettings?.keymapMode}
             editorKeymap={editorSettings?.keymapMode}
           />
           />

+ 53 - 0
apps/app/src/components/PageHeader/EditingUserList.tsx

@@ -0,0 +1,53 @@
+import { type FC, useState } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { UserPicture } from '@growi/ui/dist/components';
+import { Popover, PopoverBody } from 'reactstrap';
+
+import UserPictureList from '../Common/UserPictureList';
+
+import popoverStyles from './user-list-popover.module.scss';
+
+type Props = {
+  className: string,
+  userList: IUserHasId[]
+}
+
+export const EditingUserList: FC<Props> = ({ className, userList }) => {
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
+
+  const firstFourUsers = userList.slice(0, 4);
+  const remainingUsers = userList.slice(4);
+
+  return (
+    <div className={className}>
+      {userList.length > 0 && (
+        <div className="d-flex justify-content-end">
+          {firstFourUsers.map(user => (
+            <div className="ms-1">
+              <UserPicture
+                user={user}
+                noLink
+                additionalClassName="border border-info"
+              />
+            </div>
+          ))}
+          {remainingUsers.length > 0 && (
+            <div className="ms-1">
+              <button type="button" id="btn-editing-user" className="btn border-0 bg-info-subtle rounded-pill p-0">
+                <span className="fw-bold text-info p-1">+{remainingUsers.length}</span>
+              </button>
+              <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-editing-user" toggle={togglePopover} trigger="legacy">
+                <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
+                  <UserPictureList users={remainingUsers} />
+                </PopoverBody>
+              </Popover>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+};

+ 17 - 4
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -1,7 +1,11 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 
 
+import { DevidedPagePath } from '@growi/core/dist/models';
+
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
+import { useEditingUsers } from '~/stores/use-editing-users';
 
 
+import { EditingUserList } from './EditingUserList';
 import { PagePathHeader } from './PagePathHeader';
 import { PagePathHeader } from './PagePathHeader';
 import { PageTitleHeader } from './PageTitleHeader';
 import { PageTitleHeader } from './PageTitleHeader';
 
 
@@ -11,20 +15,29 @@ const moduleClass = styles['page-header'] ?? '';
 
 
 export const PageHeader: FC = () => {
 export const PageHeader: FC = () => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
+  const { data: editingUsers } = useEditingUsers();
 
 
   if (currentPage == null) {
   if (currentPage == null) {
     return <></>;
     return <></>;
   }
   }
 
 
+  const dPagePath = new DevidedPagePath(currentPage.path, true);
+
   return (
   return (
     <div className={moduleClass}>
     <div className={moduleClass}>
       <PagePathHeader
       <PagePathHeader
         currentPage={currentPage}
         currentPage={currentPage}
       />
       />
-      <PageTitleHeader
-        className="mt-2"
-        currentPage={currentPage}
-      />
+      <div className="row mt-2">
+        <PageTitleHeader
+          className="col"
+          currentPage={currentPage}
+        />
+        <EditingUserList
+          className={`${dPagePath.isRoot ? 'mt-1' : 'col mt-2'}`}
+          userList={editingUsers?.userList ?? []}
+        />
+      </div>
     </div>
     </div>
   );
   );
 };
 };

+ 5 - 0
apps/app/src/components/PageHeader/user-list-popover.module.scss

@@ -0,0 +1,5 @@
+@use '@growi/ui/scss/molecules/user-list-popover';
+
+.user-list-popover :global {
+  @extend %user-list-popover;
+}

+ 2 - 1
apps/app/src/pages/trash.page.tsx

@@ -1,4 +1,5 @@
-import React, { ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React from 'react';
 
 
 import type { IUser, IUserHasId } from '@growi/core';
 import type { IUser, IUserHasId } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';

+ 33 - 0
apps/app/src/stores/use-editing-users.ts

@@ -0,0 +1,33 @@
+import { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+
+type EditingUsersStatus = {
+  userList: IUserHasId[],
+}
+
+type EditingUsersStatusUtils = {
+  onEditorsUpdated(
+    userList: IUserHasId[],
+  ): void,
+}
+
+export const useEditingUsers = (status?: EditingUsersStatus): SWRResponse<EditingUsersStatus, Error> & EditingUsersStatusUtils => {
+  const initialData: EditingUsersStatus = {
+    userList: [],
+  };
+  const swrResponse = useSWRStatic<EditingUsersStatus, Error>('editingUsers', status, { fallbackData: initialData });
+
+  const { mutate } = swrResponse;
+
+  const onEditorsUpdated = useCallback((userList: IUserHasId[]): void => {
+    mutate({ userList });
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+    onEditorsUpdated,
+  };
+};

+ 6 - 4
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -2,6 +2,7 @@ import { useEffect } from 'react';
 
 
 import { type Extension } from '@codemirror/state';
 import { type Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
 import { keymap, scrollPastEnd } from '@codemirror/view';
+import type { IUserHasId } from '@growi/core/dist/interfaces';
 
 
 import { GlobalCodeMirrorEditorKey } from '../consts';
 import { GlobalCodeMirrorEditorKey } from '../consts';
 import { setDataLine } from '../services/extensions/setDataLine';
 import { setDataLine } from '../services/extensions/setDataLine';
@@ -17,23 +18,24 @@ const additionalExtensions: Extension[] = [
 ];
 ];
 
 
 type Props = CodeMirrorEditorProps & {
 type Props = CodeMirrorEditorProps & {
-  userName?: string,
+  user?: IUserHasId,
   pageId?: string,
   pageId?: string,
   initialValue?: string,
   initialValue?: string,
   onOpenEditor?: (markdown: string) => void,
   onOpenEditor?: (markdown: string) => void,
+  onEditorsUpdated?: (userList: IUserHasId[]) => void,
 }
 }
 
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
   const {
     acceptedUploadFileType,
     acceptedUploadFileType,
-    indentSize, userName, pageId, initialValue,
+    indentSize, user, pageId, initialValue,
     editorTheme, editorKeymap,
     editorTheme, editorKeymap,
-    onSave, onChange, onUpload, onScroll, onOpenEditor,
+    onSave, onChange, onUpload, onScroll, onOpenEditor, onEditorsUpdated,
   } = props;
   } = props;
 
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
 
-  useCollaborativeEditorMode(userName, pageId, initialValue, onOpenEditor, codeMirrorEditor);
+  useCollaborativeEditorMode(user, pageId, initialValue, onOpenEditor, onEditorsUpdated, codeMirrorEditor);
 
 
   // setup additional extensions
   // setup additional extensions
   useEffect(() => {
   useEffect(() => {

+ 36 - 11
packages/editor/src/stores/use-collaborative-editor-mode.ts

@@ -1,6 +1,6 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 
 
-import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
+import { GlobalSocketEventName, type IUserHasId } from '@growi/core/dist/interfaces';
 import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
 import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
 // see: https://github.com/yjs/y-codemirror.next#example
 // see: https://github.com/yjs/y-codemirror.next#example
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -12,11 +12,19 @@ import * as Y from 'yjs';
 import { userColor } from '../consts';
 import { userColor } from '../consts';
 import { UseCodeMirrorEditor } from '../services';
 import { UseCodeMirrorEditor } from '../services';
 
 
+type UserLocalState = {
+  name: string;
+  user?: IUserHasId;
+  color: string;
+  colorLight: string;
+}
+
 export const useCollaborativeEditorMode = (
 export const useCollaborativeEditorMode = (
-    userName?: string,
+    user?: IUserHasId,
     pageId?: string,
     pageId?: string,
     initialValue?: string,
     initialValue?: string,
     onOpenEditor?: (markdown: string) => void,
     onOpenEditor?: (markdown: string) => void,
+    onEditorsUpdated?: (userList: IUserHasId[]) => void,
     codeMirrorEditor?: UseCodeMirrorEditor,
     codeMirrorEditor?: UseCodeMirrorEditor,
 ): void => {
 ): void => {
   const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
   const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
@@ -34,8 +42,8 @@ export const useCollaborativeEditorMode = (
     ydoc?.destroy();
     ydoc?.destroy();
     setYdoc(null);
     setYdoc(null);
 
 
-    // NOTICE: Destorying the provider leaves awareness in the other user's connection,
-    // so only awareness is destoryed here
+    // NOTICE: Destroying the provider leaves awareness in the other user's connection,
+    // so only awareness is destroyed here
     provider?.awareness.destroy();
     provider?.awareness.destroy();
 
 
     // TODO: catch ydoc:sync:error GlobalSocketEventName.YDocSyncError
     // TODO: catch ydoc:sync:error GlobalSocketEventName.YDocSyncError
@@ -43,6 +51,9 @@ export const useCollaborativeEditorMode = (
 
 
     setIsInit(false);
     setIsInit(false);
     setCPageId(pageId);
     setCPageId(pageId);
+
+    // reset editors
+    onEditorsUpdated?.([]);
   };
   };
 
 
   const setupYDoc = () => {
   const setupYDoc = () => {
@@ -50,7 +61,7 @@ export const useCollaborativeEditorMode = (
       return;
       return;
     }
     }
 
 
-    // NOTICE: Old provider destory at the time of ydoc setup,
+    // NOTICE: Old provider destroy at the time of ydoc setup,
     // because the awareness destroying is not sync to other clients
     // because the awareness destroying is not sync to other clients
     provider?.destroy();
     provider?.destroy();
     setProvider(null);
     setProvider(null);
@@ -60,7 +71,7 @@ export const useCollaborativeEditorMode = (
   };
   };
 
 
   const setupProvider = () => {
   const setupProvider = () => {
-    if (provider != null || ydoc == null || socket == null) {
+    if (provider != null || ydoc == null || socket == null || onEditorsUpdated == null) {
       return;
       return;
     }
     }
 
 
@@ -71,15 +82,29 @@ export const useCollaborativeEditorMode = (
       { autoConnect: true },
       { autoConnect: true },
     );
     );
 
 
-    socketIOProvider.awareness.setLocalStateField('user', {
-      name: userName ? `${userName}` : `Guest User ${Math.floor(Math.random() * 100)}`,
+    const userLocalState: UserLocalState = {
+      name: user?.name ? `${user.name}` : `Guest User ${Math.floor(Math.random() * 100)}`,
+      user,
       color: userColor.color,
       color: userColor.color,
       colorLight: userColor.light,
       colorLight: userColor.light,
-    });
+    };
+
+    socketIOProvider.awareness.setLocalStateField('user', userLocalState);
 
 
     socketIOProvider.on('sync', (isSync: boolean) => {
     socketIOProvider.on('sync', (isSync: boolean) => {
       if (isSync) {
       if (isSync) {
         socket.emit(GlobalSocketEventName.YDocSync, { pageId, initialValue });
         socket.emit(GlobalSocketEventName.YDocSync, { pageId, initialValue });
+        const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
+        onEditorsUpdated(userList);
+      }
+    });
+
+    // update args type see: SocketIOProvider.Awareness.awarenessUpdate
+    socketIOProvider.awareness.on('update', (update: any) => {
+      const { added, removed } = update;
+      if (added.length > 0 || removed.length > 0) {
+        const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
+        onEditorsUpdated(userList);
       }
       }
     });
     });
 
 
@@ -113,9 +138,9 @@ export const useCollaborativeEditorMode = (
     setIsInit(true);
     setIsInit(true);
   };
   };
 
 
-  useEffect(cleanupYDocAndProvider, [cPageId, pageId, provider, socket, ydoc]);
+  useEffect(cleanupYDocAndProvider, [cPageId, onEditorsUpdated, pageId, provider, socket, ydoc]);
   useEffect(setupYDoc, [provider, ydoc]);
   useEffect(setupYDoc, [provider, ydoc]);
-  useEffect(setupProvider, [initialValue, pageId, provider, socket, userName, ydoc]);
+  useEffect(setupProvider, [initialValue, onEditorsUpdated, pageId, provider, socket, user, ydoc]);
   useEffect(setupYDocExtensions, [codeMirrorEditor, provider, ydoc]);
   useEffect(setupYDocExtensions, [codeMirrorEditor, provider, ydoc]);
   useEffect(initializeEditor, [codeMirrorEditor, isInit, onOpenEditor, ydoc]);
   useEffect(initializeEditor, [codeMirrorEditor, isInit, onOpenEditor, ydoc]);
 };
 };

+ 12 - 0
packages/ui/scss/molecules/_user-list-popover.scss

@@ -0,0 +1,12 @@
+%user-list-popover {
+  --bs-popover-max-width: 200px;
+  --bs-popover-body-padding-x: .5rem;
+  --bs-popover-body-padding-y: .5rem;
+
+  .user-list-content {
+    direction: rtl;
+  }
+  .cls-1 {
+    isolation: isolate;
+  }
+}

+ 5 - 1
packages/ui/src/components/UserPicture.tsx

@@ -74,18 +74,22 @@ type Props = {
   size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
   size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
   noLink?: boolean,
   noLink?: boolean,
   noTooltip?: boolean,
   noTooltip?: boolean,
+  additionalClassName?: string
 };
 };
 
 
 export const UserPicture = memo((props: Props): JSX.Element => {
 export const UserPicture = memo((props: Props): JSX.Element => {
 
 
   const {
   const {
-    user, size, noLink, noTooltip,
+    user, size, noLink, noTooltip, additionalClassName,
   } = props;
   } = props;
 
 
   const classNames = ['rounded-circle', 'picture'];
   const classNames = ['rounded-circle', 'picture'];
   if (size != null) {
   if (size != null) {
     classNames.push(`picture-${size}`);
     classNames.push(`picture-${size}`);
   }
   }
+  if (additionalClassName != null) {
+    classNames.push(additionalClassName);
+  }
   const className = classNames.join(' ');
   const className = classNames.join(' ');
 
 
   if (user == null || !isUserObj(user)) {
   if (user == null || !isUserObj(user)) {