PageComment.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import type { FC, JSX } from 'react';
  2. import React, { memo, useCallback, useMemo, useState } from 'react';
  3. import type { IRevision, Ref } from '@growi/core';
  4. import { getIdStringForRef, isPopulated } from '@growi/core';
  5. import { UserPicture } from '@growi/ui/dist/components';
  6. import { useTranslation } from 'next-i18next';
  7. import { apiPost } from '~/client/util/apiv1-client';
  8. import { toastError } from '~/client/util/toastr';
  9. import type { RendererOptions } from '~/interfaces/renderer-options';
  10. import { useSWRMUTxPageInfo } from '~/stores/page';
  11. import { useCommentForCurrentPageOptions } from '~/stores/renderer';
  12. import type {
  13. ICommentHasId,
  14. ICommentHasIdList,
  15. } from '../../interfaces/comment';
  16. import { useSWRxPageComment } from '../../stores/comment';
  17. import { NotAvailableForGuest } from './NotAvailableForGuest';
  18. import { NotAvailableIfReadOnlyUserNotAllowedToComment } from './NotAvailableForReadOnlyUser';
  19. import { Comment } from './PageComment/Comment';
  20. import { CommentEditor } from './PageComment/CommentEditor';
  21. import { DeleteCommentModalLazyLoaded } from './PageComment/DeleteCommentModal';
  22. import { ReplyComments } from './PageComment/ReplyComments';
  23. import styles from './PageComment.module.scss';
  24. type PageCommentProps = {
  25. rendererOptions?: RendererOptions;
  26. pageId: string;
  27. pagePath: string;
  28. revision: Ref<IRevision>;
  29. currentUser: any;
  30. isReadOnly: boolean;
  31. };
  32. export const PageComment: FC<PageCommentProps> = memo(
  33. (props: PageCommentProps): JSX.Element => {
  34. const {
  35. rendererOptions: rendererOptionsByProps,
  36. pageId,
  37. pagePath,
  38. revision,
  39. currentUser,
  40. isReadOnly,
  41. } = props;
  42. const { data: comments, mutate } = useSWRxPageComment(pageId);
  43. const { data: rendererOptionsForCurrentPage } =
  44. useCommentForCurrentPageOptions();
  45. const [commentToBeDeleted, setCommentToBeDeleted] =
  46. useState<ICommentHasId | null>(null);
  47. const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] =
  48. useState<boolean>(false);
  49. const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
  50. const [errorMessageOnDelete, setErrorMessageOnDelete] =
  51. useState<string>('');
  52. const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
  53. const { t } = useTranslation('');
  54. const commentsFromOldest = useMemo(
  55. () => (comments != null ? [...comments].reverse() : null),
  56. [comments],
  57. );
  58. const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
  59. () => commentsFromOldest?.filter((comment) => comment.replyTo == null),
  60. [commentsFromOldest],
  61. );
  62. const allReplies = {};
  63. if (commentsFromOldest != null) {
  64. commentsFromOldest.forEach((comment) => {
  65. if (comment.replyTo != null) {
  66. allReplies[comment.replyTo] =
  67. allReplies[comment.replyTo] == null
  68. ? [comment]
  69. : [...allReplies[comment.replyTo], comment];
  70. }
  71. });
  72. }
  73. const onClickDeleteButton = useCallback((comment: ICommentHasId) => {
  74. setCommentToBeDeleted(comment);
  75. setIsDeleteConfirmModalShown(true);
  76. }, []);
  77. const onCancelDeleteComment = useCallback(() => {
  78. setCommentToBeDeleted(null);
  79. setIsDeleteConfirmModalShown(false);
  80. }, []);
  81. const onDeleteCommentAfterOperation = useCallback(() => {
  82. onCancelDeleteComment();
  83. mutate();
  84. mutatePageInfo();
  85. }, [mutate, onCancelDeleteComment, mutatePageInfo]);
  86. const onDeleteComment = useCallback(async () => {
  87. if (commentToBeDeleted == null) return;
  88. try {
  89. await apiPost('/comments.remove', {
  90. comment_id: commentToBeDeleted._id,
  91. });
  92. onDeleteCommentAfterOperation();
  93. } catch (error: unknown) {
  94. const message =
  95. error instanceof Error ? error.message : (error as any).toString();
  96. setErrorMessageOnDelete(message);
  97. toastError(message);
  98. }
  99. }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
  100. const removeShowEditorId = useCallback((commentId: string) => {
  101. setShowEditorIds((previousState) => {
  102. return new Set([...previousState].filter((id) => id !== commentId));
  103. });
  104. }, []);
  105. const onReplyButtonClickHandler = useCallback((commentId: string) => {
  106. setShowEditorIds(
  107. (previousState) => new Set([...previousState, commentId]),
  108. );
  109. }, []);
  110. const onCommentButtonClickHandler = useCallback(
  111. (commentId: string) => {
  112. removeShowEditorId(commentId);
  113. mutate();
  114. mutatePageInfo();
  115. },
  116. [removeShowEditorId, mutate, mutatePageInfo],
  117. );
  118. if (comments?.length === 0) {
  119. return <></>;
  120. }
  121. const rendererOptions =
  122. rendererOptionsByProps ?? rendererOptionsForCurrentPage;
  123. if (
  124. commentsFromOldest == null ||
  125. commentsExceptReply == null ||
  126. rendererOptions == null
  127. ) {
  128. return <></>;
  129. }
  130. const revisionId = getIdStringForRef(revision);
  131. const revisionCreatedAt = isPopulated(revision)
  132. ? revision.createdAt
  133. : undefined;
  134. const commentElement = (comment: ICommentHasId) => (
  135. <Comment
  136. rendererOptions={rendererOptions}
  137. comment={comment}
  138. revisionId={revisionId}
  139. revisionCreatedAt={revisionCreatedAt as Date}
  140. currentUser={currentUser}
  141. isReadOnly={isReadOnly}
  142. pageId={pageId}
  143. pagePath={pagePath}
  144. deleteBtnClicked={onClickDeleteButton}
  145. onComment={mutate}
  146. />
  147. );
  148. const replyCommentsElement = (replyComments: ICommentHasIdList) => (
  149. <ReplyComments
  150. rendererOptions={rendererOptions}
  151. isReadOnly={isReadOnly}
  152. revisionId={revisionId}
  153. revisionCreatedAt={revisionCreatedAt as Date}
  154. currentUser={currentUser}
  155. replyList={replyComments}
  156. pageId={pageId}
  157. pagePath={pagePath}
  158. deleteBtnClicked={onClickDeleteButton}
  159. onComment={mutate}
  160. />
  161. );
  162. return (
  163. <div
  164. className={`${styles['page-comment-styles']} page-comments-row comment-list`}
  165. >
  166. <div className="page-comments">
  167. <div className="page-comments-list mb-3" id="page-comments-list">
  168. {commentsExceptReply.map((comment) => {
  169. const defaultCommentThreadClasses = 'page-comment-thread mb-2';
  170. const hasReply: boolean = Object.keys(allReplies).includes(
  171. comment._id,
  172. );
  173. let commentThreadClasses = '';
  174. commentThreadClasses = hasReply
  175. ? `${defaultCommentThreadClasses} page-comment-thread-no-replies`
  176. : defaultCommentThreadClasses;
  177. return (
  178. <div key={comment._id} className={commentThreadClasses}>
  179. {/* Comment */}
  180. {commentElement(comment)}
  181. {/* Reply comments */}
  182. {hasReply && replyCommentsElement(allReplies[comment._id])}
  183. {!isReadOnly && !showEditorIds.has(comment._id) && (
  184. <div className="d-flex flex-row-reverse">
  185. <NotAvailableForGuest>
  186. <NotAvailableIfReadOnlyUserNotAllowedToComment>
  187. <button
  188. type="button"
  189. data-testid="comment-reply-button"
  190. className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
  191. onClick={() =>
  192. onReplyButtonClickHandler(comment._id)
  193. }
  194. >
  195. <UserPicture
  196. user={currentUser}
  197. noLink
  198. noTooltip
  199. className="me-2"
  200. />
  201. <span className="material-symbols-outlined me-1 fs-5 pb-1">
  202. reply
  203. </span>
  204. <small>{t('page_comment.reply')}...</small>
  205. </button>
  206. </NotAvailableIfReadOnlyUserNotAllowedToComment>
  207. </NotAvailableForGuest>
  208. </div>
  209. )}
  210. {/* Editor to reply */}
  211. {!isReadOnly && showEditorIds.has(comment._id) && (
  212. <CommentEditor
  213. pageId={pageId}
  214. replyTo={comment._id}
  215. onCanceled={() => {
  216. removeShowEditorId(comment._id);
  217. }}
  218. onCommented={() =>
  219. onCommentButtonClickHandler(comment._id)
  220. }
  221. revisionId={revisionId}
  222. />
  223. )}
  224. </div>
  225. );
  226. })}
  227. </div>
  228. </div>
  229. {!isReadOnly && (
  230. <DeleteCommentModalLazyLoaded
  231. isShown={isDeleteConfirmModalShown}
  232. comment={commentToBeDeleted}
  233. errorMessage={errorMessageOnDelete}
  234. cancelToDelete={onCancelDeleteComment}
  235. confirmToDelete={onDeleteComment}
  236. />
  237. )}
  238. </div>
  239. );
  240. },
  241. );
  242. PageComment.displayName = 'PageComment';