From bd802ede10db1936c5f3440d78e78d689c77b7dc Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Wed, 1 Nov 2023 20:56:47 +0600 Subject: [PATCH] let users like comments --- package.json | 2 +- src/api/node/index.ts | 17 ++- src/components/comment/Comment/index.tsx | 14 +- .../comment/CommentContent/index.tsx | 125 +++++++++--------- .../comment/CommentContent/styles.module.scss | 39 ++++-- src/components/comment/CommentLike/index.tsx | 44 ++++++ .../comment/CommentLike/styles.module.scss | 34 +++++ .../comment/CommentMenu/styles.module.scss | 7 - src/components/node/NodeLikeButton/index.tsx | 2 +- .../node/NodeLikeButton/styles.module.scss | 29 +--- src/constants/api.ts | 2 + src/containers/node/NodeComments/index.tsx | 11 +- src/hooks/comments/useNodeComments.ts | 97 +++++++++----- src/pages/boris.tsx | 2 + src/pages/node/[id].tsx | 4 +- src/styles/_animations.scss | 29 ++++ src/styles/variables.scss | 1 + src/types/index.ts | 2 + src/types/node/index.ts | 4 + src/utils/context/CommentContextProvider.tsx | 2 + src/utils/node.ts | 11 ++ yarn.lock | 8 +- 22 files changed, 332 insertions(+), 154 deletions(-) create mode 100644 src/components/comment/CommentLike/index.tsx create mode 100644 src/components/comment/CommentLike/styles.module.scss create mode 100644 src/styles/_animations.scss diff --git a/package.json b/package.json index 73566297..1fcd6c74 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@tippyjs/react": "^4.2.6", "@v9v/ts-react-telegram-login": "^1.1.1", "autosize": "^4.0.2", - "axios": "^0.21.2", + "axios": "^0.21.4", "body-scroll-lock": "^2.6.4", "classnames": "^2.2.6", "color2k": "^1.2.4", diff --git a/src/api/node/index.ts b/src/api/node/index.ts index 9ae675cc..89bfa4a8 100644 --- a/src/api/node/index.ts +++ b/src/api/node/index.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig } from 'axios'; +import axios, { AxiosRequestConfig, CancelToken } from 'axios'; import { API } from '~/constants/api'; import { COMMENTS_DISPLAY } from '~/constants/node'; @@ -10,6 +10,7 @@ import { ApiGetNodeRelatedResult, ApiGetNodeRequest, ApiGetNodeResponse, + ApiLikeCommentRequest, ApiLockCommentRequest, ApiLockcommentResult, ApiLockNodeRequest, @@ -97,6 +98,20 @@ export const apiGetNodeWithCancel = ({ id }: ApiGetNodeRequest) => { export const apiPostComment = ({ id, data }: ApiPostCommentRequest) => api.post(API.NODES.COMMENT(id), data).then(cleanResult); +export const apiLikeComment = ( + nodeId: number, + commentId: number, + data: ApiLikeCommentRequest, + options?: { cancelToken?: CancelToken }, +) => + api + .post( + API.NODES.COMMENT_LIKES(nodeId, commentId), + data, + { cancelToken: options?.cancelToken }, + ) + .then(cleanResult); + export const apiGetNodeComments = ({ id, take = COMMENTS_DISPLAY, diff --git a/src/components/comment/Comment/index.tsx b/src/components/comment/Comment/index.tsx index 021a608f..4be90124 100644 --- a/src/components/comment/Comment/index.tsx +++ b/src/components/comment/Comment/index.tsx @@ -13,20 +13,22 @@ import { CommendDeleted } from '../../node/CommendDeleted'; import styles from './styles.module.scss'; -type IProps = HTMLAttributes & { +type Props = HTMLAttributes & { nodeId: number; isEmpty?: boolean; isLoading?: boolean; group: ICommentGroup; isSame?: boolean; canEdit?: boolean; + canLike?: boolean; highlighted?: boolean; saveComment: (data: IComment) => Promise; onDelete: (id: IComment['id'], isLocked: boolean) => void; + onLike: (id: IComment['id'], isLiked: boolean) => void; onShowImageModal: (images: IFile[], index: number) => void; }; -const Comment: FC = memo( +const Comment: FC = memo( ({ group, nodeId, @@ -36,7 +38,9 @@ const Comment: FC = memo( className, highlighted, canEdit, + canLike, onDelete, + onLike, onShowImageModal, saveComment, ...props @@ -84,10 +88,12 @@ const Comment: FC = memo( saveComment={saveComment} nodeId={nodeId} comment={comment} - key={comment.id} canEdit={!!canEdit} - onDelete={onDelete} + canLike={!!canLike} + onLike={() => onLike(comment.id, !comment.liked)} + onDelete={(val: boolean) => onDelete(comment.id, val)} onShowImageModal={onShowImageModal} + key={comment.id} /> ); })} diff --git a/src/components/comment/CommentContent/index.tsx b/src/components/comment/CommentContent/index.tsx index 2e4e1e48..a3b77369 100644 --- a/src/components/comment/CommentContent/index.tsx +++ b/src/components/comment/CommentContent/index.tsx @@ -1,7 +1,6 @@ -import React, { +import { createElement, FC, - Fragment, memo, ReactNode, useCallback, @@ -11,7 +10,6 @@ import React, { import classnames from 'classnames'; -import { Authorized } from '~/components/containers/Authorized'; import { Group } from '~/components/containers/Group'; import { AudioPlayer } from '~/components/media/AudioPlayer'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; @@ -22,6 +20,7 @@ import { append, assocPath, path, reduce } from '~/utils/ramda'; import { CommentEditingForm } from '../CommentEditingForm'; import { CommentImageGrid } from '../CommentImageGrid'; +import { CommentLike } from '../CommentLike'; import { CommentMenu } from '../CommentMenu'; import styles from './styles.module.scss'; @@ -31,17 +30,21 @@ interface IProps { nodeId: number; comment: IComment; canEdit: boolean; + canLike: boolean; saveComment: (data: IComment) => Promise; - onDelete: (id: IComment['id'], isLocked: boolean) => void; + onDelete: (isLocked: boolean) => void; + onLike: () => void; onShowImageModal: (images: IFile[], index: number) => void; } const CommentContent: FC = memo( ({ comment, - canEdit, nodeId, saveComment, + canEdit, + canLike, + onLike, onDelete, onShowImageModal, prefix, @@ -65,7 +68,7 @@ const CommentContent: FC = memo( ); const onLockClick = useCallback(() => { - onDelete(comment.id, !comment.deleted_at); + onDelete(!comment.deleted_at); }, [comment, onDelete]); const onImageClick = useCallback( @@ -75,15 +78,12 @@ const CommentContent: FC = memo( ); const menu = useMemo( - () => ( -
- {canEdit && ( - - - - )} -
- ), + () => + canEdit && ( +
+ +
+ ), [canEdit, startEditing, onLockClick], ); @@ -110,57 +110,56 @@ const CommentContent: FC = memo(
{!!prefix &&
{prefix}
} - {comment.text.trim() && ( - - {menu} +
+ {menu} - - {blocks.map( - (block, key) => - COMMENT_BLOCK_RENDERERS[block.type] && - createElement(COMMENT_BLOCK_RENDERERS[block.type], { - block, - key, - }), - )} - +
+ {comment.text.trim() && ( + + + {blocks.map( + (block, key) => + COMMENT_BLOCK_RENDERERS[block.type] && + createElement(COMMENT_BLOCK_RENDERERS[block.type], { + block, + key, + }), + )} + + + )} -
- {getPrettyDate(comment.created_at)} -
- - )} - - {groupped.image && groupped.image.length > 0 && ( -
- {menu} - - - -
- {getPrettyDate(comment.created_at)} -
-
- )} - - {groupped.audio && groupped.audio.length > 0 && ( - - {groupped.audio.map((file) => ( -
- {menu} - - - -
- {getPrettyDate(comment.created_at)} -
+ {groupped.image && groupped.image.length > 0 && ( +
+
- ))} - - )} + )} + + {groupped.audio && + groupped.audio.length > 0 && + groupped.audio.map((file) => ( +
+ +
+ ))} +
+
+ +
+ {getPrettyDate(comment.created_at)} + +
); }, diff --git a/src/components/comment/CommentContent/styles.module.scss b/src/components/comment/CommentContent/styles.module.scss index 7b6fd4c2..4000befa 100644 --- a/src/components/comment/CommentContent/styles.module.scss +++ b/src/components/comment/CommentContent/styles.module.scss @@ -54,7 +54,14 @@ top: 28px; } +.content { + width: 100%; + position: relative; +} + .block { + @include row_shadow; + min-height: $comment_height; display: flex; align-items: flex-start; @@ -90,13 +97,7 @@ } .block_image { - padding-bottom: 0 !important; - - .date { - background: $content_bg; - border-radius: $radius 0 $radius 0; - color: $gray_25; - } + padding: $gap / 2; } .block_text { @@ -105,16 +106,20 @@ .date { position: absolute; - bottom: 1px; + bottom: 1px; // should not cover block shadow right: 0; - font: $font_12_regular; + font: $font_12_medium; color: $gray_75; - padding: 0 6px 2px; + fill: $gray_75; + padding: 3px 6px; z-index: 2; background: $content_bg_light; border-radius: 4px; - pointer-events: none; - touch-action: none; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; } .audios { @@ -141,3 +146,13 @@ align-items: center; justify-content: center; } + +.menu { + position: absolute; + right: 0; + top: 0; + width: 48px; + height: 48px; + z-index: 10; + outline: none; +} diff --git a/src/components/comment/CommentLike/index.tsx b/src/components/comment/CommentLike/index.tsx new file mode 100644 index 00000000..95b55a55 --- /dev/null +++ b/src/components/comment/CommentLike/index.tsx @@ -0,0 +1,44 @@ +import React, { FC } from 'react'; + +import classnames from 'classnames'; + +import { Icon } from '~/components/input/Icon'; + +import styles from './styles.module.scss'; + +interface CommentLikeProps { + className?: string; + count?: number; + active?: boolean; + liked?: boolean; + onLike?: () => void; +} + +const CommentLike: FC = ({ + className, + count, + active, + liked, + onLike, +}) => { + if (!active && !count) { + return null; + } + + return ( +
+
+ +
+ + {Boolean(count) && {count}} +
+ ); +}; +export { CommentLike }; diff --git a/src/components/comment/CommentLike/styles.module.scss b/src/components/comment/CommentLike/styles.module.scss new file mode 100644 index 00000000..8cc3e5ac --- /dev/null +++ b/src/components/comment/CommentLike/styles.module.scss @@ -0,0 +1,34 @@ +@import 'src/styles/variables'; + +.likes { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + position: relative; + + &.active { + cursor: pointer; + + &::before { + content: ' '; + position: absolute; + inset: -10px -10px -8px -16px; + } + + &:not(.liked):hover .icon { + @include pulse; + } + } + + &.liked { + color: $color_like; + fill: currentColor; + } +} + +.icon { + position: relative; + width: 14px; + height: 14px; +} diff --git a/src/components/comment/CommentMenu/styles.module.scss b/src/components/comment/CommentMenu/styles.module.scss index 5d493464..25e05db6 100644 --- a/src/components/comment/CommentMenu/styles.module.scss +++ b/src/components/comment/CommentMenu/styles.module.scss @@ -1,13 +1,6 @@ @import 'src/styles/variables'; .wrap { - position: absolute; - right: -3px; - top: -3px; - width: 48px; - height: 48px; - z-index: 10; - outline: none; cursor: pointer; } diff --git a/src/components/node/NodeLikeButton/index.tsx b/src/components/node/NodeLikeButton/index.tsx index e9772201..5a05fcde 100644 --- a/src/components/node/NodeLikeButton/index.tsx +++ b/src/components/node/NodeLikeButton/index.tsx @@ -25,7 +25,7 @@ const NodeLikeButton: FC = ({ [styles.is_liked]: active, })} > - {active ? ( + {count ? ( ) : ( diff --git a/src/components/node/NodeLikeButton/styles.module.scss b/src/components/node/NodeLikeButton/styles.module.scss index b4ff99d2..43166ef7 100644 --- a/src/components/node/NodeLikeButton/styles.module.scss +++ b/src/components/node/NodeLikeButton/styles.module.scss @@ -1,31 +1,5 @@ @import 'src/styles/variables'; -@keyframes pulse { - 0% { - transform: scale(1); - } - - 45% { - transform: scale(1); - } - - 60% { - transform: scale(1.4); - } - - 75% { - transform: scale(1); - } - - 90% { - transform: scale(1.4); - } - - 100% { - transform: scale(1); - } -} - .like { transition: fill, stroke 0.25s; will-change: transform; @@ -46,8 +20,9 @@ } &:hover { + @include pulse; + fill: $color_like; - animation: pulse 0.75s infinite; .count { opacity: 0; diff --git a/src/constants/api.ts b/src/constants/api.ts index 0835efaf..b9a287f4 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -41,6 +41,8 @@ export const API = { `/nodes/${id}/tags/${tagId}`, COMMENT: (id: INode['id'] | string) => `/nodes/${id}/comments`, + COMMENT_LIKES: (id: INode['id'] | string, cid: number) => + `/nodes/${id}/comments/${cid}/likes`, LOCK_COMMENT: (id: INode['id'], comment_id: IComment['id']) => `/nodes/${id}/comments/${comment_id}`, }, diff --git a/src/containers/node/NodeComments/index.tsx b/src/containers/node/NodeComments/index.tsx index 0487d935..63656bf1 100644 --- a/src/containers/node/NodeComments/index.tsx +++ b/src/containers/node/NodeComments/index.tsx @@ -1,4 +1,6 @@ -import React, { FC, memo, useMemo } from 'react'; +import React, { FC, useMemo } from 'react'; + +import { observer } from 'mobx-react-lite'; import { Comment } from '~/components/comment/Comment'; import { LoadMoreButton } from '~/components/input/LoadMoreButton'; @@ -8,7 +10,7 @@ import { ICommentGroup } from '~/types'; import { useCommentContext } from '~/utils/context/CommentContextProvider'; import { useNodeContext } from '~/utils/context/NodeContextProvider'; import { useUserContext } from '~/utils/context/UserContextProvider'; -import { canEditComment } from '~/utils/node'; +import { canEditComment, canLikeComment } from '~/utils/node'; import styles from './styles.module.scss'; @@ -16,7 +18,7 @@ interface IProps { order: 'ASC' | 'DESC'; } -const NodeComments: FC = memo(({ order }) => { +const NodeComments: FC = observer(({ order }) => { const user = useUserContext(); const { node } = useNodeContext(); @@ -26,6 +28,7 @@ const NodeComments: FC = memo(({ order }) => { isLoading, isLoadingMore, lastSeenCurrent, + onLike, onLoadMoreComments, onDeleteComment, onShowImageModal, @@ -68,6 +71,8 @@ const NodeComments: FC = memo(({ order }) => { highlighted={ node.id === BORIS_NODE_ID && group.user.id === ANNOUNCE_USER_ID } + onLike={onLike} + canLike={canLikeComment(group, user)} canEdit={canEditComment(group, user)} onDelete={onDeleteComment} onShowImageModal={onShowImageModal} diff --git a/src/hooks/comments/useNodeComments.ts b/src/hooks/comments/useNodeComments.ts index 428e2871..239e33a5 100644 --- a/src/hooks/comments/useNodeComments.ts +++ b/src/hooks/comments/useNodeComments.ts @@ -1,11 +1,33 @@ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; -import { apiLockComment, apiPostComment } from '~/api/node'; +import { CancelTokenSource } from 'axios'; +import axios from 'axios'; + +import { apiLikeComment, apiLockComment, apiPostComment } from '~/api/node'; import { useGetComments } from '~/hooks/comments/useGetComments'; import { IComment } from '~/types'; import { showErrorToast } from '~/utils/errors/showToast'; +const updateComment = + (id: number, data: Partial) => (pages?: IComment[][]) => + pages?.map((comments) => + comments.map((comment) => + comment.id === id ? { ...comment, ...data } : comment, + ), + ); + +const transformComment = + (id: number, cb: (val: IComment) => IComment) => (pages?: IComment[][]) => + pages?.map((comments) => + comments.map((comment) => (comment.id === id ? cb(comment) : comment)), + ); + +const insertComment = (data: IComment) => (pages?: IComment[][]) => + pages?.map((list, index) => (index === 0 ? [data, ...list] : list)); + export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => { + const likeAbortController = useRef(); + const { comments, isLoading, @@ -17,7 +39,7 @@ export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => { } = useGetComments(nodeId, fallbackData); const onDelete = useCallback( - async (id: IComment['id'], isLocked: boolean) => { + async (id: number, isLocked: boolean) => { try { const { deleted_at } = await apiLockComment({ id, nodeId, isLocked }); @@ -25,15 +47,7 @@ export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => { return; } - await mutate( - (prev) => - prev?.map((list) => - list.map((comment) => - comment.id === id ? { ...comment, deleted_at } : comment, - ), - ), - false, - ); + await mutate(updateComment(id, { deleted_at }), false); } catch (error) { showErrorToast(error); } @@ -51,34 +65,57 @@ export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => { // Comment was created if (!comment.id) { - await mutate( - data.map((list, index) => - index === 0 ? [result.comment, ...list] : list, - ), - false, - ); - - return result.comment; + await mutate(insertComment(result.comment), false); + } else { + await mutate(updateComment(comment.id, result.comment), false); } - await mutate( - (prev) => - prev?.map((list) => - list.map((it) => - it.id === result.comment.id ? { ...it, ...result.comment } : it, - ), - ), - false, - ); - return result.comment; }, [data, mutate, nodeId], ); + const sendLikeRequest = useCallback( + async (id: number, liked: boolean) => { + if (likeAbortController.current) { + likeAbortController.current.cancel(); + } + + likeAbortController.current = axios.CancelToken.source(); + + await apiLikeComment( + nodeId, + id, + { liked }, + { cancelToken: likeAbortController.current?.token }, + ); + + likeAbortController.current = undefined; + }, + [nodeId], + ); + + const onLike = useCallback( + async (id: number, liked: boolean) => { + const increment = liked ? 1 : -1; + await mutate( + transformComment(id, (val) => ({ + ...val, + liked, + like_count: (val.like_count ?? 0) + increment, + })), + false, + ); + + sendLikeRequest(id, liked).catch(showErrorToast); + }, + [mutate, sendLikeRequest], + ); + return { onLoadMoreComments, onDelete, + onLike, comments, hasMore, isLoading, diff --git a/src/pages/boris.tsx b/src/pages/boris.tsx index ac8154d4..53625202 100644 --- a/src/pages/boris.tsx +++ b/src/pages/boris.tsx @@ -22,6 +22,7 @@ const BorisPage: VFC = observer(() => { onLoadMoreComments, onDelete: onDeleteComment, onEdit: onSaveComment, + onLike: onLikeComment, comments, hasMore, isLoading: isLoadingComments, @@ -36,6 +37,7 @@ const BorisPage: VFC = observer(() => { onSaveComment={onSaveComment} comments={comments} hasMore={hasMore} + onLike={onLikeComment} isLoading={isLoadingComments} isLoadingMore={isLoadingMore} onShowImageModal={onShowImageModal} diff --git a/src/pages/node/[id].tsx b/src/pages/node/[id].tsx index cfe8e4ac..cb1497a0 100644 --- a/src/pages/node/[id].tsx +++ b/src/pages/node/[id].tsx @@ -91,7 +91,7 @@ export const getStaticProps = async ( revalidate: 7 * 86400, // every week }; } catch (error) { - console.warn('[NEXT] can\'t generate node: ', error); + console.warn("[NEXT] can't generate node: ", error); return { notFound: true, }; @@ -112,6 +112,7 @@ const NodePage: FC = observer((props) => { const { onLoadMoreComments, + onLike: onLikeComment, onDelete: onDeleteComment, onEdit: onSaveComment, comments, @@ -141,6 +142,7 @@ const NodePage: FC = observer((props) => { > void; onSaveComment: (comment: IComment) => Promise; onDeleteComment: (id: IComment['id'], isLocked: boolean) => void; + onLike: (id: number, liked: boolean) => void; } const CommentContext = createContext({ @@ -20,6 +21,7 @@ const CommentContext = createContext({ lastSeenCurrent: null, isLoading: false, isLoadingMore: false, + onLike: () => {}, onSaveComment: async () => undefined, onShowImageModal: () => {}, onLoadMoreComments: () => {}, diff --git a/src/utils/node.ts b/src/utils/node.ts index dc1cb2f7..3906dba1 100644 --- a/src/utils/node.ts +++ b/src/utils/node.ts @@ -18,6 +18,17 @@ export const canEditComment = ( path(['role'], user) === Role.Admin || path(['user', 'id'], comment) === path(['id'], user); +export const canLikeComment = ( + comment?: Partial, + user?: Partial, +): boolean => + Boolean( + user?.role && + user?.id && + user?.role !== Role.Guest && + user.id !== comment?.user?.id, + ); + export const canLikeNode = ( node?: Partial, user?: Partial, diff --git a/yarn.lock b/yarn.lock index c05354dc..0f802208 100644 --- a/yarn.lock +++ b/yarn.lock @@ -710,7 +710,7 @@ autosize@^4.0.2: resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.4.tgz#924f13853a466b633b9309330833936d8bccce03" integrity sha512-5yxLQ22O0fCRGoxGfeLSNt3J8LB1v+umtpMnPW6XjkTWXKoN0AmXAIhelJcDtFT/Y/wYWmfE+oqU10Q0b8FhaQ== -axios@^0.21.2: +axios@^0.21.100: version "0.21.4" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== @@ -1484,9 +1484,9 @@ flexbin@^0.2.0: integrity sha1-ASYwbT1ZX8t9/LhxSbnJWZ/49Ok= follow-redirects@^1.14.0: - version "1.14.8" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" - integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== + version "1.15.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" + integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== formik@^2.2.6: version "2.2.9"