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

feat: Add user list for like button (#4346)

* feat: Add user list for like button

* ref: Rename <LikeButton/> to <LikeButtons/>

* Make LikeButtons a button group

* Remove unused code

* Refactor like-info into page-info

Co-authored-by: Yuki Takei <yuki@weseek.co.jp>
Mxchaeltrxn 4 лет назад
Родитель
Сommit
389f40b8df

+ 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.",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",
   "Target page": "Target page",

+ 56 - 25
packages/app/src/client/services/PageContainer.js

@@ -51,15 +51,19 @@ export default class PageContainer extends Container {
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path,
       tocHtml: '',
-      isLiked: false,
+
       isBookmarked: false,
+      sumOfBookmarks: 0,
+
       seenUsers: [],
-      seenUserIds: mainContent.getAttribute('data-page-ids-of-seen-users'),
-      countOfSeenUsers: mainContent.getAttribute('data-page-count-of-seen-users'),
+      seenUserIds: [],
+      sumOfSeenUsers: [],
 
-      likerUsers: [],
+      isLiked: false,
+      likers: [],
+      likerIds: [],
       sumOfLikers: 0,
-      sumOfBookmarks: 0,
+
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
       deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
@@ -109,7 +113,7 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
     this.initStateMarkdown();
-    this.checkAndUpdateImageUrlCached(this.state.likerUsers);
+    this.checkAndUpdateImageUrlCached(this.state.likers);
 
     const { isSharedUser } = this.appContainer;
 
@@ -117,8 +121,10 @@ export default class PageContainer extends Container {
     const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
 
     if (isAbleToGetAttachedInformationAboutPages) {
-      this.retrieveSeenUsers();
-      this.retrieveLikeInfo();
+      // 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.retrieveBookmarkInfo();
     }
 
@@ -219,7 +225,7 @@ export default class PageContainer extends Container {
    * whether to like button
    * not displayed on user page
    */
-  get isAbleToShowLikeButton() {
+  get isAbleToShowLikeButtons() {
     const { isUserPage } = this.state;
     const { isSharedUser } = this.appContainer;
 
@@ -264,29 +270,54 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
   }
 
-  async retrieveSeenUsers() {
-    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: this.state.seenUserIds });
 
-    this.setState({ seenUsers: users });
-    this.checkAndUpdateImageUrlCached(users);
-  }
+  async initialPageLoad() {
+    {
+      const {
+        data: {
+          likerIds, sumOfLikers, isLiked, seenUserIds, sumOfSeenUsers, isSeen,
+        },
+      } = await this.appContainer.apiv3Get('/page/info', { _id: this.state.pageId });
 
-  async retrieveLikeInfo() {
-    const res = await this.appContainer.apiv3Get('/page/like-info', { _id: this.state.pageId });
-    const { sumOfLikers, isLiked } = res.data;
+      await this.setState({
+        sumOfLikers,
+        isLiked,
+        likerIds,
+        seenUserIds,
+        sumOfSeenUsers,
+        isSeen,
+      });
+    }
 
-    this.setState({
-      sumOfLikers,
-      isLiked,
-    });
+    await this.retrieveLikersAndSeenUsers();
   }
 
   async toggleLike() {
-    const bool = !this.state.isLiked;
-    await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool });
-    this.setState({ isLiked: bool });
+    {
+      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(',') });
 
-    return this.retrieveLikeInfo();
+    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 retrieveBookmarkInfo() {

+ 35 - 13
packages/app/src/components/LikeButton.jsx → packages/app/src/components/LikeButtons.jsx

@@ -1,22 +1,35 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { UncontrolledTooltip } from 'reactstrap';
+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 LikeButton extends React.Component {
+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;
@@ -33,31 +46,40 @@ class LikeButton extends React.Component {
     }
   }
 
-
   render() {
     const { appContainer, pageContainer, t } = this.props;
     const { isGuestUser } = appContainer;
+    const {
+      state: { likers, sumOfLikers, isLiked },
+    } = pageContainer;
 
     return (
-      <div>
+      <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
-          ${pageContainer.state.isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
-          <i className="icon-like mr-3"></i>
-          <span className="total-likes">
-            {pageContainer.state.sumOfLikers}
-          </span>
+          <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">
+          {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>
     );
   }
@@ -67,9 +89,9 @@ class LikeButton extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const LikeButtonWrapper = withUnstatedContainers(LikeButton, [AppContainer, PageContainer]);
+const LikeButtonsWrapper = withUnstatedContainers(LikeButtons, [AppContainer, PageContainer]);
 
-LikeButton.propTypes = {
+LikeButtons.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
@@ -77,4 +99,4 @@ LikeButton.propTypes = {
   size: PropTypes.string,
 };
 
-export default withTranslation()(LikeButtonWrapper);
+export default withTranslation()(LikeButtonsWrapper);

+ 5 - 6
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -6,7 +6,7 @@ import PageContainer from '~/client/services/PageContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import BookmarkButton from '../BookmarkButton';
-import LikeButton from '../LikeButton';
+import LikeButtons from '../LikeButtons';
 import PageManagement from '../Page/PageManagement';
 
 const SubnavButtons = (props) => {
@@ -21,15 +21,14 @@ const SubnavButtons = (props) => {
 
     return (
       <>
-        {pageContainer.isAbleToShowLikeButton && (
+        {pageContainer.isAbleToShowLikeButtons && (
           <span>
-            <LikeButton />
+            <LikeButtons />
           </span>
         )}
         <span>
           <BookmarkButton />
         </span>
-
       </>
     );
   };
@@ -42,8 +41,8 @@ const SubnavButtons = (props) => {
     <>
       {isViewMode && (
         <>
-          { pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
-          { pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} /> }
+          {pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} />}
+          {pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} />}
         </>
       )}
     </>

+ 4 - 2
packages/app/src/components/User/SeenUserInfo.jsx

@@ -22,8 +22,10 @@ const SeenUserInfo = (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.countOfSeenUsers}</span>
+        <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">

+ 19 - 48
packages/app/src/server/routes/apiv3/page.js

@@ -112,17 +112,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          bool:
  *            type: boolean
  *            description: boolean for like status
- *
- *      LikeInfo:
- *        description: LikeInfo
- *        type: object
- *        properties:
- *          sumOfLikers:
- *            type: number
- *            description: how many people liked the page
- *          isLiked:
- *            type: boolean
- *            description: Whether the request user liked (will be returned if the user is included in the request)
  */
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
@@ -141,9 +130,6 @@ module.exports = (crowi) => {
       body('pageId').isString(),
       body('bool').isBoolean(),
     ],
-    likeInfo: [
-      query('_id').isMongoId(),
-    ],
     export: [
       query('format').isString().isIn(['md', 'pdf']),
       query('revisionId').isString(),
@@ -222,50 +208,35 @@ module.exports = (crowi) => {
     }
   });
 
-  /**
-   * @swagger
-   *
-   *    /page/like-info:
-   *      get:
-   *        tags: [Page]
-   *        summary: /page/like-info
-   *        description: Get like info
-   *        operationId: getLikeInfo
-   *        parameters:
-   *          - name: _id
-   *            in: query
-   *            description: page id
-   *            schema:
-   *              type: string
-   *        responses:
-   *          200:
-   *            description: Succeeded to get bookmark info.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/LikeInfo'
-   */
-  router.get('/like-info', loginRequired, validator.likeInfo, apiV3FormValidator, async(req, res) => {
-    const pageId = req.query._id;
-
-    const responsesParams = {};
+  router.get(('/info', loginRequired), async(req, res) => {
 
     try {
+      const pageId = req.query._id;
       const page = await Page.findById(pageId);
-      responsesParams.sumOfLikers = page.liker.length;
 
-      // guest user return nothing
-      if (!req.user) {
-        return res.apiv3(responsesParams);
+      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 isGuestUser = !req.user;
+        if (isGuestUser) {
+          return res.apiv3(guestUserResponse);
+        }
       }
 
-      responsesParams.isLiked = page.liker.includes(req.user._id);
-      return res.apiv3(responsesParams);
+      const userResponse = { ...guestUserResponse, isLiked: page.isLiked(req.user) };
+      return res.apiv3(userResponse);
     }
     catch (err) {
-      logger.error('get-like-count-failed', err);
+      logger.error('get-page-info', err);
       return res.apiv3Err(err, 500);
     }
+
   });
 
   /**

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

@@ -12,8 +12,6 @@
   data-page-grant="{{ grant }}"
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
-  data-page-is-liked="{% if user %}{{ page.isLiked(user) }}{% else %}false{% endif %}"
-  data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   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"
@@ -27,8 +25,6 @@
   data-page-deleted-at="{% if page && page.deletedAt %}{{ page.deletedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
-  data-page-ids-of-seen-users="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
-  data-page-count-of-seen-users="{{ page.seenUsers.length|default(0) }}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
   >