Skip to content

Commit

Permalink
add like button on comments
Browse files Browse the repository at this point in the history
  • Loading branch information
tokiwa-t committed Aug 26, 2024
1 parent 389b4b4 commit d1f16ba
Show file tree
Hide file tree
Showing 9 changed files with 252 additions and 5 deletions.
35 changes: 30 additions & 5 deletions apps/app/src/client/components/PageComment/Comment.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useCallback, useMemo, useState } from 'react';

import { isPopulated, type IUser } from '@growi/core';
import * as pathUtils from '@growi/core/dist/utils/path-utils';
Expand All @@ -20,6 +20,10 @@ import FormattedDistanceDate from '../FormattedDistanceDate';
import { CommentControl } from './CommentControl';
import { CommentEditor } from './CommentEditor';

import { useSWRxUsersList } from '../../../stores/user';
import LikeButtons from './LikeButtons';
import { apiPost } from '~/client/util/apiv1-client';

import styles from './Comment.module.scss';

type CommentProps = {
Expand Down Expand Up @@ -111,6 +115,15 @@ export const Comment = (props: CommentProps): JSX.Element => {
deleteBtnClicked(comment);
};

const likeClickhandler = useCallback(async() => {
if (isReadOnly ?? true) {
return;
}

await apiPost('/comments.like', { comment_id: comment._id });
onComment();
}, [isReadOnly, comment]);

const commentBody = useMemo(() => {
if (rendererOptions == null) {
return <></>;
Expand All @@ -130,6 +143,11 @@ export const Comment = (props: CommentProps): JSX.Element => {
const editedDateId = `editedDate-${comment._id}`;
const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;

const commentLikers = comment?.liker ?? [];
const { data: usersList } = useSWRxUsersList([...commentLikers]);
const likers = usersList != null ? usersList.filter(({ _id }) => commentLikers.includes(_id)).slice(0, 15) : [];
const isLiked = commentLikers.includes(currentUser._id) ?? false;

return (
<div className={`${styles['comment-styles']}`}>
{ (isReEdit && !isReadOnly) ? (
Expand Down Expand Up @@ -169,16 +187,23 @@ export const Comment = (props: CommentProps): JSX.Element => {
{t('page_comment.display_the_page_when_posting_this_comment')}
</UncontrolledTooltip>
</span>
</div>
<div className="page-comment-body">{commentBody}</div>
<div className="page-comment-meta">
{ isEdited && (
<>
<span id={editedDateId}>&nbsp;(edited)</span>
<span id={editedDateId} className="small">&nbsp;(edited)</span>
<UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
</>
) }
</div>
<div className="page-comment-body">{commentBody}</div>
<div className="page-comment-meta">
<LikeButtons
onLikeClicked={likeClickhandler}
sumOfLikers={likers.length}
isLiked={isLiked}
likers={likers}
commentId={commentId}
/>
</div>
{ (isCurrentUserEqualsToAuthor() && !isReadOnly) && (
<CommentControl
onClickDeleteBtn={deleteBtnClickedHandler}
Expand Down
24 changes: 24 additions & 0 deletions apps/app/src/client/components/PageComment/LikeButtons.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@use '@growi/core-styles/scss/bootstrap/init' as bs;
@use '@growi/ui/scss/atoms/btn-muted';
@use './button-styles';

.btn-group-like-for-comment :global {
.btn-like-for-comment {
@extend %btn-basis;
}
.btn-like-for-comment.toggle-btn-like {
padding-right: 3px;
}
.total-counts {
@extend %btn-total-counts-basis;

padding-left: 5px;
}
}

// == Colors
.btn-group-like-for-comment :global {
.btn-like-for-comment {
@include btn-muted.colorize(bs.$blue);
}
}
82 changes: 82 additions & 0 deletions apps/app/src/client/components/PageComment/LikeButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { FC } from 'react';
import React, { useState, useCallback } from 'react';

import type { IUser } from '@growi/core';
import { useTranslation } from 'next-i18next';
import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';


import UserPictureList from '../Common/UserPictureList';

import styles from './LikeButtons.module.scss';
import popoverStyles from './user-list-popover.module.scss';

type LikeButtonsProps = {

sumOfLikers: number,
likers: IUser[],
commentId: string,

isGuestUser?: boolean,
isLiked?: boolean,
onLikeClicked?: ()=>void,
}

const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
const { t } = useTranslation();

const [isPopoverOpen, setIsPopoverOpen] = useState(false);

const togglePopover = () => {
setIsPopoverOpen(!isPopoverOpen);
};

const {
isGuestUser, isLiked, sumOfLikers, onLikeClicked, commentId,
} = props;

const getTooltipMessage = useCallback(() => {

if (isLiked) {
return 'tooltip.cancel_like';
}
return 'tooltip.like';
}, [isLiked]);

return (
<div className={`btn-group btn-group-like-for-comment ${styles['btn-group-like-for-comment']}`} role="group" aria-label="Like buttons for comment">
<button
type="button"
id={`like-button-${commentId}`}
onClick={onLikeClicked}
className={`btn btn-like-for-comment toggle-btn-like
${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
>
<span className={`material-symbols-outlined ${isLiked ? 'fill' : ''}`}>favorite</span>
</button>

<UncontrolledTooltip data-testid={`like-button-tooltip-${commentId}`} target={`like-button-${commentId}`} autohide={false} fade={false}>
{t(getTooltipMessage())}
</UncontrolledTooltip>

<button
type="button"
id={`co-total-likes-${commentId}`}
className={`btn btn-like-for-comment
total-counts ${isLiked ? 'active' : ''}`}
>
{sumOfLikers}
</button>
<Popover placement="bottom" isOpen={isPopoverOpen} target={`co-total-likes-${commentId}`} toggle={togglePopover} trigger="legacy">
<PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
<div className="px-2 text-end user-list-content text-truncate text-muted">
{props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
</div>
</PopoverBody>
</Popover>
</div>
);

};

export default LikeButtons;
17 changes: 17 additions & 0 deletions apps/app/src/client/components/PageComment/_button-styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@use '@growi/core-styles/scss/bootstrap/init' as bs;

%btn-basis {
--bs-btn-padding-x: 6px;
--bs-btn-padding-y: 6px;
--bs-btn-line-height: 1em;
--bs-btn-border-width: 0;
--bs-btn-box-shadow: none;
}

%btn-total-counts-basis {
--bs-btn-font-size: 13px;
}

%text-total-counts-basis {
font-size: 13px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@use '@growi/ui/scss/molecules/user-list-popover';

.user-list-popover :global {
@extend %user-list-popover
}
3 changes: 3 additions & 0 deletions apps/app/src/features/comment/server/models/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Add = (
comment: string,
commentPosition: number,
replyTo?: Types.ObjectId | null,
liker?: Array<Types.ObjectId>,
) => Promise<CommentDocument>;
type FindCommentsByPageId = (pageId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
type FindCommentsByRevisionId = (revisionId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
Expand All @@ -44,6 +45,7 @@ const commentSchema = new Schema<CommentDocument, CommentModel>({
comment: { type: String, required: true },
commentPosition: { type: Number, default: -1 },
replyTo: { type: Schema.Types.ObjectId },
liker: { type: Array<Schema.Types.ObjectId> },
}, {
timestamps: true,
});
Expand All @@ -65,6 +67,7 @@ const add: Add = async function(
comment,
commentPosition,
replyTo,
liker: [],
});
logger.debug('Comment saved.', data);

Expand Down
1 change: 1 addition & 0 deletions apps/app/src/interfaces/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type IComment = {
comment: string;
commentPosition: number,
replyTo?: string,
liker?: Array<Ref<IUser>>,
createdAt: Date,
updatedAt: Date,
};
Expand Down
89 changes: 89 additions & 0 deletions apps/app/src/server/routes/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,95 @@ module.exports = function(crowi, app) {

return res.json(ApiResponse.success({}));
};
/**
* @swagger
*
* /comments.like:
* post:
* tags: [Comments]
* operationId: likeComment
* summary: /comments.like
* description: Like/Unlike specified comment
* requestBody:
* content:
* application/json:
* schema:
* properties:
* comment_id:
* $ref: '#/components/schemas/Comment/properties/_id'
* required:
* - comment_id
* responses:
* 200:
* description: Succeeded to remove specified comment.
* content:
* application/json:
* schema:
* properties:
* ok:
* $ref: '#/components/schemas/V1Response/properties/ok'
* comment:
* $ref: '#/components/schemas/Comment'
* 403:
* $ref: '#/components/responses/403'
* 500:
* $ref: '#/components/responses/500'
*/
/**
* @api {post} /comments.like Like/Unlike specified comment
* @apiName LikeComment
* @apiGroup Comment
*
* @apiParam {String} comment_id Comment Id.
*/
api.like = async function(req, res) {
const commentId = req.body.comment_id;
if (!commentId) {
return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
}

let updatedComment;
try {
const comment = await Comment.findById(commentId).exec();

if (comment == null) {
throw new Error('This comment does not exist.');
}

// check whether accessible
const pageId = comment.page;
const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
if (!isAccessible) {
throw new Error('Current user is not accessible to this page.');
}

const likers = comment?.liker ?? [];
const isLiked = !!(likers.find(liker => liker._id.toString() == req.user._id.toString()));

if (!isLiked) {
updatedComment = await Comment.findOneAndUpdate(
{ _id: commentId },
{ $set: { liker: [...likers, req.user._id] } },
{ timestamps: false },
);
}
else {
updatedComment = await Comment.findOneAndUpdate(
{ _id: commentId },
{ $set: { liker: likers.filter(liker => liker._id.toString() != req.user._id.toString()) } },
{ timestamps: false },
);
}
}
catch (err) {
return res.json(ApiResponse.error(err));
}

// const parameters = { action: SupportedAction.ACTION_COMMENT_LIKE };
// activityEvent.emit('like', res.locals.activity._id, parameters);

res.json(ApiResponse.success({ comment: updatedComment }));
};

return actions;
};
1 change: 1 addition & 0 deletions apps/app/src/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ module.exports = function(crowi, app) {
apiV1Router.post('/comments.add' , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.add);
apiV1Router.post('/comments.update' , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
apiV1Router.post('/comments.remove' , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
apiV1Router.post('/comments.like' , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.like);

apiV1Router.post('/attachments.uploadProfileImage' , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
apiV1Router.post('/attachments.remove' , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
Expand Down

0 comments on commit d1f16ba

Please sign in to comment.