PageComment.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import React, {
  2. FC, useEffect, useState, useMemo, memo, useCallback,
  3. } from 'react';
  4. import { Nullable } from '@growi/core';
  5. import { Button } from 'reactstrap';
  6. import { toastError } from '~/client/util/apiNotification';
  7. import { apiPost } from '~/client/util/apiv1-client';
  8. import { useCurrentPagePath } from '~/stores/context';
  9. import { useSWRxCurrentPage } from '~/stores/page';
  10. import { useCommentPreviewOptions } from '~/stores/renderer';
  11. import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
  12. import { useSWRxPageComment } from '../stores/comment';
  13. import { Comment } from './PageComment/Comment';
  14. import { CommentEditor } from './PageComment/CommentEditor';
  15. import { CommentEditorLazyRenderer } from './PageComment/CommentEditorLazyRenderer';
  16. import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
  17. import { ReplyComments } from './PageComment/ReplyComments';
  18. type Props = {
  19. pageId?: Nullable<string>
  20. isReadOnly: boolean,
  21. titleAlign?: 'center' | 'left' | 'right',
  22. highlightKeywords?: string[],
  23. hideIfEmpty?: boolean,
  24. }
  25. export const PageComment: FC<Props> = memo((props:Props): JSX.Element => {
  26. const {
  27. pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
  28. } = props;
  29. const { data: comments, mutate } = useSWRxPageComment(pageId);
  30. const { data: rendererOptions } = useCommentPreviewOptions();
  31. const { data: currentPage } = useSWRxCurrentPage();
  32. const { data: currentPagePath } = useCurrentPagePath();
  33. const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
  34. const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
  35. const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
  36. const [formatedComments, setFormatedComments] = useState<ICommentHasIdList | null>(null);
  37. const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
  38. const commentsFromOldest = useMemo(() => (formatedComments != null ? [...formatedComments].reverse() : null), [formatedComments]);
  39. const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
  40. () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
  41. );
  42. const allReplies = {};
  43. const highlightComment = useCallback((comment: string):string => {
  44. if (highlightKeywords == null) return comment;
  45. let highlightedComment = '';
  46. highlightKeywords.forEach((highlightKeyword) => {
  47. highlightedComment = comment.replaceAll(highlightKeyword, '<em class="highlighted-keyword">$&</em>');
  48. });
  49. return highlightedComment;
  50. }, [highlightKeywords]);
  51. useEffect(() => {
  52. if (comments != null) {
  53. const preprocessedCommentList: string[] = comments.map((comment) => {
  54. const highlightedComment: string = highlightComment(comment.comment);
  55. return highlightedComment;
  56. });
  57. const preprocessedComments: ICommentHasIdList = comments.map((comment, index) => {
  58. return { ...comment, comment: preprocessedCommentList[index] };
  59. });
  60. setFormatedComments(preprocessedComments);
  61. }
  62. }, [comments, highlightComment]);
  63. if (commentsFromOldest != null) {
  64. commentsFromOldest.forEach((comment) => {
  65. if (comment.replyTo != null) {
  66. allReplies[comment.replyTo] = allReplies[comment.replyTo] == null ? [comment] : [...allReplies[comment.replyTo], comment];
  67. }
  68. });
  69. }
  70. const onClickDeleteButton = useCallback((comment: ICommentHasId) => {
  71. setCommentToBeDeleted(comment);
  72. setIsDeleteConfirmModalShown(true);
  73. }, []);
  74. const onCancelDeleteComment = useCallback(() => {
  75. setCommentToBeDeleted(null);
  76. setIsDeleteConfirmModalShown(false);
  77. }, []);
  78. const onDeleteCommentAfterOperation = useCallback(() => {
  79. onCancelDeleteComment();
  80. mutate();
  81. }, [mutate, onCancelDeleteComment]);
  82. const onDeleteComment = useCallback(async() => {
  83. if (commentToBeDeleted == null) return;
  84. try {
  85. await apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
  86. onDeleteCommentAfterOperation();
  87. }
  88. catch (error:unknown) {
  89. setErrorMessageOnDelete(error as string);
  90. toastError(`error: ${error}`);
  91. }
  92. }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
  93. const removeShowEditorId = useCallback((commentId: string) => {
  94. setShowEditorIds((previousState) => {
  95. const previousShowEditorIds = new Set(...previousState);
  96. previousShowEditorIds.delete(commentId);
  97. return previousShowEditorIds;
  98. });
  99. }, []);
  100. if (commentsFromOldest == null || commentsExceptReply == null) return <></>;
  101. if (hideIfEmpty && comments?.length === 0) {
  102. return <></>;
  103. }
  104. if (rendererOptions == null || currentPagePath == null || currentPage == null) {
  105. return <></>;
  106. }
  107. const generateCommentInnerElement = (comment: ICommentHasId) => (
  108. <Comment
  109. comment={comment}
  110. isReadOnly={isReadOnly}
  111. deleteBtnClicked={onClickDeleteButton}
  112. onComment={mutate}
  113. rendererOptions={rendererOptions}
  114. currentPagePath={currentPagePath}
  115. currentRevisionId={currentPage.revision._id}
  116. currentRevisionCreatedAt={currentPage.revision.createdAt}
  117. />
  118. );
  119. const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
  120. <ReplyComments
  121. isReadOnly={isReadOnly}
  122. replyList={replyComments}
  123. deleteBtnClicked={onClickDeleteButton}
  124. onComment={mutate}
  125. rendererOptions={rendererOptions}
  126. currentPagePath={currentPagePath}
  127. currentRevisionId={currentPage.revision._id}
  128. currentRevisionCreatedAt={currentPage.revision.createdAt}
  129. />
  130. );
  131. let commentTitleClasses = 'border-bottom py-3 mb-3';
  132. commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
  133. return (
  134. <>
  135. <div className="page-comments-row comment-list">
  136. <div className="container-lg">
  137. <div className="page-comments">
  138. <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
  139. <div className="page-comments-list" id="page-comments-list">
  140. { commentsExceptReply.map((comment) => {
  141. const defaultCommentThreadClasses = 'page-comment-thread pb-5';
  142. const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
  143. let commentThreadClasses = '';
  144. commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
  145. return (
  146. <div key={comment._id} className={commentThreadClasses}>
  147. {/* display comment */}
  148. {generateCommentInnerElement(comment)}
  149. {/* display reply comment */}
  150. {hasReply && generateAllRepliesElement(allReplies[comment._id])}
  151. {/* display reply button */}
  152. {(!isReadOnly && !showEditorIds.has(comment._id)) && (
  153. <div className="text-right">
  154. <Button
  155. outline
  156. color="secondary"
  157. size="sm"
  158. className="btn-comment-reply"
  159. onClick={() => {
  160. setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
  161. }}
  162. >
  163. <i className="icon-fw icon-action-undo"></i> Reply
  164. </Button>
  165. </div>
  166. )}
  167. {/* display reply editor */}
  168. {(!isReadOnly && showEditorIds.has(comment._id)) && (
  169. <CommentEditor
  170. rendererOptions={rendererOptions}
  171. replyTo={comment._id}
  172. onCancelButtonClicked={() => {
  173. removeShowEditorId(comment._id);
  174. }}
  175. onCommentButtonClicked={() => {
  176. removeShowEditorId(comment._id);
  177. mutate();
  178. }}
  179. />
  180. )}
  181. </div>
  182. );
  183. })}
  184. </div>
  185. {/* TODO: Check if identical-page */}
  186. <CommentEditorLazyRenderer pageId={pageId} rendererOptions={rendererOptions}/>
  187. </div>
  188. </div>
  189. </div>
  190. {(!isReadOnly && commentToBeDeleted != null) && (
  191. <DeleteCommentModal
  192. isShown={isDeleteConfirmModalShown}
  193. comment={commentToBeDeleted}
  194. errorMessage={errorMessageOnDelete}
  195. cancel={onCancelDeleteComment}
  196. confirmedToDelete={onDeleteComment}
  197. />
  198. )}
  199. </>
  200. );
  201. });
  202. PageComment.displayName = 'PageComment';