PageCommentList.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import React, {
  2. FC, useEffect, useState, useMemo, memo, useCallback,
  3. } from 'react';
  4. import { UncontrolledTooltip } from 'reactstrap';
  5. import { useTranslation } from 'react-i18next';
  6. import { UserPicture } from '@growi/ui';
  7. import AppContainer from '~/client/services/AppContainer';
  8. import RevisionBody from './Page/RevisionBody';
  9. import Username from './User/Username';
  10. import FormattedDistanceDate from './FormattedDistanceDate';
  11. import HistoryIcon from './Icons/HistoryIcon';
  12. import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
  13. import { useSWRxPageComment } from '../stores/comment';
  14. import MathJaxConfigurer from '~/client/util/markdown-it/mathjax';
  15. const COMMENT_BOTTOM_MARGIN = 'mb-5';
  16. type Props = {
  17. appContainer: AppContainer,
  18. pageId: string,
  19. highlightKeywords?:string[],
  20. }
  21. // todo: update this component to shared PageComment component
  22. const PageCommentList:FC<Props> = memo((props:Props):JSX.Element => {
  23. const { appContainer, pageId, highlightKeywords } = props;
  24. const { t } = useTranslation();
  25. const { data: comments, mutate } = useSWRxPageComment(pageId);
  26. const [formatedComments, setFormatedComments] = useState<ICommentHasIdList | null>(null);
  27. const commentsFromOldest = useMemo(() => (formatedComments != null ? [...formatedComments].reverse() : null), [formatedComments]);
  28. const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
  29. () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
  30. );
  31. const allReplies = {};
  32. /**
  33. * preprocess:
  34. * parse, sanitize, convert markdown to html
  35. */
  36. const preprocessComment = useCallback(async(comment:string):Promise<string> => {
  37. const { interceptorManager } = appContainer;
  38. const growiRenderer = appContainer.getRenderer('comment');
  39. const context: {markdown: string, parsedHTML: string} = { markdown: comment, parsedHTML: '' };
  40. if (interceptorManager != null) {
  41. await interceptorManager.process('preRenderComment', context);
  42. await interceptorManager.process('prePreProcess', context);
  43. context.markdown = await growiRenderer.preProcess(context.markdown);
  44. await interceptorManager.process('postPreProcess', context);
  45. context.parsedHTML = await growiRenderer.process(context.markdown);
  46. await interceptorManager.process('prePostProcess', context);
  47. context.parsedHTML = await growiRenderer.postProcess(context.parsedHTML);
  48. await interceptorManager.process('postPostProcess', context);
  49. await interceptorManager.process('preRenderCommentHtml', context);
  50. await interceptorManager.process('postRenderCommentHtml', context);
  51. }
  52. return context.parsedHTML;
  53. }, [appContainer]);
  54. const highlightComment = useCallback((comment: string):string => {
  55. if (highlightKeywords == null) return comment;
  56. let highlightedComment = '';
  57. highlightKeywords.forEach((highlightKeyword) => {
  58. highlightedComment = comment.replaceAll(highlightKeyword, '<em class="highlighted-keyword">$&</em>');
  59. });
  60. return highlightedComment;
  61. }, [highlightKeywords]);
  62. useEffect(() => { mutate() }, [pageId, mutate]);
  63. useEffect(() => {
  64. const formatAndHighlightComments = async() => {
  65. if (comments != null) {
  66. const preprocessedCommentList: string[] = await Promise.all(comments.map((comment) => {
  67. const highlightedComment: string = highlightComment(comment.comment);
  68. return preprocessComment(highlightedComment);
  69. }));
  70. const preprocessedComments: ICommentHasIdList = comments.map((comment, index) => {
  71. return { ...comment, comment: preprocessedCommentList[index] };
  72. });
  73. setFormatedComments(preprocessedComments);
  74. }
  75. };
  76. formatAndHighlightComments();
  77. }, [comments, highlightComment, preprocessComment]);
  78. if (commentsFromOldest != null) {
  79. commentsFromOldest.forEach((comment) => {
  80. if (comment.replyTo != null) {
  81. allReplies[comment.replyTo] = allReplies[comment.replyTo] == null ? [comment] : [...allReplies[comment.replyTo], comment];
  82. }
  83. });
  84. }
  85. const generateMarkdownBody = (comment: string): JSX.Element => {
  86. const isMathJaxEnabled: boolean = (new MathJaxConfigurer(appContainer)).isEnabled;
  87. return (
  88. <RevisionBody
  89. html={comment}
  90. isMathJaxEnabled={isMathJaxEnabled}
  91. renderMathJaxOnInit
  92. additionalClassName="comment"
  93. />
  94. );
  95. };
  96. const generateBodyFromPlainText = (comment: string): JSX.Element => {
  97. return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
  98. };
  99. const generateCommentInnerElement = (comment: ICommentHasId) => {
  100. const revisionHref = `/${comment.page}?revision=${comment.revision}`;
  101. const commentBody: string = comment.comment;
  102. const formatedCommentBody = comment.isMarkdown ? generateMarkdownBody(commentBody) : generateBodyFromPlainText(commentBody);
  103. return (
  104. <div key={comment._id} className="page-comment flex-column">
  105. <div className="page-comment-writer">
  106. <UserPicture user={comment.creator} />
  107. </div>
  108. <div className="page-comment-main">
  109. <div className="page-comment-creator">
  110. <Username user={comment.creator} />
  111. </div>
  112. <div className="page-comment-body">
  113. {formatedCommentBody}
  114. </div>
  115. <div className="page-comment-meta">
  116. <a href={`/${comment.page}#${comment._id}`}>
  117. <FormattedDistanceDate id={comment._id} date={comment.createdAt} />
  118. </a>
  119. <span className="ml-2">
  120. <a id={`page-comment-revision-${comment._id}`} className="page-comment-revision" href={revisionHref}>
  121. <HistoryIcon />
  122. </a>
  123. <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${comment._id}`}>
  124. {t('page_comment.display_the_page_when_posting_this_comment')}
  125. </UncontrolledTooltip>
  126. </span>
  127. </div>
  128. </div>
  129. </div>
  130. );
  131. };
  132. const generateAllRepliesElement = (replyComments: ICommentHasIdList) => {
  133. return (
  134. replyComments.map((comment: ICommentHasId, index: number) => {
  135. const lastIndex: number = replyComments.length - 1;
  136. const isLastIndex: boolean = index === lastIndex;
  137. const defaultReplyClasses = 'page-comment-reply ml-4 ml-sm-5 mr-3';
  138. const replyClasses: string = isLastIndex ? `${defaultReplyClasses} ${COMMENT_BOTTOM_MARGIN}` : defaultReplyClasses;
  139. return (
  140. <div key={comment._id} className={replyClasses}>
  141. {generateCommentInnerElement(comment)}
  142. </div>
  143. );
  144. })
  145. );
  146. };
  147. if (commentsFromOldest == null || commentsExceptReply == null) return <></>;
  148. return (
  149. <div className="page-comments-row comment-list border border-top mt-5 px-2">
  150. <div className="page-comments">
  151. <h2 className="text-center border-bottom my-4 pb-2"><i className="icon-fw icon-bubbles"></i>Comments</h2>
  152. <div className="page-comments-list" id="page-comments-list">
  153. { commentsExceptReply.map((comment, index) => {
  154. const defaultCommentThreadClasses = 'page-comment-thread';
  155. const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
  156. const isLastComment: boolean = index === commentsExceptReply.length - 1;
  157. let commentThreadClasses = '';
  158. commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
  159. commentThreadClasses = isLastComment ? `${commentThreadClasses} ${COMMENT_BOTTOM_MARGIN}` : commentThreadClasses;
  160. return (
  161. <div key={comment._id} className={commentThreadClasses}>
  162. {/* display comment */}
  163. {generateCommentInnerElement(comment)}
  164. {/* display reply comment */}
  165. {hasReply && generateAllRepliesElement(allReplies[comment._id])}
  166. </div>
  167. );
  168. })}
  169. </div>
  170. </div>
  171. </div>
  172. );
  173. });
  174. export default PageCommentList;