Просмотр исходного кода

Merge branch 'master' of https://github.com/weseek/growi into feat/91119-remove-child-group

Shun Miyazawa 4 лет назад
Родитель
Сommit
48d11fc290
36 измененных файлов с 595 добавлено и 484 удалено
  1. 66 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 2
      packages/app/docker/README.md
  5. 7 7
      packages/app/package.json
  6. 1 1
      packages/app/resource/locales/en_US/translation.json
  7. 9 4
      packages/app/src/client/app.jsx
  8. 56 54
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  9. 219 0
      packages/app/src/components/PageComment.tsx
  10. 23 12
      packages/app/src/components/PageComment/Comment.jsx
  11. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  12. 0 31
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.jsx
  13. 33 0
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  14. 2 0
      packages/app/src/components/PageComment/ReplayComments.jsx
  15. 0 241
      packages/app/src/components/PageComments.jsx
  16. 0 45
      packages/app/src/components/PageContentFooter.jsx
  17. 33 0
      packages/app/src/components/PageContentFooter.tsx
  18. 39 28
      packages/app/src/components/PageEditor.jsx
  19. 1 11
      packages/app/src/components/PageEditor/Editor.jsx
  20. 10 0
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  21. 4 0
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  22. 20 0
      packages/app/src/interfaces/comment.ts
  23. 0 22
      packages/app/src/server/models/obsolete-page.js
  24. 1 6
      packages/app/src/server/routes/admin.js
  25. 1 1
      packages/app/src/server/routes/apiv3/security-setting.js
  26. 37 3
      packages/app/src/server/service/search.ts
  27. 0 3
      packages/app/src/server/views/layout-growi/widget/comments.html
  28. 19 0
      packages/app/src/stores/comment.tsx
  29. 1 1
      packages/codemirror-textlint/package.json
  30. 1 1
      packages/core/package.json
  31. 1 1
      packages/plugin-attachment-refs/package.json
  32. 1 1
      packages/plugin-lsx/package.json
  33. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  34. 1 1
      packages/slack/package.json
  35. 2 2
      packages/slackbot-proxy/package.json
  36. 1 1
      packages/ui/package.json

+ 66 - 1
CHANGELOG.md

@@ -1,9 +1,74 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.14...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.0...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v5.0.0](https://github.com/weseek/growi/compare/v4.5.15...v5.0.0) - 2022-04-01
+
+### 💎 Features
+
+- feat: Support Elasticsearch 7 (#5080) @yuki-takei
+- feat: Elasticsearch reindex on boot (#5149) @LuqmanHakim-Grune
+- feat: PageTree and re-impl SearchResult with list group (#5286) @yuki-takei
+- feat: Rename(Move) by Drag & Drop (#5292) @hakumizuki
+- feat: Maintenance mode (#5486) @hakumizuki
+- feat: Delete permission config (#5527) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: Show comments in search page result (#5645) @yuki-takei
+- imprv: Add description for user addition (#5614) @hakumizuki
+- imprv: Validate deletion settings (#5581) @hakumizuki
+
+### 🐛 Bug Fixes
+
+- fix: Swiping to previous/next page for Mac users (5.0.x) (#5491) @hakumizuki
+- fix: Guest User Access Dropdown shows wrong value (#5643) @miya
+- fix: Show full text on presentation mode (#5636) @hakumizuki
+- fix: Displaying minimum length of password (#5630) @Yohei-Shiina
+- fix: Domain whitelist is not respected (fix #5408) (#5470) @yuto-oweseek
+- fix: Add tags to pages restricted by specified groups on View mode (#5457) @yuto-oweseek
+
+### 🧰 Maintenance
+
+- ci(deps-dev): bump plantuml-encoder from 1.2.5 to 1.4.0 (#5633) @dependabot
+- ci(deps-dev): bump codemirror from 5.63.0 to 5.64.0 (#4777) @dependabot
+- ci(deps): bump nanoid from 3.1.30 to 3.2.0 (#5142) @dependabot
+- support: Upgrade openid client (#5185) @mudana-grune
+- ci(deps): bump amannn/action-semantic-pull-request from 3.4.2 to 3.4.5 (#4559) @dependabot
+- ci(deps): bump extend from 3.0.1 to 3.0.2 (#5222) @dependabot
+- ci(deps-dev): bump jquery-ui from 1.12.1 to 1.13.0 (#4548) @dependabot
+- ci(deps): bump actions/setup-node from 2 to 3 (#5437) @dependabot
+- ci(deps): bump actions/checkout from 2 to 3 (#5462) @dependabot
+- ci(deps): bump peter-evans/dockerhub-description from 2 to 3 (#5615) @dependabot
+- ci(deps): bump actions/cache from 2 to 3 (#5584) @dependabot
+- ci(deps-dev): bump reveal.js from 3.6.0 to 4.3.1 (#5603) @dependabot
+- support: Update yarn git-hosted-info v2.8.8 to v2.8.9 (#5215) @LuqmanHakim-Grune
+- support: dependabot trim-off-newlines (#5336) @mudana-grune
+- support: dependabot @npmcli/git (#5337) @mudana-grune
+- support: dependabot highlight.js (#5352) @mudana-grune
+- support: dependabot extend (#5335) @mudana-grune
+- support: dependabot ajv (#5333) @mudana-grune
+- support: dependabot dot-drop (#5204) @LuqmanHakim-Grune
+- support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
+- support: update validator version (#5562) @LuqmanHakim-Grune
+
+## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
+
+### 🚀 Improvement
+
+- imprv: Hide forgot password when localstrategy is disabled (#5380) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: The condition to attempt to reconnect to Elasticsearch (#5344) @yuki-takei
+- fix: Highlight-addons and drawio-viewer for view missing (#5376) @yuki-takei
+
+### 🧰 Maintenance
+
+- support:  modify docker-compose indent (#5322) @yuto-oweseek
+
 ## [v4.5.14](https://github.com/weseek/growi/compare/v4.5.13...v4.5.14) - 2022-02-10
 ## [v4.5.14](https://github.com/weseek/growi/compare/v4.5.13...v4.5.14) - 2022-02-10
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
 {
   "npmClient": "yarn",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "packages": [
   "packages": [
     "packages/*"
     "packages/*"
   ]
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

+ 2 - 2
packages/app/docker/README.md

@@ -12,8 +12,8 @@ Supported tags and respective Dockerfile links
 
 
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.14`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
-* [`4.5.14-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.14/docker/Dockerfile)
+* [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
+* [`4.5.15-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 
 

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.0-RC.15",
-    "@growi/plugin-attachment-refs": "^5.0.0-RC.15",
-    "@growi/plugin-lsx": "^5.0.0-RC.15",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.15",
-    "@growi/slack": "^5.0.0-RC.15",
+    "@growi/codemirror-textlint": "^5.0.1-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.1-RC.0",
+    "@growi/plugin-lsx": "^5.0.1-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.1-RC.0",
+    "@growi/slack": "^5.0.1-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0-RC.15",
+    "@growi/ui": "^5.0.1-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",

+ 1 - 1
packages/app/resource/locales/en_US/translation.json

@@ -655,7 +655,7 @@
   },
   },
   "security_setting": {
   "security_setting": {
     "Guest Users Access": "Guest users access",
     "Guest Users Access": "Guest users access",
-    "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",
+    "Fixed by env var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "Register limitation": "Register limitation",
     "Register limitation": "Register limitation",
     "Register limitation desc": "Restriction of new users' registration",
     "Register limitation desc": "Restriction of new users' registration",
     "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
     "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",

+ 9 - 4
packages/app/src/client/app.jsx

@@ -18,8 +18,8 @@ import TagsList from '../components/TagsList';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import Page from '../components/Page';
 import Page from '../components/Page';
-import PageComments from '../components/PageComments';
 import PageContentFooter from '../components/PageContentFooter';
 import PageContentFooter from '../components/PageContentFooter';
+import PageComment from '../components/PageComment';
 import PageTimeline from '../components/PageTimeline';
 import PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
@@ -120,9 +120,14 @@ Object.assign(componentMappings, {
 // additional definitions if data exists
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
 if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {
-    'page-comments-list': <PageComments />,
-    'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-content-footer': <PageContentFooter />,
+    'page-comments-list': <PageComment appContainer={appContainer} pageId={pageContainer.state.pageId} isReadOnly={false} titleAlign="left" />,
+    'page-comment-write': <CommentEditorLazyRenderer appContainer={appContainer} pageId={pageContainer.state.pageId} />,
+    'page-content-footer': <PageContentFooter
+      createdAt={new Date(pageContainer.state.createdAt)}
+      updatedAt={new Date(pageContainer.state.updatedAt)}
+      creator={pageContainer.state.creator}
+      revisionAuthor={pageContainer.state.revisionAuthor}
+    />,
 
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'recent-created-icon': <RecentlyCreatedIcon />,
   });
   });

+ 56 - 54
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -335,58 +335,60 @@ class SecuritySetting extends React.Component {
         )}
         )}
 
 
         <h4 className="mt-4">{ t('security_setting.page_list_and_search_results') }</h4>
         <h4 className="mt-4">{ t('security_setting.page_list_and_search_results') }</h4>
-        <table className="table table-bordered col-lg-9 mb-5">
-          <thead>
-            <tr>
-              <th scope="col">{ t('scope_of_page_disclosure') }</th>
-              <th scope="col">{ t('set_point') }</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr>
-              <th scope="row">{ t('Public') }</th>
-              <td>{ t('always_displayed') }</td>
-            </tr>
-            <tr>
-              <th scope="row">{ t('Anyone with the link') }</th>
-              <td>{ t('always_hidden') }</td>
-            </tr>
-            <tr>
-              <th scope="row">{ t('Only me') }</th>
-              <td>
-                <div className="custom-control custom-switch custom-checkbox-success">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="isShowRestrictedByOwner"
-                    checked={adminGeneralSecurityContainer.state.isShowRestrictedByOwner}
-                    onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByOwner() }}
-                  />
-                  <label className="custom-control-label" htmlFor="isShowRestrictedByOwner">
-                    {t('displayed_or_hidden')}
-                  </label>
-                </div>
-              </td>
-            </tr>
-            <tr>
-              <th scope="row">{ t('Only inside the group') }</th>
-              <td>
-                <div className="custom-control custom-switch custom-checkbox-success">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="isShowRestrictedByGroup"
-                    checked={adminGeneralSecurityContainer.state.isShowRestrictedByGroup}
-                    onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByGroup() }}
-                  />
-                  <label className="custom-control-label" htmlFor="isShowRestrictedByGroup">
-                    {t('displayed_or_hidden')}
-                  </label>
-                </div>
-              </td>
-            </tr>
-          </tbody>
-        </table>
+        <div className="row justify-content-md-center">
+          <table className="table table-bordered col-lg-9 mb-5">
+            <thead>
+              <tr>
+                <th scope="col">{ t('scope_of_page_disclosure') }</th>
+                <th scope="col">{ t('set_point') }</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th scope="row">{ t('Public') }</th>
+                <td>{ t('always_displayed') }</td>
+              </tr>
+              <tr>
+                <th scope="row">{ t('Anyone with the link') }</th>
+                <td>{ t('always_hidden') }</td>
+              </tr>
+              <tr>
+                <th scope="row">{ t('Only me') }</th>
+                <td>
+                  <div className="custom-control custom-switch custom-checkbox-success">
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id="isShowRestrictedByOwner"
+                      checked={adminGeneralSecurityContainer.state.isShowRestrictedByOwner}
+                      onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByOwner() }}
+                    />
+                    <label className="custom-control-label" htmlFor="isShowRestrictedByOwner">
+                      {t('displayed_or_hidden')}
+                    </label>
+                  </div>
+                </td>
+              </tr>
+              <tr>
+                <th scope="row">{ t('Only inside the group') }</th>
+                <td>
+                  <div className="custom-control custom-switch custom-checkbox-success">
+                    <input
+                      type="checkbox"
+                      className="custom-control-input"
+                      id="isShowRestrictedByGroup"
+                      checked={adminGeneralSecurityContainer.state.isShowRestrictedByGroup}
+                      onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByGroup() }}
+                    />
+                    <label className="custom-control-label" htmlFor="isShowRestrictedByGroup">
+                      {t('displayed_or_hidden')}
+                    </label>
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
 
 
         <h4>{t('security_setting.page_access_rights')}</h4>
         <h4>{t('security_setting.page_access_rights')}</h4>
         <div className="row mb-4">
         <div className="row mb-4">
@@ -419,13 +421,13 @@ class SecuritySetting extends React.Component {
               </div>
               </div>
             </div>
             </div>
             {adminGeneralSecurityContainer.isWikiModeForced && (
             {adminGeneralSecurityContainer.isWikiModeForced && (
-              <p className="alert alert-warning mt-2 text-left offset-3 col-6">
+              <p className="alert alert-warning mt-2 col-6">
                 <i className="icon-exclamation icon-fw">
                 <i className="icon-exclamation icon-fw">
                 </i><b>FIXED</b><br />
                 </i><b>FIXED</b><br />
                 <b
                 <b
                   dangerouslySetInnerHTML={{
                   dangerouslySetInnerHTML={{
                     __html: t('security_setting.Fixed by env var',
                     __html: t('security_setting.Fixed by env var',
-                      { forcewikimode: 'FORCE_WIKI_MODE', wikimode: adminGeneralSecurityContainer.state.wikiMode }),
+                      { key: 'FORCE_WIKI_MODE', value: adminGeneralSecurityContainer.state.wikiMode }),
                   }}
                   }}
                 />
                 />
               </p>
               </p>

+ 219 - 0
packages/app/src/components/PageComment.tsx

@@ -0,0 +1,219 @@
+import React, {
+  FC, useEffect, useState, useMemo, memo, useCallback,
+} from 'react';
+
+import { Button } from 'reactstrap';
+
+import CommentEditor from './PageComment/CommentEditor';
+import Comment from './PageComment/Comment';
+import ReplayComments from './PageComment/ReplayComments';
+import DeleteCommentModal from './PageComment/DeleteCommentModal';
+
+import AppContainer from '~/client/services/AppContainer';
+import { toastError } from '~/client/util/apiNotification';
+
+import { useSWRxPageComment } from '../stores/comment';
+
+import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
+
+type Props = {
+  appContainer: AppContainer,
+  pageId: string,
+  isReadOnly : boolean,
+  titleAlign?: 'center' | 'left' | 'right',
+  highlightKeywords?:string[],
+  hideIfEmpty?: boolean,
+}
+
+
+const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
+
+  const {
+    appContainer, pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
+  } = props;
+
+  const { data: comments, mutate } = useSWRxPageComment(pageId);
+
+  const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
+  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
+  const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
+  const [formatedComments, setFormatedComments] = useState<ICommentHasIdList | null>(null);
+  const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
+
+  const commentsFromOldest = useMemo(() => (formatedComments != null ? [...formatedComments].reverse() : null), [formatedComments]);
+  const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
+    () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
+  );
+  const allReplies = {};
+
+  const highlightComment = useCallback((comment: string):string => {
+    if (highlightKeywords == null) return comment;
+
+    let highlightedComment = '';
+    highlightKeywords.forEach((highlightKeyword) => {
+      highlightedComment = comment.replaceAll(highlightKeyword, '<em class="highlighted-keyword">$&</em>');
+    });
+    return highlightedComment;
+  }, [highlightKeywords]);
+
+  useEffect(() => {
+
+    if (comments != null) {
+      const preprocessedCommentList: string[] = comments.map((comment) => {
+        const highlightedComment: string = highlightComment(comment.comment);
+        return highlightedComment;
+      });
+      const preprocessedComments: ICommentHasIdList = comments.map((comment, index) => {
+        return { ...comment, comment: preprocessedCommentList[index] };
+      });
+      setFormatedComments(preprocessedComments);
+    }
+
+  }, [comments, highlightComment]);
+
+  if (commentsFromOldest != null) {
+    commentsFromOldest.forEach((comment) => {
+      if (comment.replyTo != null) {
+        allReplies[comment.replyTo] = allReplies[comment.replyTo] == null ? [comment] : [...allReplies[comment.replyTo], comment];
+      }
+    });
+  }
+
+  const onClickDeleteButton = useCallback((comment: ICommentHasId) => {
+    setCommentToBeDeleted(comment);
+    setIsDeleteConfirmModalShown(true);
+  }, []);
+
+  const onCancelDeleteComment = useCallback(() => {
+    setCommentToBeDeleted(null);
+    setIsDeleteConfirmModalShown(false);
+  }, []);
+
+  const onDeleteCommentAfterOperation = useCallback(() => {
+    onCancelDeleteComment();
+    mutate();
+  }, [mutate, onCancelDeleteComment]);
+
+  const onDeleteComment = useCallback(async() => {
+    if (commentToBeDeleted == null) return;
+    try {
+      await appContainer.apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
+      onDeleteCommentAfterOperation();
+    }
+    catch (error:unknown) {
+      setErrorMessageOnDelete(error as string);
+      toastError(`error: ${error}`);
+    }
+  }, [appContainer, commentToBeDeleted, onDeleteCommentAfterOperation]);
+
+  const generateCommentInnerElement = (comment: ICommentHasId) => (
+    <Comment
+      growiRenderer={appContainer.getRenderer('comment')}
+      deleteBtnClicked={onClickDeleteButton}
+      comment={comment}
+      onComment={mutate}
+      isReadOnly={isReadOnly}
+    />
+  );
+
+  const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
+    <ReplayComments
+      replyList={replyComments}
+      deleteBtnClicked={onClickDeleteButton}
+      growiRenderer={appContainer.getRenderer('comment')}
+      isReadOnly={isReadOnly}
+    />
+  );
+
+  const removeShowEditorId = useCallback((commentId: string) => {
+    setShowEditorIds((previousState) => {
+      const previousShowEditorIds = new Set(...previousState);
+      previousShowEditorIds.delete(commentId);
+      return previousShowEditorIds;
+    });
+  }, []);
+
+
+  if (commentsFromOldest == null || commentsExceptReply == null) return <></>;
+
+  if (hideIfEmpty && comments?.length === 0) {
+    return <></>;
+  }
+
+  let commentTitleClasses = 'border-bottom py-3 mb-3';
+  commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
+
+  return (
+    <>
+      <div className="page-comments-row comment-list">
+        <div className="container-lg">
+          <div className="page-comments">
+            <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
+            <div className="page-comments-list" id="page-comments-list">
+              { commentsExceptReply.map((comment) => {
+
+                const defaultCommentThreadClasses = 'page-comment-thread pb-5';
+                const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
+
+                let commentThreadClasses = '';
+                commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
+
+                return (
+                  <div key={comment._id} className={commentThreadClasses}>
+                    {/* display comment */}
+                    {generateCommentInnerElement(comment)}
+                    {/* display reply comment */}
+                    {hasReply && generateAllRepliesElement(allReplies[comment._id])}
+                    {/* display reply button */}
+                    {(!isReadOnly && !showEditorIds.has(comment._id)) && (
+                      <div className="text-right">
+                        <Button
+                          outline
+                          color="secondary"
+                          size="sm"
+                          className="btn-comment-reply"
+                          onClick={() => {
+                            setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
+                          }}
+                        >
+                          <i className="icon-fw icon-action-undo"></i> Reply
+                        </Button>
+                      </div>
+                    )}
+                    {/* display reply editor */}
+                    {(!isReadOnly && showEditorIds.has(comment._id)) && (
+                      <CommentEditor
+                        growiRenderer={appContainer.getRenderer('comment')}
+                        replyTo={comment._id}
+                        onCancelButtonClicked={() => {
+                          removeShowEditorId(comment._id);
+                        }}
+                        onCommentButtonClicked={() => {
+                          removeShowEditorId(comment._id);
+                          mutate();
+                        }}
+                      />
+                    )}
+                  </div>
+                );
+
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+      {(!isReadOnly && commentToBeDeleted != null) && (
+        <DeleteCommentModal
+          isShown={isDeleteConfirmModalShown}
+          comment={commentToBeDeleted}
+          errorMessage={errorMessageOnDelete}
+          cancel={onCancelDeleteComment}
+          confirmedToDelete={onDeleteComment}
+        />
+      )}
+    </>
+  );
+});
+
+
+export default PageComment;

+ 23 - 12
packages/app/src/components/PageComment/Comment.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
 
 
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -147,8 +147,9 @@ class Comment extends React.PureComponent {
   }
   }
 
 
   render() {
   render() {
-    const { t } = this.props;
-    const comment = this.props.comment;
+    const {
+      t, comment, isReadOnly, onComment,
+    } = this.props;
     const commentId = comment._id;
     const commentId = comment._id;
     const creator = comment.creator;
     const creator = comment.creator;
     const isMarkdown = comment.isMarkdown;
     const isMarkdown = comment.isMarkdown;
@@ -167,7 +168,7 @@ class Comment extends React.PureComponent {
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
-        {this.state.isReEdit ? (
+        {(this.state.isReEdit && !isReadOnly) ? (
           <CommentEditor
           <CommentEditor
             growiRenderer={this.props.growiRenderer}
             growiRenderer={this.props.growiRenderer}
             currentCommentId={commentId}
             currentCommentId={commentId}
@@ -175,7 +176,10 @@ class Comment extends React.PureComponent {
             replyTo={undefined}
             replyTo={undefined}
             commentCreator={creator?.username}
             commentCreator={creator?.username}
             onCancelButtonClicked={() => this.setState({ isReEdit: false })}
             onCancelButtonClicked={() => this.setState({ isReEdit: false })}
-            onCommentButtonClicked={() => this.setState({ isReEdit: false })}
+            onCommentButtonClicked={() => {
+              this.setState({ isReEdit: false });
+              if (onComment != null) onComment();
+            }}
           />
           />
         ) : (
         ) : (
           <div id={commentId} className={rootClassName}>
           <div id={commentId} className={rootClassName}>
@@ -206,7 +210,7 @@ class Comment extends React.PureComponent {
                   </UncontrolledTooltip>
                   </UncontrolledTooltip>
                 </span>
                 </span>
               </div>
               </div>
-              {this.isCurrentUserEqualsToAuthor() && (
+              {(this.isCurrentUserEqualsToAuthor() && !isReadOnly) && (
                 <CommentControl
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
@@ -222,19 +226,26 @@ class Comment extends React.PureComponent {
 
 
 }
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const CommentWrapper = withUnstatedContainers(Comment, [AppContainer, PageContainer]);
-
 Comment.propTypes = {
 Comment.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
 
   comment: PropTypes.object.isRequired,
   comment: PropTypes.object.isRequired,
+  isReadOnly: PropTypes.bool.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  onComment: PropTypes.func,
+};
+
+const CommentWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <Comment t={t} {...props} />;
 };
 };
 
 
-export default withTranslation()(CommentWrapper);
+/**
+ * Wrapper component for using unstated
+ */
+const CommentWrapper = withUnstatedContainers(CommentWrapperFC, [AppContainer, PageContainer]);
+
+export default CommentWrapper;

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -171,7 +171,7 @@ class CommentEditor extends React.Component {
       this.initializeEditor();
       this.initializeEditor();
 
 
       if (onCommentButtonClicked != null) {
       if (onCommentButtonClicked != null) {
-        onCommentButtonClicked(replyTo || currentCommentId);
+        onCommentButtonClicked();
       }
       }
     }
     }
     catch (err) {
     catch (err) {

+ 0 - 31
packages/app/src/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -1,31 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-import CommentEditor from './CommentEditor';
-
-const CommentEditorLazyRenderer = (props) => {
-
-  const growiRenderer = props.appContainer.getRenderer('comment');
-
-  return (
-    <CommentEditor
-      growiRenderer={growiRenderer}
-      replyTo={undefined}
-      isForNewComment
-    />
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorLazyRendererWrapper = withUnstatedContainers(CommentEditorLazyRenderer, [AppContainer]);
-
-CommentEditorLazyRenderer.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default CommentEditorLazyRendererWrapper;

+ 33 - 0
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -0,0 +1,33 @@
+import React, { FC } from 'react';
+
+import { useSWRxPageComment } from '../../stores/comment';
+
+import AppContainer from '~/client/services/AppContainer';
+
+import CommentEditor from './CommentEditor';
+
+type Props = {
+  appContainer: AppContainer,
+  pageId: string,
+}
+
+const CommentEditorLazyRenderer:FC<Props> = (props:Props):JSX.Element => {
+
+  const { pageId } = props;
+  const { mutate } = useSWRxPageComment(pageId);
+
+  const { appContainer } = props;
+  const growiRenderer = appContainer.getRenderer('comment');
+
+  return (
+    <CommentEditor
+      appContainer={appContainer}
+      growiRenderer={growiRenderer}
+      replyTo={undefined}
+      onCommentButtonClicked={mutate}
+      isForNewComment
+    />
+  );
+};
+
+export default CommentEditorLazyRenderer;

+ 2 - 0
packages/app/src/components/PageComment/ReplayComments.jsx

@@ -33,6 +33,7 @@ class ReplayComments extends React.PureComponent {
           comment={reply}
           comment={reply}
           deleteBtnClicked={this.props.deleteBtnClicked}
           deleteBtnClicked={this.props.deleteBtnClicked}
           growiRenderer={this.props.growiRenderer}
           growiRenderer={this.props.growiRenderer}
+          isReadOnly={this.props.isReadOnly}
         />
         />
       </div>
       </div>
     );
     );
@@ -108,6 +109,7 @@ ReplayComments.propTypes = {
 
 
   growiRenderer: PropTypes.object.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  isReadOnly: PropTypes.bool.isRequired,
   replyList: PropTypes.array,
   replyList: PropTypes.array,
 };
 };
 
 

+ 0 - 241
packages/app/src/components/PageComments.jsx

@@ -1,241 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Button,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-
-import AppContainer from '~/client/services/AppContainer';
-import CommentContainer from '~/client/services/CommentContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import CommentEditor from './PageComment/CommentEditor';
-import Comment from './PageComment/Comment';
-import DeleteCommentModal from './PageComment/DeleteCommentModal';
-import ReplayComments from './PageComment/ReplayComments';
-
-
-/**
- * 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 = {
-      // for deleting comment
-      commentToDelete: undefined,
-      isDeleteConfirmModalShown: false,
-      errorMessageForDeleting: undefined,
-
-      showEditorIds: new Set(),
-    };
-
-    this.growiRenderer = this.props.appContainer.getRenderer('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.editorCancelHandler = this.editorCancelHandler.bind(this);
-    this.editorCommentHandler = this.editorCommentHandler.bind(this);
-    this.resetEditor = this.resetEditor.bind(this);
-  }
-
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    if (!this.props.pageContainer.state.pageId) {
-      return;
-    }
-
-    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 });
-  }
-
-  editorCancelHandler(commentId) {
-    this.resetEditor(commentId);
-  }
-
-  editorCommentHandler(commentId) {
-    this.resetEditor(commentId);
-  }
-
-  resetEditor(commentId) {
-    this.setState((prevState) => {
-      prevState.showEditorIds.delete(commentId);
-      return {
-        showEditorIds: prevState.showEditorIds,
-      };
-    });
-  }
-
-  // get replies to specific comment object
-  getRepliesFor(comment, allReplies) {
-    const replyList = [];
-    allReplies.forEach((reply) => {
-      if (reply.replyTo === comment._id) {
-        replyList.push(reply);
-      }
-    });
-    return replyList;
-  }
-
-  /**
-   * render Elements of Comment Thread
-   *
-   * @param {any} comment Comment Model Obj
-   * @param {any} replies List of Reply Comment Model Obj
-   *
-   * @memberOf PageComments
-   */
-  renderThread(comment, replies) {
-    const commentId = comment._id;
-    const showEditor = this.state.showEditorIds.has(commentId);
-    const isLoggedIn = this.props.appContainer.currentUser != null;
-
-    let rootClassNames = 'page-comment-thread';
-    if (replies.length === 0) {
-      rootClassNames += ' page-comment-thread-no-replies';
-    }
-
-    return (
-      <div key={commentId} className={rootClassNames}>
-        <Comment
-          comment={comment}
-          deleteBtnClicked={this.confirmToDeleteComment}
-          growiRenderer={this.growiRenderer}
-        />
-        {replies.length !== 0 && (
-          <ReplayComments
-            replyList={replies}
-            deleteBtnClicked={this.confirmToDeleteComment}
-            growiRenderer={this.growiRenderer}
-          />
-        )}
-        { !showEditor && isLoggedIn && (
-          <div className="text-right">
-            <Button
-              outline
-              color="secondary"
-              size="sm"
-              className="btn-comment-reply"
-              onClick={() => { return this.replyButtonClickedHandler(commentId) }}
-            >
-              <i className="icon-fw icon-action-undo"></i> Reply
-            </Button>
-          </div>
-        )}
-        { showEditor && (
-          <div className="page-comment-reply-form ml-4 ml-sm-5 mr-3">
-            <CommentEditor
-              growiRenderer={this.growiRenderer}
-              replyTo={commentId}
-              onCancelButtonClicked={this.editorCancelHandler}
-              onCommentButtonClicked={this.editorCommentHandler}
-            />
-          </div>
-        )}
-      </div>
-    );
-  }
-
-  render() {
-    const topLevelComments = [];
-    const allReplies = [];
-    const comments = this.props.commentContainer.state.comments
-      .slice().reverse(); // create shallow copy and reverse
-
-    comments.forEach((comment) => {
-      if (comment.replyTo === undefined) {
-        // comment is not a reply
-        topLevelComments.push(comment);
-      }
-      else {
-        // comment is a reply
-        allReplies.push(comment);
-      }
-    });
-
-    return (
-      <div>
-        { topLevelComments.map((topLevelComment) => {
-          // get related replies
-          const replies = this.getRepliesFor(topLevelComment, allReplies);
-
-          return this.renderThread(topLevelComment, replies);
-        }) }
-
-        <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
- */
-const PageCommentsWrapper = withUnstatedContainers(PageComments, [AppContainer, PageContainer, CommentContainer]);
-
-PageComments.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
-};
-
-export default withTranslation()(PageCommentsWrapper);

+ 0 - 45
packages/app/src/components/PageContentFooter.jsx

@@ -1,45 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import AuthorInfo from './Navbar/AuthorInfo';
-
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
-
-const PageContentFooter = (props) => {
-  const { pageContainer } = props;
-  const { data: createdAt } = useCurrentCreatedAt();
-  const { data: updatedAt } = useCurrentUpdatedAt();
-
-
-  const {
-    creator, revisionAuthor,
-  } = pageContainer.state;
-
-
-  return (
-    <div className="page-content-footer py-4 d-edit-none d-print-none">
-      <div className="grw-container-convertible">
-        <div className="page-meta">
-          <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
-          <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="footer" />
-        </div>
-      </div>
-    </div>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageContentFooterWrapper = withUnstatedContainers(PageContentFooter, [AppContainer, PageContainer]);
-
-
-PageContentFooter.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default PageContentFooterWrapper;

+ 33 - 0
packages/app/src/components/PageContentFooter.tsx

@@ -0,0 +1,33 @@
+import React, { FC, memo } from 'react';
+
+import AuthorInfo from './Navbar/AuthorInfo';
+
+import { Ref } from '../interfaces/common';
+import { IUser } from '../interfaces/user';
+
+type Props = {
+  createdAt: Date,
+  updatedAt: Date,
+  creator: Ref<IUser>,
+  revisionAuthor: Ref<IUser>,
+}
+
+const PageContentFooter:FC<Props> = memo((props:Props):JSX.Element => {
+  const {
+    createdAt, updatedAt, creator, revisionAuthor,
+  } = props;
+
+  return (
+    <div className="page-content-footer py-4 d-edit-none d-print-none">
+      <div className="grw-container-convertible">
+        <div className="page-meta">
+          <AuthorInfo user={creator as IUser} date={createdAt} mode="create" locate="footer" />
+          <AuthorInfo user={revisionAuthor as IUser} date={updatedAt} mode="update" locate="footer" />
+        </div>
+      </div>
+    </div>
+  );
+});
+
+
+export default PageContentFooter;

+ 39 - 28
packages/app/src/components/PageEditor.jsx

@@ -13,6 +13,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import Editor from './PageEditor/Editor';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
+import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 
 
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
@@ -331,35 +333,44 @@ class PageEditor extends React.Component {
     const { path } = this.props.pageContainer.state;
     const { path } = this.props.pageContainer.state;
 
 
     return (
     return (
-      <div className="d-flex flex-wrap">
-        <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
-          <Editor
-            ref={(c) => { this.editor = c }}
-            value={this.state.markdown}
-            noCdn={noCdn}
-            isMobile={this.props.appContainer.isMobile}
-            isUploadable={this.state.isUploadable}
-            isUploadableFile={this.state.isUploadableFile}
-            emojiStrategy={emojiStrategy}
-            onScroll={this.onEditorScroll}
-            onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
-            onChange={this.onMarkdownChanged}
-            onUpload={this.onUpload}
-            onSave={this.onSaveWithShortcut}
-          />
-        </div>
-        <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
-          <Preview
-            markdown={this.state.markdown}
-            pagePath={path}
-            // eslint-disable-next-line no-return-assign
-            inputRef={(el) => { return this.previewElement = el }}
-            isMathJaxEnabled={this.state.isMathJaxEnabled}
-            renderMathJaxOnInit={false}
-            onScroll={this.onPreviewScroll}
-          />
+      <>
+        <div className="d-flex flex-wrap">
+          <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
+            <Editor
+              ref={(c) => { this.editor = c }}
+              value={this.state.markdown}
+              noCdn={noCdn}
+              isMobile={this.props.appContainer.isMobile}
+              isUploadable={this.state.isUploadable}
+              isUploadableFile={this.state.isUploadableFile}
+              emojiStrategy={emojiStrategy}
+              onScroll={this.onEditorScroll}
+              onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
+              onChange={this.onMarkdownChanged}
+              onUpload={this.onUpload}
+              onSave={this.onSaveWithShortcut}
+            />
+          </div>
+          <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
+            <Preview
+              markdown={this.state.markdown}
+              pagePath={path}
+              // eslint-disable-next-line no-return-assign
+              inputRef={(el) => { return this.previewElement = el }}
+              isMathJaxEnabled={this.state.isMathJaxEnabled}
+              renderMathJaxOnInit={false}
+              onScroll={this.onPreviewScroll}
+            />
+          </div>
         </div>
         </div>
-      </div>
+        <ConflictDiffModal
+          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
+          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
+          appContainer={this.props.appContainer}
+          pageContainer={this.props.pageContainer}
+          markdownOnEdit={this.state.markdown}
+        />
+      </>
     );
     );
   }
   }
 
 

+ 1 - 11
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,7 +10,6 @@ import {
 import Dropzone from 'react-dropzone';
 import Dropzone from 'react-dropzone';
 
 
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
@@ -20,7 +19,6 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
-import { ConflictDiffModal } from './ConflictDiffModal';
 
 
 class Editor extends AbstractEditor {
 class Editor extends AbstractEditor {
 
 
@@ -373,13 +371,6 @@ class Editor extends AbstractEditor {
           { this.renderCheatsheetModal() }
           { this.renderCheatsheetModal() }
 
 
         </div>
         </div>
-        <ConflictDiffModal
-          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
-          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
-          appContainer={this.props.appContainer}
-          pageContainer={this.props.pageContainer}
-          markdownOnEdit={this.props.value}
-        />
       </>
       </>
     );
     );
   }
   }
@@ -397,8 +388,7 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 }, AbstractEditor.propTypes);
 }, AbstractEditor.propTypes);
 
 
-export default withUnstatedContainers(Editor, [EditorContainer, PageContainer, AppContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, AppContainer]);

+ 10 - 0
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -15,6 +15,9 @@ import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
 
 
+import PageContentFooter from '../PageContentFooter';
+import PageComment from '../PageComment';
+
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
@@ -215,6 +218,13 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           revisionId={page.revision}
           revisionId={page.revision}
           highlightKeywords={highlightKeywords}
           highlightKeywords={highlightKeywords}
         />
         />
+        <PageComment appContainer={appContainer} pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
+        <PageContentFooter
+          createdAt={new Date(pageWithMeta.data.createdAt)}
+          updatedAt={new Date(pageWithMeta.data.updatedAt)}
+          creator={pageWithMeta.data.creator}
+          revisionAuthor={pageWithMeta.data.lastUpdateUser}
+        />
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 4 - 0
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -195,6 +195,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
       await mutateChildren();
       await mutateChildren();
 
 
+      if (onRenamed != null) {
+        onRenamed();
+      }
+
       // force open
       // force open
       setIsOpen(true);
       setIsOpen(true);
     }
     }

+ 20 - 0
packages/app/src/interfaces/comment.ts

@@ -0,0 +1,20 @@
+import { Nullable, Ref } from './common';
+import { IPage } from './page';
+import { IUser } from './user';
+import { IRevision } from './revision';
+import { HasObjectId } from './has-object-id';
+
+export type IComment = {
+  comment: string;
+  commentPosition: number,
+  isMarkdown: boolean,
+  replyTo: Nullable<string>,
+  createdAt: Date,
+  updatedAt: Date,
+  page: Ref<IPage>,
+  revision: Ref<IRevision>,
+  creator: IUser,
+};
+
+export type ICommentHasId = IComment & HasObjectId;
+export type ICommentHasIdList = ICommentHasId[];

+ 0 - 22
packages/app/src/server/models/obsolete-page.js

@@ -473,28 +473,6 @@ export const getPageSchema = (crowi) => {
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
   };
   };
 
 
-  pageSchema.statics.findListByPageIds = async function(ids, option = {}, shouldIncludeEmpty = false) {
-    const User = crowi.model('User');
-
-    const opt = Object.assign({}, option);
-    const builder = new this.PageQueryBuilder(this.find({ _id: { $in: ids } }), shouldIncludeEmpty);
-
-    builder.addConditionToPagenate(opt.offset, opt.limit);
-
-    // count
-    const totalCount = await builder.query.exec('count');
-
-    // find
-    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-    const pages = await builder.query.clone().exec('find');
-
-    const result = {
-      pages, totalCount, offset: opt.offset, limit: opt.limit,
-    };
-    return result;
-  };
-
-
   /**
   /**
    * find pages by PageQueryBuilder
    * find pages by PageQueryBuilder
    * @param {PageQueryBuilder} builder
    * @param {PageQueryBuilder} builder

+ 1 - 6
packages/app/src/server/routes/admin.js

@@ -101,13 +101,8 @@ module.exports = function(crowi, app) {
   // app.get('/admin/security'                  , admin.security.index);
   // app.get('/admin/security'                  , admin.security.index);
   actions.security = {};
   actions.security = {};
   actions.security.index = function(req, res) {
   actions.security.index = function(req, res) {
-    const isWikiModeForced = aclService.isWikiModeForced();
-    const guestModeValue = aclService.getGuestModeValue();
 
 
-    return res.render('admin/security', {
-      isWikiModeForced,
-      guestModeValue,
-    });
+    return res.render('admin/security');
   };
   };
 
 
   // app.get('/admin/markdown'                  , admin.markdown.index);
   // app.get('/admin/markdown'                  , admin.markdown.index);

+ 1 - 1
packages/app/src/server/routes/apiv3/security-setting.js

@@ -367,7 +367,7 @@ module.exports = (crowi) => {
 
 
     const securityParams = {
     const securityParams = {
       generalSetting: {
       generalSetting: {
-        restrictGuestMode: await crowi.configManager.getConfig('crowi', 'security:restrictGuestMode'),
+        restrictGuestMode: crowi.aclService.getGuestModeValue(),
         pageDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
         pageDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
         pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageRecursiveDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),

+ 37 - 3
packages/app/src/server/service/search.ts

@@ -16,6 +16,8 @@ import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages
 import { PageModel } from '../models/page';
 import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 
 
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 const logger = loggerFactory('growi:service:search');
 
 
@@ -37,6 +39,31 @@ const normalizeQueryString = (_queryString: string): string => {
   return queryString;
   return queryString;
 };
 };
 
 
+const findPageListByIds = async(pageIds: ObjectIdLike[], crowi: any) => {
+
+  const Page = crowi.model('Page') as unknown as PageModel;
+  const User = crowi.model('User');
+
+  const builder = new Page.PageQueryBuilder(Page.find(({ _id: { $in: pageIds } })), false);
+
+  builder.addConditionToPagenate(undefined, undefined); // offset and limit are unnesessary
+
+  builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL); // populate lastUpdateUser
+  builder.query = builder.query.populate({
+    path: 'creator',
+    select: User.USER_FIELDS_EXCEPT_CONFIDENTIAL,
+  });
+
+  const pages = await builder.query.clone().exec('find');
+  const totalCount = await builder.query.exec('count');
+
+  return {
+    pages,
+    totalCount,
+  };
+
+};
+
 class SearchService implements SearchQueryParser, SearchResolver {
 class SearchService implements SearchQueryParser, SearchResolver {
 
 
   crowi!: any
   crowi!: any
@@ -369,13 +396,13 @@ class SearchService implements SearchQueryParser, SearchResolver {
     /*
     /*
      * Format ElasticSearch result
      * Format ElasticSearch result
      */
      */
-    const Page = this.crowi.model('Page') as unknown as PageModel;
     const User = this.crowi.model('User');
     const User = this.crowi.model('User');
     const result = {} as IFormattedSearchResult;
     const result = {} as IFormattedSearchResult;
 
 
     // get page data
     // get page data
     const pageIds = searchResult.data.map((page) => { return page._id });
     const pageIds = searchResult.data.map((page) => { return page._id });
-    const findPageResult = await Page.findListByPageIds(pageIds);
+
+    const findPageResult = await findPageListByIds(pageIds, this.crowi);
 
 
     // set meta data
     // set meta data
     result.meta = searchResult.meta;
     result.meta = searchResult.meta;
@@ -403,7 +430,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
       let elasticSearchResult;
       let elasticSearchResult;
       const highlightData = data._highlight;
       const highlightData = data._highlight;
       if (highlightData != null) {
       if (highlightData != null) {
-        const snippet = this.canShowSnippet(pageData, user, userGroups) ? highlightData['body.en'] || highlightData['body.ja'] : null;
+        const snippet = this.canShowSnippet(pageData, user, userGroups)
+          ? highlightData['body.en'] || highlightData['body.ja'] || highlightData['comments.en'] || highlightData['comments.ja']
+          : null;
         const pathMatch = highlightData['path.en'] || highlightData['path.ja'];
         const pathMatch = highlightData['path.en'] || highlightData['path.ja'];
         const isHtmlInPath = highlightData['path.en'] != null || highlightData['path.ja'] != null;
         const isHtmlInPath = highlightData['path.en'] != null || highlightData['path.ja'] != null;
 
 
@@ -414,6 +443,11 @@ class SearchService implements SearchQueryParser, SearchResolver {
         };
         };
       }
       }
 
 
+      // serialize creator
+      if (pageData.creator != null && pageData.creator instanceof User) {
+        pageData.creator = serializeUserSecurely(pageData.creator);
+      }
+
       // generate pageMeta data
       // generate pageMeta data
       const pageMeta = {
       const pageMeta = {
         bookmarkCount: data._source.bookmark_count || 0,
         bookmarkCount: data._source.bookmark_count || 0,

+ 0 - 3
packages/app/src/server/views/layout-growi/widget/comments.html

@@ -2,9 +2,6 @@
   <div class="container-lg">
   <div class="container-lg">
 
 
     <div class="page-comments">
     <div class="page-comments">
-
-      <h2 class="border-bottom pb-2 mb-3"><i class="icon-fw icon-bubbles"></i> Comments</h2>
-
       <div class="page-comments-list" id="page-comments-list"></div>
       <div class="page-comments-list" id="page-comments-list"></div>
 
 
       {% if page and not page.isDeleted() %}
       {% if page and not page.isDeleted() %}

+ 19 - 0
packages/app/src/stores/comment.tsx

@@ -0,0 +1,19 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiGet } from '~/client/util/apiv1-client';
+
+import { ICommentHasIdList } from '../interfaces/comment';
+import { Nullable } from '../interfaces/common';
+
+type IResponseComment = {
+  comments: ICommentHasIdList,
+  ok: boolean,
+}
+
+export const useSWRxPageComment = (pageId: Nullable<string>): SWRResponse<ICommentHasIdList, Error> => {
+  const shouldFetch: boolean = pageId != null;
+  return useSWR(
+    shouldFetch ? ['/comments.get', pageId] : null,
+    (endpoint, pageId) => apiGet(endpoint, { page_id: pageId }).then((response:IResponseComment) => response.comments),
+  );
+};

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/codemirror-textlint",
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core",
   "name": "@growi/core",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "description": "GROWI Core Libraries",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-attachment-refs",
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-lsx",
   "name": "@growi/plugin-lsx",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "description": "GROWI plugin to list pages",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slack",
   "name": "@growi/slack",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.0-slackbot-proxy.0",
+  "version": "5.0.1-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.0-RC.15",
+    "@growi/slack": "^5.0.1-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/ui",
   "name": "@growi/ui",
-  "version": "5.0.0-RC.15",
+  "version": "5.0.1-RC.0",
   "description": "GROWI UI Libraries",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [