Răsfoiți Sursa

Merge pull request #994 from weseek/feat/thread_comments

Feat/thread comments
Yuki Takei 6 ani în urmă
părinte
comite
06aaa28910

+ 2 - 0
package.json

@@ -85,6 +85,7 @@
     "express-form": "~0.12.0",
     "express-form": "~0.12.0",
     "express-sanitizer": "^1.0.4",
     "express-sanitizer": "^1.0.4",
     "express-session": "^1.16.1",
     "express-session": "^1.16.1",
+    "express-validator": "^5.3.1",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
     "googleapis": "^39.1.0",
     "googleapis": "^39.1.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
@@ -215,6 +216,7 @@
     "terser-webpack-plugin": "^1.2.2",
     "terser-webpack-plugin": "^1.2.2",
     "throttle-debounce": "^2.0.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
+    "unstated": "^2.1.1",
     "webpack": "^4.29.3",
     "webpack": "^4.29.3",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.0.2",
     "webpack-bundle-analyzer": "^3.0.2",

+ 42 - 20
src/client/js/app.js

@@ -2,6 +2,7 @@
 
 
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
+import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 
 
@@ -28,7 +29,8 @@ import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import Page from './components/Page';
 import PageHistory from './components/PageHistory';
 import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
 import PageComments from './components/PageComments';
-import CommentForm from './components/PageComment/CommentForm';
+import CommentContainer from './components/PageComment/CommentContainer';
+import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageAttachment from './components/PageAttachment';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
 import RevisionPath from './components/Page/RevisionPath';
@@ -112,6 +114,9 @@ const crowiRenderer = new GrowiRenderer(crowi, null, {
 });
 });
 window.crowiRenderer = crowiRenderer;
 window.crowiRenderer = crowiRenderer;
 
 
+// create unstated container instance
+const commentContainer = new CommentContainer(crowi, pageId, pagePath, pageRevisionId);
+
 // FIXME
 // FIXME
 const isEnabledPlugins = $('body').data('plugin-enabled');
 const isEnabledPlugins = $('body').data('plugin-enabled');
 if (isEnabledPlugins) {
 if (isEnabledPlugins) {
@@ -287,6 +292,8 @@ if (!pageRevisionId && draft != null) {
   markdown = draft;
   markdown = draft;
 }
 }
 
 
+const pageEditorOptions = new EditorOptions(crowi.editorOptions);
+
 /**
 /**
  * define components
  * define components
  *  key: id of element
  *  key: id of element
@@ -308,9 +315,31 @@ const componentMappings = {
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
 
 
 };
 };
+
 // additional definitions if data exists
 // additional definitions if data exists
+let pageComments = null;
 if (pageId) {
 if (pageId) {
-  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
+  componentMappings['page-comments-list'] = (
+    <I18nextProvider i18n={i18n}>
+      <Provider inject={[commentContainer]}>
+        <PageComments
+          ref={(elem) => {
+            if (pageComments == null) {
+              pageComments = elem;
+            }
+          }}
+          revisionCreatedAt={pageRevisionCreatedAt}
+          pageId={pageId}
+          pagePath={pagePath}
+          editorOptions={pageEditorOptions}
+          slackChannels={slackChannels}
+          crowi={crowi}
+          crowiOriginRenderer={crowiRenderer}
+          revisionId={pageRevisionId}
+        />
+      </Provider>
+    </I18nextProvider>
+  );
   componentMappings['page-attachment'] = <PageAttachment pageId={pageId} markdown={markdown} crowi={crowi} />;
   componentMappings['page-attachment'] = <PageAttachment pageId={pageId} markdown={markdown} crowi={crowi} />;
 }
 }
 if (pagePath) {
 if (pagePath) {
@@ -485,25 +514,18 @@ if (pageEditorElem) {
 // render comment form
 // render comment form
 const writeCommentElem = document.getElementById('page-comment-write');
 const writeCommentElem = document.getElementById('page-comment-write');
 if (writeCommentElem) {
 if (writeCommentElem) {
-  const pageCommentsElem = componentInstances['page-comments-list'];
-  const postCompleteHandler = (comment) => {
-    if (pageCommentsElem != null) {
-      pageCommentsElem.retrieveData();
-    }
-  };
   ReactDOM.render(
   ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <CommentForm
-        crowi={crowi}
-        crowiOriginRenderer={crowiRenderer}
-        pageId={pageId}
-        pagePath={pagePath}
-        revisionId={pageRevisionId}
-        onPostComplete={postCompleteHandler}
-        editorOptions={editorOptions}
-        slackChannels={slackChannels}
-      />
-    </I18nextProvider>,
+    <Provider inject={[commentContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <CommentEditorLazyRenderer
+          crowi={crowi}
+          crowiOriginRenderer={crowiRenderer}
+          editorOptions={pageEditorOptions}
+          slackChannels={slackChannels}
+        >
+        </CommentEditorLazyRenderer>
+      </I18nextProvider>
+    </Provider>,
     writeCommentElem,
     writeCommentElem,
   );
   );
 }
 }

+ 73 - 19
src/client/js/components/PageComment/Comment.jsx

@@ -24,6 +24,7 @@ export default class Comment extends React.Component {
 
 
     this.state = {
     this.state = {
       html: '',
       html: '',
+      isLayoutTypeGrowi: false,
     };
     };
 
 
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
@@ -36,6 +37,12 @@ export default class Comment extends React.Component {
 
 
   componentWillMount() {
   componentWillMount() {
     this.renderHtml(this.props.comment.comment);
     this.renderHtml(this.props.comment.comment);
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.crowi.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
   }
 
 
   componentWillReceiveProps(nextProps) {
   componentWillReceiveProps(nextProps) {
@@ -48,11 +55,11 @@ export default class Comment extends React.Component {
   }
   }
 
 
   isCurrentUserEqualsToAuthor() {
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.currentUserId;
+    return this.props.comment.creator.username === this.props.crowi.me;
   }
   }
 
 
   isCurrentRevision() {
   isCurrentRevision() {
-    return this.props.comment.revision === this.props.currentRevisionId;
+    return this.props.comment.revision === this.props.revisionId;
   }
   }
 
 
   getRootClassName() {
   getRootClassName() {
@@ -113,6 +120,29 @@ export default class Comment extends React.Component {
 
 
   }
   }
 
 
+  renderReplies() {
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    let replyList = this.props.replyList;
+    if (!isLayoutTypeGrowi) {
+      replyList = replyList.slice().reverse();
+    }
+    return replyList.map((reply) => {
+      return (
+        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+          <Comment
+            comment={reply}
+            deleteBtnClicked={this.props.deleteBtnClicked}
+            crowiRenderer={this.props.crowiRenderer}
+            crowi={this.props.crowi}
+            replyList={[]}
+            revisionCreatedAt={this.props.revisionCreatedAt}
+            revisionId={this.props.revisionId}
+          />
+        </div>
+      );
+    });
+  }
+
   render() {
   render() {
     const comment = this.props.comment;
     const comment = this.props.comment;
     const creator = comment.creator;
     const creator = comment.creator;
@@ -125,22 +155,45 @@ export default class Comment extends React.Component {
     const revFirst8Letters = comment.revision.substr(-8);
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
 
+    const revisionId = this.props.revisionId;
+    const revisionCreatedAt = this.props.revisionCreatedAt;
+    let isNewer;
+    if (comment.revision === revisionId) {
+      isNewer = 'page-comments-list-current';
+    }
+    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
+      isNewer = 'page-comments-list-newer';
+    }
+    else {
+      isNewer = 'page-comments-list-older';
+    }
+
+
     return (
     return (
-      <div className={rootClassName}>
-        <UserPicture user={creator} />
-        <div className="page-comment-main">
-          <div className="page-comment-creator">
-            <Username user={creator} />
-          </div>
-          <div className="page-comment-body">{commentBody}</div>
-          <div className="page-comment-meta">
-            {commentDate}&nbsp;
-            <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
+      <div>
+        <div className={isNewer}>
+          <div className={rootClassName}>
+            <UserPicture user={creator} />
+            <div className="page-comment-main">
+              <div className="page-comment-creator">
+                <Username user={creator} />
+              </div>
+              <div className="page-comment-body">{commentBody}</div>
+              <div className="page-comment-meta">
+                {commentDate}&nbsp;
+                <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
+              </div>
+              <div className="page-comment-control">
+                <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
+                  <i className="ti-close"></i>
+                </button>
+              </div>
+            </div>
           </div>
           </div>
-          <div className="page-comment-control">
-            <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
-              <i className="ti-close"></i>
-            </button>
+        </div>
+        <div className="container-fluid">
+          <div className="row">
+            {this.renderReplies()}
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
@@ -151,9 +204,10 @@ export default class Comment extends React.Component {
 
 
 Comment.propTypes = {
 Comment.propTypes = {
   comment: PropTypes.object.isRequired,
   comment: PropTypes.object.isRequired,
-  currentRevisionId: PropTypes.string.isRequired,
-  currentUserId: PropTypes.string.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   crowi: PropTypes.object.isRequired,
   crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  revisionId: PropTypes.string,
+  replyList: PropTypes.array,
+  revisionCreatedAt: PropTypes.number,
 };
 };

+ 102 - 0
src/client/js/components/PageComment/CommentContainer.jsx

@@ -0,0 +1,102 @@
+import { Container } from 'unstated';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @extends {Container} unstated Container
+ */
+export default class CommentContainer extends Container {
+
+  constructor(crowi, pageId, pagePath, revisionId) {
+    super();
+
+    this.crowi = crowi;
+    this.pageId = pageId;
+    this.pagePath = pagePath;
+    this.revisionId = revisionId;
+
+    this.state = {
+      comments: [],
+    };
+
+    this.retrieveComments = this.retrieveComments.bind(this);
+  }
+
+  init() {
+    if (!this.props.pageId) {
+      return;
+    }
+  }
+
+  findAndSplice(comment) {
+    const comments = this.state.comments;
+
+    const index = comments.indexOf(comment);
+    if (index < 0) {
+      return;
+    }
+    comments.splice(index, 1);
+
+    this.setState({ comments });
+  }
+
+  /**
+   * Load data of comments and store them in state
+   */
+  retrieveComments() {
+    // get data (desc order array)
+    return this.crowi.apiGet('/comments.get', { page_id: this.pageId })
+      .then((res) => {
+        if (res.ok) {
+          this.setState({ comments: res.comments });
+        }
+      });
+  }
+
+  /**
+   * Load data of comments and rerender <PageComments />
+   */
+  postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
+    return this.crowi.apiPost('/comments.add', {
+      commentForm: {
+        comment,
+        _csrf: this.crowi.csrfToken,
+        page_id: this.pageId,
+        revision_id: this.revisionId,
+        is_markdown: isMarkdown,
+        replyTo,
+      },
+      slackNotificationForm: {
+        isSlackEnabled,
+        slackChannels,
+      },
+    })
+      .then((res) => {
+        if (res.ok) {
+          return this.retrieveComments();
+        }
+      });
+  }
+
+  deleteComment(comment) {
+    return this.crowi.apiPost('/comments.remove', { comment_id: comment._id })
+      .then((res) => {
+        if (res.ok) {
+          this.findAndSplice(comment);
+        }
+      });
+  }
+
+  uploadAttachment(file) {
+    const endpoint = '/attachments.add';
+    const formData = new FormData();
+    formData.append('_csrf', this.crowi.csrfToken);
+    formData.append('file', file);
+    formData.append('path', this.pagePath);
+    formData.append('page_id', this.pageId);
+
+    return this.crowi.apiPost(endpoint, formData);
+  }
+
+}

+ 359 - 0
src/client/js/components/PageComment/CommentEditor.jsx

@@ -0,0 +1,359 @@
+/* eslint-disable react/no-multi-comp */
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Subscribe } from 'unstated';
+
+import Button from 'react-bootstrap/es/Button';
+import Tab from 'react-bootstrap/es/Tab';
+import Tabs from 'react-bootstrap/es/Tabs';
+import * as toastr from 'toastr';
+import UserPicture from '../User/UserPicture';
+import ReactUtils from '../ReactUtils';
+
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import Editor from '../PageEditor/Editor';
+import CommentContainer from './CommentContainer';
+import CommentPreview from './CommentPreview';
+import SlackNotification from '../SlackNotification';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @extends {React.Component}
+ */
+
+class CommentEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const config = this.props.crowi.getConfig();
+    const isUploadable = config.upload.image || config.upload.file;
+    const isUploadableFile = config.upload.file;
+
+    this.state = {
+      isLayoutTypeGrowi: false,
+      comment: '',
+      isMarkdown: true,
+      html: '',
+      key: 1,
+      isUploadable,
+      isUploadableFile,
+      errorMessage: undefined,
+      hasSlackConfig: config.hasSlackConfig,
+      isSlackEnabled: false,
+      slackChannels: this.props.slackChannels,
+    };
+
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
+
+    this.updateState = this.updateState.bind(this);
+    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
+
+    this.postHandler = this.postHandler.bind(this);
+    this.uploadHandler = this.uploadHandler.bind(this);
+
+    this.renderHtml = this.renderHtml.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
+    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.crowi.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+  }
+
+  updateState(value) {
+    this.setState({ comment: value });
+  }
+
+  updateStateCheckbox(event) {
+    const value = event.target.checked;
+    this.setState({ isMarkdown: value });
+    // changeMode
+    this.editor.setGfmMode(value);
+  }
+
+  handleSelect(key) {
+    this.setState({ key });
+    this.renderHtml(this.state.comment);
+  }
+
+  onSlackEnabledFlagChange(value) {
+    this.setState({ isSlackEnabled: value });
+  }
+
+  onSlackChannelsChange(value) {
+    this.setState({ slackChannels: value });
+  }
+
+  /**
+   * Post comment with CommentContainer and update state
+   */
+  postHandler(event) {
+    if (event != null) {
+      event.preventDefault();
+    }
+
+    this.props.commentContainer.postComment(
+      this.state.comment,
+      this.state.isMarkdown,
+      this.props.replyTo,
+      this.state.isSlackEnabled,
+      this.state.slackChannels,
+    )
+      .then((res) => {
+        this.setState({
+          comment: '',
+          isMarkdown: true,
+          html: '',
+          key: 1,
+          errorMessage: undefined,
+          isSlackEnabled: false,
+        });
+        // reset value
+        this.editor.setValue('');
+        this.props.commentButtonClickedHandler(this.props.replyTo);
+      })
+      .catch((err) => {
+        const errorMessage = err.message || 'An unknown error occured when posting comment';
+        this.setState({ errorMessage });
+      });
+  }
+
+  uploadHandler(file) {
+    this.props.commentContainer.uploadAttachment(file)
+      .then((res) => {
+        const attachment = res.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${attachment.filePathProxied})`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = `!${insertText}`;
+        }
+        this.editor.insertText(insertText);
+      })
+      .catch(this.apiErrorHandler)
+      // finally
+      .then(() => {
+        this.editor.terminateUploadingState();
+      });
+  }
+
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  getCommentHtml() {
+    return (
+      <CommentPreview
+        inputRef={(el) => { this.previewElement = el }}
+        html={this.state.html}
+      />
+    );
+  }
+
+  renderHtml(markdown) {
+    const context = {
+      markdown,
+    };
+
+    const growiRenderer = this.growiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRenderCommnetPreview', context)
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
+  }
+
+  generateInnerHtml(html) {
+    return { __html: html };
+  }
+
+  render() {
+    const crowi = this.props.crowi;
+    const username = crowi.me;
+    const user = crowi.findUser(username);
+    const comment = this.state.comment;
+    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
+    const emojiStrategy = this.props.crowi.getEmojiStrategy();
+
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+
+    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
+    const submitButton = (
+      <Button
+        bsStyle="primary"
+        className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b"
+        onClick={this.postHandler}
+      >
+        Comment
+      </Button>
+    );
+
+    return (
+      <div className="form page-comment-form">
+
+        { username
+          && (
+          <div className="comment-form">
+            { isLayoutTypeGrowi
+              && (
+              <div className="comment-form-user">
+                <UserPicture user={user} />
+              </div>
+              )
+            }
+            <div className="comment-form-main">
+              <div className="comment-write">
+                <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                  <Tab eventKey={1} title="Write">
+                    <Editor
+                      ref={(c) => { this.editor = c }}
+                      value={this.state.comment}
+                      isGfmMode={this.state.isMarkdown}
+                      editorOptions={this.props.editorOptions}
+                      lineNumbers={false}
+                      isMobile={this.props.crowi.isMobile}
+                      isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
+                      isUploadableFile={this.state.isUploadableFile}
+                      emojiStrategy={emojiStrategy}
+                      onChange={this.updateState}
+                      onUpload={this.uploadHandler}
+                      onCtrlEnter={this.postHandler}
+                    />
+                  </Tab>
+                  { this.state.isMarkdown
+                    && (
+                    <Tab eventKey={2} title="Preview">
+                      <div className="comment-form-preview">
+                        {commentPreview}
+                      </div>
+                    </Tab>
+                    )
+                  }
+                </Tabs>
+              </div>
+              <div className="comment-submit">
+                <div className="d-flex">
+                  <label style={{ flex: 1 }}>
+                    { isLayoutTypeGrowi && this.state.key === 1
+                      && (
+                      <span>
+                        <input
+                          type="checkbox"
+                          id="comment-form-is-markdown"
+                          name="isMarkdown"
+                          checked={this.state.isMarkdown}
+                          value="1"
+                          onChange={this.updateStateCheckbox}
+                        />
+                        <span className="ml-2">Markdown</span>
+                      </span>
+                      )
+                  }
+                  </label>
+                  <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                  { this.state.hasSlackConfig
+                    && (
+                    <div className="form-inline align-self-center mr-md-2">
+                      <SlackNotification
+                        isSlackEnabled={this.state.isSlackEnabled}
+                        slackChannels={this.state.slackChannels}
+                        onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                        onChannelChange={this.onSlackChannelsChange}
+                      />
+                    </div>
+                    )
+                  }
+                  <div className="hidden-xs">{submitButton}</div>
+                </div>
+                <div className="visible-xs mt-2">
+                  <div className="d-flex justify-content-end">
+                    { this.state.errorMessage && errorMessage }
+                    <div>{submitButton}</div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          )
+        }
+
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+class CommentEditorWrapper extends React.Component {
+
+  render() {
+    return (
+      <Subscribe to={[CommentContainer]}>
+        { commentContainer => (
+          // eslint-disable-next-line arrow-body-style
+          <CommentEditor commentContainer={commentContainer} {...this.props} />
+        )}
+      </Subscribe>
+    );
+  }
+
+}
+
+CommentEditor.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+  replyTo: PropTypes.string,
+  commentButtonClickedHandler: PropTypes.func.isRequired,
+};
+CommentEditorWrapper.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+  replyTo: PropTypes.string,
+  commentButtonClickedHandler: PropTypes.func.isRequired,
+};
+
+export default CommentEditorWrapper;

+ 89 - 0
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -0,0 +1,89 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import CommentEditor from './CommentEditor';
+
+import UserPicture from '../User/UserPicture';
+
+export default class CommentEditorLazyRenderer extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isEditorShown: false,
+      isLayoutTypeGrowi: false,
+    };
+
+    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    const layoutType = this.props.crowi.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+  }
+
+  showCommentFormBtnClickHandler() {
+    this.setState({ isEditorShown: !this.state.isEditorShown });
+  }
+
+  render() {
+    const crowi = this.props.crowi;
+    const username = crowi.me;
+    const user = crowi.findUser(username);
+    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    return (
+      <React.Fragment>
+        { !this.state.isEditorShown
+          && (
+          <div className="form page-comment-form">
+            { username
+              && (
+                <div className="comment-form">
+                  { isLayoutTypeGrowi
+                  && (
+                    <div className="comment-form-user">
+                      <UserPicture user={user} />
+                    </div>
+                  )
+                  }
+                  <div className="comment-form-main">
+                    <button
+                      type="button"
+                      className={`btn btn-lg ${this.state.isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
+                      onClick={this.showCommentFormBtnClickHandler}
+                    >
+                      <i className="icon-bubble"></i> Add Comment
+                    </button>
+                  </div>
+                </div>
+              )
+            }
+          </div>
+          )
+        }
+        { this.state.isEditorShown
+          && (
+          <CommentEditor
+            {...this.props}
+            replyTo={undefined}
+            commentButtonClickedHandler={this.showCommentFormBtnClickHandler}
+          >
+          </CommentEditor>
+)
+        }
+      </React.Fragment>
+    );
+  }
+
+}
+
+CommentEditorLazyRenderer.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+};

+ 0 - 381
src/client/js/components/PageComment/CommentForm.jsx

@@ -1,381 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import Button from 'react-bootstrap/es/Button';
-import Tab from 'react-bootstrap/es/Tab';
-import Tabs from 'react-bootstrap/es/Tabs';
-import * as toastr from 'toastr';
-import UserPicture from '../User/UserPicture';
-import ReactUtils from '../ReactUtils';
-
-import GrowiRenderer from '../../util/GrowiRenderer';
-
-import Editor from '../PageEditor/Editor';
-import CommentPreview from './CommentPreview';
-import SlackNotification from '../SlackNotification';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class Comment
- * @extends {React.Component}
- */
-
-export default class CommentForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.crowi.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-
-    this.state = {
-      isLayoutTypeGrowi: false,
-      isFormShown: false,
-      comment: '',
-      isMarkdown: true,
-      html: '',
-      key: 1,
-      isUploadable,
-      isUploadableFile,
-      errorMessage: undefined,
-      hasSlackConfig: config.hasSlackConfig,
-      isSlackEnabled: false,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-    this.postComment = this.postComment.bind(this);
-    this.renderHtml = this.renderHtml.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
-    this.onUpload = this.onUpload.bind(this);
-    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
-    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
-    this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    if (!this.props.pageId) {
-      return;
-    }
-
-    const layoutType = this.props.crowi.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-  }
-
-  updateState(value) {
-    this.setState({ comment: value });
-  }
-
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isMarkdown: value });
-    // changeMode
-    this.editor.setGfmMode(value);
-  }
-
-  handleSelect(key) {
-    this.setState({ key });
-    this.renderHtml(this.state.comment);
-  }
-
-  onSlackEnabledFlagChange(value) {
-    this.setState({ isSlackEnabled: value });
-  }
-
-  onSlackChannelsChange(value) {
-    this.setState({ slackChannels: value });
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  postComment(event) {
-    if (event != null) {
-      event.preventDefault();
-    }
-
-    this.props.crowi.apiPost('/comments.add', {
-      commentForm: {
-        comment: this.state.comment,
-        _csrf: this.props.crowi.csrfToken,
-        page_id: this.props.pageId,
-        revision_id: this.props.revisionId,
-        is_markdown: this.state.isMarkdown,
-      },
-      slackNotificationForm: {
-        isSlackEnabled: this.state.isSlackEnabled,
-        slackChannels: this.state.slackChannels,
-      },
-    })
-      .then((res) => {
-        if (this.props.onPostComplete != null) {
-          this.props.onPostComplete(res.comment);
-        }
-        this.setState({
-          comment: '',
-          isMarkdown: true,
-          html: '',
-          key: 1,
-          errorMessage: undefined,
-          isSlackEnabled: false,
-        });
-        // reset value
-        this.editor.setValue('');
-      })
-      .catch((err) => {
-        const errorMessage = err.message || 'An unknown error occured when posting comment';
-        this.setState({ errorMessage });
-      });
-  }
-
-  getCommentHtml() {
-    return (
-      <CommentPreview
-        inputRef={(el) => { this.previewElement = el }}
-        html={this.state.html}
-      />
-    );
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
-    interceptorManager.process('preRenderCommnetPreview', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }
-
-  generateInnerHtml(html) {
-    return { __html: html };
-  }
-
-  onUpload(file) {
-    const endpoint = '/attachments.add';
-
-    // create a FromData instance
-    const formData = new FormData();
-    formData.append('_csrf', this.props.crowi.csrfToken);
-    formData.append('file', file);
-    formData.append('path', this.props.pagePath);
-    formData.append('page_id', this.props.pageId || 0);
-
-    // post
-    this.props.crowi.apiPost(endpoint, formData)
-      .then((res) => {
-        const attachment = res.attachment;
-        const fileName = attachment.originalName;
-
-        let insertText = `[${fileName}](${attachment.filePathProxied})`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = `!${insertText}`;
-        }
-        this.editor.insertText(insertText);
-      })
-      .catch(this.apiErrorHandler)
-      // finally
-      .then(() => {
-        this.editor.terminateUploadingState();
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  showCommentFormBtnClickHandler() {
-    this.setState({ isFormShown: true });
-  }
-
-  renderControls() {
-
-  }
-
-  render() {
-    const crowi = this.props.crowi;
-    const username = crowi.me;
-    const user = crowi.findUser(username);
-    const comment = this.state.comment;
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
-
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
-
-    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
-    const submitButton = (
-      <Button type="submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
-        Comment
-      </Button>
-    );
-
-    return (
-      <div>
-
-        <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
-          { username
-            && (
-            <div className="comment-form">
-              { isLayoutTypeGrowi
-                && (
-                <div className="comment-form-user">
-                  <UserPicture user={user} />
-                </div>
-                )
-              }
-              <div className="comment-form-main">
-                {/* Add Comment Button */}
-                { !this.state.isFormShown
-                  && (
-                  <button
-                    type="button"
-                    className={`btn btn-lg ${isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
-                    onClick={this.showCommentFormBtnClickHandler}
-                  >
-                    <i className="icon-bubble"></i> Add Comment
-                  </button>
-                  )
-                }
-                {/* Editor */}
-                { this.state.isFormShown
-                  && (
-                  <React.Fragment>
-                    <div className="comment-write">
-                      <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
-                        <Tab eventKey={1} title="Write">
-                          <Editor
-                            ref={(c) => { this.editor = c }}
-                            value={this.state.comment}
-                            isGfmMode={this.state.isMarkdown}
-                            editorOptions={this.props.editorOptions}
-                            lineNumbers={false}
-                            isMobile={this.props.crowi.isMobile}
-                            isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
-                            isUploadableFile={this.state.isUploadableFile}
-                            emojiStrategy={emojiStrategy}
-                            onChange={this.updateState}
-                            onUpload={this.onUpload}
-                            onCtrlEnter={this.postComment}
-                          />
-                        </Tab>
-                        { this.state.isMarkdown
-                          && (
-                          <Tab eventKey={2} title="Preview">
-                            <div className="comment-form-preview">
-                              {commentPreview}
-                            </div>
-                          </Tab>
-                          )
-                        }
-                      </Tabs>
-                    </div>
-                    <div className="comment-submit">
-                      <div className="d-flex">
-                        <label style={{ flex: 1 }}>
-                          { isLayoutTypeGrowi && this.state.key === 1
-                            && (
-                            <span>
-                              <input
-                                type="checkbox"
-                                id="comment-form-is-markdown"
-                                name="isMarkdown"
-                                checked={this.state.isMarkdown}
-                                value="1"
-                                onChange={this.updateStateCheckbox}
-                              />
-                              <span className="ml-2">Markdown</span>
-                            </span>
-                            )
-                        }
-                        </label>
-                        <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
-                        { this.state.hasSlackConfig
-                          && (
-                          <div className="form-inline align-self-center mr-md-2">
-                            <SlackNotification
-                              isSlackEnabled={this.state.isSlackEnabled}
-                              slackChannels={this.state.slackChannels}
-                              onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                              onChannelChange={this.onSlackChannelsChange}
-                            />
-                          </div>
-                          )
-                        }
-                        <div className="hidden-xs">{submitButton}</div>
-                      </div>
-                      <div className="visible-xs mt-2">
-                        <div className="d-flex justify-content-end">
-                          { this.state.errorMessage && errorMessage }
-                          <div>{submitButton}</div>
-                        </div>
-                      </div>
-                    </div>
-                  </React.Fragment>
-                  )
-                }
-              </div>
-            </div>
-            )
-          }
-        </form>
-
-      </div>
-    );
-  }
-
-}
-
-CommentForm.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  onPostComplete: PropTypes.func,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
-};
-CommentForm.defaultProps = {
-  editorOptions: {},
-};

+ 0 - 256
src/client/js/components/PageComments.js

@@ -1,256 +0,0 @@
-/* eslint-disable react/no-access-state-in-setstate */
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import GrowiRenderer from '../util/GrowiRenderer';
-
-import Comment from './PageComment/Comment';
-import DeleteCommentModal from './PageComment/DeleteCommentModal';
-
-/**
- * Load data of comments and render the list of <Comment />
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageComments
- * @extends {React.Component}
- */
-export default class PageComments extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      // desc order array
-      comments: [],
-
-      isLayoutTypeGrowi: false,
-
-      // for deleting comment
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    };
-
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
-
-    this.init = this.init.bind(this);
-    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
-    this.deleteComment = this.deleteComment.bind(this);
-    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
-    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-    this.retrieveData = this.retrieveData.bind(this);
-  }
-
-  init() {
-    if (!this.props.pageId) {
-      return;
-    }
-
-    const layoutType = this.props.crowi.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-
-    this.retrieveData();
-  }
-
-  /**
-   * Load data of comments and store them in state
-   */
-  retrieveData() {
-    // get data (desc order array)
-    this.props.crowi.apiGet('/comments.get', { page_id: this.props.pageId })
-      .then((res) => {
-        if (res.ok) {
-          this.setState({ comments: res.comments });
-        }
-      });
-  }
-
-  confirmToDeleteComment(comment) {
-    this.setState({ commentToDelete: comment });
-    this.showDeleteConfirmModal();
-  }
-
-  deleteComment() {
-    const comment = this.state.commentToDelete;
-
-    this.props.crowi.apiPost('/comments.remove', { comment_id: comment._id })
-      .then((res) => {
-        if (res.ok) {
-          this.findAndSplice(comment);
-        }
-        this.closeDeleteConfirmModal();
-      })
-      .catch((err) => {
-        this.setState({ errorMessageForDeleting: err.message });
-      });
-  }
-
-  findAndSplice(comment) {
-    const comments = this.state.comments;
-
-    const index = comments.indexOf(comment);
-    if (index < 0) {
-      return;
-    }
-    comments.splice(index, 1);
-
-    this.setState({ comments });
-  }
-
-  showDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModal() {
-    this.setState({
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-    });
-  }
-
-  /**
-   * generate Elements of Comment
-   *
-   * @param {any} comments Array of Comment Model Obj
-   *
-   * @memberOf PageComments
-   */
-  generateCommentElements(comments) {
-    return comments.map((comment) => {
-      return (
-        <Comment
-          key={comment._id}
-          comment={comment}
-          currentUserId={this.props.crowi.me}
-          currentRevisionId={this.props.revisionId}
-          deleteBtnClicked={this.confirmToDeleteComment}
-          crowi={this.props.crowi}
-          crowiRenderer={this.growiRenderer}
-        />
-      );
-    });
-  }
-
-  render() {
-    const currentComments = [];
-    const newerComments = [];
-    const olderComments = [];
-
-    let comments = this.state.comments;
-    if (this.state.isLayoutTypeGrowi) {
-      // replace with asc order array
-      comments = comments.slice().reverse(); // non-destructive reverse
-    }
-
-    // divide by revisionId and createdAt
-    const revisionId = this.props.revisionId;
-    const revisionCreatedAt = this.props.revisionCreatedAt;
-    comments.forEach((comment) => {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
-      if (comment.revision == revisionId) {
-        currentComments.push(comment);
-      }
-      else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-        newerComments.push(comment);
-      }
-      else {
-        olderComments.push(comment);
-      }
-    });
-
-    // generate elements
-    const currentElements = this.generateCommentElements(currentComments);
-    const newerElements = this.generateCommentElements(newerComments);
-    const olderElements = this.generateCommentElements(olderComments);
-    // generate blocks
-    const currentBlock = (
-      <div className="page-comments-list-current" id="page-comments-list-current">
-        {currentElements}
-      </div>
-    );
-    const newerBlock = (
-      <div className="page-comments-list-newer collapse in" id="page-comments-list-newer">
-        {newerElements}
-      </div>
-    );
-    const olderBlock = (
-      <div className="page-comments-list-older collapse in" id="page-comments-list-older">
-        {olderElements}
-      </div>
-    );
-
-    // generate toggle elements
-    const iconForNewer = (this.state.isLayoutTypeGrowi)
-      ? <i className="fa fa-angle-double-down"></i>
-      : <i className="fa fa-angle-double-up"></i>;
-    const toggleNewer = (newerElements.length === 0)
-      ? <div></div>
-      : (
-        <a className="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer">
-          {iconForNewer} Comments for Newer Revision {iconForNewer}
-        </a>
-      );
-    const iconForOlder = (this.state.isLayoutTypeGrowi)
-      ? <i className="fa fa-angle-double-up"></i>
-      : <i className="fa fa-angle-double-down"></i>;
-    const toggleOlder = (olderElements.length === 0)
-      ? <div></div>
-      : (
-        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
-          {iconForOlder} Comments for Older Revision {iconForOlder}
-        </a>
-      );
-
-    // layout blocks
-    const commentsElements = (this.state.isLayoutTypeGrowi)
-      ? (
-        <div>
-          {olderBlock}
-          {toggleOlder}
-          {currentBlock}
-          {toggleNewer}
-          {newerBlock}
-        </div>
-      )
-      : (
-        <div>
-          {newerBlock}
-          {toggleNewer}
-          {currentBlock}
-          {toggleOlder}
-          {olderBlock}
-        </div>
-      );
-
-    return (
-      <div>
-        {commentsElements}
-
-        <DeleteCommentModal
-          isShown={this.state.isDeleteConfirmModalShown}
-          comment={this.state.commentToDelete}
-          errorMessage={this.state.errorMessageForDeleting}
-          cancel={this.closeDeleteConfirmModal}
-          confirmedToDelete={this.deleteComment}
-        />
-      </div>
-    );
-  }
-
-}
-
-PageComments.propTypes = {
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  revisionCreatedAt: PropTypes.number,
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-};

+ 282 - 0
src/client/js/components/PageComments.jsx

@@ -0,0 +1,282 @@
+/* eslint-disable react/no-multi-comp */
+/* eslint-disable react/no-access-state-in-setstate */
+import React from 'react';
+import PropTypes from 'prop-types';
+import Button from 'react-bootstrap/es/Button';
+
+import { Subscribe } from 'unstated';
+
+import { withTranslation } from 'react-i18next';
+import GrowiRenderer from '../util/GrowiRenderer';
+
+import CommentContainer from './PageComment/CommentContainer';
+import CommentEditor from './PageComment/CommentEditor';
+
+import Comment from './PageComment/Comment';
+import DeleteCommentModal from './PageComment/DeleteCommentModal';
+
+/**
+ * Load data of comments and render the list of <Comment />
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class PageComments
+ * @extends {React.Component}
+ */
+class PageComments extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isLayoutTypeGrowi: false,
+
+      // for deleting comment
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+
+      showEditorIds: new Set(),
+    };
+
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
+
+    this.init = this.init.bind(this);
+    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
+    this.deleteComment = this.deleteComment.bind(this);
+    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+    this.replyButtonClickedHandler = this.replyButtonClickedHandler.bind(this);
+    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
+  }
+
+  componentWillMount() {
+    this.init();
+  }
+
+  init() {
+    if (!this.props.pageId) {
+      return;
+    }
+
+    const layoutType = this.props.crowi.getConfig().layoutType;
+    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
+
+    this.props.commentContainer.retrieveComments();
+  }
+
+  confirmToDeleteComment(comment) {
+    this.setState({ commentToDelete: comment });
+    this.showDeleteConfirmModal();
+  }
+
+  deleteComment() {
+    const comment = this.state.commentToDelete;
+
+    this.props.commentContainer.deleteComment(comment)
+      .then(() => {
+        this.closeDeleteConfirmModal();
+      })
+      .catch((err) => {
+        this.setState({ errorMessageForDeleting: err.message });
+      });
+  }
+
+  showDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  closeDeleteConfirmModal() {
+    this.setState({
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    });
+  }
+
+  replyButtonClickedHandler(commentId) {
+    const ids = this.state.showEditorIds.add(commentId);
+    this.setState({ showEditorIds: ids });
+  }
+
+  commentButtonClickedHandler(commentId) {
+    this.setState((prevState) => {
+      prevState.showEditorIds.delete(commentId);
+      return {
+        showEditorIds: prevState.showEditorIds,
+      };
+    });
+  }
+
+  // adds replies to specific comment object
+  addRepliesToComments(comment, replies) {
+    const replyList = [];
+    replies.forEach((reply) => {
+      if (reply.replyTo === comment._id) {
+        replyList.push(reply);
+      }
+    });
+    return replyList;
+  }
+
+  /**
+   * generate Elements of Comment
+   *
+   * @param {any} comments Array of Comment Model Obj
+   *
+   * @memberOf PageComments
+   */
+  generateCommentElements(comments, replies) {
+    return comments.map((comment) => {
+
+      const commentId = comment._id;
+      const showEditor = this.state.showEditorIds.has(commentId);
+      const crowi = this.props.crowi;
+      const username = crowi.me;
+
+      const replyList = this.addRepliesToComments(comment, replies);
+
+      return (
+        <div key={commentId}>
+          <Comment
+            comment={comment}
+            deleteBtnClicked={this.confirmToDeleteComment}
+            crowiRenderer={this.growiRenderer}
+            crowi={this.props.crowi}
+            replyList={replyList}
+            revisionCreatedAt={this.props.revisionCreatedAt}
+            revisionId={this.props.revisionId}
+          />
+          <div className="container-fluid">
+            <div className="row">
+              <div className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
+                { !showEditor && (
+                  <div>
+                    { username
+                    && (
+                      <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
+                        <Button
+                          bsStyle="primary"
+                          className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b"
+                          onClick={() => { return this.replyButtonClickedHandler(commentId) }}
+                        >
+                          <i className="icon-bubble"></i> Reply
+                        </Button>
+                      </div>
+                    )
+                  }
+                  </div>
+                )}
+                { showEditor && (
+                  <CommentEditor
+                    crowi={this.props.crowi}
+                    crowiOriginRenderer={this.props.crowiOriginRenderer}
+                    editorOptions={this.props.editorOptions}
+                    slackChannels={this.props.slackChannels}
+                    replyTo={commentId}
+                    commentButtonClickedHandler={this.commentButtonClickedHandler}
+                  />
+                )}
+              </div>
+            </div>
+          </div>
+          <br />
+        </div>
+      );
+    });
+  }
+
+  render() {
+    const currentComments = [];
+    const currentReplies = [];
+
+    let comments = this.props.commentContainer.state.comments;
+    if (this.state.isLayoutTypeGrowi) {
+      // replace with asc order array
+      comments = comments.slice().reverse(); // non-destructive reverse
+    }
+
+    comments.forEach((comment) => {
+      if (comment.replyTo === undefined) {
+      // comment is not a reply
+        currentComments.push(comment);
+      }
+      else {
+      // comment is a reply
+        currentReplies.push(comment);
+      }
+    });
+
+    // generate elements
+    const currentElements = this.generateCommentElements(currentComments, currentReplies);
+
+    // generate blocks
+    const currentBlock = (
+      <div className="page-comments-list-current" id="page-comments-list-current">
+        {currentElements}
+      </div>
+    );
+
+    // layout blocks
+    const commentsElements = (<div>{currentBlock}</div>);
+
+    return (
+      <div>
+        {commentsElements}
+
+        <DeleteCommentModal
+          isShown={this.state.isDeleteConfirmModalShown}
+          comment={this.state.commentToDelete}
+          errorMessage={this.state.errorMessageForDeleting}
+          cancel={this.closeDeleteConfirmModal}
+          confirmedToDelete={this.deleteComment}
+        />
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+class PageCommentsWrapper extends React.Component {
+
+  render() {
+    return (
+      <Subscribe to={[CommentContainer]}>
+        { commentContainer => (
+          // eslint-disable-next-line arrow-body-style
+          <PageComments commentContainer={commentContainer} {...this.props} />
+        )}
+      </Subscribe>
+    );
+  }
+
+}
+
+PageCommentsWrapper.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
+  revisionCreatedAt: PropTypes.number,
+  pagePath: PropTypes.string,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+};
+PageComments.propTypes = {
+  commentContainer: PropTypes.object.isRequired,
+
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
+  revisionCreatedAt: PropTypes.number,
+  pagePath: PropTypes.string,
+  editorOptions: PropTypes.object,
+  slackChannels: PropTypes.string,
+};
+
+export default withTranslation(null, { withRef: true })(PageCommentsWrapper);

+ 1 - 0
src/server/form/comment.js

@@ -8,6 +8,7 @@ module.exports = form(
   field('commentForm.comment').trim().required(),
   field('commentForm.comment').trim().required(),
   field('commentForm.comment_position').trim().toInt(),
   field('commentForm.comment_position').trim().toInt(),
   field('commentForm.is_markdown').trim().toBooleanStrict(),
   field('commentForm.is_markdown').trim().toBooleanStrict(),
+  field('commentForm.replyTo').trim(),
 
 
   field('slackNotificationForm.isSlackEnabled').trim().toBooleanStrict().required(),
   field('slackNotificationForm.isSlackEnabled').trim().toBooleanStrict().required(),
   field('slackNotificationForm.slackChannels').trim(),
   field('slackNotificationForm.slackChannels').trim(),

+ 3 - 1
src/server/models/comment.js

@@ -14,9 +14,10 @@ module.exports = function(crowi) {
     commentPosition: { type: Number, default: -1 },
     commentPosition: { type: Number, default: -1 },
     createdAt: { type: Date, default: Date.now },
     createdAt: { type: Date, default: Date.now },
     isMarkdown: { type: Boolean, default: false },
     isMarkdown: { type: Boolean, default: false },
+    replyTo: { type: ObjectId },
   });
   });
 
 
-  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown) {
+  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown, replyTo) {
     const Comment = this;
     const Comment = this;
 
 
     return new Promise(((resolve, reject) => {
     return new Promise(((resolve, reject) => {
@@ -28,6 +29,7 @@ module.exports = function(crowi) {
       newComment.comment = comment;
       newComment.comment = comment;
       newComment.commentPosition = position;
       newComment.commentPosition = position;
       newComment.isMarkdown = isMarkdown || false;
       newComment.isMarkdown = isMarkdown || false;
+      newComment.replyTo = replyTo;
 
 
       newComment.save((err, data) => {
       newComment.save((err, data) => {
         if (err) {
         if (err) {

+ 30 - 4
src/server/routes/comment.js

@@ -5,11 +5,15 @@ module.exports = function(crowi, app) {
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const { body } = require('express-validator/check');
+  const mongoose = require('mongoose');
+  const ObjectId = mongoose.Types.ObjectId;
 
 
   const actions = {};
   const actions = {};
   const api = {};
   const api = {};
 
 
   actions.api = api;
   actions.api = api;
+  api.validators = {};
 
 
   /**
   /**
    * @api {get} /comments.get Get comments of the page of the revision
    * @api {get} /comments.get Get comments of the page of the revision
@@ -50,6 +54,25 @@ module.exports = function(crowi, app) {
     res.json(ApiResponse.success({ comments }));
     res.json(ApiResponse.success({ comments }));
   };
   };
 
 
+  api.validators.add = function() {
+    const validator = [
+      body('commentForm.page_id').exists(),
+      body('commentForm.revision_id').exists(),
+      body('commentForm.comment').exists(),
+      body('commentForm.comment_position').isInt(),
+      body('commentForm.is_markdown').isBoolean(),
+      body('commentForm.replyTo').exists().custom((value) => {
+        if (value === '') {
+          return undefined;
+        }
+        return ObjectId(value);
+      }),
+
+      body('slackNotificationForm.isSlackEnabled').isBoolean().exists(),
+    ];
+    return validator;
+  };
+
   /**
   /**
    * @api {post} /comments.add Post comment for the page
    * @api {post} /comments.add Post comment for the page
    * @apiName PostComment
    * @apiName PostComment
@@ -61,11 +84,13 @@ module.exports = function(crowi, app) {
    * @apiParam {Number} comment_position=-1 Line number of the comment
    * @apiParam {Number} comment_position=-1 Line number of the comment
    */
    */
   api.add = async function(req, res) {
   api.add = async function(req, res) {
-    const commentForm = req.form.commentForm;
-    const slackNotificationForm = req.form.slackNotificationForm;
+    const { commentForm, slackNotificationForm } = req.body;
+    const { validationResult } = require('express-validator/check');
 
 
-    if (!req.form.isValid) {
+    const errors = validationResult(req.body);
+    if (!errors.isEmpty()) {
       // return res.json(ApiResponse.error('Invalid comment.'));
       // return res.json(ApiResponse.error('Invalid comment.'));
+      // return res.status(422).json({ errors: errors.array() });
       return res.json(ApiResponse.error('コメントを入力してください。'));
       return res.json(ApiResponse.error('コメントを入力してください。'));
     }
     }
 
 
@@ -74,6 +99,7 @@ module.exports = function(crowi, app) {
     const comment = commentForm.comment;
     const comment = commentForm.comment;
     const position = commentForm.comment_position || -1;
     const position = commentForm.comment_position || -1;
     const isMarkdown = commentForm.is_markdown;
     const isMarkdown = commentForm.is_markdown;
+    const replyTo = commentForm.replyTo;
 
 
     // check whether accessible
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
@@ -81,7 +107,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
     }
 
 
-    const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
+    const createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo)
       .catch((err) => {
       .catch((err) => {
         return res.json(ApiResponse.error(err));
         return res.json(ApiResponse.error(err));
       });
       });

+ 1 - 1
src/server/routes/index.js

@@ -209,7 +209,7 @@ module.exports = function(crowi, app) {
   app.get('/_api/tags.search'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.search);
   app.get('/_api/tags.search'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.search);
   app.post('/_api/tags.update'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.update);
   app.post('/_api/tags.update'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.update);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
-  app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
+  app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);
   app.get('/_api/bookmarks.get'      , accessTokenParser , loginRequired(crowi, app, false) , bookmark.api.get);
   app.get('/_api/bookmarks.get'      , accessTokenParser , loginRequired(crowi, app, false) , bookmark.api.get);
   app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.add);
   app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.add);

+ 21 - 1
yarn.lock

@@ -2636,6 +2636,11 @@ create-react-context@<=0.2.2:
     fbjs "^0.8.0"
     fbjs "^0.8.0"
     gud "^1.0.0"
     gud "^1.0.0"
 
 
+create-react-context@^0.1.5:
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.1.6.tgz#0f425931d907741127acc6e31acb4f9015dd9fdc"
+  integrity sha512-eCnYYEUEc5i32LHwpE/W7NlddOB9oHwsPaWtWzYtflNkkwa3IfindIcoXdVWs12zCbwaMCavKNu84EXogVIWHw==
+
 create-react-context@^0.2.3:
 create-react-context@^0.2.3:
   version "0.2.3"
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.3.tgz#9ec140a6914a22ef04b8b09b7771de89567cb6f3"
   resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.3.tgz#9ec140a6914a22ef04b8b09b7771de89567cb6f3"
@@ -3882,6 +3887,14 @@ express-session@^1.16.1:
     safe-buffer "5.1.2"
     safe-buffer "5.1.2"
     uid-safe "~2.1.5"
     uid-safe "~2.1.5"
 
 
+express-validator@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-5.3.1.tgz#6f42c6d52554441b0360c40ccfb555b1770affe2"
+  integrity sha512-g8xkipBF6VxHbO1+ksC7nxUU7+pWif0+OZXjZTybKJ/V0aTVhuCoHbyhIPgSYVldwQLocGExPtB2pE0DqK4jsw==
+  dependencies:
+    lodash "^4.17.10"
+    validator "^10.4.0"
+
 express-webpack-assets@^0.1.0:
 express-webpack-assets@^0.1.0:
   version "0.1.0"
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/express-webpack-assets/-/express-webpack-assets-0.1.0.tgz#000fb3413eb0d512cbd6cd3f6a10b5e70dbe0079"
   resolved "https://registry.yarnpkg.com/express-webpack-assets/-/express-webpack-assets-0.1.0.tgz#000fb3413eb0d512cbd6cd3f6a10b5e70dbe0079"
@@ -10796,6 +10809,13 @@ unset-value@^1.0.0:
     has-value "^0.3.1"
     has-value "^0.3.1"
     isobject "^3.0.0"
     isobject "^3.0.0"
 
 
+unstated@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/unstated/-/unstated-2.1.1.tgz#36b124dfb2e7a12d39d0bb9c46dfb6e51276e3a2"
+  integrity sha512-fORlTWMZxq7NuMJDxyIrrYIZKN7wEWYQ9SiaJfIRcSpsowr6Ph/JIfK2tgtXLW614JfPG/t5q9eEIhXRCf55xg==
+  dependencies:
+    create-react-context "^0.1.5"
+
 unzip-response@^2.0.1:
 unzip-response@^2.0.1:
   version "2.0.1"
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
   resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
@@ -10928,7 +10948,7 @@ validate-npm-package-license@^3.0.1:
     spdx-correct "~1.0.0"
     spdx-correct "~1.0.0"
     spdx-expression-parse "~1.0.0"
     spdx-expression-parse "~1.0.0"
 
 
-validator@>=10.11.0, validator@^10.0.0:
+validator@>=10.11.0, validator@^10.0.0, validator@^10.4.0:
   version "10.11.0"
   version "10.11.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-10.11.0.tgz#003108ea6e9a9874d31ccc9e5006856ccd76b228"
   resolved "https://registry.yarnpkg.com/validator/-/validator-10.11.0.tgz#003108ea6e9a9874d31ccc9e5006856ccd76b228"
   integrity sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==
   integrity sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==