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

Merge pull request #5236 from weseek/imprv/retrieve-pages-meta

Imprv/retrieve pages meta
Yuki Takei 4 лет назад
Родитель
Сommit
133453909d
45 измененных файлов с 778 добавлено и 661 удалено
  1. 2 6
      packages/app/src/client/services/ContextExtractor.tsx
  2. 0 2
      packages/app/src/client/services/PageContainer.js
  3. 53 0
      packages/app/src/client/services/page-operation.ts
  4. 20 14
      packages/app/src/components/BookmarkButtons.tsx
  5. 175 104
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  6. 16 4
      packages/app/src/components/IdenticalPathPage.tsx
  7. 7 14
      packages/app/src/components/LikeButtons.tsx
  8. 61 4
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  9. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  10. 87 59
      packages/app/src/components/Navbar/SubNavButtons.tsx
  11. 11 1
      packages/app/src/components/NotFoundPage.tsx
  12. 0 5
      packages/app/src/components/PageAccessoriesModalControl.jsx
  13. 18 20
      packages/app/src/components/PageList/PageListItemL.tsx
  14. 0 49
      packages/app/src/components/PageReactionButtons.tsx
  15. 8 14
      packages/app/src/components/SearchPage.jsx
  16. 1 3
      packages/app/src/components/SearchPage/SearchResultList.tsx
  17. 27 79
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  18. 9 41
      packages/app/src/components/SubscribeButton.tsx
  19. 8 16
      packages/app/src/components/User/SeenUserInfo.tsx
  20. 0 8
      packages/app/src/interfaces/page-info.ts
  21. 24 3
      packages/app/src/interfaces/page.ts
  22. 4 0
      packages/app/src/interfaces/revision.ts
  23. 1 0
      packages/app/src/interfaces/search.ts
  24. 6 0
      packages/app/src/interfaces/subscription.ts
  25. 2 13
      packages/app/src/server/models/obsolete-page.js
  26. 13 0
      packages/app/src/server/models/page.ts
  27. 8 9
      packages/app/src/server/models/subscription.ts
  28. 51 3
      packages/app/src/server/routes/apiv3/page-listing.ts
  29. 40 66
      packages/app/src/server/routes/apiv3/page.js
  30. 0 4
      packages/app/src/server/routes/page.js
  31. 4 3
      packages/app/src/server/service/in-app-notification.ts
  32. 45 9
      packages/app/src/server/service/page.ts
  33. 0 7
      packages/app/src/server/util/swigFunctions.js
  34. 0 1
      packages/app/src/server/views/layout-growi/identical-path-page.html
  35. 0 2
      packages/app/src/server/views/widget/page_content.html
  36. 6 5
      packages/app/src/stores/bookmark.ts
  37. 0 8
      packages/app/src/stores/context.tsx
  38. 2 1
      packages/app/src/stores/page-listing.tsx
  39. 16 34
      packages/app/src/stores/page.tsx
  40. 1 11
      packages/app/src/stores/user.tsx
  41. 0 27
      packages/app/src/styles/_page-accessories-control.scss
  42. 45 6
      packages/app/src/styles/_subnav.scss
  43. 3 3
      packages/app/src/styles/atoms/_buttons.scss
  44. 1 0
      packages/app/test/integration/models/page.test.js
  45. 2 2
      packages/ui/src/components/SearchPage/FootstampIcon.jsx

+ 2 - 6
packages/app/src/client/services/ContextExtractor.tsx

@@ -2,8 +2,8 @@ import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 
 import {
-  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
-  useIsDeletable, useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
+  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
@@ -55,9 +55,7 @@ const ContextExtractorOnce: FC = () => {
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
   const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
-  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
-  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull) ?? false;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
@@ -100,8 +98,6 @@ const ContextExtractorOnce: FC = () => {
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useIsIdenticalPath(isIdenticalPath);
-  useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
-  useIsDeletable(isDeletable);
   useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsForbidden(isForbidden);

+ 0 - 2
packages/app/src/client/services/PageContainer.js

@@ -62,9 +62,7 @@ export default class PageContainer extends Container {
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
-      isDeletable: JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
-      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),

+ 53 - 0
packages/app/src/client/services/page-operation.ts

@@ -0,0 +1,53 @@
+import { SubscriptionStatusType } from '~/interfaces/subscription';
+
+import { toastError } from '../util/apiNotification';
+import { apiv3Put } from '../util/apiv3-client';
+
+export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
+  try {
+    const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
+      ? SubscriptionStatusType.UNSUBSCRIBE
+      : SubscriptionStatusType.SUBSCRIBE;
+
+    await apiv3Put('/page/subscribe', { pageId, status: newStatus });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleLike = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/page/likes', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleBookmark = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const bookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: true });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const unbookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: false });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};

+ 20 - 14
packages/app/src/components/BookmarkButtons.tsx

@@ -9,16 +9,20 @@ import UserPictureList from './User/UserPictureList';
 import { useIsGuestUser } from '~/stores/context';
 
 interface Props {
+  bookmarkCount: number
+  isBookmarked?: boolean
+  bookmarkedUsers?: IUser[]
   hideTotalNumber?: boolean
-  isBookmarked: boolean
-  sumOfBookmarks: number
-  bookmarkedUsers: IUser[]
   onBookMarkClicked: ()=>void;
 }
 
 const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
 
+  const {
+    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+  } = props;
+
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -40,9 +44,9 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         id="bookmark-button"
         onClick={handleClick}
         className={`btn btn-bookmark border-0
-          ${props.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <i className={`fa ${props.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
+        <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
 
       {isGuestUser && (
@@ -51,18 +55,20 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         </UncontrolledTooltip>
       )}
 
-      { !props.hideTotalNumber && (
+      { !hideTotalNumber && (
         <>
           <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
-            {props.sumOfBookmarks}
+            {bookmarkCount}
           </button>
-          <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
-            <PopoverBody className="seen-user-popover">
-              <div className="px-2 text-right user-list-content text-truncate text-muted">
-                {props.bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
-              </div>
-            </PopoverBody>
-          </Popover>
+          { bookmarkedUsers != null && (
+            <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
+              <PopoverBody className="user-list-popover">
+                <div className="px-2 text-right user-list-content text-truncate text-muted">
+                  {bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
+                </div>
+              </PopoverBody>
+            </Popover>
+          ) }
         </>
       ) }
     </div>

+ 175 - 104
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,4 +1,4 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
 import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
@@ -6,132 +6,203 @@ import {
 import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
 
-import { IPageHasId } from '~/interfaces/page';
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
-import { useSWRBookmarkInfo } from '~/stores/bookmark';
-
-type PageItemControlProps = {
-  page: Partial<IPageHasId>
-  isEnableActions?: boolean
-  isDeletable: boolean
-  onClickDeleteButtonHandler?: (pageId: string) => void
-  onClickRenameButtonHandler?: (pageId: string) => void
+import loggerFactory from '~/utils/logger';
+
+import {
+  IPageInfo, IPageInfoCommon, isExistPageInfo,
+} from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+const logger = loggerFactory('growi:cli:PageItemControl');
+
+
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoCommon | IPageInfo };
+
+type CommonProps = {
+  pageInfo?: IPageInfoCommon | IPageInfo,
+  isEnableActions?: boolean,
+  hideBookmarkMenuItem?: boolean,
+  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
+  onClickRenameMenuItem?: (pageId: string) => void,
+  onClickDeleteMenuItem?: (pageId: string) => void,
+
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
 
-const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
+
+type DropdownMenuProps = CommonProps & {
+  pageId: string,
+}
+
+const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
+  const { t } = useTranslation('');
 
   const {
-    page, isEnableActions, onClickDeleteButtonHandler, isDeletable, onClickRenameButtonHandler,
+    pageId, pageInfo, isEnableActions, hideBookmarkMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
-  const { t } = useTranslation('');
-  const [isOpen, setIsOpen] = useState(false);
-  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id, isOpen);
 
-  const deleteButtonClickedHandler = useCallback(() => {
-    if (onClickDeleteButtonHandler != null && page._id != null) {
-      onClickDeleteButtonHandler(page._id);
-    }
-  }, [onClickDeleteButtonHandler, page._id]);
 
-  const renameButtonClickedHandler = useCallback(() => {
-    if (onClickRenameButtonHandler != null && page._id != null) {
-      onClickRenameButtonHandler(page._id);
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const bookmarkItemClickedHandler = useCallback(async() => {
+    if (!isExistPageInfo(pageInfo) || onClickBookmarkMenuItem == null) {
+      return;
     }
-  }, [onClickRenameButtonHandler, page._id]);
+    await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
+  }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const renameItemClickedHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
 
-  const bookmarkToggleHandler = (async() => {
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/bookmarks', { pageId: page._id, bool: !bookmarkInfo!.isBookmarked });
-      mutateBookmarkInfo();
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const deleteItemClickedHandler = useCallback(async() => {
+    if (!isExistPageInfo(pageInfo) || onClickDeleteMenuItem == null) {
+      return;
     }
-    catch (err) {
-      toastError(err);
+    if (!pageInfo.isDeletable) {
+      logger.warn('This page could not be deleted.');
+      return;
     }
-  });
+    await onClickDeleteMenuItem(pageId);
+  }, [onClickDeleteMenuItem, pageId, pageInfo]);
+
+  if (pageId == null || pageInfo == null) {
+    return <></>;
+  }
+
+  return (
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+
+      { !isEnableActions && (
+        <DropdownItem>
+          <p>
+            {t('search_result.currently_not_implemented')}
+          </p>
+        </DropdownItem>
+      ) }
+
+      {/* Bookmark */}
+      { !hideBookmarkMenuItem && isExistPageInfo(pageInfo) && isEnableActions && (
+        <DropdownItem onClick={bookmarkItemClickedHandler}>
+          <i className="fa fa-fw fa-bookmark-o"></i>
+          { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
+        </DropdownItem>
+      ) }
+
+      {/* Duplicate */}
+      { isExistPageInfo(pageInfo) && isEnableActions && (
+        <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw icon-docs"></i>
+          {t('Duplicate')}
+        </DropdownItem>
+      ) }
+
+      {/* Move/Rename */}
+      { isEnableActions && pageInfo.isMovable && (
+        <DropdownItem onClick={renameItemClickedHandler}>
+          <i className="icon-fw  icon-action-redo"></i>
+          {t('Move/Rename')}
+        </DropdownItem>
+      ) }
+
+      { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+
+      {/* divider */}
+      {/* Delete */}
+      { isExistPageInfo(pageInfo) && isEnableActions && pageInfo.isMovable && (
+        <>
+          <DropdownItem divider />
+          <DropdownItem
+            className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+            disabled={!pageInfo.isDeletable}
+            onClick={deleteItemClickedHandler}
+          >
+            <i className="icon-fw icon-trash"></i>
+            {t('Delete')}
+          </DropdownItem>
+        </>
+      )}
+    </DropdownMenu>
+  );
+});
 
-  const renderBookmarkText = () => {
-    if (bookmarkInfoError != null || bookmarkInfo == null) {
-      return '';
-    }
-    return bookmarkInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark');
-  };
 
+type PageItemControlSubstanceProps = CommonProps & {
+  pageId: string,
+  fetchOnOpen?: boolean,
+}
 
-  const dropdownToggle = () => {
-    setIsOpen(!isOpen);
-  };
+export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 
+  const {
+    pageId, pageInfo: presetPageInfo, fetchOnOpen,
+    onClickBookmarkMenuItem,
+  } = props;
+
+  const [isOpen, setIsOpen] = useState(false);
+
+  const shouldFetch = presetPageInfo == null && (!fetchOnOpen || isOpen);
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+
+  // mutate after handle event
+  const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
+    if (onClickBookmarkMenuItem != null) {
+      await onClickBookmarkMenuItem(_pageId, _newValue);
+    }
+
+    if (shouldFetch) {
+      mutatePageInfo();
+    }
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
 
   return (
-    <Dropdown isOpen={isOpen} toggle={dropdownToggle}>
-      <DropdownToggle color="transparent" className="btn-link border-0 rounded grw-btn-page-management p-0">
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+      <DropdownToggle color="transparent" className="border-0 rounded grw-btn-page-management p-0">
         <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
       </DropdownToggle>
-      <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
-
-        {/* TODO: if there is the following button in XD add it here
-        <button
-          type="button"
-          className="btn btn-link p-0"
-          value={page.path}
-          onClick={(e) => {
-            window.location.href = e.currentTarget.value;
-          }}
-        >
-          <i className="icon-login" />
-        </button>
-        */}
-
-        {/*
-          TODO: add function to the following buttons like using modal or others
-          ref: https://estoc.weseek.co.jp/redmine/issues/79026
-        */}
-
-        {/* TODO: show dropdown when permalink section is implemented */}
-
-        {!isEnableActions && (
-          <DropdownItem>
-            <p>
-              {t('search_result.currently_not_implemented')}
-            </p>
-          </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={bookmarkToggleHandler}>
-            <i className="fa fa-fw fa-bookmark-o"></i>
-            {renderBookmarkText()}
-          </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-            <i className="icon-fw icon-docs"></i>
-            {t('Duplicate')}
-          </DropdownItem>
-        )}
-        {isEnableActions && (
-          <DropdownItem onClick={renameButtonClickedHandler}>
-            <i className="icon-fw  icon-action-redo"></i>
-            {t('Move/Rename')}
-          </DropdownItem>
-        )}
-        {isDeletable && isEnableActions && (
-          <>
-            <DropdownItem divider />
-            <DropdownItem className="text-danger pt-2" onClick={deleteButtonClickedHandler}>
-              <i className="icon-fw icon-trash"></i>
-              {t('Delete')}
-            </DropdownItem>
-          </>
-        )}
-      </DropdownMenu>
-
 
+      <PageItemControlDropdownMenu
+        {...props}
+        pageInfo={presetPageInfo ?? fetchedPageInfo}
+        onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+      />
     </Dropdown>
   );
 
 };
 
-export default PageItemControl;
+
+type PageItemControlProps = CommonProps & {
+  pageId?: string,
+}
+
+export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} {...props} />;
+};
+
+
+type AsyncPageItemControlProps = CommonProps & {
+  pageId?: string,
+}
+
+export const AsyncPageItemControl = (props: AsyncPageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} fetchOnOpen {...props} />;
+};

+ 16 - 4
packages/app/src/components/IdenticalPathPage.tsx

@@ -8,6 +8,8 @@ import { DevidedPagePath } from '@growi/core';
 import { useCurrentPagePath } from '~/stores/context';
 
 import { PageListItemL } from './PageList/PageListItemL';
+import { useSWRxPageInfoForList } from '~/stores/page';
+import { IPageInfoForList, IPageWithMeta } from '~/interfaces/page';
 
 
 type IdenticalPathAlertProps = {
@@ -34,7 +36,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
       <p>
         {t('duplicated_page_alert.same_page_name_exists_at_path',
           { path: _path, pageName: _pageName })}<br />
-        <p
+        <span
           // eslint-disable-next-line react/no-danger
           dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
         />
@@ -56,7 +58,10 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
 
   const identicalPageDocument = document.getElementById('identical-path-page');
   const pageDataList = JSON.parse(identicalPageDocument?.getAttribute('data-identical-page-data-list') || jsonNull);
-  const shortbodyMap = JSON.parse(identicalPageDocument?.getAttribute('data-shortody-map') || jsonNull);
+
+  const pageIds = pageDataList.map(data => data.pageData._id) as string[];
+
+  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
 
   const { data: currentPath } = useCurrentPagePath();
 
@@ -78,14 +83,21 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
         <div className="page-list">
           <ul className="page-list-ul list-group-flush border px-3">
             {pageDataList.map((data) => {
+              const pageId = data.pageData._id;
+              const pageInfo = (idToPageInfoMap ?? {})[pageId];
+
+              const pageWithMeta: IPageWithMeta = {
+                pageData: data.pageData,
+                pageMeta: pageInfo,
+              };
+
               return (
                 <PageListItemL
                   key={data.pageData._id}
-                  page={data}
+                  page={pageWithMeta}
                   isSelected={false}
                   isChecked={false}
                   isEnableActions
-                  shortBody={shortbodyMap[data.pageData._id]}
                 // Todo: add onClickDeleteButton when delete feature implemented
                 />
               );

+ 7 - 14
packages/app/src/components/LikeButtons.tsx

@@ -9,12 +9,13 @@ import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '../interfaces/user';
 
 type LikeButtonsProps = {
-  appContainer: AppContainer,
 
   hideTotalNumber?: boolean,
   sumOfLikers: number,
-  isLiked: boolean,
   likers: IUser[],
+
+  isGuestUser?: boolean,
+  isLiked?: boolean,
   onLikeClicked?: ()=>void,
 }
 
@@ -27,30 +28,22 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
-  const handleClick = () => {
-    if (props.onLikeClicked == null) {
-      return;
-    }
-    props.onLikeClicked();
-  };
-
   const {
-    appContainer, hideTotalNumber, isLiked, sumOfLikers,
+    hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
   } = props;
-  const { isGuestUser } = appContainer;
 
   return (
     <div className="btn-group" role="group" aria-label="Like buttons">
       <button
         type="button"
         id="like-button"
-        onClick={handleClick}
+        onClick={onLikeClicked}
         className={`btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
-      {isGuestUser && (
+      { isGuestUser && (
         <UncontrolledTooltip placement="top" target="like-button" fade={false}>
           {t('Not available for guest')}
         </UncontrolledTooltip>
@@ -62,7 +55,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
             {sumOfLikers}
           </button>
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
-            <PopoverBody className="seen-user-popover">
+            <PopoverBody className="user-list-popover">
               <div className="px-2 text-right user-list-content text-truncate text-muted">
                 {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
               </div>

+ 61 - 4
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,6 +1,12 @@
 import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 
+import { useTranslation } from 'react-i18next';
+
+import { DropdownItem } from 'reactstrap';
+
+import urljoin from 'url-join';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
@@ -9,10 +15,11 @@ import {
 } from '~/stores/ui';
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useIsGuestUser,
+  useCreator, useRevisionAuthor, useIsGuestUser, useIsSharedUser,
 } from '~/stores/context';
 import { useSWRTagsInfo } from '~/stores/page';
 
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
 import { SubNavButtons } from './SubNavButtons';
 import PageEditorModeManager from './PageEditorModeManager';
 
@@ -20,6 +27,52 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { IPageHasId } from '~/interfaces/page';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
+import PresentationIcon from '../Icons/PresentationIcon';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { pageId, revisionId } = props;
+
+  const exportPageHandler = useCallback(async(format: string): Promise<void> => {
+    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+    url.searchParams.append('format', format);
+    url.searchParams.append('revisionId', revisionId);
+    window.location.href = url.href;
+  }, [pageId, revisionId]);
+
+  return (
+    <>
+      <DropdownItem divider />
+
+      {/* Presentation */}
+      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87672 */ }}>
+        <i className="icon-fw"><PresentationIcon /></i>
+        { t('Presentation Mode') }
+      </DropdownItem>
+
+      {/* Export markdown */}
+      <DropdownItem onClick={() => exportPageHandler('md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/* Create template */}
+      <DropdownItem onClick={() => { /* TODO: implement in https://redmine.weseek.co.jp/issues/87673 */ }}>
+        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      </DropdownItem>
+    </>
+  );
+};
+
 
 const GrowiContextualSubNavigation = (props) => {
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
@@ -33,6 +86,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: creator } = useCreator();
   const { data: revisionAuthor } = useRevisionAuthor();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
@@ -77,11 +131,14 @@ const GrowiContextualSubNavigation = (props) => {
     return (
       <>
         <div className="h-50 d-flex flex-column align-items-end justify-content-center">
-          { isViewMode && (
+          { pageId != null && isViewMode && (
             <SubNavButtons
               isCompactMode={isCompactMode}
               pageId={pageId}
+              revisionId={revisionId}
+              disableSeenUserInfoPopover={isSharedUser}
               showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={pageId} revisionId={revisionId} />}
             />
           ) }
         </div>
@@ -98,9 +155,9 @@ const GrowiContextualSubNavigation = (props) => {
       </>
     );
   }, [
-    pageId,
+    pageId, revisionId,
     editorMode, mutateEditorMode,
-    isCompactMode, isDeviceSmallerThanMd, isGuestUser,
+    isCompactMode, isDeviceSmallerThanMd, isGuestUser, isSharedUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
   ]);
 

+ 1 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -29,7 +29,7 @@ type Props = {
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
 
-  controls?: any,
+  controls?: React.FunctionComponent,
 }
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {

+ 87 - 59
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,95 +1,116 @@
 import React, { useCallback } from 'react';
 
-import SubscribeButton from '../SubscribeButton';
-import PageReactionButtons from '../PageReactionButtons';
-import { useSWRPageInfo } from '../../stores/page';
+import { IPageInfo, isExistPageInfo } from '~/interfaces/page';
+
+import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
-import { toastError } from '../../client/util/apiNotification';
-import { apiv3Put } from '../../client/util/apiv3-client';
-import { useSWRxLikerList } from '../../stores/user';
+import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
 
+import SubscribeButton from '../SubscribeButton';
+import LikeButtons from '../LikeButtons';
+import BookmarkButtons from '../BookmarkButtons';
+import SeenUserInfo from '../User/SeenUserInfo';
+import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
+import { AdditionalMenuItemsRendererProps, PageItemControl } from '../Common/Dropdown/PageItemControl';
+
 
-type SubNavButtonsSubstanceProps= {
+type CommonProps = {
   isCompactMode?: boolean,
+  disableSeenUserInfoPopover?: boolean,
   showPageControlDropdown?: boolean,
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
-const SubNavButtonsSubstance = (props: { pageId: string } & SubNavButtonsSubstanceProps): JSX.Element => {
+
+type SubNavButtonsSubstanceProps= CommonProps & {
+  pageId: string,
+  revisionId: string,
+  pageInfo: IPageInfo,
+}
+
+const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
   const {
-    isCompactMode, pageId, showPageControlDropdown,
+    pageInfo, pageId, isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
 
-  const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
-  const { data: likers } = useSWRxLikerList(pageInfo?.likerIds);
-  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId, true);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
-  const likeClickhandler = useCallback(async() => {
+  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+
+  const likerIds = pageInfo.likerIds != null ? pageInfo.likerIds.slice(0, 15) : [];
+  const seenUserIds = pageInfo.seenUserIds != null ? pageInfo.seenUserIds.slice(0, 15) : [];
+
+  // Put in a mixture of seenUserIds and likerIds data to make the cache work
+  const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
+  const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
+  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+
+  const subscribeClickhandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
       return;
     }
 
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/page/likes', { pageId, bool: !pageInfo!.isLiked });
-      mutatePageInfo();
-    }
-    catch (err) {
-      toastError(err);
+    await toggleSubscribe(pageId, pageInfo.subscriptionStatus);
+    mutatePageInfo();
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo.subscriptionStatus]);
+
+  const likeClickhandler = useCallback(async() => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
     }
-  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+    await toggleLike(pageId, pageInfo.isLiked);
+    mutatePageInfo();
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo.isLiked]);
 
   const bookmarkClickHandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
       return;
     }
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await apiv3Put('/bookmarks', { pageId, bool: !bookmarkInfo!.isBookmarked });
-      mutateBookmarkInfo();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkInfo, isGuestUser, mutateBookmarkInfo, pageId]);
-
 
-  if (pageInfoError != null || pageInfo == null) {
-    return <></>;
-  }
+    await toggleBookmark(pageId, pageInfo.isBookmarked);
+    mutatePageInfo();
+    mutateBookmarkInfo();
+  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo.isBookmarked]);
 
-  if (bookmarkInfoError != null || bookmarkInfo == null) {
-    return <></>;
-  }
 
-  const { sumOfLikers, isLiked } = pageInfo;
-  const { sumOfBookmarks, isBookmarked, bookmarkedUsers } = bookmarkInfo;
+  const {
+    sumOfLikers, isLiked, bookmarkCount, isBookmarked,
+  } = pageInfo;
 
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
       <span>
-        <SubscribeButton pageId={props.pageId} />
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
       </span>
-      <PageReactionButtons
-        isCompactMode={isCompactMode}
+      <LikeButtons
+        hideTotalNumber={isCompactMode}
+        onLikeClicked={likeClickhandler}
         sumOfLikers={sumOfLikers}
         isLiked={isLiked}
-        likers={likers || []}
-        onLikeClicked={likeClickhandler}
-        sumOfBookmarks={sumOfBookmarks}
+        likers={likers}
+      />
+      <BookmarkButtons
+        hideTotalNumber={isCompactMode}
+        bookmarkCount={bookmarkCount}
         isBookmarked={isBookmarked}
-        bookmarkedUsers={bookmarkedUsers}
+        bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
         onBookMarkClicked={bookmarkClickHandler}
-      >
-      </PageReactionButtons>
-
+      />
+      <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
       { showPageControlDropdown && (
-        /*
-          TODO:
-          replace with PageItemControl
-        */
-        <></>
+        <PageItemControl
+          pageId={pageId}
+          pageInfo={pageInfo}
+          isEnableActions={!isGuestUser}
+          additionalMenuItemRenderer={additionalMenuItemRenderer}
+          hideBookmarkMenuItem
+        />
         // <PageManagement
         //   pageId={pageId}
         //   revisionId={revisionId}
@@ -104,16 +125,23 @@ const SubNavButtonsSubstance = (props: { pageId: string } & SubNavButtonsSubstan
   );
 };
 
-type SubNavButtonsProps= SubNavButtonsSubstanceProps & {
-  pageId?: string | null,
+type SubNavButtonsProps= CommonProps & {
+  pageId: string,
+  revisionId?: string | null,
 };
 
 export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
-  const { pageId, isCompactMode } = props;
+  const { pageId, revisionId } = props;
+
+  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null);
+
+  if (revisionId == null || pageInfo == null || error != null) {
+    return <></>;
+  }
 
-  if (pageId == null) {
+  if (!isExistPageInfo(pageInfo)) {
     return <></>;
   }
 
-  return <SubNavButtonsSubstance pageId={pageId} isCompactMode={isCompactMode} />;
+  return <SubNavButtonsSubstance {...props} pageInfo={pageInfo} pageId={pageId} revisionId={revisionId} />;
 };

+ 11 - 1
packages/app/src/components/NotFoundPage.tsx

@@ -6,15 +6,25 @@ import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import DescendantsPageList from './DescendantsPageList';
 import PageTimeline from './PageTimeline';
+import { useCurrentPagePath } from '~/stores/context';
+
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const DescendantsPageListForThisPage = (): JSX.Element => {
+    return currentPagePath != null
+      ? <DescendantsPageList path={currentPagePath} />
+      : <></>;
+  };
+
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: DescendantsPageList,
+        Content: DescendantsPageListForThisPage,
         i18n: t('page_list'),
         index: 0,
       },

+ 0 - 5
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -11,7 +11,6 @@ import TimeLineIcon from './Icons/TimeLineIcon';
 import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-import SeenUserInfo from './User/SeenUserInfo';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
@@ -91,10 +90,6 @@ const PageAccessoriesModalControl = (props) => {
           </Fragment>
         );
       })}
-      <div className="d-flex align-items-center">
-        <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo disabled={isSharedUser} pageId={pageId} />
-      </div>
     </div>
   );
 };

+ 18 - 20
packages/app/src/components/PageList/PageListItemL.tsx

@@ -3,21 +3,18 @@ import React, { FC, memo, useCallback } from 'react';
 import Clamp from 'react-multiline-clamp';
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
-import { pagePathUtils, DevidedPagePath } from '@growi/core';
+import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageInfoForList, IPageWithMeta, isIPageInfoForList } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 
-import PageItemControl from '../Common/Dropdown/PageItemControl';
-
-const { isTopPage, isUserNamePage } = pagePathUtils;
+import { AsyncPageItemControl } from '../Common/Dropdown/PageItemControl';
 
 type Props = {
-  page: IPageWithMeta | IPageWithMeta<IPageSearchMeta>,
+  page: IPageWithMeta | IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForList>,
   isSelected?: boolean, // is item selected(focused)
   isChecked?: boolean, // is checkbox of item checked
   isEnableActions?: boolean,
-  shortBody?: string
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onClickCheckbox?: (pageId: string) => void,
   onClickItem?: (pageId: string) => void,
@@ -27,7 +24,7 @@ type Props = {
 export const PageListItemL: FC<Props> = memo((props:Props) => {
   const {
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
+    page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions,
     showPageUpdatedTime,
   } = props;
 
@@ -36,6 +33,7 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
   const pagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
+  const revisionShortBody = isIPageInfoForList(pageMeta) ? pageMeta.revisionShortBody : null;
 
   const pageTitle = (
     <PagePathLabel
@@ -105,7 +103,7 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
             <div className="d-flex align-items-center mb-2">
               {/* Picture */}
               <span className="mr-2 d-none d-md-block">
-                <UserPicture user={pageData.lastUpdateUser} size="md" />
+                {/* <UserPicture user={pageData.lastUpdateUser} size="md" /> */}
               </span>
               {/* page title */}
               <Clamp lines={1}>
@@ -120,23 +118,23 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
               </div>
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
-                <PageItemControl
-                  page={pageData}
-                  onClickDeleteButtonHandler={props.onClickDeleteButton}
+                {/* TODO: use PageItemControl with prefetched IPageInfo object */}
+                <AsyncPageItemControl
+                  pageId={pageData._id}
+                  onClickDeleteMenuItem={props.onClickDeleteButton}
                   isEnableActions={isEnableActions}
-                  isDeletable={!isTopPage(pageData.path) && !isUserNamePage(pageData.path)}
                 />
               </div>
             </div>
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
-                {
-                  elasticSearchResult != null && elasticSearchResult?.snippet.length !== 0 ? (
-                    <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
-                  ) : (
-                    <div>{ shortBody != null ? shortBody : 'Loading ...' }</div> // TODO: improve indicator
-                  )
-                }
+                { elasticSearchResult != null && elasticSearchResult?.snippet.length > 0 && (
+                  // eslint-disable-next-line react/no-danger
+                  <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
+                ) }
+                { revisionShortBody != null && (
+                  <div>{revisionShortBody}</div>
+                ) }
               </Clamp>
             </div>
           </div>

+ 0 - 49
packages/app/src/components/PageReactionButtons.tsx

@@ -1,49 +0,0 @@
-import React, { FC } from 'react';
-import LikeButtons from './LikeButtons';
-import { IUser } from '../interfaces/user';
-import BookmarkButtons from './BookmarkButtons';
-
-type Props = {
-  isCompactMode?: boolean,
-
-  isLiked: boolean,
-  sumOfLikers: number,
-  likers: IUser[],
-  onLikeClicked?: ()=>void,
-
-  isBookmarked: boolean,
-  sumOfBookmarks: number,
-  bookmarkedUsers: IUser[]
-  onBookMarkClicked: ()=>void,
-}
-
-
-const PageReactionButtons : FC<Props> = (props: Props) => {
-  const {
-    isCompactMode, sumOfLikers, isLiked, likers, onLikeClicked, sumOfBookmarks, isBookmarked, bookmarkedUsers, onBookMarkClicked,
-  } = props;
-
-
-  return (
-    <>
-      <LikeButtons
-        hideTotalNumber={isCompactMode}
-        onLikeClicked={onLikeClicked}
-        sumOfLikers={sumOfLikers}
-        isLiked={isLiked}
-        likers={likers}
-      >
-      </LikeButtons>
-      <BookmarkButtons
-        hideTotalNumber={isCompactMode}
-        sumOfBookmarks={sumOfBookmarks}
-        isBookmarked={isBookmarked}
-        bookmarkedUsers={bookmarkedUsers}
-        onBookMarkClicked={onBookMarkClicked}
-      >
-      </BookmarkButtons>
-    </>
-  );
-};
-
-export default PageReactionButtons;

+ 8 - 14
packages/app/src/components/SearchPage.jsx

@@ -40,7 +40,6 @@ class SearchPage extends React.Component {
       focusedSearchResultData: null,
       selectedPagesIdList: new Set(),
       searchResultCount: 0,
-      shortBodiesMap: null,
       activePage: 1,
       pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       excludeUserPages: true,
@@ -152,11 +151,6 @@ class SearchPage extends React.Component {
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
   }
 
-  async fetchShortBodiesMap(pageIds) {
-    const res = await apiv3Get('/page-listing/short-bodies', { pageIds });
-    this.setState({ shortBodiesMap: res.data.shortBodiesMap });
-  }
-
   // todo: refactoring
   // refs: https://redmine.weseek.co.jp/issues/82139
   async search(data) {
@@ -195,17 +189,18 @@ class SearchPage extends React.Component {
         order,
       });
 
+      // TODO: fetch with /page-listing/info
+      // https://redmine.weseek.co.jp/issues/87695
       /*
        * non-await asynchronous short body fetch
        */
-      const pageIds = res.data.map((page) => {
-        if (page.pageMeta?.elasticSearchResult != null && page.pageMeta?.elasticSearchResult?.snippet.length !== 0) {
-          return null;
-        }
+      // const pageIds = res.data.map((page) => {
+      //   if (page.pageMeta?.elasticSearchResult != null && page.pageMeta?.elasticSearchResult?.snippet.length !== 0) {
+      //     return null;
+      //   }
 
-        return page.pageData._id;
-      }).filter(id => id != null);
-      this.fetchShortBodiesMap(pageIds);
+      //   return page.pageData._id;
+      // }).filter(id => id != null);
 
       this.changeURL(keyword);
       if (res.data.length > 0) {
@@ -324,7 +319,6 @@ class SearchPage extends React.Component {
         focusedSearchResultData={this.state.focusedSearchResultData}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         searchResultCount={this.state.searchResultCount}
-        shortBodiesMap={this.state.shortBodiesMap}
         activePage={this.state.activePage}
         pagingLimit={this.state.pagingLimit}
         onClickItem={this.selectPage}

+ 1 - 3
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -13,7 +13,6 @@ type Props = {
   searchResultCount?: number,
   activePage?: number,
   pagingLimit?: number,
-  shortBodiesMap?: Record<string, string>
   focusedSearchResultData?: IPageWithMeta<IPageSearchMeta>,
   onPagingNumberChanged?: (activePage: number) => void,
   onClickItem?: (pageId: string) => void,
@@ -24,7 +23,7 @@ type Props = {
 
 const SearchResultList: FC<Props> = (props:Props) => {
   const {
-    focusedSearchResultData, selectedPagesIdList, isEnableActions, shortBodiesMap,
+    focusedSearchResultData, selectedPagesIdList, isEnableActions,
   } = props;
 
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
@@ -38,7 +37,6 @@ const SearchResultList: FC<Props> = (props:Props) => {
             key={page.pageData._id}
             page={page}
             isEnableActions={isEnableActions}
-            shortBody={shortBodiesMap?.[page.pageData._id]}
             onClickItem={props.onClickItem}
             onClickCheckbox={props.onClickCheckbox}
             isChecked={isChecked}

+ 27 - 79
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,23 +1,20 @@
 import React, {
-  useCallback, useState, FC, useEffect, memo,
+  useCallback, useState, FC, useEffect,
 } from 'react';
 import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
-import { pagePathUtils } from '@growi/core';
 import { useDrag, useDrop } from 'react-dnd';
 import { toastWarning, toastError } from '~/client/util/apiNotification';
 
-import { ItemNode } from './ItemNode';
-import { IPageHasId } from '~/interfaces/page';
-import { useSWRxPageChildren } from '../../../stores/page-listing';
-import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
-import PageItemControl from '../../Common/Dropdown/PageItemControl';
+import { useSWRxPageChildren } from '~/stores/page-listing';
 import { IPageForPageDeleteModal } from '~/stores/ui';
 import { apiv3Put } from '~/client/util/apiv3-client';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
-
-const { isTopPage, isUserNamePage } = pagePathUtils;
+import { bookmark, unbookmark } from '~/client/services/page-operation';
+import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import { AsyncPageItemControl } from '../../Common/Dropdown/PageItemControl';
+import { ItemNode } from './ItemNode';
 
 
 interface ItemProps {
@@ -42,64 +39,11 @@ const markTarget = (children: ItemNode[], targetPathOrId?: string): void => {
   });
 };
 
-type ItemControlProps = {
-  page: Partial<IPageHasId>
-  isEnableActions: boolean
-  isDeletable: boolean
-  onClickPlusButton?(): void
-  onClickDeleteButton?(): void
-  onClickRenameButton?(): void
-}
-
-
-const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
-  const onClickPlusButton = () => {
-    if (props.onClickPlusButton == null) {
-      return;
-    }
-
-    props.onClickPlusButton();
-  };
-
-  const onClickDeleteButtonHandler = () => {
-    if (props.onClickDeleteButton == null) {
-      return;
-    }
-
-    props.onClickDeleteButton();
-  };
 
-  const onClickRenameButtonHandler = () => {
-    if (props.onClickRenameButton == null) {
-      return;
-    }
-
-    props.onClickRenameButton();
-  };
-
-  if (props.page == null) {
-    return <></>;
-  }
-
-  return (
-    <>
-      <PageItemControl
-        page={props.page}
-        onClickDeleteButtonHandler={onClickDeleteButtonHandler}
-        isEnableActions={props.isEnableActions}
-        isDeletable={props.isDeletable}
-        onClickRenameButtonHandler={onClickRenameButtonHandler}
-      />
-      <button
-        type="button"
-        className="border-0 rounded grw-btn-page-management p-0"
-        onClick={onClickPlusButton}
-      >
-        <i className="icon-plus text-muted d-block p-1" />
-      </button>
-    </>
-  );
-});
+const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+  const bookmarkOperation = _newValue ? bookmark : unbookmark;
+  await bookmarkOperation(_pageId);
+};
 
 
 type ItemCountProps = {
@@ -134,8 +78,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   const hasDescendants = (page.descendantCount != null && page?.descendantCount > 0);
 
-  const isDeletable = !page.isEmpty && !isTopPage(page.path as string) && !isUserNamePage(page.path as string);
-
   const [{ isDragging }, drag] = useDrag(() => ({
     type: 'PAGE_TREE',
     item: { page },
@@ -180,7 +122,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     setNewPageInputShown(true);
   }, []);
 
-  const onClickDeleteButton = useCallback(() => {
+  const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDeleteByPage == null) {
       return;
     }
@@ -201,7 +143,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   }, [page, onClickDeleteByPage]);
 
 
-  const onClickRenameButton = useCallback(() => {
+  const onClickRenameButton = useCallback(async(_pageId: string): Promise<void> => {
     setRenameInputShown(true);
   }, []);
 
@@ -254,7 +196,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   // didMount
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
-  }, []);
+  }, [hasChildren]);
 
   /*
    * Make sure itemNode.children and currentChildren are synced
@@ -264,7 +206,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       markTarget(children, targetPathOrId);
       setCurrentChildren(children);
     }
-  }, []);
+  }, [children, currentChildren.length, targetPathOrId]);
 
   /*
    * When swr fetch succeeded
@@ -275,7 +217,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       markTarget(newChildren, targetPathOrId);
       setCurrentChildren(newChildren);
     }
-  }, [data, isOpen]);
+  }, [data, error, isOpen, targetPathOrId]);
 
   return (
     <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
@@ -317,14 +259,20 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           </div>
         )}
         <div className="grw-pagetree-control d-none">
-          <ItemControl
-            page={page}
-            onClickPlusButton={onClickPlusButton}
-            onClickDeleteButton={onClickDeleteButton}
-            onClickRenameButton={onClickRenameButton}
+          <AsyncPageItemControl
+            pageId={page._id}
             isEnableActions={isEnableActions}
-            isDeletable={isDeletable}
+            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+            onClickDeleteMenuItem={onClickDeleteButton}
+            onClickRenameMenuItem={onClickRenameButton}
           />
+          <button
+            type="button"
+            className="border-0 rounded grw-btn-page-management p-0"
+            onClick={onClickPlusButton}
+          >
+            <i className="icon-plus text-muted d-block p-1" />
+          </button>
         </div>
       </li>
 

+ 9 - 41
packages/app/src/components/SubscribeButton.tsx

@@ -2,62 +2,30 @@ import React, { FC } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
-import { useSWRxSubscriptionStatus } from '../stores/page';
+import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 
-import { toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { useIsGuestUser } from '~/stores/context';
-
 type Props = {
-  pageId: string,
+  isGuestUser?: boolean,
+  status?: SubscriptionStatusType,
+  onClick?: () => Promise<void>,
 };
 
 const SubscribeButton: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
-  const { pageId } = props;
-
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: subscriptionData, mutate } = useSWRxSubscriptionStatus(pageId);
-
-  let isSubscribed;
-
-  switch (subscriptionData?.status) {
-    case true:
-      isSubscribed = true;
-      break;
-    case false:
-      isSubscribed = false;
-      break;
-    default:
-      isSubscribed = null;
-  }
-
-  const buttonClass = `${isSubscribed ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
-  const iconClass = isSubscribed || isSubscribed == null ? 'fa fa-eye' : 'fa fa-eye-slash';
+  const { isGuestUser, status } = props;
 
-  const handleClick = async() => {
-    if (isGuestUser) {
-      return;
-    }
+  const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
 
-    try {
-      const res = await apiv3Put('/page/subscribe', { pageId, status: !isSubscribed });
-      if (res) {
-        mutate();
-      }
-    }
-    catch (err) {
-      toastError(err);
-    }
-  };
+  const buttonClass = `${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
+  const iconClass = isSubscribing === false ? 'fa fa-eye-slash' : 'fa fa-eye';
 
   return (
     <>
       <button
         type="button"
         id="subscribe-button"
-        onClick={handleClick}
+        onClick={props.onClick}
         className={`btn btn-subscribe border-0 ${buttonClass}`}
       >
         <i className={iconClass}></i>

+ 8 - 16
packages/app/src/components/User/SeenUserInfo.tsx

@@ -3,40 +3,32 @@ import React, { FC, useState } from 'react';
 import { Button, Popover, PopoverBody } from 'reactstrap';
 import { FootstampIcon } from '@growi/ui';
 
+import { IUser } from '~/interfaces/user';
+
 import UserPictureList from './UserPictureList';
-import { useSWRxPageInfo } from '~/stores/page';
-import { useSWRxUsersList } from '~/stores/user';
 
 interface Props {
-  pageId: string,
-  disabled: boolean
+  seenUsers: IUser[],
+  disabled?: boolean,
 }
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
-  const { pageId, disabled } = props;
-
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
-  const { data: pageInfo } = useSWRxPageInfo(pageId);
-  const likerIds = pageInfo?.likerIds != null ? pageInfo.likerIds.slice(0, 15) : [];
-  const seenUserIds = pageInfo?.seenUserIds != null ? pageInfo.seenUserIds.slice(0, 15) : [];
-
-  // Put in a mixture of seenUserIds and likerIds data to make the cache work
-  const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
-  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+  const { seenUsers, disabled } = props;
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
 
   return (
     <div className="grw-seen-user-info">
-      <Button id="po-seen-user" color="link" className="px-2">
+      <Button id="btn-seen-user" color="link" className="btn-seen-user">
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
         </span>
         <span className="seen-user-count">{seenUsers.length}</span>
       </Button>
-      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
-        <PopoverBody className="seen-user-popover">
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
+        <PopoverBody className="user-list-popover">
           <div className="px-2 text-right user-list-content text-truncate text-muted">
             <UserPictureList users={seenUsers} />
           </div>

+ 0 - 8
packages/app/src/interfaces/page-info.ts

@@ -1,8 +0,0 @@
-export type IPageInfo = {
-  sumOfLikers: number;
-  likerIds: string[];
-  seenUserIds: string[];
-  sumOfSeenUsers: number;
-  isSeen: boolean;
-  isLiked: boolean;
-};

+ 24 - 3
packages/app/src/interfaces/page.ts

@@ -1,8 +1,9 @@
 import { Ref } from './common';
 import { IUser } from './user';
-import { IRevision } from './revision';
+import { IRevision, HasRevisionShortbody } from './revision';
 import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
+import { SubscriptionStatusType } from './subscription';
 
 
 export interface IPage {
@@ -35,16 +36,36 @@ export type IPageHasId = IPage & HasObjectId;
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 
-export type IPageInfo = {
+export type IPageInfoCommon = {
+  isEmpty: boolean,
+  isMovable: boolean,
+  isDeletable: boolean,
+  isAbleToDeleteCompletely: boolean,
+}
+
+export type IPageInfo = IPageInfoCommon & {
   bookmarkCount: number,
   sumOfLikers: number,
   likerIds: string[],
   sumOfSeenUsers: number,
   seenUserIds: string[],
-  isSeen?: boolean,
+
+  isBookmarked?: boolean,
   isLiked?: boolean,
+  subscriptionStatus?: SubscriptionStatusType,
 }
 
+export type IPageInfoForList = IPageInfo & HasRevisionShortbody;
+
+export const isExistPageInfo = (pageInfo: IPageInfoCommon | undefined): pageInfo is IPageInfo => {
+  return pageInfo != null && !pageInfo.isEmpty;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
+export const isIPageInfoForList = (pageInfo: any): pageInfo is IPageInfoForList => {
+  return pageInfo != null && pageInfo.revisionShortBody != null;
+};
+
 export type IPageWithMeta<M = Record<string, unknown>> = {
   pageData: IPageHasId,
   pageMeta?: Partial<IPageInfo> & M,

+ 4 - 0
packages/app/src/interfaces/revision.ts

@@ -14,3 +14,7 @@ export type IRevisionOnConflict = {
   createdAt: Date,
   user: IUser
 }
+
+export type HasRevisionShortbody = {
+  revisionShortBody?: string,
+}

+ 1 - 0
packages/app/src/interfaces/search.ts

@@ -14,6 +14,7 @@ export type IPageSearchMeta = {
   };
 }
 
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
   return !!(meta as IPageSearchMeta)?.elasticSearchResult;
 };

+ 6 - 0
packages/app/src/interfaces/subscription.ts

@@ -0,0 +1,6 @@
+export const SubscriptionStatusType = {
+  SUBSCRIBE: 'SUBSCRIBE',
+  UNSUBSCRIBE: 'UNSUBSCRIBE',
+} as const;
+export const AllSubscriptionStatusType = Object.values(SubscriptionStatusType);
+export type SubscriptionStatusType = typeof SubscriptionStatusType[keyof typeof SubscriptionStatusType];

+ 2 - 13
packages/app/src/server/models/obsolete-page.js

@@ -15,7 +15,7 @@ const differenceInYears = require('date-fns/differenceInYears');
 const { pathUtils } = require('@growi/core');
 const escapeStringRegexp = require('escape-string-regexp');
 
-const { isTopPage, isTrashPage } = pagePathUtils;
+const { isTopPage, isTrashPage, isUserNamePage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
@@ -564,18 +564,7 @@ export const getPageSchema = (crowi) => {
   };
 
   pageSchema.statics.isDeletableName = function(path) {
-    const notDeletable = [
-      /^\/user\/[^/]+$/, // user page
-    ];
-
-    for (let i = 0; i < notDeletable.length; i++) {
-      const pattern = notDeletable[i];
-      if (path.match(pattern)) {
-        return false;
-      }
-    }
-
-    return true;
+    return !isTopPage(path) && !isUserNamePage(path);
   };
 
   pageSchema.statics.fixToCreatableName = function(path) {

+ 13 - 0
packages/app/src/server/models/page.ts

@@ -45,6 +45,7 @@ export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
   getParentAndFillAncestors(path: string): Promise<PageDocument & { _id: any }>
+  findByIdsAndViewer(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
@@ -267,6 +268,18 @@ const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroup
   queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, false);
 };
 
+/*
+ * Find pages by ID and viewer.
+ */
+schema.statics.findByIdsAndViewer = async function(pageIds: string[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]> {
+  const baseQuery = this.find({ _id: { $in: pageIds } });
+  const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
+  await addViewerCondition(queryBuilder, user, userGroups);
+
+  return queryBuilder.query.exec();
+};
+
 /*
  * Find a page by path and viewer. Pass false to useFindOne to use findOne method.
  */

+ 8 - 9
packages/app/src/server/models/subscription.ts

@@ -3,11 +3,10 @@ import {
 } from 'mongoose';
 
 import { getOrCreateModel } from '@growi/core';
-import ActivityDefine from '../util/activityDefine';
 
-export const STATUS_SUBSCRIBE = 'SUBSCRIBE';
-export const STATUS_UNSUBSCRIBE = 'UNSUBSCRIBE';
-const STATUSES = [STATUS_SUBSCRIBE, STATUS_UNSUBSCRIBE];
+import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
+
+import ActivityDefine from '../util/activityDefine';
 
 export interface ISubscription {
   user: Types.ObjectId
@@ -50,17 +49,17 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   status: {
     type: String,
     require: true,
-    enum: STATUSES,
+    enum: AllSubscriptionStatusType,
   },
   createdAt: { type: Date, default: new Date() },
 });
 
 subscriptionSchema.methods.isSubscribing = function() {
-  return this.status === STATUS_SUBSCRIBE;
+  return this.status === SubscriptionStatusType.SUBSCRIBE;
 };
 
 subscriptionSchema.methods.isUnsubscribing = function() {
-  return this.status === STATUS_UNSUBSCRIBE;
+  return this.status === SubscriptionStatusType.UNSUBSCRIBE;
 };
 
 subscriptionSchema.statics.findByUserIdAndTargetId = function(userId, targetId) {
@@ -81,11 +80,11 @@ subscriptionSchema.statics.subscribeByPageId = function(user, pageId, status) {
 };
 
 subscriptionSchema.statics.getSubscription = async function(target) {
-  return this.find({ target, status: STATUS_SUBSCRIBE }).distinct('user');
+  return this.find({ target, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
 };
 
 subscriptionSchema.statics.getUnsubscription = async function(target) {
-  return this.find({ target, status: STATUS_UNSUBSCRIBE }).distinct('user');
+  return this.find({ target, status: SubscriptionStatusType.UNSUBSCRIBE }).distinct('user');
 };
 
 export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);

+ 51 - 3
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -1,11 +1,15 @@
 import express, { Request, Router } from 'express';
 import { query, oneOf } from 'express-validator';
 
-import { PageDocument, PageModel } from '../../models/page';
+import mongoose from 'mongoose';
+
+import { PageModel } from '../../models/page';
 import ErrorV3 from '../../models/vo/error-apiv3';
 import loggerFactory from '../../../utils/logger';
 import Crowi from '../../crowi';
 import { ApiV3Response } from './interfaces/apiv3-response';
+import { IPageInfoForList, IPageInfoCommon, isExistPageInfo } from '~/interfaces/page';
+import PageService from '../../service/page';
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
@@ -93,14 +97,58 @@ export default (crowi: Crowi): Router => {
     }
   });
 
+  // eslint-disable-next-line max-len
+  router.get('/info', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { pageIds } = req.query;
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Bookmark = crowi.model('Bookmark');
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const pageService: PageService = crowi.pageService!;
+
+    try {
+      const pages = await Page.findByIdsAndViewer(pageIds as string[], req.user, null, true);
+
+      const foundIds = pages.map(page => page._id);
+
+      const shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
+      const bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
+
+      const idToPageInfoMap: Record<string, IPageInfoCommon|IPageInfoForList> = {};
+
+      for (const page of pages) {
+        // construct IPageInfoForList
+        const basicPageInfo = pageService.constructBasicPageInfo(page);
+
+        const pageInfo: IPageInfoCommon | IPageInfoForList = (!isExistPageInfo(basicPageInfo))
+          ? basicPageInfo
+          // create IPageInfoForList
+          : {
+            ...basicPageInfo,
+            bookmarkCount: bookmarkCountMap[page._id],
+            revisionShortBody: shortBodiesMap[page._id],
+          } as IPageInfoForList;
+
+        idToPageInfoMap[page._id] = pageInfo;
+      }
+
+      return res.apiv3(idToPageInfoMap);
+    }
+    catch (err) {
+      logger.error('Error occurred while fetching page informations.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while fetching page informations.'));
+    }
+  });
+
   // eslint-disable-next-line max-len
   router.get('/short-bodies', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { pageIds } = req.query;
 
     try {
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const shortBodiesMap = await crowi.pageService!.shortBodiesMapByPageIds(pageIds as string[], req.user);
-      return res.apiv3({ shortBodiesMap });
+      // const shortBodiesMap = await crowi.pageService!.shortBodiesMapByPageIds(pageIds as string[], req.user);
+      // return res.apiv3({ shortBodiesMap });
+      return res.apiv3();
     }
     catch (err) {
       logger.error('Error occurred while fetching shortBodiesMap.', err);

+ 40 - 66
packages/app/src/server/routes/apiv3/page.js

@@ -1,7 +1,8 @@
 import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
-import Subscription, { STATUS_SUBSCRIBE, STATUS_UNSUBSCRIBE } from '~/server/models/subscription';
+import { AllSubscriptionStatusType } from '~/interfaces/subscription';
+import Subscription from '~/server/models/subscription';
 
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
@@ -9,7 +10,7 @@ const express = require('express');
 const { body, query } = require('express-validator');
 
 const router = express.Router();
-const { convertToNewAffiliationPath } = pagePathUtils;
+const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -114,15 +115,11 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *        description: PageInfo
  *        type: object
  *        required:
- *          - isSeen
  *          - sumOfLikers
  *          - likerIds
  *          - sumOfSeenUsers
  *          - seenUserIds
  *        properties:
- *          isSeen:
- *            type: boolean
- *            description: Whether the page has ever been seen
  *          isLiked:
  *            type: boolean
  *            description: Whether the page is liked by the logged in user
@@ -165,7 +162,7 @@ module.exports = (crowi) => {
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
-  const { Page, GlobalNotificationSetting } = crowi.models;
+  const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { pageService, exportService } = crowi;
 
   const validator = {
@@ -199,7 +196,7 @@ module.exports = (crowi) => {
     ],
     subscribe: [
       body('pageId').isString(),
-      body('status').isBoolean(),
+      body('status').isIn(AllSubscriptionStatusType),
     ],
     subscribeStatus: [
       query('pageId').isString(),
@@ -358,26 +355,47 @@ module.exports = (crowi) => {
    *            description: Internal server error.
    */
   router.get('/info', loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+    const { user } = req;
     const { pageId } = req.query;
 
     try {
-      const page = await Page.findById(pageId);
-
-      const guestUserResponse = {
-        sumOfLikers: page.liker.length,
-        likerIds: page.liker.slice(0, 15),
-        seenUserIds: page.seenUsers.slice(0, 15),
-        sumOfSeenUsers: page.seenUsers.length,
-        isSeen: page.seenUsers.length > 0,
-      };
+      const page = await Page.findByIdAndViewer(pageId, user, null, true);
+
+      if (page == null) {
+        return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
+      }
 
       const isGuestUser = !req.user;
+      const pageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
+
+      const bookmarkCount = await Bookmark.countByPageId(pageId);
+
+      const responseBodyForGuest = {
+        ...pageInfo,
+        bookmarkCount,
+      };
+
       if (isGuestUser) {
-        return res.apiv3(guestUserResponse);
+        return res.apiv3(responseBodyForGuest);
       }
 
-      const userResponse = { ...guestUserResponse, isLiked: page.isLiked(req.user) };
-      return res.apiv3(userResponse);
+      const isBookmarked = await Bookmark.findByPageIdAndUserId(pageId, user._id);
+      const isLiked = page.isLiked(user);
+      const isMovable = !isTopPage(page.path);
+      const isAbleToDeleteCompletely = pageService.canDeleteCompletely(page.creator?._id, user);
+
+      const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
+
+      const responseBody = {
+        ...responseBodyForGuest,
+        isMovable,
+        isAbleToDeleteCompletely,
+        isBookmarked,
+        isLiked,
+        subscriptionStatus: subscription?.status,
+      };
+
+      return res.apiv3(responseBody);
     }
     catch (err) {
       logger.error('get-page-info', err);
@@ -608,9 +626,9 @@ module.exports = (crowi) => {
    *            description: Internal server error.
    */
   router.put('/subscribe', accessTokenParser, loginRequiredStrictly, csrf, validator.subscribe, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.body;
+    const { pageId, status } = req.body;
     const userId = req.user._id;
-    const status = req.body.status ? STATUS_SUBSCRIBE : STATUS_UNSUBSCRIBE;
+
     try {
       const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
       return res.apiv3({ subscription });
@@ -621,49 +639,5 @@ module.exports = (crowi) => {
     }
   });
 
-  /**
-   * @swagger
-   *
-   *    /page/subscribe:
-   *      get:
-   *        tags: [Page]
-   *        summary: /page/subscribe
-   *        description: Get subscription status
-   *        operationId: getSubscriptionStatus
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  pageId:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get subscription status.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/Page'
-   *          500:
-   *            description: Internal server error.
-   */
-  router.get('/subscribe', loginRequiredStrictly, validator.subscribeStatus, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.query;
-    const userId = req.user._id;
-
-    const page = await Page.findById(pageId);
-    if (!page) throw new Error('Page not found');
-
-    try {
-      const subscription = await Subscription.findByUserIdAndTargetId(userId, pageId);
-      const subscribing = subscription ? subscription.isSubscribing() : null;
-      return res.apiv3({ subscribing });
-    }
-    catch (err) {
-      logger.error('Failed to ge subscribe status', err);
-      return res.apiv3(err, 500);
-    }
-  });
-
   return router;
 };

+ 0 - 4
packages/app/src/server/routes/page.js

@@ -285,21 +285,17 @@ module.exports = function(crowi, app) {
 
   async function addRenderVarsForIdenticalPage(renderVars, pages) {
     const pageIds = pages.map(p => p._id);
-    const shortBodyMap = await crowi.pageService.shortBodiesMapByPageIds(pageIds);
 
     const identicalPageDataList = await Promise.all(pages.map(async(page) => {
-      const bookmarkCount = await Bookmark.countByPageId(page._id);
       page._doc.seenUserCount = (page.seenUsers && page.seenUsers.length) || 0;
       return {
         pageData: page,
         pageMeta: {
-          bookmarkCount,
         },
       };
     }));
 
     renderVars.identicalPageDataList = identicalPageDataList;
-    renderVars.shortBodyMap = shortBodyMap;
   }
 
   function replacePlaceholdersOfTemplate(template, req) {

+ 4 - 3
packages/app/src/server/service/in-app-notification.ts

@@ -1,6 +1,6 @@
 import { Types } from 'mongoose';
 import { subDays } from 'date-fns';
-import { InAppNotificationStatuses, PaginateResult, IInAppNotification } from '~/interfaces/in-app-notification';
+import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
 import Crowi from '../crowi';
 import {
   InAppNotification,
@@ -9,13 +9,14 @@ import {
 
 import { ActivityDocument } from '~/server/models/activity';
 import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
-import Subscription, { STATUS_SUBSCRIBE } from '~/server/models/subscription';
+import Subscription from '~/server/models/subscription';
 
 import { IUser } from '~/interfaces/user';
 
 import { HasObjectId } from '~/interfaces/has-object-id';
 import loggerFactory from '~/utils/logger';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
+import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
@@ -167,7 +168,7 @@ export default class InAppNotificationService {
     if (inAppNotificationSettings != null) {
       const subscribeRule = inAppNotificationSettings.subscribeRules.find(subscribeRule => subscribeRule.name === targetRuleName);
       if (subscribeRule != null && subscribeRule.isEnabled) {
-        await Subscription.subscribeByPageId(userId, pageId, STATUS_SUBSCRIBE);
+        await Subscription.subscribeByPageId(userId, pageId, SubscriptionStatusType.SUBSCRIBE);
       }
     }
 

+ 45 - 9
packages/app/src/server/service/page.ts

@@ -1,5 +1,5 @@
 import { pagePathUtils } from '@growi/core';
-import mongoose, { QueryCursor } from 'mongoose';
+import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import escapeStringRegexp from 'escape-string-regexp';
 import streamToPromise from 'stream-to-promise';
 import pathlib from 'path';
@@ -13,15 +13,20 @@ import {
 } from '~/server/models/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import ActivityDefine from '../util/activityDefine';
-import { IPage } from '~/interfaces/page';
+import {
+  IPage, IPageInfo, IPageInfoCommon,
+} from '~/interfaces/page';
 import { PageRedirectModel } from '../models/page-redirect';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { IUserHasId } from '~/interfaces/user';
+import { Ref } from '~/interfaces/common';
+import { HasObjectId } from '~/interfaces/has-object-id';
 
 const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
-  isCreatablePage, isDeletablePage, isTrashPage, collectAncestorPaths, isTopPage,
+  isCreatablePage, isTrashPage, collectAncestorPaths, isTopPage,
 } = pagePathUtils;
 
 const BULK_REINDEX_SIZE = 100;
@@ -222,8 +227,6 @@ class PageService {
       result.isForbidden = isExist;
       result.isNotFound = !isExist;
       result.isCreatable = isCreatablePage(path);
-      result.isDeletable = false;
-      result.canDeleteCompletely = false;
       result.page = page;
 
       return result;
@@ -233,9 +236,7 @@ class PageService {
     result.isForbidden = false;
     result.isNotFound = false;
     result.isCreatable = false;
-    result.isDeletable = isDeletablePage(path);
     result.isDeleted = page.isDeleted();
-    result.canDeleteCompletely = user != null && this.canDeleteCompletely(page.creator, user);
 
     return result;
   }
@@ -1538,14 +1539,49 @@ class PageService {
     }
   }
 
-  async shortBodiesMapByPageIds(pageIds: string[] = [], user) {
+  private extractStringIds(refs: Ref<HasObjectId>[]) {
+    return refs.map((ref: Ref<HasObjectId>) => {
+      return (typeof ref === 'string') ? ref : ref._id.toString();
+    });
+  }
+
+  constructBasicPageInfo(page: IPage, isGuestUser?: boolean): IPageInfoCommon | IPageInfo {
+    if (page.isEmpty) {
+      return {
+        isEmpty: true,
+        isMovable: true,
+        isDeletable: false,
+        isAbleToDeleteCompletely: false,
+      };
+    }
+
+    const isMovable = isGuestUser ? false : !isTopPage(page.path);
+
+    const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
+    const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
+
+    const Page = this.crowi.model('Page');
+    return {
+      isEmpty: false,
+      sumOfLikers: page.liker.length,
+      likerIds: this.extractStringIds(likers),
+      seenUserIds: this.extractStringIds(seenUsers),
+      sumOfSeenUsers: page.seenUsers.length,
+      isMovable,
+      isDeletable: Page.isDeletableName(page.path),
+      isAbleToDeleteCompletely: false,
+    };
+
+  }
+
+  async shortBodiesMapByPageIds(pageIds: ObjectId[] = [], user): Promise<Record<string, string | null>> {
     const Page = mongoose.model('Page');
     const MAX_LENGTH = 350;
 
     // aggregation options
     const viewerCondition = await generateGrantCondition(user, null);
     const filterByIds = {
-      _id: { $in: pageIds.map(id => new mongoose.Types.ObjectId(id)) },
+      _id: { $in: pageIds },
     };
 
     let pages;

+ 0 - 7
packages/app/src/server/util/swigFunctions.js

@@ -155,13 +155,6 @@ module.exports = function(crowi, req, locals) {
     return false;
   };
 
-  locals.isDeletablePage = function() {
-    const Page = crowi.model('Page');
-    const path = req.path || '';
-
-    return Page.isDeletableName(path);
-  };
-
   locals.userPageRoot = function(user) {
     if (!user || !user.username) {
       return '';

+ 0 - 1
packages/app/src/server/views/layout-growi/identical-path-page.html

@@ -19,7 +19,6 @@
         <div
           id="identical-path-page"
           data-identical-page-data-list="{{ identicalPageDataList|json }}"
-          data-shortody-map="{{ shortBodyMap|json }}"
         ></div>
       </div>
       <div id="page-context"></div>

+ 0 - 2
packages/app/src/server/views/widget/page_content.html

@@ -13,9 +13,7 @@
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
-  data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
-  data-page-is-able-to-delete-completely="{% if pageService.canDeleteCompletely(page.creator._id, user) %}true{% else %}false{% endif %}"
   data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-created-at="{{ page.createdAt|datetz('Y/m/d H:i:s') }}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"

+ 6 - 5
packages/app/src/stores/bookmark.ts

@@ -1,12 +1,13 @@
-import useSWR, { SWRResponse } from 'swr';
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
 import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
-export const useSWRBookmarkInfo = (pageId: string | null | undefined, isOpen = false): SWRResponse<IBookmarkInfo, Error> => {
-  return useSWR(
-    pageId != null && isOpen
-      ? `/bookmarks/info?pageId=${pageId}` : null,
+export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRResponse<IBookmarkInfo, Error> => {
+  return useSWRImmutable(
+    pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
         sumOfBookmarks: response.data.sumOfBookmarks,

+ 0 - 8
packages/app/src/stores/context.tsx

@@ -59,18 +59,10 @@ export const useIsDeleted = (initialData?: boolean): SWRResponse<boolean, Error>
   return useStaticSWR<boolean, Error>('isDeleted', initialData, { fallbackData: false });
 };
 
-export const useIsDeletable = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isDeletable', initialData, { fallbackData: false });
-};
-
 export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 };
 
-export const useIsAbleToDeleteCompletely = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isAbleToDeleteCompletely', initialData, { fallbackData: false });
-};
-
 export const useIsForbidden = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
 };

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

@@ -1,4 +1,5 @@
 import useSWR, { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import {
@@ -47,7 +48,7 @@ export const useSWRxPageChildren = (
 
 export const useSWRxV5MigrationStatus = (
 ): SWRResponse<V5MigrationStatus, Error> => {
-  return useSWR(
+  return useSWRImmutable(
     '/pages/v5-migration-status',
     endpoint => apiv3Get(endpoint).then((response) => {
       return {

+ 16 - 34
packages/app/src/stores/page.tsx

@@ -1,14 +1,15 @@
 import useSWR, { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 
-import { IPage, IPageHasId } from '~/interfaces/page';
+import {
+  IPageInfo, IPageInfoCommon, IPageInfoForList, IPageHasId,
+} from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
 
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
-import { IPageInfo } from '../interfaces/page-info';
-import { useIsGuestUser } from './context';
 
 
 export const useSWRxPageByPath = (path: string, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
@@ -48,48 +49,29 @@ export const useSWRxPageList = (
   );
 };
 
-export const useSWRPageInfo = (pageId: string | null): SWRResponse<IPageInfo, Error> => {
-  return useSWR(pageId != null ? `/page/info?pageId=${pageId}` : null, endpoint => apiv3Get(endpoint).then((response) => {
-    return {
-      sumOfLikers: response.data.sumOfLikers,
-      likerIds: response.data.likerIds,
-      seenUserIds: response.data.seenUserIds,
-      sumOfSeenUsers: response.data.sumOfSeenUsers,
-      isSeen: response.data.isSeen,
-      isLiked: response.data?.isLiked,
-    };
-  }));
-};
-
 export const useSWRTagsInfo = (pageId: string | null | undefined): SWRResponse<IPageTagsInfo, Error> => {
   const key = pageId == null ? null : `/pages.getPageTag?pageId=${pageId}`;
 
-  return useSWR(key, endpoint => apiGet(endpoint).then((response: IPageTagsInfo) => {
+  return useSWRImmutable(key, endpoint => apiGet(endpoint).then((response: IPageTagsInfo) => {
     return {
       tags: response.tags,
     };
   }));
 };
-type GetSubscriptionStatusResult = { subscribing: boolean };
 
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRResponse<{status: boolean | null}, Error> => {
-  const { data: isGuestUser } = useIsGuestUser();
-  const key = isGuestUser === false ? ['/page/subscribe', pageId] : null;
-  return useSWR(
-    key,
-    (endpoint, pageId) => apiv3Get<GetSubscriptionStatusResult>(endpoint, { pageId }).then((response) => {
-      return {
-        status: response.data.subscribing,
-      };
-    }),
+export const useSWRxPageInfo = (pageId: string | null | undefined): SWRResponse<IPageInfoCommon | IPageInfo, Error> => {
+  return useSWRImmutable(
+    pageId != null ? ['/page/info', pageId] : null,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
   );
 };
 
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxPageInfo = <Data, Error>(pageId: string | undefined): SWRResponse<IPageInfo, Error> => {
-  return useSWR(
-    pageId != null ? ['/page/info', pageId] : null,
-    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+export const useSWRxPageInfoForList = (pageIds: string[] | null | undefined): SWRResponse<Record<string, IPageInfoCommon|IPageInfoForList>, Error> => {
+
+  const shouldFetch = pageIds != null && pageIds.length > 0;
+
+  return useSWRImmutable(
+    shouldFetch ? ['/page-listing/info', pageIds] : null,
+    (endpoint, pageIds) => apiv3Get(endpoint, { pageIds }).then(response => response.data),
   );
 };

+ 1 - 11
packages/app/src/stores/user.tsx

@@ -5,17 +5,7 @@ import { IUserHasId } from '~/interfaces/user';
 
 import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
 
-import { apiGet } from '../client/util/apiv1-client';
-
-export const useSWRxLikerList = (likerIds: string[] = []): SWRResponse<IUserHasId[], Error> => {
-  const shouldFetch = likerIds.length > 0;
-  return useSWR(shouldFetch ? ['/users.list', [...likerIds].join(',')] : null, (endpoint:string, userIds:string) => {
-    return apiGet(endpoint, { user_ids: userIds }).then((response:any) => response.users);
-  });
-};
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxUsersList = <Data, Error>(userIds: string[]): SWRResponse<IUserHasId[], Error> => {
+export const useSWRxUsersList = (userIds: string[]): SWRResponse<IUserHasId[], Error> => {
   const distinctUserIds = userIds.length > 0 ? Array.from(new Set(userIds)).sort() : [];
   return useSWR(
     distinctUserIds.length > 0 ? ['/users/list', distinctUserIds] : null,

+ 0 - 27
packages/app/src/styles/_page-accessories-control.scss

@@ -15,31 +15,4 @@
     height: 25px;
     border-left: solid 1px transparent;
   }
-
-  .seen-user-count {
-    font-size: 12px;
-    font-weight: bolder;
-  }
-  .grw-seen-user-info {
-    .btn {
-      white-space: nowrap;
-    }
-  }
-
-  .seen-user-popover {
-    max-width: 200px;
-
-    .user-list-content {
-      direction: rtl;
-
-      .liker-user-count,
-      .seen-user-count {
-        font-size: 12px;
-        font-weight: bolder;
-      }
-    }
-    .cls-1 {
-      isolation: isolate;
-    }
-  }
 }

+ 45 - 6
packages/app/src/styles/_subnav.scss

@@ -38,22 +38,44 @@
     }
   }
 
-  .btn-like,
-  .btn-bookmark,
   .btn-subscribe {
     height: 40px;
     font-size: 20px;
   }
-  .grw-btn-page-management {
+
+  .btn-like,
+  .btn-bookmark,
+  .btn-seen-user {
     height: 40px;
-    font-size: 16px;
+    padding-right: 6px;
+    padding-left: 8px;
+    font-size: 20px;
+    svg {
+      width: 20px;
+      height: 20px;
+    }
   }
-
   .total-likes,
   .total-bookmarks {
-    font-size: 17px;
+    display: flex;
+    align-items: end;
+    padding-right: 8px;
+    padding-left: 6px;
+    font-size: 14px;
     font-weight: $font-weight-bold;
   }
+  .seen-user-count {
+    padding-right: 6px;
+    padding-left: 6px;
+    font-size: 14px;
+    font-weight: $font-weight-bold;
+    vertical-align: bottom;
+  }
+
+  .grw-btn-page-management {
+    height: 40px;
+    font-size: 16px;
+  }
 
   ul.authors {
     li {
@@ -133,3 +155,20 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     }
   }
 }
+
+.user-list-popover {
+  max-width: 200px;
+
+  .user-list-content {
+    direction: rtl;
+
+    .liker-user-count,
+    .seen-user-count {
+      font-size: 12px;
+      font-weight: bolder;
+    }
+  }
+  .cls-1 {
+    isolation: isolate;
+  }
+}

+ 3 - 3
packages/app/src/styles/atoms/_buttons.scss

@@ -1,5 +1,5 @@
 .btn.btn-like {
-  @include button-outline-variant($secondary, lighten($red, 15%), rgba(lighten($red, 10%), 0.15), rgba(lighten($red, 10%), 0.5));
+  @include button-outline-variant(rgba($secondary, 50%), lighten($red, 15%), rgba(lighten($red, 10%), 0.15), rgba(lighten($red, 10%), 0.5));
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
     color: lighten($red, 15%);
@@ -11,7 +11,7 @@
 }
 
 .btn.btn-bookmark {
-  @include button-outline-variant($secondary, $orange, rgba(lighten($orange, 20%), 0.5), rgba(lighten($orange, 20%), 0.5));
+  @include button-outline-variant(rgba($secondary, 50%), $orange, rgba(lighten($orange, 20%), 0.5), rgba(lighten($orange, 20%), 0.5));
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
     color: $orange;
@@ -23,7 +23,7 @@
 }
 
 .btn.btn-subscribe {
-  @include button-outline-variant($secondary, $success, rgba(lighten($success, 10%), 0.15), rgba(lighten($success, 10%), 0.5));
+  @include button-outline-variant(rgba($secondary, 50%), $success, rgba(lighten($success, 10%), 0.15), rgba(lighten($success, 10%), 0.5));
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
     color: lighten($success, 15%);

+ 1 - 0
packages/app/test/integration/models/page.test.js

@@ -158,6 +158,7 @@ describe('Page', () => {
 
   describe('.isDeletableName', () => {
     test('should decide deletable or not', () => {
+      expect(Page.isDeletableName('/')).toBeFalsy();
       expect(Page.isDeletableName('/hoge')).toBeTruthy();
       expect(Page.isDeletableName('/user/xxx')).toBeFalsy();
       expect(Page.isDeletableName('/user/xxx123')).toBeFalsy();

+ 2 - 2
packages/ui/src/components/SearchPage/FootstampIcon.jsx

@@ -3,8 +3,8 @@ import React from 'react';
 export const FootstampIcon = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
-    width="16"
-    height="16"
+    width={16}
+    height={16}
     viewBox="0 0 16 16"
   >
     <path d="M7.34,8,3.31,9a1.83,1.83,0,0,1-1.24-.08A1.28,1.28,0,0,1,1.34,8a3.24,3.24,0,0,1,.2-1.82A6.06,6.06,0,0,1,2.6,4.35h0a2.56,