Comment.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { format, formatDistanceStrict } from 'date-fns';
  4. // TODO: GW-333
  5. // import Tooltip from 'react-bootstrap/es/Tooltip';
  6. // import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
  7. import {
  8. Button,
  9. Collapse,
  10. } from 'reactstrap';
  11. import AppContainer from '../../services/AppContainer';
  12. import PageContainer from '../../services/PageContainer';
  13. import { createSubscribedElement } from '../UnstatedUtils';
  14. import RevisionBody from '../Page/RevisionBody';
  15. import UserPicture from '../User/UserPicture';
  16. import Username from '../User/Username';
  17. import CommentEditor from './CommentEditor';
  18. /**
  19. *
  20. * @author Yuki Takei <yuki@weseek.co.jp>
  21. *
  22. * @export
  23. * @class Comment
  24. * @extends {React.Component}
  25. */
  26. class Comment extends React.Component {
  27. constructor(props) {
  28. super(props);
  29. this.state = {
  30. html: '',
  31. isOlderRepliesShown: false,
  32. showReEditorIds: new Set(),
  33. };
  34. this.growiRenderer = this.props.appContainer.getRenderer('comment');
  35. this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
  36. this.isCurrentRevision = this.isCurrentRevision.bind(this);
  37. this.getRootClassName = this.getRootClassName.bind(this);
  38. this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
  39. this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
  40. this.renderText = this.renderText.bind(this);
  41. this.renderHtml = this.renderHtml.bind(this);
  42. this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
  43. }
  44. componentWillMount() {
  45. this.renderHtml(this.props.comment.comment);
  46. }
  47. componentWillReceiveProps(nextProps) {
  48. this.renderHtml(nextProps.comment.comment);
  49. }
  50. // not used
  51. setMarkdown(markdown) {
  52. this.renderHtml(markdown);
  53. }
  54. checkPermissionToControlComment() {
  55. return this.props.appContainer.isAdmin || this.isCurrentUserEqualsToAuthor();
  56. }
  57. isCurrentUserEqualsToAuthor() {
  58. return this.props.comment.creator.username === this.props.appContainer.me;
  59. }
  60. isCurrentRevision() {
  61. return this.props.comment.revision === this.props.pageContainer.state.revisionId;
  62. }
  63. getRootClassName(comment) {
  64. let className = 'page-comment';
  65. const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
  66. if (comment.revision === revisionId) {
  67. className += ' page-comment-current';
  68. }
  69. else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
  70. className += ' page-comment-newer';
  71. }
  72. else {
  73. className += ' page-comment-older';
  74. }
  75. if (this.isCurrentUserEqualsToAuthor()) {
  76. className += ' page-comment-me';
  77. }
  78. return className;
  79. }
  80. getRevisionLabelClassName() {
  81. return `page-comment-revision label ${
  82. this.isCurrentRevision() ? 'label-primary' : 'label-default'}`;
  83. }
  84. editBtnClickedHandler(commentId) {
  85. const ids = this.state.showReEditorIds.add(commentId);
  86. this.setState({ showReEditorIds: ids });
  87. }
  88. commentButtonClickedHandler(commentId) {
  89. this.setState((prevState) => {
  90. prevState.showReEditorIds.delete(commentId);
  91. return {
  92. showReEditorIds: prevState.showReEditorIds,
  93. };
  94. });
  95. }
  96. deleteBtnClickedHandler() {
  97. this.props.deleteBtnClicked(this.props.comment);
  98. }
  99. renderText(comment) {
  100. return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
  101. }
  102. renderRevisionBody() {
  103. const config = this.props.appContainer.getConfig();
  104. const isMathJaxEnabled = !!config.env.MATHJAX;
  105. return (
  106. <RevisionBody
  107. html={this.state.html}
  108. isMathJaxEnabled={isMathJaxEnabled}
  109. renderMathJaxOnInit
  110. additionalClassName="comment"
  111. />
  112. );
  113. }
  114. toggleOlderReplies() {
  115. this.setState((prevState) => {
  116. return {
  117. showOlderReplies: !prevState.showOlderReplies,
  118. };
  119. });
  120. }
  121. renderHtml(markdown) {
  122. const context = {
  123. markdown,
  124. };
  125. const growiRenderer = this.props.growiRenderer;
  126. const interceptorManager = this.props.appContainer.interceptorManager;
  127. interceptorManager.process('preRenderComment', context)
  128. .then(() => { return interceptorManager.process('prePreProcess', context) })
  129. .then(() => {
  130. context.markdown = growiRenderer.preProcess(context.markdown);
  131. })
  132. .then(() => { return interceptorManager.process('postPreProcess', context) })
  133. .then(() => {
  134. const parsedHTML = growiRenderer.process(context.markdown);
  135. context.parsedHTML = parsedHTML;
  136. })
  137. .then(() => { return interceptorManager.process('prePostProcess', context) })
  138. .then(() => {
  139. context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
  140. })
  141. .then(() => { return interceptorManager.process('postPostProcess', context) })
  142. .then(() => { return interceptorManager.process('preRenderCommentHtml', context) })
  143. .then(() => {
  144. this.setState({ html: context.parsedHTML });
  145. })
  146. // process interceptors for post rendering
  147. .then(() => { return interceptorManager.process('postRenderCommentHtml', context) });
  148. }
  149. renderReply(reply) {
  150. return (
  151. <div key={reply._id} className="page-comment-reply">
  152. <CommentWrapper
  153. comment={reply}
  154. deleteBtnClicked={this.props.deleteBtnClicked}
  155. growiRenderer={this.props.growiRenderer}
  156. />
  157. </div>
  158. );
  159. }
  160. renderReplies() {
  161. const layoutType = this.props.appContainer.getConfig().layoutType;
  162. const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
  163. let replyList = this.props.replyList;
  164. if (!isBaloonStyle) {
  165. replyList = replyList.slice().reverse();
  166. }
  167. const areThereHiddenReplies = replyList.length > 2;
  168. const { isOlderRepliesShown } = this.state;
  169. const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
  170. const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
  171. const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
  172. const toggleButton = (
  173. <Button
  174. color="link"
  175. className="page-comments-list-toggle-older"
  176. onClick={() => { this.setState({ isOlderRepliesShown: !isOlderRepliesShown }) }}
  177. >
  178. {toggleButtonIcon} {toggleButtonLabel}
  179. </Button>
  180. );
  181. const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
  182. const hiddenReplies = replyList.slice(0, replyList.length - 2);
  183. const hiddenElements = hiddenReplies.map((reply) => {
  184. return this.renderReply(reply);
  185. });
  186. const shownElements = shownReplies.map((reply) => {
  187. return this.renderReply(reply);
  188. });
  189. return (
  190. <React.Fragment>
  191. { areThereHiddenReplies && (
  192. <div className="page-comments-hidden-replies">
  193. <Collapse isOpen={this.state.isOlderRepliesShown}>
  194. <div>{hiddenElements}</div>
  195. </Collapse>
  196. <div className="text-center">{toggleButton}</div>
  197. </div>
  198. ) }
  199. {shownElements}
  200. </React.Fragment>
  201. );
  202. }
  203. renderCommentControl(comment) {
  204. return (
  205. <div className="page-comment-control">
  206. <button type="button" className="btn btn-link p-2" onClick={() => { this.editBtnClickedHandler(comment._id) }}>
  207. <i className="ti-pencil"></i>
  208. </button>
  209. <button type="button" className="btn btn-link p-2 mr-2" onClick={this.deleteBtnClickedHandler}>
  210. <i className="ti-close"></i>
  211. </button>
  212. </div>
  213. );
  214. }
  215. render() {
  216. const comment = this.props.comment;
  217. const commentId = comment._id;
  218. const creator = comment.creator;
  219. const isMarkdown = comment.isMarkdown;
  220. const createdAt = new Date(comment.createdAt);
  221. const updatedAt = new Date(comment.updatedAt);
  222. const isEdited = createdAt < updatedAt;
  223. const showReEditor = this.state.showReEditorIds.has(commentId);
  224. const rootClassName = this.getRootClassName(comment);
  225. const commentDate = formatDistanceStrict(createdAt, new Date());
  226. const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
  227. const revHref = `?revision=${comment.revision}`;
  228. const revFirst8Letters = comment.revision.substr(-8);
  229. const revisionLavelClassName = this.getRevisionLabelClassName();
  230. const commentDateTooltip = (
  231. <Tooltip id={`commentDateTooltip-${comment._id}`}>
  232. {format(createdAt, 'yyyy/MM/dd HH:mm')}
  233. </Tooltip>
  234. );
  235. const editedDateTooltip = isEdited
  236. ? (
  237. <Tooltip id={`editedDateTooltip-${comment._id}`}>
  238. {format(updatedAt, 'yyyy/MM/dd HH:mm')}
  239. </Tooltip>
  240. )
  241. : null;
  242. return (
  243. <React.Fragment>
  244. {showReEditor ? (
  245. <CommentEditor
  246. growiRenderer={this.growiRenderer}
  247. currentCommentId={commentId}
  248. commentBody={comment.comment}
  249. replyTo={undefined}
  250. commentButtonClickedHandler={this.commentButtonClickedHandler}
  251. />
  252. ) : (
  253. <div className={rootClassName}>
  254. <UserPicture user={creator} />
  255. <div className="page-comment-main">
  256. <div className="page-comment-creator">
  257. <Username user={creator} />
  258. </div>
  259. <div className="page-comment-body">{commentBody}</div>
  260. <div className="page-comment-meta">
  261. <OverlayTrigger overlay={commentDateTooltip} placement="bottom">
  262. <span>{commentDate}</span>
  263. </OverlayTrigger>
  264. { isEdited && (
  265. <OverlayTrigger overlay={editedDateTooltip} placement="bottom">
  266. <span>&nbsp;(edited)</span>
  267. </OverlayTrigger>
  268. ) }
  269. <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
  270. </div>
  271. { this.checkPermissionToControlComment() && this.renderCommentControl(comment) }
  272. </div>
  273. </div>
  274. )
  275. }
  276. {this.renderReplies()}
  277. </React.Fragment>
  278. );
  279. }
  280. }
  281. /**
  282. * Wrapper component for using unstated
  283. */
  284. const CommentWrapper = (props) => {
  285. return createSubscribedElement(Comment, props, [AppContainer, PageContainer]);
  286. };
  287. Comment.propTypes = {
  288. appContainer: PropTypes.instanceOf(AppContainer).isRequired,
  289. pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
  290. comment: PropTypes.object.isRequired,
  291. growiRenderer: PropTypes.object.isRequired,
  292. deleteBtnClicked: PropTypes.func.isRequired,
  293. replyList: PropTypes.array,
  294. };
  295. Comment.defaultProps = {
  296. replyList: [],
  297. };
  298. export default CommentWrapper;