Przeglądaj źródła

Merge pull request #5137 from weseek/imprv/migrate-like-states-to-swr

imprv: Migrate like states to swr
Yuki Takei 4 lat temu
rodzic
commit
1402b00e95

+ 1 - 0
packages/app/resource/locales/en_US/translation.json

@@ -57,6 +57,7 @@
   "Presentation Mode": "Presentation",
   "The end": "The end",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "No users have liked this yet",
   "No users have liked this yet.": "No users have liked this yet.",
   "No users have bookmarked yet": "No users have bookmarked yet",
   "Create Archive Page": "Create Archive Page",

+ 1 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -58,6 +58,7 @@
   "Presentation Mode": "プレゼンテーション",
   "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "No users have liked this yet": "いいねをしているユーザーはいません",
   "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",

+ 1 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -59,6 +59,7 @@
 	"Presentation Mode": "演示文稿",
   "The end": "结束",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet": "还没有用户喜欢这个",
   "No users have bookmarked yet": "还没有用户加入书签",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",

+ 0 - 2
packages/app/src/client/app.jsx

@@ -36,7 +36,6 @@ import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import BookmarkIcon from '../components/Icons/BookmarkIcon';
 import BookmarkList from '../components/PageList/BookmarkList';
-import LikerList from '../components/User/LikerList';
 import Fab from '../components/Fab';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
@@ -125,7 +124,6 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
-    'liker-list': <LikerList />,
     'page-content-footer': <PageContentFooter />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,

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

@@ -54,15 +54,6 @@ export default class PageContainer extends Container {
       path,
       tocHtml: '',
 
-      seenUsers: [],
-      seenUserIds: [],
-      sumOfSeenUsers: [],
-
-      isLiked: false,
-      likers: [],
-      likerIds: [],
-      sumOfLikers: 0,
-
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
@@ -117,23 +108,9 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
     this.initStateMarkdown();
-    this.checkAndUpdateImageUrlCached(this.state.likers);
-
-    const { isSharedUser } = this.appContainer;
-
-    // see https://dev.growi.org/5fabddf8bbeb1a0048bcb9e9
-    const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
-
-    if (isAbleToGetAttachedInformationAboutPages) {
-      // We don't retrieve bookmarks in the initial page load
-      // as it is stored in a separate collection to like and seen user
-      // data so it has a separate api endpoint.
-      this.initialPageLoad();
-    }
 
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
-    this.checkAndUpdateImageUrlCached = this.checkAndUpdateImageUrlCached.bind(this);
 
     this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
     this.emitJoinPageRoomRequest();
@@ -266,72 +243,6 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
   }
 
-
-  async initialPageLoad() {
-    {
-      const {
-        data: {
-          likerIds, sumOfLikers, isLiked, seenUserIds, sumOfSeenUsers, isSeen,
-        },
-      } = await this.appContainer.apiv3Get('/page/info', { pageId: this.state.pageId });
-
-      await this.setState({
-        sumOfLikers,
-        isLiked,
-        likerIds,
-        seenUserIds,
-        sumOfSeenUsers,
-        isSeen,
-      });
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
-
-  async toggleLike() {
-    {
-      const toggledIsLiked = !this.state.isLiked;
-      await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool: toggledIsLiked });
-
-      await this.setState(state => ({
-        isLiked: toggledIsLiked,
-        sumOfLikers: toggledIsLiked ? state.sumOfLikers + 1 : state.sumOfLikers - 1,
-        likerIds: toggledIsLiked
-          ? [...this.state.likerIds, this.appContainer.currentUserId]
-          : state.likerIds.filter(id => id !== this.appContainer.currentUserId),
-      }));
-    }
-
-    await this.retrieveLikersAndSeenUsers();
-  }
-
-  async retrieveLikersAndSeenUsers() {
-    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });
-
-    await this.setState({
-      likers: users.filter(({ id }) => this.state.likerIds.includes(id)).slice(0, 15),
-      seenUsers: users.filter(({ id }) => this.state.seenUserIds.includes(id)).slice(0, 15),
-    });
-
-    this.checkAndUpdateImageUrlCached(users);
-  }
-
-  async checkAndUpdateImageUrlCached(users) {
-    const noImageCacheUsers = users.filter((user) => { return user.imageUrlCached == null });
-    if (noImageCacheUsers.length === 0) {
-      return;
-    }
-
-    const noImageCacheUserIds = noImageCacheUsers.map((user) => { return user.id });
-    try {
-      await this.appContainer.apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
-    }
-    catch (err) {
-      // Error alert doesn't apear, because user don't need to notice this error.
-      logger.error(err);
-    }
-  }
-
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,

+ 0 - 102
packages/app/src/components/LikeButtons.jsx

@@ -1,102 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import UserPictureList from './User/UserPictureList';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import { toastError } from '~/client/util/apiNotification';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-class LikeButtons extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isPopoverOpen: false,
-    };
-
-    this.togglePopover = this.togglePopover.bind(this);
-    this.handleClick = this.handleClick.bind(this);
-  }
-
-  togglePopover() {
-    this.setState(prevState => ({
-      ...prevState,
-      isPopoverOpen: !prevState.isPopoverOpen,
-    }));
-  }
-
-  async handleClick() {
-    const { appContainer, pageContainer } = this.props;
-    const { isGuestUser } = appContainer;
-
-    if (isGuestUser) {
-      return;
-    }
-
-    try {
-      pageContainer.toggleLike();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { appContainer, pageContainer, t } = this.props;
-    const { isGuestUser } = appContainer;
-    const {
-      state: { likers, sumOfLikers, isLiked },
-    } = pageContainer;
-
-    return (
-      <div className="btn-group" role="group" aria-label="Like buttons">
-        <button
-          type="button"
-          id="like-button"
-          onClick={this.handleClick}
-          className={`btn btn-like border-0
-            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
-        >
-          <i className="icon-like"></i>
-        </button>
-        {isGuestUser && (
-          <UncontrolledTooltip placement="top" target="like-button" fade={false}>
-            {t('Not available for guest')}
-          </UncontrolledTooltip>
-        )}
-
-        <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
-          {sumOfLikers}
-        </button>
-        <Popover placement="bottom" isOpen={this.state.isPopoverOpen} target="po-total-likes" toggle={this.togglePopover} trigger="legacy">
-          <PopoverBody className="seen-user-popover">
-            <div className="px-2 text-right user-list-content text-truncate text-muted">
-              {likers.length ? <UserPictureList users={likers} /> : t('No users have liked this yet.')}
-            </div>
-          </PopoverBody>
-        </Popover>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const LikeButtonsWrapper = withUnstatedContainers(LikeButtons, [AppContainer, PageContainer]);
-
-LikeButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  t: PropTypes.func.isRequired,
-  size: PropTypes.string,
-};
-
-export default withTranslation()(LikeButtonsWrapper);

+ 86 - 0
packages/app/src/components/LikeButtons.tsx

@@ -0,0 +1,86 @@
+import React, { FC, useState } from 'react';
+
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import UserPictureList from './User/UserPictureList';
+import { toastError } from '~/client/util/apiNotification';
+import { useIsGuestUser } from '~/stores/context';
+import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRxUsersList } from '~/stores/user';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
+interface Props {
+  pageId: string,
+}
+
+const LikeButtons: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { pageId } = props;
+
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: pageInfo, mutate } = useSWRxPageInfo(pageId);
+  const isLiked = pageInfo?.isLiked ?? false;
+  const sumOfLikers = pageInfo?.sumOfLikers != null ? pageInfo.sumOfLikers : 0;
+  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 togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
+
+  const handleClick = async() => {
+    if (isGuestUser) {
+      return;
+    }
+
+    try {
+      const res = await apiv3Put('/page/likes', { pageId, bool: !isLiked });
+      if (res) {
+        mutate();
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <div className="btn-group" role="group" aria-label="Like buttons">
+      <button
+        type="button"
+        id="like-button"
+        onClick={handleClick}
+        className={`btn btn-like border-0
+          ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+      >
+        <i className="icon-like"></i>
+      </button>
+
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+        {sumOfLikers}
+      </button>
+
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            {likers.length ? <UserPictureList users={likers} /> : t('No users have liked this yet')}
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
+
+export default LikeButtons;

+ 1 - 1
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -29,7 +29,7 @@ const SubnavButtons = React.memo((props) => {
         </span>
         {pageContainer.isAbleToShowLikeButtons && (
           <span>
-            <LikeButtons />
+            <LikeButtons pageId={pageId} />
           </span>
         )}
         <span>

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

@@ -15,12 +15,16 @@ import SeenUserInfo from './User/SeenUserInfo';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
+import { usePageId } from '~/stores/context';
+
 const PageAccessoriesModalControl = (props) => {
   const {
     t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
+  const { data: pageId } = usePageId();
+
   const accessoriesBtnList = useMemo(() => {
     return [
       {
@@ -90,7 +94,7 @@ const PageAccessoriesModalControl = (props) => {
       })}
       <div className="d-flex align-items-center">
         <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo disabled={isSharedUser} />
+        <SeenUserInfo disabled={isSharedUser} pageId={pageId} />
       </div>
     </div>
   );

+ 0 - 38
packages/app/src/components/User/LikerList.jsx

@@ -1,38 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import UserPictureList from './UserPictureList';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
-
-class LikerList extends React.Component {
-
-  render() {
-    const { pageContainer } = this.props;
-    return (
-      <div className="user-list-content text-truncate text-muted text-right">
-        <span className="text-info">
-          <span className="liker-user-count">{pageContainer.state.sumOfLikers}</span>
-          <i className="icon-fw icon-like"></i>
-        </span>
-        <span className="mr-1">
-          <UserPictureList users={pageContainer.state.likerUsers} />
-        </span>
-      </div>
-    );
-  }
-
-}
-
-LikerList.propTypes = {
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const LikerListWrapper = withUnstatedContainers(LikerList, [PageContainer]);
-
-export default (LikerListWrapper);

+ 0 - 51
packages/app/src/components/User/SeenUserInfo.jsx

@@ -1,51 +0,0 @@
-// import React from 'react';
-import PropTypes from 'prop-types';
-
-import React, { useState } from 'react';
-import {
-  Button, Popover, PopoverBody,
-} from 'reactstrap';
-import UserPictureList from './UserPictureList';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
-
-import FootstampIcon from '../FootstampIcon';
-
-/* eslint react/no-multi-comp: 0, react/prop-types: 0 */
-
-const SeenUserInfo = (props) => {
-  const [popoverOpen, setPopoverOpen] = useState(false);
-  const toggle = () => setPopoverOpen(!popoverOpen);
-  const { pageContainer, disabled } = props;
-  return (
-    <div className="grw-seen-user-info">
-      <Button id="po-seen-user" color="link" className="px-2">
-        <span className="mr-1 footstamp-icon">
-          <FootstampIcon />
-        </span>
-        <span className="seen-user-count">{pageContainer.state.sumOfSeenUsers}</span>
-      </Button>
-      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy" disabled={disabled}>
-        <PopoverBody className="seen-user-popover">
-          <div className="px-2 text-right user-list-content text-truncate text-muted">
-            <UserPictureList users={pageContainer.state.seenUsers} />
-          </div>
-        </PopoverBody>
-      </Popover>
-    </div>
-  );
-};
-
-SeenUserInfo.propTypes = {
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  disabled: PropTypes.bool,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SeenUserInfoWrapper = withUnstatedContainers(SeenUserInfo, [PageContainer]);
-
-export default (SeenUserInfoWrapper);

+ 49 - 0
packages/app/src/components/User/SeenUserInfo.tsx

@@ -0,0 +1,49 @@
+import React, { FC, useState } from 'react';
+
+import { Button, Popover, PopoverBody } from 'reactstrap';
+
+import UserPictureList from './UserPictureList';
+import FootstampIcon from '../FootstampIcon';
+import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRxUsersList } from '~/stores/user';
+
+interface Props {
+  pageId: string,
+  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 togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
+
+  return (
+    <div className="grw-seen-user-info">
+      <Button id="po-seen-user" color="link" className="px-2">
+        <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">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            <UserPictureList users={seenUsers} />
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
+
+export default SeenUserInfo;

+ 8 - 1
packages/app/src/interfaces/page.ts

@@ -4,7 +4,6 @@ import { IRevision } from './revision';
 import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
 
-
 export type IPage = {
   path: string,
   status: string,
@@ -31,6 +30,14 @@ export type IPage = {
   deletedAt: Date,
 }
 
+export type IPageInfo = {
+  sumOfLikers: number
+  likerIds: string[]
+  seenUserIds: string[]
+  isSeen: boolean
+  isLiked: boolean
+}
+
 export type IPageHasId = IPage & HasObjectId;
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;

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

@@ -1,3 +1,5 @@
+import { HasObjectId } from '~/interfaces/has-object-id';
+
 export type IUser = {
   name: string;
   username: string;
@@ -7,6 +9,8 @@ export type IUser = {
   admin: boolean;
 }
 
+export type IUserHasId = IUser & HasObjectId;
+
 export type IUserGroupRelation = {
   relatedGroup: IUserGroup,
   relatedUser: IUser,

+ 64 - 1
packages/app/src/server/routes/apiv3/users.js

@@ -743,7 +743,7 @@ module.exports = (crowi) => {
   router.put('/update.imageUrlCache', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     try {
       const userIds = req.body.userIds;
-      const users = await User.find({ _id: { $in: userIds } });
+      const users = await User.find({ _id: { $in: userIds }, imageUrlCached: null });
       const requests = await Promise.all(users.map(async(user) => {
         return {
           updateOne: {
@@ -862,5 +862,68 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    paths:
+   *      /users/list:
+   *        get:
+   *          tags: [Users]
+   *          summary: /users/list
+   *          operationId: getUsersList
+   *          description: Get list of users
+   *          parameters:
+   *            - in: query
+   *              name: userIds
+   *              schema:
+   *                type: string
+   *                description: user IDs
+   *                example: 5e06fcc7516d64004dbf4da6,5e098d53baa2ac004e7d24ad
+   *          responses:
+   *            200:
+   *              description: Succeeded to get list of users.
+   *              content:
+   *                application/json:
+   *                  schema:
+   *                    properties:
+   *                      users:
+   *                        type: array
+   *                        items:
+   *                          $ref: '#/components/schemas/User'
+   *                        description: user list
+   *            403:
+   *              $ref: '#/components/responses/403'
+   *            500:
+   *              $ref: '#/components/responses/500'
+   */
+  router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
+    const userIds = req.query.userIds || null;
+
+    let userFetcher;
+    if (!userIds || userIds.split(',').length <= 0) {
+      userFetcher = User.findAllUsers();
+    }
+    else {
+      userFetcher = User.findUsersByIds(userIds.split(','));
+    }
+
+    const data = {};
+    try {
+      const users = await userFetcher;
+      data.users = users.map((user) => {
+        // omit email
+        if (user.isEmailPublished !== true) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
+          user.email = undefined;
+        }
+        return user.toObject({ virtuals: true });
+      });
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(err));
+    }
+
+    return res.apiv3(data);
+  });
+
   return router;
 };

+ 0 - 1
packages/app/src/server/routes/index.js

@@ -159,7 +159,6 @@ module.exports = function(crowi, app) {
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
   app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);

+ 0 - 71
packages/app/src/server/routes/user.js

@@ -75,76 +75,5 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({ valid }));
   };
 
-  /**
-   * @swagger
-   *
-   *    /users.list:
-   *      get:
-   *        tags: [Users, CrowiCompatibles]
-   *        operationId: listUsersV1
-   *        summary: /users.list
-   *        description: Get list of users
-   *        parameters:
-   *          - in: query
-   *            name: user_ids
-   *            schema:
-   *              type: string
-   *              description: user IDs
-   *              example: 5e06fcc7516d64004dbf4da6,5e098d53baa2ac004e7d24ad
-   *        responses:
-   *          200:
-   *            description: Succeeded to get list of users.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    users:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/User'
-   *                      description: user list
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /users.list Get user list
-   * @apiName GetUserList
-   * @apiGroup User
-   *
-   * @apiParam {String} user_ids
-   */
-  api.list = async function(req, res) {
-    const userIds = req.query.user_ids || null; // TODO: handling
-
-    let userFetcher;
-    if (!userIds || userIds.split(',').length <= 0) {
-      userFetcher = User.findAllUsers();
-    }
-    else {
-      userFetcher = User.findUsersByIds(userIds.split(','));
-    }
-
-    const data = {};
-    try {
-      const users = await userFetcher;
-      data.users = users.map((user) => {
-        // omit email
-        if (user.isEmailPublished !== true) { // compare to 'true' because Crowi original data doesn't have 'isEmailPublished'
-          user.email = undefined;
-        }
-        return user.toObject({ virtuals: true });
-      });
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-
-    return res.json(ApiResponse.success(data));
-  };
-
   return actions;
 };

+ 23 - 0
packages/app/src/stores/middlewares/user.ts

@@ -0,0 +1,23 @@
+import { Middleware, SWRHook } from 'swr';
+
+import { IUserHasId } from '~/interfaces/user';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+
+export const checkAndUpdateImageUrlCached: Middleware = (useSWRNext: SWRHook) => {
+  return (key, fetcher, config) => {
+    const swrNext = useSWRNext(key, fetcher, config);
+    if (swrNext.data != null) {
+
+      const userIds = Object(swrNext.data)
+        .filter((user: IUserHasId) => user.imageUrlCached == null)
+        .map((user: IUserHasId) => user._id);
+
+      if (userIds.length > 0) {
+        const distinctUserIds = Array.from(new Set(userIds));
+        apiv3Put('/users/update.imageUrlCache', { userIds: distinctUserIds });
+      }
+    }
+    return swrNext;
+  };
+};

+ 9 - 3
packages/app/src/stores/page.tsx

@@ -1,10 +1,9 @@
 import useSWR, { SWRResponse } from 'swr';
 
-import { Types } from 'mongoose';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
 
-import { IPage } from '~/interfaces/page';
+import { IPage, IPageInfo } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 
 import { useIsGuestUser } from './context';
@@ -52,7 +51,6 @@ 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,
@@ -63,3 +61,11 @@ export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRRespo
     }),
   );
 };
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxPageInfo = <Data, Error>(pageId: string): SWRResponse<IPageInfo, Error> => {
+  return useSWR(
+    ['/page/info', pageId],
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+  );
+};

+ 19 - 0
packages/app/src/stores/user.tsx

@@ -0,0 +1,19 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IUserHasId } from '~/interfaces/user';
+
+import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxUsersList = <Data, Error>(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,
+    (endpoint, userIds) => apiv3Get(endpoint, { userIds: userIds.join(',') }).then((response) => {
+      return response.data.users;
+    }),
+    { use: [checkAndUpdateImageUrlCached] },
+  );
+};