PageComment.tsx 8.0 KB

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