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

Merge branch 'master' into feat/notification

# Conflicts:
#	packages/app/src/components/Navbar/GrowiNavbar.jsx
#	packages/app/src/components/Navbar/SubNavButtons.jsx
#	packages/app/src/server/events/comment.ts
#	packages/app/src/server/routes/index.js
#	packages/app/src/server/service/page.js
#	packages/app/src/server/service/search.js
Shun Miyazawa 4 лет назад
Родитель
Сommit
42dabbfeb0
100 измененных файлов с 2508 добавлено и 1648 удалено
  1. 78 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 3 1
      package.json
  4. 1 1
      packages/app/bin/download-cdn-resources.ts
  5. 2 2
      packages/app/bin/github-actions/update-readme.sh
  6. 0 2
      packages/app/config/webpack.common.js
  7. 0 3
      packages/app/config/webpack.dev.dll.js
  8. 4 14
      packages/app/docker/README.md
  9. 21 22
      packages/app/package.json
  10. 8 5
      packages/app/resource/locales/en_US/admin/admin.json
  11. 10 0
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  12. 11 3
      packages/app/resource/locales/en_US/translation.json
  13. 8 5
      packages/app/resource/locales/ja_JP/admin/admin.json
  14. 11 0
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  15. 10 2
      packages/app/resource/locales/ja_JP/translation.json
  16. 8 5
      packages/app/resource/locales/zh_CN/admin/admin.json
  17. 10 0
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  18. 11 3
      packages/app/resource/locales/zh_CN/translation.json
  19. 14 0
      packages/app/resource/search/mappings.json
  20. 0 4
      packages/app/src/client/admin.jsx
  21. 36 21
      packages/app/src/client/app.jsx
  22. 32 80
      packages/app/src/client/legacy/crowi.js
  23. 25 0
      packages/app/src/client/nologin.jsx
  24. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  25. 127 0
      packages/app/src/client/services/ContextExtractor.tsx
  26. 3 5
      packages/app/src/client/services/EditorContainer.js
  27. 0 239
      packages/app/src/client/services/NavigationContainer.js
  28. 5 21
      packages/app/src/client/services/PageContainer.js
  29. 28 0
      packages/app/src/client/services/user-ui-settings.ts
  30. 1 1
      packages/app/src/client/util/apiv3-client.ts
  31. 27 0
      packages/app/src/client/util/blink-section-header.ts
  32. 47 0
      packages/app/src/client/util/codemirror/drawio-fold.ext.js
  33. 15 0
      packages/app/src/client/util/editor.ts
  34. 1 1
      packages/app/src/client/util/interceptor/detach-code-blocks.js
  35. 17 2
      packages/app/src/client/util/interceptor/drawio-interceptor.js
  36. 45 0
      packages/app/src/client/util/smooth-scroll.ts
  37. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  38. 4 2
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  39. 46 2
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  40. 2 1
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  41. 2 0
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  42. 5 4
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  43. 238 122
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  44. 56 23
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  45. 2 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  46. 11 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  47. 5 2
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  48. 148 0
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  49. 6 7
      packages/app/src/components/ContentLinkButtons.jsx
  50. 40 0
      packages/app/src/components/EventListeneres/HashChanged.tsx
  51. 9 6
      packages/app/src/components/Fab.jsx
  52. 9 11
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  53. 13 10
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  54. 3 3
      packages/app/src/components/Icons/GrowiLogo.jsx
  55. 59 27
      packages/app/src/components/LoginForm.jsx
  56. 0 46
      packages/app/src/components/Navbar/DrawerToggler.jsx
  57. 28 0
      packages/app/src/components/Navbar/DrawerToggler.tsx
  58. 1 3
      packages/app/src/components/Navbar/GlobalSearch.jsx
  59. 0 120
      packages/app/src/components/Navbar/GrowiNavbar.jsx
  60. 134 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  61. 7 12
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  62. 13 12
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  63. 17 15
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  64. 21 21
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  65. 6 6
      packages/app/src/components/Navbar/SubNavButtons.jsx
  66. 42 7
      packages/app/src/components/Page.jsx
  67. 30 17
      packages/app/src/components/Page/DisplaySwitcher.jsx
  68. 13 13
      packages/app/src/components/Page/NotFoundAlert.jsx
  69. 21 8
      packages/app/src/components/Page/RevisionRenderer.jsx
  70. 4 3
      packages/app/src/components/Page/TagLabels.jsx
  71. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  72. 1 0
      packages/app/src/components/PageContentFooter.jsx
  73. 8 7
      packages/app/src/components/PageCreateModal.jsx
  74. 48 6
      packages/app/src/components/PageEditor.jsx
  75. 32 3
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  76. 36 30
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  77. 2 1
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  78. 16 0
      packages/app/src/components/PageEditor/MarkdownDrawioUtil.js
  79. 1 1
      packages/app/src/components/PageEditor/MarkdownTableInterceptor.js
  80. 36 0
      packages/app/src/components/PageEditor/OptionsSelector.jsx
  81. 1 1
      packages/app/src/components/PageEditor/PreventMarkdownListInterceptor.js
  82. 37 4
      packages/app/src/components/PageEditorByHackmd.jsx
  83. 49 6
      packages/app/src/components/SavePageControls.jsx
  84. 0 242
      packages/app/src/components/Sidebar.jsx
  85. 282 0
      packages/app/src/components/Sidebar.tsx
  86. 17 39
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  87. 20 0
      packages/app/src/components/Sidebar/NavigationResizeHexagon.tsx
  88. 6 100
      packages/app/src/components/Sidebar/RecentChanges.tsx
  89. 0 49
      packages/app/src/components/Sidebar/SidebarContents.jsx
  90. 31 0
      packages/app/src/components/Sidebar/SidebarContents.tsx
  91. 0 94
      packages/app/src/components/Sidebar/SidebarNav.jsx
  92. 96 0
      packages/app/src/components/Sidebar/SidebarNav.tsx
  93. 0 87
      packages/app/src/components/SlackNotification.jsx
  94. 67 0
      packages/app/src/components/SlackNotification.tsx
  95. 9 16
      packages/app/src/components/StickyStretchableScroller.jsx
  96. 6 6
      packages/app/src/components/TableOfContents.jsx
  97. 7 0
      packages/app/src/interfaces/common.ts
  98. 14 0
      packages/app/src/interfaces/named-query.ts
  99. 26 4
      packages/app/src/interfaces/page.ts
  100. 31 0
      packages/app/src/interfaces/search.ts

+ 78 - 1
CHANGELOG.md

@@ -1,9 +1,86 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.12...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.3...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.*
 
 
+## [v4.5.3](https://github.com/weseek/growi/compare/v4.5.2...v4.5.3) - 2021-12-17
+
+### 💎 Features
+
+- feat: user activation by email (#4862) @kaoritokashiki
+
+### 🚀 Improvement
+
+- imprv: Use SWR for isSlackEnabled (#4827) @stevenfukase
+- imprv: Disable rubber band scroll for Mac & iOS users (#4834) @hakumizuki
+- imprv: Omit atlaskit and implement sidebar only with original codes (#4598) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: GROWI Bot search command after transplanting search service from dev/5.0.x (#4916) @hakumizuki
+- fix: Set min-height to sidebar scroll target (#4884) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: fix dependabot alert for kind-of (#4891) @LuqmanHakim-Grune
+- support: fix dependabot alert for ini (#4892) @LuqmanHakim-Grune
+- support: fix and debug mixin-deep dependabot alert (#4867) @LuqmanHakim-Grune
+- support: dependabot alert xmlhttprequest-ssl (#4878) @mudana-grune
+- support: Transplant search service from dev/5.0.x (#4869) @hakumizuki
+- support: dependabot alert set-value (#4864) @LuqmanHakim-Grune
+- ci(deps): bump aws-sdk from 2.179.0 to 2.1044.0 (#4821) @dependabot
+
+## [v4.5.2](https://github.com/weseek/growi/compare/v4.5.1...v4.5.2) - 2021-12-06
+
+### 🐛 Bug Fixes
+
+- fix: Added scope for unfurl (#4811) @hakumizuki
+
+## [v4.5.1](https://github.com/weseek/growi/compare/v4.5.0...v4.5.1) - 2021-12-06
+
+### 🐛 Bug Fixes
+
+- fix: /admin/slack-integration page dump undefined error (#4806) @yuki-takei
+
+## [v4.5.0](https://github.com/weseek/growi/compare/v4.4.13...v4.5.0) - 2021-12-06
+
+### BREAKING CHANGES
+
+- imprv: APIv3 payload (#4770) @LuqmanHakim-Grune
+
+### 💎 Features
+
+- feat: Slackbot unfurl (#4720) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: APIv3 payload (#4770) @LuqmanHakim-Grune
+- imprv: upgrade passport from v0.4.x to v0.5.x (#4727) @mudana-grune
+- imprv: Show site url in unfurl footer (#4755) @hakumizuki
+- imprv: SWRize context (#4740) @hakumizuki
+- imprv: Upgrade mongoose from 5.x to 6.x (#4659) @mudana-grune
+
+### 🐛 Bug Fixes
+
+- fix(slackbot-proxy): Support new API v3 data scheme (#4800) @yuki-takei
+- fix(Slackbot): Slash commands response when sent from disabled channels (#4754) @stevenfukase
+
+### 🧰 Maintenance
+
+- ci(deps): bump detect-indent from 6.0.0 to 7.0.0 (#4635) @dependabot
+- ci(deps): bump passport-saml from 2.2.0 to 3.2.0 (#4431) @dependabot
+
+## [v4.4.13](https://github.com/weseek/growi/compare/v4.4.12...v4.4.13) - 2021-11-19
+
+### 💎 Features
+
+- feat: Including comments in full text search (#4703) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix(slackbot): Interactions from private channels not working (#4688) @stevenfukase
+
 ## [v4.4.12](https://github.com/weseek/growi/compare/v4.4.11...v4.4.12) - 2021-11-15
 ## [v4.4.12](https://github.com/weseek/growi/compare/v4.4.11...v4.4.12) - 2021-11-15
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes

+ 1 - 1
lerna.json

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

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "4.4.13-RC.0",
+  "version": "4.5.4-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -35,6 +35,8 @@
     "app:server": "yarn lerna run server --scope @growi/app",
     "app:server": "yarn lerna run server --scope @growi/app",
     "slackbot-proxy:build": "yarn lerna run build --scope @growi/slackbot-proxy --scope @growi/slack",
     "slackbot-proxy:build": "yarn lerna run build --scope @growi/slackbot-proxy --scope @growi/slack",
     "slackbot-proxy:server": "yarn lerna run start:prod --scope @growi/slackbot-proxy",
     "slackbot-proxy:server": "yarn lerna run start:prod --scope @growi/slackbot-proxy",
+    "bump-versions:premajor": "node ./bin/github-actions/bump-versions -i premajor",
+    "bump-versions:preminor": "node ./bin/github-actions/bump-versions -i preminor",
     "bump-versions:patch": "node ./bin/github-actions/bump-versions -i patch",
     "bump-versions:patch": "node ./bin/github-actions/bump-versions -i patch",
     "bump-versions:rc": "node ./bin/github-actions/bump-versions -i prerelease",
     "bump-versions:rc": "node ./bin/github-actions/bump-versions -i prerelease",
     "bump-versions:slackbot-proxy": "node ./bin/github-actions/bump-versions -i prerelease -d packages/slackbot-proxy --preid slackbot-proxy --update-dependencies false",
     "bump-versions:slackbot-proxy": "node ./bin/github-actions/bump-versions -i prerelease -d packages/slackbot-proxy --preid slackbot-proxy --update-dependencies false",

+ 1 - 1
packages/app/bin/download-cdn-resources.ts

@@ -3,7 +3,7 @@
  *
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
  */
-import { envUtils } from 'growi-commons';
+import { envUtils } from '@growi/core';
 
 
 import CdnResourcesDownloader from './cdn/cdn-resources-downloader';
 import CdnResourcesDownloader from './cdn/cdn-resources-downloader';
 import loggerFactory from '../src/utils/logger';
 import loggerFactory from '../src/utils/logger';

+ 2 - 2
packages/app/bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 
 cd docker
 cd docker
 
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.4\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.4-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.5\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.5-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md

+ 0 - 2
packages/app/config/webpack.common.js

@@ -83,8 +83,6 @@ module.exports = (options) => {
           exclude: {
           exclude: {
             test: /node_modules/,
             test: /node_modules/,
             exclude: [ // include as a result
             exclude: [ // include as a result
-              { test: /node_modules\/growi-plugin-/ },
-              /node_modules\/growi-commons/,
               /node_modules\/codemirror/,
               /node_modules\/codemirror/,
             ],
             ],
           },
           },

+ 0 - 3
packages/app/config/webpack.dev.dll.js

@@ -10,8 +10,6 @@ module.exports = {
   entry: {
   entry: {
     dlls: [
     dlls: [
       // Libraries
       // Libraries
-      '@atlaskit/drawer',
-      '@atlaskit/navigation-next',
       'axios',
       'axios',
       'browser-bunyan', 'bunyan-format',
       'browser-bunyan', 'bunyan-format',
       'codemirror', 'react-codemirror2',
       'codemirror', 'react-codemirror2',
@@ -19,7 +17,6 @@ module.exports = {
       'diff2html',
       'diff2html',
       'debug',
       'debug',
       'entities',
       'entities',
-      'growi-commons',
       'i18next', 'i18next-browser-languagedetector',
       'i18next', 'i18next-browser-languagedetector',
       'jquery-slimscroll',
       'jquery-slimscroll',
       'lodash', 'pako',
       'lodash', 'pako',

+ 4 - 14
packages/app/docker/README.md

@@ -10,10 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`4.4.12`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.12/docker/Dockerfile)
-* [`4.4.12-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.12/docker/Dockerfile)
-* [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
-* [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
+* [`4.5.3`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.3/docker/Dockerfile)
+* [`4.5.3-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.3/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)
 
 
 
 
 What is GROWI?
 What is GROWI?
@@ -23,16 +23,6 @@ GROWI is a team collaboration software and it forked from [crowi](https://github
 
 
 see: [weseek/growi](https://github.com/weseek/growi)
 see: [weseek/growi](https://github.com/weseek/growi)
 
 
-What is growi-docker?
--------------------
-
-The GROWI official docker image for production use which concludes several official plugins.
-
-- [growi-plugin-lsx](https://www.npmjs.com/package/growi-plugin-lsx)
-- [growi-plugin-pukiwiki-like-linker](https://www.npmjs.com/package/growi-plugin-pukiwiki-like-linker)
-- [growi-plugin-attachment-refs](https://www.npmjs.com/package/growi-plugin-attachment-refs)
-
-
 
 
 Requirements
 Requirements
 -------------
 -------------

+ 21 - 22
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "4.4.13-RC.0",
+  "version": "4.5.4-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -58,11 +58,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@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": "^4.4.13-RC.0",
-    "@growi/plugin-attachment-refs": "^4.4.13-RC.0",
-    "@growi/plugin-lsx": "^4.4.13-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.4.13-RC.0",
-    "@growi/slack": "^4.4.13-RC.0",
+    "@growi/codemirror-textlint": "^4.5.4-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.4-RC.0",
+    "@growi/plugin-lsx": "^4.5.4-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.4-RC.0",
+    "@growi/slack": "^4.5.4-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -72,19 +72,19 @@
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
-    "aws-sdk": "^2.88.0",
+    "aws-sdk": "^2.1044.0",
     "axios": "^0.24.0",
     "axios": "^0.24.0",
     "body-parser": "^1.18.2",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
     "check-node-version": "^4.1.0",
     "connect-flash": "~0.1.1",
     "connect-flash": "~0.1.1",
-    "connect-mongo": "^4.4.1",
+    "connect-mongo": "^4.6.0",
     "connect-redis": "^4.0.4",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "cookie-parser": "^1.4.5",
     "csrf": "^3.1.0",
     "csrf": "^3.1.0",
     "date-fns": "^2.23.0",
     "date-fns": "^2.23.0",
-    "detect-indent": "^6.0.0",
+    "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
     "elasticsearch": "^16.0.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "entities": "^2.0.0",
@@ -99,20 +99,19 @@
     "express-validator": "^6.1.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^5.0.4",
     "helmet": "^4.6.0",
     "helmet": "^4.6.0",
     "http-errors": "~1.8.0",
     "http-errors": "~1.8.0",
     "i18next": "^20.3.2",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
     "i18next-express-middleware": "^2.0.0",
-    "i18next-node-fs-backend": "^2.1.0",
+    "i18next-node-fs-backend": "^2.1.3",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^8.2.2",
+    "migrate-mongo": "^8.2.3",
     "mkdirp": "^1.0.3",
     "mkdirp": "^1.0.3",
-    "mongoose": "=5.13.12",
+    "mongoose": "^6.0.13",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
     "mongoose-unique-validator": "^2.0.3",
@@ -122,15 +121,16 @@
     "nodemailer": "^6.6.2",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "=2.5.0",
     "openid-client": "=2.5.0",
-    "passport": "^0.4.0",
+    "passport": "^0.5.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-http": "^0.3.0",
     "passport-http": "^0.3.0",
     "passport-ldapauth": "^3.0.1",
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
-    "passport-saml": "^2.2.0",
+    "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "prom-client": "^13.0.0",
+    "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
@@ -146,7 +146,7 @@
     "unzipper": "^0.10.5",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
     "validator": "^13.6.0",
     "validator": "^13.6.0",
-    "ws": "^7.4.6",
+    "ws": "^8.3.0",
     "xss": "^1.0.6"
     "xss": "^1.0.6"
   },
   },
   "// comments for defDependencies": {
   "// comments for defDependencies": {
@@ -155,18 +155,17 @@
     "ts-loader": "v9 is not compatible with webpack@5"
     "ts-loader": "v9 is not compatible with webpack@5"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@alienfast/i18next-loader": "^1.0.16",
-    "@atlaskit/drawer": "^5.3.7",
-    "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.4.13-RC.0",
+    "@alienfast/i18next-loader": "^1.1.4",
+    "@growi/ui": "^4.5.4-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",
+    "@types/jquery": "^3.5.8",
     "@types/multer": "^1.4.5",
     "@types/multer": "^1.4.5",
     "@types/react-dom": "^17.0.9",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
     "bootstrap": "^4.5.0",
     "bootstrap": "^4.5.0",
-    "browser-sync": "^2.26.3",
+    "browser-sync": "^2.27.7",
     "bunyan-debug": "^2.0.0",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
     "cli": "~1.0.1",
     "codemirror": "^5.63.0",
     "codemirror": "^5.63.0",
@@ -227,7 +226,7 @@
     "sass-loader": "^10.1.1",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^4.2.0",
     "socket.io-client": "^4.2.0",
-    "sticky-events": "^3.1.3",
+    "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
     "styled-components": "^5.0.1",
     "stylelint": "^14.0.1",
     "stylelint": "^14.0.1",

+ 8 - 5
packages/app/resource/locales/en_US/admin/admin.json

@@ -339,16 +339,19 @@
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "invite_bot_to_channel": "Invite GROWI bot to channel by calling @example.",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
       "register_secret_and_token": "Set Signing Secret and Bot Token",
-      "manage_commands": "Manage GROWI commands",
+      "manage_permission": "Manage Permission",
+      "growi_commands": "GROWI Commands",
       "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
       "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
       "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
       "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
-      "allowed_channels_description": "Input allowed channels for \"{{commandName}}\" command. Separate each channel with \",\" . Users can will be able to use \"{{commandName}}\" command from channels written here.",
+      "allowed_channels_description": "Input allowed channels for \"{{keyName}}\" command. Separate each channel with \",\" . Users can will be able to use \"{{keyName}}\" command from channels written here.",
+      "unfurl_description": "Show GROWI page contents when page links have been shared on Slack",
+      "unfurl_allowed_channels_description": "Input allowed channel IDs for \"unfurl\" . Separate each channel with \",\" . GROWI public page links or permanent links sent in specified channels will show the content in the message.",
       "allow_all": "Allow all",
       "allow_all": "Allow all",
       "deny_all": "Deny all",
       "deny_all": "Deny all",
       "allow_specified": "Allow specified",
       "allow_specified": "Allow specified",
-      "allow_all_long": "Allow all (The command is allowed from any channel)",
-      "deny_all_long": "Deny all (The command is denied from any channel)",
-      "allow_specified_long": "Allow specified (The command is allowed from only specified channels)",
+      "allow_all_long": "Allow all (Allowed from any channel)",
+      "deny_all_long": "Deny all (Denied from any channel)",
+      "allow_specified_long": "Allow specified (Allowed from only specified channels)",
       "test_connection": "Test Connection",
       "test_connection": "Test Connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "test_connection_only_public_channel":"Please test connection in a public channel",
       "test_connection_only_public_channel":"Please test connection in a public channel",

+ 10 - 0
packages/app/resource/locales/en_US/notifications/userActivation.txt

@@ -0,0 +1,10 @@
+Account confirmation
+
+Hi, {{ email }}
+
+An acount has been created in GROWI {{ appTitle }}.
+To activate your account, click on the link below.
+
+{{ url }}
+
+If you did not created the account, you can safely ignore this email.

+ 11 - 3
packages/app/resource/locales/en_US/translation.json

@@ -185,6 +185,7 @@
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
   },
   },
   "page_register": {
   "page_register": {
+    "send_email": "Send email",
     "notice": {
     "notice": {
       "restricted": "Admin approval required.",
       "restricted": "Admin approval required.",
       "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki."
       "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki."
@@ -665,7 +666,12 @@
       "enable_local": "Enable ID/Password",
       "enable_local": "Enable ID/Password",
       "password_reset_by_users": "Password reset by users",
       "password_reset_by_users": "Password reset by users",
       "enable_password_reset_by_users": "Enable password reset by users",
       "enable_password_reset_by_users": "Enable password reset by users",
-      "password_reset_desc": "when forgot password, users are able to reset it by themselves."
+      "password_reset_desc": "when forgot password, users are able to reset it by themselves.",
+      "email_authentication": "Email authentication on user registration",
+      "enable_email_authentication": "Enable email authentication",
+      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration.",
+      "please_enable_mailer": "Please setup mailer first.",
+      "need_complete_mail_setting_warning": "To use the following functions, please complete the mail settings."
     },
     },
     "ldap": {
     "ldap": {
       "enable_ldap": "Enable LDAP",
       "enable_ldap": "Enable LDAP",
@@ -883,7 +889,7 @@
     "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
     "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
     "application_already_installed": "Application already installed.",
     "application_already_installed": "Application already installed.",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
-    "user_id_is_not_available.":"This User ID is not available.",
+    "user_id_is_not_available":"This User ID is not available.",
     "email_address_is_already_registered":"This email address is already registered.",
     "email_address_is_already_registered":"This email address is already registered.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "failed_to_register":"Failed to register.",
     "failed_to_register":"Failed to register.",
@@ -893,7 +899,9 @@
     "unable_to_use_this_user":"Unable to use this user.",
     "unable_to_use_this_user":"Unable to use this user.",
     "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
     "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
-    "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
+    "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}",
+    "successfully_send_email_auth":"We sent an email to {{email}}. Please click the URL in the email and complete the registration.",
+    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired."
   },
   },
   "grid_edit":{
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",

+ 8 - 5
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -338,16 +338,19 @@
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "invite_bot_to_channel": "GROWI bot を使いたいチャンネルに @example を使用して招待します。",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
       "register_secret_and_token": "Signing Secret と Bot Token を登録する",
-      "manage_commands": "使用可能なGROWIコマンドを設定する",
+      "manage_permission": "権限を設定する",
+      "growi_commands": "GROWI コマンド",
       "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
       "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
       "single_growi_command": "一つのGROWIに対して送信できるコマンド",
       "single_growi_command": "一つのGROWIに対して送信できるコマンド",
-      "allowed_channels_description": "\"{{commandName}}\" コマンドの使用を許可するチャンネルを \",\" 区切りで入力してください。ユーザーはここに記入されているチャンネルから \"{{commandName}}\" コマンドを使用することができます。",
+      "allowed_channels_description": "\"{{keyName}}\" コマンドの使用を許可するチャンネルを \",\" 区切りで入力してください。ユーザーはここに記入されているチャンネルから \"{{keyName}}\" コマンドを使用することができます。",
+      "unfurl_description": "Slack で GROWI のリンクを共有したときにページの内容を表示する",
+      "unfurl_allowed_channels_description": "\"unfurl\" の使用を許可するチャンネルの ID を \",\" 区切りで入力してください。ここに記入されているチャンネルで GROWI の ページリンクを共有するとページの内容が表示されます。",
       "allow_all": "全てのチャンネルを許可",
       "allow_all": "全てのチャンネルを許可",
       "deny_all": "全てのチャンネルを拒否",
       "deny_all": "全てのチャンネルを拒否",
       "allow_specified": "特定のチャンネルを許可",
       "allow_specified": "特定のチャンネルを許可",
-      "allow_all-long": "全て許可 (このコマンドは全てのチャンネルから使用することができます)",
-      "deny_all-long": "全て拒否 (このコマンドはどのチャンネルからも使用することはできません)",
-      "allow_specified-long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
+      "allow_all_long": "全て許可 (全てのチャンネルから使用することができます)",
+      "deny_all_long": "全て拒否 (どのチャンネルからも使用することはできません)",
+      "allow_specified_long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
       "test_connection": "連携状況のテストをする",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "test_connection_only_public_channel":"連携テストは public チャンネルで確認してください",
       "test_connection_only_public_channel":"連携テストは public チャンネルで確認してください",

+ 11 - 0
packages/app/resource/locales/ja_JP/notifications/userActivation.txt

@@ -0,0 +1,11 @@
+仮登録完了のお知らせ
+
+{{ email }} さん
+
+GROWI {{ appTitle }} で仮登録が完了いたしました。
+
+ご本人様確認のため、下記リンクをクリックし、アカウントの本登録を完了させて下さい。
+
+{{ url }}
+
+※当メールの内容に心当たりがない場合は、このメールを無視してください。

+ 10 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -187,6 +187,7 @@
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
   },
   },
   "page_register": {
   "page_register": {
+    "send_email": "メールを送る",
     "notice": {
     "notice": {
       "restricted": "この Wiki への新規登録は制限されています。",
       "restricted": "この Wiki への新規登録は制限されています。",
       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
@@ -662,7 +663,12 @@
       "enable_local": "ID/Password を有効にする",
       "enable_local": "ID/Password を有効にする",
       "password_reset_by_users": "ユーザーによるパスワード再設定",
       "password_reset_by_users": "ユーザーによるパスワード再設定",
       "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
       "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
-      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。"
+      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。",
+      "email_authentication": "ユーザー登録時のメール認証",
+      "enable_email_authentication": "メール認証を有効にする",
+      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
+      "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
+      "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
     },
     },
     "ldap": {
     "ldap": {
       "enable_ldap": "LDAP を有効にする",
       "enable_ldap": "LDAP を有効にする",
@@ -886,7 +892,9 @@
     "unable_to_use_this_user":"利用できないユーザーIDです。",
     "unable_to_use_this_user":"利用できないユーザーIDです。",
     "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
     "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
-    "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
+    "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}",
+    "successfully_send_email_auth":"{{email}} にメールを送信しました。添付されたURLをクリックし、本登録を完了させてください",
+    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。"
   },
   },
   "grid_edit":{
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",

+ 8 - 5
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -348,16 +348,19 @@
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "invite_bot_to_channel": "通过调用 @example 邀请 GROWI Bot 进行频道。",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
       "register_secret_and_token": "设置签名秘密和BOT令牌",
-      "manage_commands": "管理 GROWI 命令",
+      "manage_permission": "设置权限",
+      "growi_commands": "GROWI 命令",
       "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
       "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
       "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
       "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
-      "allowed_channels_description": "为 \"{{commandName}}\" 命令输入允许的通道。每个通道之间用 \",\" 隔开。用户可以从这里写入的通道中使用 \"{{commandName}}\"。",
+      "allowed_channels_description": "为 \"{{keyName}}\" 命令输入允许的通道。每个通道之间用 \",\" 隔开。用户可以从这里写入的通道中使用 \"{{keyName}}\"。",
+      "unfurl_description": "在 Slack 中共享 GROWI 链接时显示页面内容",
+      "unfurl_allowed_channels_description": "为 \"unfurl\" 输入允许的通道ID。每个频道用 \",\"分开。在指定频道中发送的GROWI公共页面链接或永久链接将显示消息中的内容。",
       "allow_all": "允许所有",
       "allow_all": "允许所有",
       "deny_all": "拒绝所有",
       "deny_all": "拒绝所有",
       "allow_specified": "允许指定",
       "allow_specified": "允许指定",
-      "allow_all_long": "允许所有(允许从任何通道发出命令)",
-      "deny_all_long": "拒绝所有(该命令被拒绝于任何通道)",
-      "allow_specified_long": "允许指定(该命令只允许来自指定的通道)",
+      "allow_all_long": "允许所有(允许从任何渠道)",
+      "deny_all_long": "拒绝所有(拒绝来自任何渠道)",
+      "allow_specified_long": "允许指定(只允许来自指定的渠道)",
       "test_connection": "测试连接",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "test_connection_only_public_channel":"请在一个公共频道中测试连接",
       "test_connection_only_public_channel":"请在一个公共频道中测试连接",

+ 10 - 0
packages/app/resource/locales/zh_CN/notifications/userActivation.txt

@@ -0,0 +1,10 @@
+确认账户创建
+
+致{{ email }},
+
+已使用 GROWI {{ appTitle }} 创建帐户。
+单击下面的链接以激活您的帐户。
+
+{{ url }}
+
+如果您尚未创建,请忽略此电子邮件。

+ 11 - 3
packages/app/resource/locales/zh_CN/translation.json

@@ -185,6 +185,7 @@
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
 	},
 	},
 	"page_register": {
 	"page_register": {
+    "send_email": "发电子邮件",
 		"notice": {
 		"notice": {
 			"restricted": "需要管理员批准。",
 			"restricted": "需要管理员批准。",
 			"restricted_defail": "一旦管理员批准您的注册,您就可以访问此wiki。"
 			"restricted_defail": "一旦管理员批准您的注册,您就可以访问此wiki。"
@@ -651,7 +652,12 @@
       "enable_local": "Enable ID/Password",
       "enable_local": "Enable ID/Password",
       "password_reset_by_users": "用户重置密码",
       "password_reset_by_users": "用户重置密码",
       "enable_password_reset_by_users": "启用用户重置密码",
       "enable_password_reset_by_users": "启用用户重置密码",
-      "password_reset_desc": "忘记密码时,用户可以自行重置"
+      "password_reset_desc": "忘记密码时,用户可以自行重置",
+      "email_authentication": "用户注册时的电子邮件身份验证",
+      "enable_email_authentication": "启用电子邮件身份验证",
+      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
+      "please_enable_mailer": "请先设置邮件程序。",
+      "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
 		},
 		},
 		"ldap": {
 		"ldap": {
 			"enable_ldap": "Enable LDAP",
 			"enable_ldap": "Enable LDAP",
@@ -886,7 +892,7 @@
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"application_already_installed": "应用程序已安装。",
 		"application_already_installed": "应用程序已安装。",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
-		"user_id_is_not_available.": "此用户ID不可用。",
+		"user_id_is_not_available": "此用户ID不可用。",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"failed_to_register": "注册失败。",
 		"failed_to_register": "注册失败。",
@@ -896,7 +902,9 @@
 		"unable_to_use_this_user": "无法使用此用户。",
 		"unable_to_use_this_user": "无法使用此用户。",
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
-		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
+		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}",
+    "successfully_send_email_auth":"我们向 {{email}} 发送了一封电子邮件。 请点击邮件中的网址并完成注册。",
+    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。"
 	},
 	},
   "grid_edit":{
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",

+ 14 - 0
packages/app/resource/search/mappings.json

@@ -65,6 +65,20 @@
             }
             }
           }
           }
         },
         },
+        "comments": {
+          "type": "text",
+          "fields": {
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english_edge_ngram",
+              "search_analyzer": "standard"
+            }
+          }
+        },
         "username": {
         "username": {
           "type": "keyword"
           "type": "keyword"
         },
         },

+ 0 - 4
packages/app/src/client/admin.jsx

@@ -25,8 +25,6 @@ import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
 import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
 import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -57,7 +55,6 @@ appContainer.initContents();
 const { i18n } = appContainer;
 const { i18n } = appContainer;
 
 
 // create unstated container instance
 // create unstated container instance
-const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);
 const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
 const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
@@ -72,7 +69,6 @@ const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appConta
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const injectableContainers = [
 const injectableContainers = [
   appContainer,
   appContainer,
-  navigationContainer,
   adminAppContainer,
   adminAppContainer,
   adminImportContainer,
   adminImportContainer,
   adminSocketIoContainer,
   adminSocketIoContainer,

+ 36 - 21
packages/app/src/client/app.jsx

@@ -42,7 +42,7 @@ import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
+import ContextExtractor from '~/client/services/ContextExtractor';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
@@ -62,7 +62,6 @@ const { i18n } = appContainer;
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 
 // create unstated container instance
 // create unstated container instance
-const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
@@ -72,7 +71,7 @@ const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
-  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
+  appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
   commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
   commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
 ];
 ];
 
 
@@ -100,7 +99,6 @@ Object.assign(componentMappings, {
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
   'not-found-alert': <NotFoundAlert
   'not-found-alert': <NotFoundAlert
-    onPageCreateClicked={navigationContainer.setEditorMode}
     isGuestUserMode={appContainer.isGuestUser}
     isGuestUserMode={appContainer.isGuestUser}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
   />,
   />,
@@ -155,23 +153,40 @@ if (pageContainer.state.path != null) {
   });
   });
 }
 }
 
 
-Object.keys(componentMappings).forEach((key) => {
-  const elem = document.getElementById(key);
-  if (elem) {
-    ReactDOM.render(
-      <I18nextProvider i18n={i18n}>
-        <ErrorBoundary>
-          <SWRConfig value={swrGlobalConfiguration}>
-            <Provider inject={injectableContainers}>
-              {componentMappings[key]}
-            </Provider>
-          </SWRConfig>
-        </ErrorBoundary>
-      </I18nextProvider>,
-      elem,
-    );
-  }
-});
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <ErrorBoundary>
+            <SWRConfig value={swrGlobalConfiguration}>
+              <Provider inject={injectableContainers}>
+                {componentMappings[key]}
+              </Provider>
+            </SWRConfig>
+          </ErrorBoundary>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
+
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
+  );
+}
+else {
+  renderMainComponents();
+}
 
 
 // initialize scrollpos-styler
 // initialize scrollpos-styler
 ScrollPosStyler.init();
 ScrollPosStyler.init();

+ 32 - 80
packages/app/src/client/legacy/crowi.js

@@ -1,3 +1,5 @@
+const { blinkElem, blinkSectionHeaderAtBoot } = require('../util/blink-section-header');
+
 /* eslint-disable react/jsx-filename-extension */
 /* eslint-disable react/jsx-filename-extension */
 require('jquery.cookie');
 require('jquery.cookie');
 
 
@@ -16,8 +18,6 @@ window.Crowi = Crowi;
  */
  */
 Crowi.setCaretLineData = function(line) {
 Crowi.setCaretLineData = function(line) {
   const { appContainer } = window;
   const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
-  navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   const pageEditorDom = document.querySelector('#page-editor');
   pageEditorDom.setAttribute('data-caret-line', line);
   pageEditorDom.setAttribute('data-caret-line', line);
 };
 };
@@ -112,74 +112,32 @@ Crowi.initClassesByOS = function() {
   });
   });
 };
 };
 
 
-Crowi.findHashFromUrl = function(url) {
-  let match;
-  /* eslint-disable no-cond-assign */
-  if (match = url.match(/#(.+)$/)) {
-    return `#${match[1]}`;
-  }
-  /* eslint-enable no-cond-assign */
-
-  return '';
-};
-
-Crowi.findSectionHeader = function(hash) {
-  if (hash.length === 0) {
-    return;
-  }
-
-  // omit '#'
-  const id = hash.replace('#', '');
-  // don't use jQuery and document.querySelector
-  //  because hash may containe Base64 encoded strings
-  const elem = document.getElementById(id);
-  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
-    return elem;
-  }
-
-  return null;
-};
-
-Crowi.unblinkSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.remove('blink');
-  }
-};
-
-Crowi.blinkSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.add('blink');
-  }
-};
-
-window.addEventListener('load', () => {
-  const { appContainer } = window;
-  const pageContainer = appContainer.getContainer('PageContainer');
-
-  // Do nothing if the page does not exist
-  // ex.) admin page,login page
-  if (pageContainer == null) {
-    return null;
-  }
-  const { isAbleToOpenPageEditor } = pageContainer;
-
-  // hash on page
-  if (window.location.hash) {
-    const navigationContainer = appContainer.getContainer('NavigationContainer');
-
-    if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
-      navigationContainer.setEditorMode('edit');
-
-      // focus
-      Crowi.setCaretLineAndFocusToEditor();
-    }
-    else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
-    }
-  }
-});
+// window.addEventListener('load', () => {
+//   const { appContainer } = window;
+//   const pageContainer = appContainer.getContainer('PageContainer');
+
+//   // Do nothing if the page does not exist
+//   // ex.) admin page,login page
+//   if (pageContainer == null) {
+//     return null;
+//   }
+//   const { isAbleToOpenPageEditor } = pageContainer;
+
+//   // hash on page
+//   if (window.location.hash) {
+//     const navigationContainer = appContainer.getContainer('NavigationContainer');
+
+//     if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
+//       navigationContainer.setEditorMode('edit');
+
+//       // focus
+//       Crowi.setCaretLineAndFocusToEditor();
+//     }
+//     else if (window.location.hash === '#hackmd') {
+//       navigationContainer.setEditorMode('hackmd');
+//     }
+//   }
+// });
 
 
 window.addEventListener('load', () => {
 window.addEventListener('load', () => {
   const crowi = window.crowi;
   const crowi = window.crowi;
@@ -219,28 +177,22 @@ window.addEventListener('load', () => {
     });
     });
   }
   }
 
 
-  Crowi.blinkSelectedSection(window.location.hash);
+  blinkSectionHeaderAtBoot();
+
   Crowi.modifyScrollTop();
   Crowi.modifyScrollTop();
   Crowi.initClassesByOS();
   Crowi.initClassesByOS();
 });
 });
 
 
 window.addEventListener('hashchange', (e) => {
 window.addEventListener('hashchange', (e) => {
-  Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
-  Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
   Crowi.modifyScrollTop();
-  const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
-
 
 
   // hash on page
   // hash on page
   if (window.location.hash) {
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
     if (window.location.hash === '#edit') {
-      navigationContainer.setEditorMode('edit');
       Crowi.setCaretLineAndFocusToEditor();
       Crowi.setCaretLineAndFocusToEditor();
     }
     }
-    else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
-    }
+    // else if (window.location.hash === '#hackmd') {
+    // }
   }
   }
 });
 });
 
 

+ 25 - 0
packages/app/src/client/nologin.jsx

@@ -11,6 +11,7 @@ import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import LoginForm from '../components/LoginForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
+import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 
 
 const i18n = i18nFactory();
 const i18n = i18nFactory();
 
 
@@ -39,6 +40,7 @@ if (loginFormElem) {
   const name = loginFormElem.dataset.name;
   const name = loginFormElem.dataset.name;
   const email = loginFormElem.dataset.email;
   const email = loginFormElem.dataset.email;
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
+  const isEmailAuthenticationEnabled = loginFormElem.dataset.isEmailAuthenticationEnabled === 'true';
   const registrationMode = loginFormElem.dataset.registrationMode;
   const registrationMode = loginFormElem.dataset.registrationMode;
   const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
   const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
 
 
@@ -69,6 +71,7 @@ if (loginFormElem) {
           name={name}
           name={name}
           email={email}
           email={email}
           isRegistrationEnabled={isRegistrationEnabled}
           isRegistrationEnabled={isRegistrationEnabled}
+          isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
           registrationMode={registrationMode}
           registrationMode={registrationMode}
           registrationWhiteList={registrationWhiteList}
           registrationWhiteList={registrationWhiteList}
           isPasswordResetEnabled={isPasswordResetEnabled}
           isPasswordResetEnabled={isPasswordResetEnabled}
@@ -111,3 +114,25 @@ if (passwordResetExecutionFormElem) {
     passwordResetExecutionFormElem,
     passwordResetExecutionFormElem,
   );
   );
 }
 }
+
+// render UserActivationForm
+const UserActivationForm = document.getElementById('user-activation-form');
+if (UserActivationForm) {
+
+  const messageErrors = UserActivationForm.dataset.messageErrors;
+  const inputs = UserActivationForm.dataset.inputs;
+  const email = UserActivationForm.dataset.email;
+  const token = UserActivationForm.dataset.token;
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <CompleteUserRegistrationForm
+        messageErrors={messageErrors}
+        inputs={inputs}
+        email={email}
+        token={token}
+      />
+    </I18nextProvider>,
+    UserActivationForm,
+  );
+}

+ 12 - 1
packages/app/src/client/services/AdminLocalSecurityContainer.js

@@ -23,6 +23,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationWhiteList: [],
       registrationWhiteList: [],
       useOnlyEnvVars: false,
       useOnlyEnvVars: false,
       isPasswordResetEnabled: false,
       isPasswordResetEnabled: false,
+      isEmailAuthenticationEnabled: false,
     };
     };
 
 
   }
   }
@@ -36,6 +37,7 @@ export default class AdminLocalSecurityContainer extends Container {
         registrationMode: localSetting.registrationMode,
         registrationMode: localSetting.registrationMode,
         registrationWhiteList: localSetting.registrationWhiteList,
         registrationWhiteList: localSetting.registrationWhiteList,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
+        isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -75,15 +77,23 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
     this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
   }
   }
 
 
+  /**
+   * Switch email authentication enabled
+   */
+  switchIsEmailAuthenticationEnabled() {
+    this.setState({ isEmailAuthenticationEnabled: !this.state.isEmailAuthenticationEnabled });
+  }
+
   /**
   /**
    * update local security setting
    * update local security setting
    */
    */
   async updateLocalSecuritySetting() {
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList, isPasswordResetEnabled } = this.state;
+    const { registrationWhiteList, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
       registrationWhiteList,
       isPasswordResetEnabled,
       isPasswordResetEnabled,
+      isEmailAuthenticationEnabled,
     });
     });
 
 
     const { localSettingParams } = response.data;
     const { localSettingParams } = response.data;
@@ -92,6 +102,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationMode: localSettingParams.registrationMode,
       registrationMode: localSettingParams.registrationMode,
       registrationWhiteList: localSettingParams.registrationWhiteList,
       registrationWhiteList: localSettingParams.registrationWhiteList,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
+      isEmailAuthenticationEnabled: localSettingParams.isEmailAuthenticationEnabled,
     });
     });
 
 
     return localSettingParams;
     return localSettingParams;

+ 127 - 0
packages/app/src/client/services/ContextExtractor.tsx

@@ -0,0 +1,127 @@
+import React, { FC, useEffect, useState } from 'react';
+import { pagePathUtils } from '@growi/core';
+
+import {
+  useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
+  useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser,
+  useSlackChannels,
+} from '../../stores/context';
+import {
+  useIsDeviceSmallerThanMd,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+} from '~/stores/ui';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+
+const { isTrashPage: _isTrashPage } = pagePathUtils;
+
+const jsonNull = 'null';
+
+const ContextExtractorOnce: FC = () => {
+
+  const mainContent = document.querySelector('#content-main');
+
+  /*
+   * App Context from DOM
+   */
+  const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
+
+  /*
+   * UserUISettings from DOM
+   */
+  const userUISettings: Partial<IUserUISettings> = JSON.parse(document.getElementById('growi-user-ui-settings')?.textContent || jsonNull);
+
+  /*
+   * Page Context from DOM
+   */
+  const revisionId = mainContent?.getAttribute('data-page-revision-id');
+  const path = decodeURI(mainContent?.getAttribute('data-path') || '');
+  const pageId = mainContent?.getAttribute('data-page-id') || null;
+  const revisionCreatedAt = +(mainContent?.getAttribute('data-page-revision-created') || '');
+  const createdAt = mainContent?.getAttribute('data-page-created-at');
+  const updatedAt = mainContent?.getAttribute('data-page-updated-at');
+  const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
+  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const isTrashPage = _isTrashPage(path);
+  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull);
+  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull);
+  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull);
+  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull);
+  const isPageExist = mainContent?.getAttribute('data-page-id') != null;
+  const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
+  const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
+  const shareLinksNumber = mainContent?.getAttribute('data-share-links-number');
+  const shareLinkId = JSON.parse(mainContent?.getAttribute('data-share-link-id') || jsonNull);
+  const revisionIdHackmdSynced = mainContent?.getAttribute('data-page-revision-id-hackmd-synced') || null;
+  const lastUpdateUsername = mainContent?.getAttribute('data-page-last-update-username') || null;
+  const deleteUsername = mainContent?.getAttribute('data-page-delete-username') || null;
+  const pageIdOnHackmd = mainContent?.getAttribute('data-page-id-on-hackmd') || null;
+  const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
+  const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
+  const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
+  const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
+  /*
+   * use static swr
+   */
+  // App
+  useCurrentUser(currentUser);
+
+  // UserUISettings
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
+  usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed);
+  useCurrentSidebarContents(userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
+
+  // Page
+  useCreatedAt(createdAt);
+  useDeleteUsername(deleteUsername);
+  useDeletedAt(deletedAt);
+  useHasChildren(hasChildren);
+  useHasDraftOnHackmd(hasDraftOnHackmd);
+  useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
+  useIsDeletable(isDeletable);
+  useIsDeleted(isDeleted);
+  useIsNotCreatable(isNotCreatable);
+  useIsPageExist(isPageExist);
+  useIsTrashPage(isTrashPage);
+  useIsUserPage(isUserPage);
+  useLastUpdateUsername(lastUpdateUsername);
+  usePageId(pageId);
+  usePageIdOnHackmd(pageIdOnHackmd);
+  usePageUser(pageUser);
+  useCurrentPagePath(path);
+  useRevisionCreatedAt(revisionCreatedAt);
+  useRevisionId(revisionId);
+  useRevisionIdHackmdSynced(revisionIdHackmdSynced);
+  useShareLinkId(shareLinkId);
+  useShareLinksNumber(shareLinksNumber);
+  useTemplateTagData(templateTagData);
+  useUpdatedAt(updatedAt);
+  useCreator(creator);
+  useRevisionAuthor(revisionAuthor);
+
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
+  // Editor
+  useSlackChannels(slackChannels);
+
+  return null;
+};
+
+const ContextExtractor: FC = React.memo(() => {
+  const [isRunOnce, setRunOnce] = useState(false);
+
+  useEffect(() => {
+    setRunOnce(true);
+  }, []);
+
+  return isRunOnce ? null : <ContextExtractorOnce></ContextExtractorOnce>;
+});
+
+export default ContextExtractor;

+ 3 - 5
packages/app/src/client/services/EditorContainer.js

@@ -27,9 +27,6 @@ export default class EditorContainer extends Container {
     this.state = {
     this.state = {
       tags: null,
       tags: null,
 
 
-      isSlackEnabled: false,
-      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
-
       grant: 1, // default: public
       grant: 1, // default: public
       grantGroupId: null,
       grantGroupId: null,
       grantGroupName: null,
       grantGroupName: null,
@@ -143,10 +140,11 @@ export default class EditorContainer extends Container {
     }
     }
   }
   }
 
 
+  // TODO: Remove when SWR is complete
   getCurrentOptionsToSave() {
   getCurrentOptionsToSave() {
     const opt = {
     const opt = {
-      isSlackEnabled: this.state.isSlackEnabled,
-      slackChannels: this.state.slackChannels,
+      // isSlackEnabled: this.state.isSlackEnabled,
+      // slackChannels: this.state.slackChannels,
       grant: this.state.grant,
       grant: this.state.grant,
       pageTags: this.state.tags,
       pageTags: this.state.tags,
     };
     };

+ 0 - 239
packages/app/src/client/services/NavigationContainer.js

@@ -1,239 +0,0 @@
-import { Container } from 'unstated';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:services:NavigationContainer');
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-const SCROLL_THRES_SKIP = 200;
-const WIKI_HEADER_LINK = 120;
-
-export default class NavigationContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    const { localStorage } = window;
-
-    this.state = {
-      editorMode: 'view',
-
-      isDeviceSmallerThanMd: null,
-      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
-      preferDrawerModeOnEditByUser: // default: true
-        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
-      isDrawerMode: null,
-      isDrawerOpened: false,
-
-      sidebarContentsId: localStorage.sidebarContentsId || 'recent',
-
-      isScrollTop: true,
-
-      isPageCreateModalShown: false,
-    };
-
-    this.openPageCreateModal = this.openPageCreateModal.bind(this);
-    this.closePageCreateModal = this.closePageCreateModal.bind(this);
-    this.setEditorMode = this.setEditorMode.bind(this);
-    this.initDeviceSize();
-    this.initScrollEvent();
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'NavigationContainer';
-  }
-
-  getPageContainer() {
-    return this.appContainer.getContainer('PageContainer');
-  }
-
-  initDeviceSize() {
-    const mdOrAvobeHandler = async(mql) => {
-      let isDeviceSmallerThanMd;
-
-      // sm -> md
-      if (mql.matches) {
-        isDeviceSmallerThanMd = false;
-      }
-      // md -> sm
-      else {
-        isDeviceSmallerThanMd = true;
-      }
-
-      this.setState({ isDeviceSmallerThanMd });
-      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
-    };
-
-    this.appContainer.addBreakpointListener('md', mdOrAvobeHandler, true);
-  }
-
-  initScrollEvent() {
-    window.addEventListener('scroll', () => {
-      const currentYOffset = window.pageYOffset;
-
-      // original throttling
-      if (SCROLL_THRES_SKIP < currentYOffset) {
-        return;
-      }
-
-      this.setState({
-        isScrollTop: currentYOffset === 0,
-      });
-    });
-  }
-
-  setEditorMode(editorMode) {
-    const { isNotCreatable } = this.getPageContainer().state;
-
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to edit the page or use hackmd.');
-      return;
-    }
-
-    if (isNotCreatable) {
-      logger.warn('This page could not edit.');
-      return;
-    }
-
-    this.setState({ editorMode });
-    if (editorMode === 'view') {
-      $('body').removeClass('on-edit');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      $('body').removeClass('pathname-sidebar');
-      window.history.replaceState(null, '', window.location.pathname);
-    }
-
-    if (editorMode === 'edit') {
-      $('body').addClass('on-edit');
-      $('body').addClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      // editing /Sidebar
-      if (window.location.pathname === '/Sidebar') {
-        $('body').addClass('pathname-sidebar');
-      }
-      window.location.hash = '#edit';
-    }
-
-    if (editorMode === 'hackmd') {
-      $('body').addClass('on-edit');
-      $('body').addClass('hackmd');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('pathname-sidebar');
-      window.location.hash = '#hackmd';
-    }
-
-    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
-  }
-
-  toggleDrawer() {
-    const { isDrawerOpened } = this.state;
-    this.setState({ isDrawerOpened: !isDrawerOpened });
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreference(bool) {
-    this.setState({ preferDrawerModeByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeByUser = bool;
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreferenceOnEdit(bool) {
-    this.setState({ preferDrawerModeOnEditByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeOnEditByUser = bool;
-  }
-
-  /**
-   * Update drawer related state by specified 'newState' object
-   * @param {object} newState A newest state object
-   *
-   * Specify 'newState' like following code:
-   *
-   *   { ...this.state, overwriteParam: overwriteValue }
-   *
-   * because updating state of unstated container will be delayed unless you use await
-   */
-  updateDrawerMode(newState) {
-    const {
-      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-    } = newState;
-
-    // get preference on view or edit
-    const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
-    const isDrawerOpened = false; // close Drawer anyway
-
-    this.setState({ isDrawerMode, isDrawerOpened });
-  }
-
-  selectSidebarContents(contentsId) {
-    window.localStorage.setItem('sidebarContentsId', contentsId);
-    this.setState({ sidebarContentsId: contentsId });
-  }
-
-  openPageCreateModal() {
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to create a new page.');
-      return;
-    }
-    this.setState({ isPageCreateModalShown: true });
-  }
-
-  closePageCreateModal() {
-    this.setState({ isPageCreateModalShown: false });
-  }
-
-  /**
-   * Function that implements the click event for realizing smooth scroll
-   * @param {array} elements
-   */
-  addSmoothScrollEvent(elements = {}) {
-    elements.forEach(link => link.addEventListener('click', (e) => {
-      e.preventDefault();
-
-      const href = link.getAttribute('href').replace('#', '');
-      window.location.hash = href;
-      const targetDom = document.getElementById(href);
-      this.smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
-    }));
-  }
-
-  smoothScrollIntoView(element = null, offsetTop = 0) {
-    const targetElement = element || window.document.body;
-
-    // get the distance to the target element top
-    const rectTop = targetElement.getBoundingClientRect().top;
-
-    const top = window.pageYOffset + rectTop - offsetTop;
-
-    window.scrollTo({
-      top,
-      behavior: 'smooth',
-    });
-  }
-
-}

+ 5 - 21
packages/app/src/client/services/PageContainer.js

@@ -161,13 +161,6 @@ export default class PageContainer extends Container {
   }
   }
 
 
 
 
-  get isAbleToOpenPageEditor() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isGuestUser } = this.appContainer;
-
-    return (!isNotCreatable && !isTrashPage && !isGuestUser);
-  }
-
   /**
   /**
    * whether to display reaction buttons
    * whether to display reaction buttons
    * ex.) like, bookmark
    * ex.) like, bookmark
@@ -350,10 +343,6 @@ export default class PageContainer extends Container {
     }
     }
   }
   }
 
 
-  get navigationContainer() {
-    return this.appContainer.getContainer('NavigationContainer');
-  }
-
   setLatestRemotePageData(s2cMessagePageUpdated) {
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
@@ -380,9 +369,7 @@ export default class PageContainer extends Container {
    * @param {Array[Tag]} tags Array of Tag
    * @param {Array[Tag]} tags Array of Tag
    * @param {object} revision Revision instance
    * @param {object} revision Revision instance
    */
    */
-  updateStateAfterSave(page, tags, revision) {
-    const { editorMode } = this.navigationContainer.state;
-
+  updateStateAfterSave(page, tags, revision, editorMode) {
     // update state of PageContainer
     // update state of PageContainer
     const newState = {
     const newState = {
       pageId: page._id,
       pageId: page._id,
@@ -426,9 +413,7 @@ export default class PageContainer extends Container {
    * @param {object} optionsToSave
    * @param {object} optionsToSave
    * @return {object} { page: Page, tags: Tag[] }
    * @return {object} { page: Page, tags: Tag[] }
    */
    */
-  async save(markdown, optionsToSave = {}) {
-    const { editorMode } = this.navigationContainer.state;
-
+  async save(markdown, editorMode, optionsToSave = {}) {
     const { pageId, path } = this.state;
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
     let { revisionId } = this.state;
 
 
@@ -448,19 +433,18 @@ export default class PageContainer extends Container {
       res = await this.updatePage(pageId, revisionId, markdown, options);
       res = await this.updatePage(pageId, revisionId, markdown, options);
     }
     }
 
 
-    this.updateStateAfterSave(res.page, res.tags, res.revision);
+    this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
     return res;
     return res;
   }
   }
 
 
-  async saveAndReload(optionsToSave) {
+  async saveAndReload(optionsToSave, editorMode) {
     if (optionsToSave == null) {
     if (optionsToSave == null) {
       const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
       const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
       throw new Error(msg);
       throw new Error(msg);
     }
     }
 
 
-    const { editorMode } = this.navigationContainer.state;
     if (editorMode == null) {
     if (editorMode == null) {
-      logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
+      logger.warn('\'saveAndReload\' requires the \'editorMode\' param');
       return;
       return;
     }
     }
 
 

+ 28 - 0
packages/app/src/client/services/user-ui-settings.ts

@@ -0,0 +1,28 @@
+// eslint-disable-next-line no-restricted-imports
+import { AxiosResponse } from 'axios';
+
+import { debounce } from 'throttle-debounce';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+
+let settingsForBulk: Partial<IUserUISettings> = {};
+const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = apiv3Put<IUserUISettings>('/user-ui-settings', { settings: settingsForBulk });
+
+  // clear partial
+  settingsForBulk = {};
+
+  return result;
+};
+
+const _putUserUISettingsInBulkDebounced = debounce(1500, false, _putUserUISettingsInBulk);
+
+export const scheduleToPutUserUISettings = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+  settingsForBulk = {
+    ...settingsForBulk,
+    ...settings,
+  };
+
+  return _putUserUISettingsInBulkDebounced();
+};

+ 1 - 1
packages/app/src/client/util/apiv3-client.ts

@@ -36,7 +36,7 @@ const apiv3ErrorHandler = (_err) => {
 export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
 export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
   try {
   try {
     const res = await axios[method](urljoin(apiv3Root, path), params);
     const res = await axios[method](urljoin(apiv3Root, path), params);
-    return res.data;
+    return res;
   }
   }
   catch (err) {
   catch (err) {
     const errors = apiv3ErrorHandler(err);
     const errors = apiv3ErrorHandler(err);

+ 27 - 0
packages/app/src/client/util/blink-section-header.ts

@@ -0,0 +1,27 @@
+let lastBlinkedElem;
+
+export const blinkElem = (elem: HTMLElement): void => {
+  if (lastBlinkedElem != null) {
+    lastBlinkedElem.classList.remove('blink');
+  }
+
+  elem.classList.add('blink');
+  lastBlinkedElem = elem;
+};
+
+export const blinkSectionHeaderAtBoot = (): HTMLElement | undefined => {
+  const { hash } = window.location;
+
+  if (hash.length === 0) {
+    return;
+  }
+
+  // omit '#'
+  const id = hash.replace('#', '');
+  // don't use jQuery and document.querySelector
+  //  because hash may containe Base64 encoded strings
+  const elem = document.getElementById(id);
+  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
+    blinkElem(elem);
+  }
+};

+ 47 - 0
packages/app/src/client/util/codemirror/drawio-fold.ext.js

@@ -0,0 +1,47 @@
+/* eslint-disable */
+
+import mdu from '../../../components/PageEditor/MarkdownDrawioUtil.js';
+
+(function(mod) {
+  mod(require("codemirror"));
+})(function(CodeMirror) {
+  "use strict"
+
+  CodeMirror.registerGlobalHelper('fold', 'drawio', function (mode, cm) {
+    return true;
+  }, function(cm, start) {
+    function isBeginningOfDrawio(lineNo) {
+      let line = cm.getLine(lineNo);
+      let match = mdu.lineBeginPartOfDrawioRE.exec(line);
+      if (match) {
+        return true;
+      }
+      return false;
+    }
+    function isEndOfDrawio(lineNo) {
+      let line = cm.getLine(lineNo);
+      let match = mdu.lineEndPartOfDrawioRE.exec(line);
+      if (match) {
+        return true;
+      }
+      return false;
+    }
+
+    let drawio = isBeginningOfDrawio(start.line);
+    if (drawio === false) { return; }
+
+    let lastLine = cm.lastLine();
+    let end = start.line;
+    while(end < lastLine) {
+      end += 1;
+      if (isEndOfDrawio(end)) {
+        break;
+      }
+    }
+
+    return {
+      from: CodeMirror.Pos(start.line, cm.getLine(start.line).length),
+      to: CodeMirror.Pos(end, cm.getLine(end).length)
+    };
+  });
+});

+ 15 - 0
packages/app/src/client/util/editor.ts

@@ -0,0 +1,15 @@
+import EditorContainer from '~/client/services/EditorContainer';
+
+type OptionsToSave = {
+  isSlackEnabled: boolean;
+  slackChannels: string;
+  grant: number;
+  pageTags: string[];
+  grantUserGroupId?: string;
+};
+
+// TODO: Remove editorContainer upon migration to SWR
+export const getOptionsToSave = (isSlackEnabled: boolean, slackChannels: string, editorContainer: EditorContainer): OptionsToSave => {
+  const optionsToSave = editorContainer.getCurrentOptionsToSave();
+  return { ...optionsToSave, isSlackEnabled, slackChannels };
+};

+ 1 - 1
packages/app/src/client/util/interceptor/detach-code-blocks.js

@@ -1,4 +1,4 @@
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 17 - 2
packages/app/src/client/util/interceptor/drawio-interceptor.js

@@ -2,7 +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 { Provider } from 'unstated';
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 
 import Drawio from '~/components/Drawio';
 import Drawio from '~/components/Drawio';
 
 
@@ -103,11 +103,18 @@ export class DrawioInterceptor extends BasicInterceptor {
    */
    */
   drawioPostRender(contextName, context) {
   drawioPostRender(contextName, context) {
     const isPreview = (contextName === 'postRenderPreviewHtml');
     const isPreview = (contextName === 'postRenderPreviewHtml');
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const renderDrawioInRealtime = editorContainer.state.previewOptions.renderDrawioInRealtime;
 
 
     Object.keys(context.DrawioMap).forEach((domId) => {
     Object.keys(context.DrawioMap).forEach((domId) => {
       const elem = document.getElementById(domId);
       const elem = document.getElementById(domId);
       if (elem) {
       if (elem) {
-        this.renderReactDOM(context.DrawioMap[domId], elem, isPreview);
+        if (isPreview && !renderDrawioInRealtime) {
+          this.renderDisabledDrawioReactDOM(context.DrawioMap[domId], elem, isPreview);
+        }
+        else {
+          this.renderReactDOM(context.DrawioMap[domId], elem, isPreview);
+        }
       }
       }
     });
     });
   }
   }
@@ -129,6 +136,14 @@ export class DrawioInterceptor extends BasicInterceptor {
     );
     );
   }
   }
 
 
+  renderDisabledDrawioReactDOM(drawioMapEntry, elem, isPreview) {
+    ReactDOM.render(
+      // eslint-disable-next-line react/jsx-filename-extension
+      <div className="alert alert-light text-dark">Rendering of draw.io is disabled.</div>,
+      elem,
+    );
+  }
+
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */

+ 45 - 0
packages/app/src/client/util/smooth-scroll.ts

@@ -0,0 +1,45 @@
+const WIKI_HEADER_LINK = 120;
+
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+  const targetElement = element || window.document.body;
+
+  // get the distance to the target element top
+  const rectTop = targetElement.getBoundingClientRect().top;
+
+  const top = window.pageYOffset + rectTop - offsetTop;
+
+  window.scrollTo({
+    top,
+    behavior: 'smooth',
+  });
+};
+
+export type SmoothScrollEventCallback = (elem: HTMLElement) => void;
+
+export const addSmoothScrollEvent = (elements: HTMLAnchorElement[], callback?: SmoothScrollEventCallback): void => {
+  elements.forEach((link) => {
+    const href = link.getAttribute('href');
+
+    if (href == null) {
+      return;
+    }
+
+    link.addEventListener('click', (e) => {
+      e.preventDefault();
+
+      // modify location.hash without scroll
+      window.history.pushState({}, '', link.href);
+
+      // smooth scroll
+      const elemId = href.replace('#', '');
+      const targetDom = document.getElementById(elemId);
+      if (targetDom != null) {
+        smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
+
+        if (callback != null) {
+          callback(targetDom);
+        }
+      }
+    });
+  });
+};

+ 1 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -31,7 +31,7 @@ class AppSettingsPageContents extends React.Component {
 
 
         <div className="row mt-5">
         <div className="row mt-5">
           <div className="col-lg-12">
           <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.mail_settings')}</h2>
+            <h2 className="admin-setting-header" id="mail-settings">{t('admin:app_setting.mail_settings')}</h2>
             <MailSetting />
             <MailSetting />
           </div>
           </div>
         </div>
         </div>

+ 4 - 2
packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -53,7 +53,8 @@ class ElasticsearchManagement extends React.Component {
       });
       });
     });
     });
 
 
-    socket.on('finishAddPage', (data) => {
+    socket.on('finishAddPage', async(data) => {
+      await this.retrieveIndicesStatus();
       this.setState({
       this.setState({
         isRebuildingProcessing: false,
         isRebuildingProcessing: false,
         isRebuildingCompleted: true,
         isRebuildingCompleted: true,
@@ -69,7 +70,8 @@ class ElasticsearchManagement extends React.Component {
     const { appContainer } = this.props;
     const { appContainer } = this.props;
 
 
     try {
     try {
-      const { info } = await appContainer.apiv3Get('/search/indices');
+      const { data } = await appContainer.apiv3Get('/search/indices');
+      const { info } = data;
 
 
       this.setState({
       this.setState({
         isConnected: true,
         isConnected: true,

+ 46 - 2
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -31,9 +31,15 @@ class LocalSecuritySettingContents extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    const { registrationMode, isPasswordResetEnabled } = adminLocalSecurityContainer.state;
+    const {
+      t,
+      adminGeneralSecurityContainer,
+      adminLocalSecurityContainer,
+      appContainer,
+    } = this.props;
+    const { registrationMode, isPasswordResetEnabled, isEmailAuthenticationEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
+    const { isMailerSetup } = appContainer.config;
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
@@ -46,6 +52,17 @@ class LocalSecuritySettingContents extends React.Component {
         )}
         )}
         <h2 className="alert-anchor border-bottom">{t('security_setting.Local.name')}</h2>
         <h2 className="alert-anchor border-bottom">{t('security_setting.Local.name')}</h2>
 
 
+        {!isMailerSetup && (
+          <div className="row">
+            <div className="col-12">
+              <div className="alert alert-danger">
+                <span>{t('security_setting.Local.need_complete_mail_setting_warning')}</span>
+                <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
+              </div>
+            </div>
+          </div>
+        )}
+
         {adminLocalSecurityContainer.state.useOnlyEnvVars && (
         {adminLocalSecurityContainer.state.useOnlyEnvVars && (
           <p
           <p
             className="alert alert-info"
             className="alert alert-info"
@@ -178,6 +195,33 @@ class LocalSecuritySettingContents extends React.Component {
               </div>
               </div>
             </div>
             </div>
 
 
+            <div className="row">
+              <label className="col-12 col-md-3 text-left text-md-right  col-form-label">{t('security_setting.Local.email_authentication')}</label>
+              <div className="col-12 col-md-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    type="checkbox"
+                    className="custom-control-input"
+                    id="isEmailAuthenticationEnabled"
+                    checked={isEmailAuthenticationEnabled}
+                    onChange={() => adminLocalSecurityContainer.switchIsEmailAuthenticationEnabled()}
+                  />
+                  <label className="custom-control-label" htmlFor="isEmailAuthenticationEnabled">
+                    {t('security_setting.Local.enable_email_authentication')}
+                  </label>
+                </div>
+                {!isMailerSetup && (
+                  <div className="alert alert-warning p-1 my-1 small d-inline-block">
+                    <span>{t('security_setting.Local.please_enable_mailer')}</span>
+                    <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
+                  </div>
+                )}
+                <p className="form-text text-muted small">
+                  {t('security_setting.Local.enable_email_authentication_desc')}
+                </p>
+              </div>
+            </div>
+
             <div className="row my-3">
             <div className="row my-3">
               <div className="offset-3 col-6">
               <div className="offset-3 col-6">
                 <button
                 <button

+ 2 - 1
packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -127,7 +127,7 @@ const CustomBotWithProxySettings = (props) => {
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
           const {
-            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
           } = slackAppIntegration;
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
@@ -150,6 +150,7 @@ const CustomBotWithProxySettings = (props) => {
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
+                permissionsForSlackEventActions={permissionsForSlackEventActions}
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

+ 2 - 0
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -51,6 +51,7 @@ const CustomBotWithoutProxySettings = (props) => {
           onTestConnectionInvoked={props.onTestConnectionInvoked}
           onTestConnectionInvoked={props.onTestConnectionInvoked}
           onUpdatedSecretToken={props.onUpdatedSecretToken}
           onUpdatedSecretToken={props.onUpdatedSecretToken}
           commandPermission={props.commandPermission}
           commandPermission={props.commandPermission}
+          eventActionsPermission={props.eventActionsPermission}
         />
         />
       </div>
       </div>
     </>
     </>
@@ -71,6 +72,7 @@ CustomBotWithoutProxySettings.propTypes = {
   onTestConnectionInvoked: PropTypes.func.isRequired,
   onTestConnectionInvoked: PropTypes.func.isRequired,
   connectionStatuses: PropTypes.object.isRequired,
   connectionStatuses: PropTypes.object.isRequired,
   commandPermission: PropTypes.object,
   commandPermission: PropTypes.object,
+  eventActionsPermission: PropTypes.object,
 };
 };
 
 
 export default CustomBotWithoutProxySettingsWrapper;
 export default CustomBotWithoutProxySettingsWrapper;

+ 5 - 4
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -21,7 +21,7 @@ export const botInstallationStep = {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
   const {
   const {
     appContainer, activeStep, onTestConnectionInvoked,
     appContainer, activeStep, onTestConnectionInvoked,
-    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission,
+    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission, eventActionsPermission,
   } = props;
   } = props;
   const successMessage = 'Successfully sent to Slack workspace.';
   const successMessage = 'Successfully sent to Slack workspace.';
 
 
@@ -125,10 +125,11 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // eslint-disable-next-line max-len
         // eslint-disable-next-line max-len
-        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.manage_commands')}</>}
+        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.manage_permission')}</>}
       >
       >
         <ManageCommandsProcessWithoutProxy
         <ManageCommandsProcessWithoutProxy
-          commandPermission={props.commandPermission}
+          commandPermission={commandPermission}
+          eventActionsPermission={eventActionsPermission}
           apiv3Put={props.appContainer.apiv3.put}
           apiv3Put={props.appContainer.apiv3.put}
         />
         />
       </Accordion>
       </Accordion>
@@ -200,7 +201,7 @@ CustomBotWithoutProxySettingsAccordion.propTypes = {
   slackBotToken: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
   commandPermission: PropTypes.object,
   commandPermission: PropTypes.object,
-
+  eventActionsPermission: PropTypes.object,
 };
 };
 
 
 export default CustomBotWithoutProxySettingsAccordionWrapper;
 export default CustomBotWithoutProxySettingsAccordionWrapper;

+ 238 - 122
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -1,7 +1,7 @@
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -19,6 +19,11 @@ const CommandUsageTypes = {
   SINGLE_USE: 'singleUse',
   SINGLE_USE: 'singleUse',
 };
 };
 
 
+const EventTypes = {
+  LINK_SHARING: 'linkSharing',
+};
+
+
 // A utility function that returns the new state but identical to the previous state
 // A utility function that returns the new state but identical to the previous state
 const getUpdatedChannelsList = (prevState, commandName, value) => {
 const getUpdatedChannelsList = (prevState, commandName, value) => {
   // string to array
   // string to array
@@ -62,9 +67,110 @@ const getPermissionTypeFromValue = (value) => {
   logger.error('The value type must be boolean or string[]');
   logger.error('The value type must be boolean or string[]');
 };
 };
 
 
+const PermissionSettingForEachPermissionTypeComponent = ({
+  keyName, onUpdatePermissions, onUpdateChannels, singleCommandDescription, allowedChannelsDescription, currentPermissionType, permissionSettings,
+}) => {
+  const { t } = useTranslation();
+  const hiddenClass = currentPermissionType === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
+
+  const permission = permissionSettings[keyName];
+  if (permission === undefined) logger.error('Must be implemented');
+  const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+
+
+  return (
+    <div className="my-1 mb-2">
+      <div className="row align-items-center mb-3">
+        <p className="col-md-5 text-md-right mb-2">
+          <strong className="text-capitalize">{keyName}</strong>
+          {singleCommandDescription && (
+            <small className="form-text text-muted small">
+              { singleCommandDescription }
+            </small>
+          )}
+        </p>
+        <div className="col dropdown">
+          <button
+            className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
+            type="button"
+            id="dropdownMenuButton"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="true"
+          >
+            <span className="float-left">
+              {currentPermissionType === PermissionTypes.ALLOW_ALL
+              && t('admin:slack_integration.accordion.allow_all')}
+              {currentPermissionType === PermissionTypes.DENY_ALL
+              && t('admin:slack_integration.accordion.deny_all')}
+              {currentPermissionType === PermissionTypes.ALLOW_SPECIFIED
+              && t('admin:slack_integration.accordion.allow_specified')}
+            </span>
+          </button>
+          <div className="dropdown-menu">
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.ALLOW_ALL}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.allow_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.DENY_ALL}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.deny_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={keyName}
+              value={PermissionTypes.ALLOW_SPECIFIED}
+              onClick={onUpdatePermissions}
+            >
+              {t('admin:slack_integration.accordion.allow_specified_long')}
+            </button>
+          </div>
+        </div>
+      </div>
+      <div className={`row ${hiddenClass}`}>
+        <div className="col-md-7 offset-md-5">
+          <textarea
+            className="form-control"
+            type="textarea"
+            name={keyName}
+            defaultValue={textareaDefaultValue}
+            onChange={onUpdateChannels}
+          />
+          <p className="form-text text-muted small">
+            {t(allowedChannelsDescription, { keyName })}
+          </p>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+PermissionSettingForEachPermissionTypeComponent.propTypes = {
+  keyName: PropTypes.string,
+  usageType: PropTypes.string,
+  currentPermissionType: PropTypes.string,
+  singleCommandDescription: PropTypes.string,
+  onUpdatePermissions: PropTypes.func,
+  onUpdateChannels: PropTypes.func,
+  allowedChannelsDescription: PropTypes.string,
+  permissionSettings: PropTypes.object,
+};
+
+
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const ManageCommandsProcess = ({
 const ManageCommandsProcess = ({
-  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -75,6 +181,9 @@ const ManageCommandsProcess = ({
     note: permissionsForSingleUseCommands.note,
     note: permissionsForSingleUseCommands.note,
     keep: permissionsForSingleUseCommands.keep,
     keep: permissionsForSingleUseCommands.keep,
   });
   });
+  const [permissionsForEventsState, setPermissionsForEventsState] = useState({
+    unfurl: permissionsForSlackEventActions.unfurl,
+  });
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
     const initialState = {};
     const initialState = {};
     Object.entries(permissionsForBroadcastUseCommandsState).forEach((entry) => {
     Object.entries(permissionsForBroadcastUseCommandsState).forEach((entry) => {
@@ -85,14 +194,28 @@ const ManageCommandsProcess = ({
       const [commandName, value] = entry;
       const [commandName, value] = entry;
       initialState[commandName] = getPermissionTypeFromValue(value);
       initialState[commandName] = getPermissionTypeFromValue(value);
     });
     });
+    Object.entries(permissionsForEventsState).forEach((entry) => {
+      const [commandName, value] = entry;
+      initialState[commandName] = getPermissionTypeFromValue(value);
+    });
     return initialState;
     return initialState;
   });
   });
 
 
-  const updatePermissionsForBroadcastUseCommandsState = useCallback((e) => {
+
+  const handleUpdateSingleUsePermissions = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setCurrentPermissionTypes((prevState) => {
+      const newState = { ...prevState };
+      newState[commandName] = value;
+      return newState;
+    });
+  }, []);
 
 
-    // update state
+  const handleUpdateBroadcastUsePermissions = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setCurrentPermissionTypes((prevState) => {
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       const newState = { ...prevState };
@@ -101,12 +224,10 @@ const ManageCommandsProcess = ({
     });
     });
   }, []);
   }, []);
 
 
-  const updatePermissionsForSingleUseCommandsState = useCallback((e) => {
+  const handleUpdateEventsPermissions = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-
-    // update state
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setPermissionsForEventsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
     setCurrentPermissionTypes((prevState) => {
     setCurrentPermissionTypes((prevState) => {
       const newState = { ...prevState };
       const newState = { ...prevState };
       newState[commandName] = value;
       newState[commandName] = value;
@@ -114,25 +235,32 @@ const ManageCommandsProcess = ({
     });
     });
   }, []);
   }, []);
 
 
-  const updateChannelsListForBroadcastUseCommandsState = useCallback((e) => {
+  const handleUpdateSingleUseChannels = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+  }, []);
+
+  const handleUpdateBroadcastUseChannels = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    // update state
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
     setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
   }, []);
   }, []);
 
 
-  const updateChannelsListForSingleUseCommandsState = useCallback((e) => {
+  const handleUpdateEventsChannels = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    // update state
-    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+    setPermissionsForEventsState(prev => getUpdatedChannelsList(prev, commandName, value));
   }, []);
   }, []);
 
 
-  const updateCommandsHandler = async(e) => {
+
+  const updateSettingsHandler = async(e) => {
     try {
     try {
-      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/supported-commands`, {
+      // TODO: add new attribute 78975
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/permissions`, {
         permissionsForBroadcastUseCommands: permissionsForBroadcastUseCommandsState,
         permissionsForBroadcastUseCommands: permissionsForBroadcastUseCommandsState,
         permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
         permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
+        permissionsForSlackEventActions: permissionsForEventsState,
       });
       });
       toastSuccess(t('toaster.update_successed', { target: 'Token' }));
       toastSuccess(t('toaster.update_successed', { target: 'Token' }));
     }
     }
@@ -142,141 +270,128 @@ const ManageCommandsProcess = ({
     }
     }
   };
   };
 
 
-  const PermissionSettingForEachCommandComponent = ({ commandName, commandUsageType }) => {
-    const hiddenClass = currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
-    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
+  const PermissionSettingsForEachCategoryComponent = ({
+    currentPermissionTypes,
+    usageType,
+    menuItem,
+  }) => {
+    const permissionMap = {
+      broadcastUse: permissionsForBroadcastUseCommandsState,
+      singleUse: permissionsForSingleUseCommandsState,
+      linkSharing: permissionsForEventsState,
+    };
 
 
-    const permissionSettings = isCommandBroadcastUse ? permissionsForBroadcastUseCommandsState : permissionsForSingleUseCommandsState;
-    const permission = permissionSettings[commandName];
-    if (permission === undefined) logger.error('Must be implemented');
-
-    const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+    const {
+      title,
+      description,
+      defaultCommandsName,
+      singleCommandDescription,
+      updatePermissionsHandler,
+      updateChannelsHandler,
+      allowedChannelsDescription,
+    } = menuItem;
 
 
     return (
     return (
-      <div className="my-1 mb-2">
-        <div className="row align-items-center mb-3">
-          <p className="col-md-5 text-md-right text-capitalize mb-2"><strong>{commandName}</strong></p>
-          <div className="col dropdown">
-            <button
-              className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
-              type="button"
-              id="dropdownMenuButton"
-              data-toggle="dropdown"
-              aria-haspopup="true"
-              aria-expanded="true"
-            >
-              <span className="float-left">
-                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_ALL
-                && t('admin:slack_integration.accordion.allow_all')}
-                {currentPermissionTypes[commandName] === PermissionTypes.DENY_ALL
-                && t('admin:slack_integration.accordion.deny_all')}
-                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED
-                && t('admin:slack_integration.accordion.allow_specified')}
-              </span>
-            </button>
-            <div className="dropdown-menu">
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.ALLOW_ALL}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.allow_all_long')}
-              </button>
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.DENY_ALL}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.deny_all_long')}
-              </button>
-              <button
-                className="dropdown-item"
-                type="button"
-                name={commandName}
-                value={PermissionTypes.ALLOW_SPECIFIED}
-                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
-              >
-                {t('admin:slack_integration.accordion.allow_specified_long')}
-              </button>
+      <>
+        {(title || description) && (
+          <div className="row">
+            <div className="col-md-7 offset-md-2">
+              { title && <p className="font-weight-bold mb-1">{title}</p> }
+              { description && <p className="text-muted">{description}</p> }
             </div>
             </div>
           </div>
           </div>
-        </div>
-        <div className={`row ${hiddenClass}`}>
-          <div className="col-md-7 offset-md-5">
-            <textarea
-              className="form-control"
-              type="textarea"
-              name={commandName}
-              defaultValue={textareaDefaultValue}
-              onChange={isCommandBroadcastUse ? updateChannelsListForBroadcastUseCommandsState : updateChannelsListForSingleUseCommandsState}
-            />
-            <p className="form-text text-muted small">
-              {t('admin:slack_integration.accordion.allowed_channels_description', { commandName })}
-              <br />
-            </p>
-          </div>
-        </div>
-      </div>
-    );
-  };
+        )}
 
 
-  PermissionSettingForEachCommandComponent.propTypes = {
-    commandName: PropTypes.string,
-    commandUsageType: PropTypes.string,
-  };
-
-  const PermissionSettingsForEachCommandTypeComponent = ({ commandUsageType }) => {
-    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
-    const defaultCommandsName = isCommandBroadcastUse ? defaultSupportedCommandsNameForBroadcastUse : defaultSupportedCommandsNameForSingleUse;
-    return (
-      <>
-        <div className="row">
-          <div className="col-md-7 offset-md-2">
-            <p className="font-weight-bold mb-1">{isCommandBroadcastUse ? 'Multiple GROWI' : 'Single GROWI'}</p>
-            <p className="text-muted">
-              {isCommandBroadcastUse
-                ? t('admin:slack_integration.accordion.multiple_growi_command')
-                : t('admin:slack_integration.accordion.single_growi_command')}
-            </p>
-          </div>
-        </div>
         <div className="custom-control custom-checkbox">
         <div className="custom-control custom-checkbox">
           <div className="row mb-5 d-block">
           <div className="row mb-5 d-block">
-            {defaultCommandsName.map((commandName) => {
-              // eslint-disable-next-line max-len
-              return <PermissionSettingForEachCommandComponent key={`${commandName}-component`} commandName={commandName} commandUsageType={commandUsageType} />;
-            })}
+            {defaultCommandsName.map(keyName => (
+              <PermissionSettingForEachPermissionTypeComponent
+                key={`${keyName}-component`}
+                keyName={keyName}
+                usageType={usageType}
+                permissionSettings={permissionMap[usageType]}
+                currentPermissionType={currentPermissionTypes[keyName]}
+                singleCommandDescription={singleCommandDescription}
+                onUpdatePermissions={updatePermissionsHandler}
+                onUpdateChannels={updateChannelsHandler}
+                allowedChannelsDescription={allowedChannelsDescription}
+              />
+            ))}
           </div>
           </div>
         </div>
         </div>
       </>
       </>
     );
     );
   };
   };
 
 
-  PermissionSettingsForEachCommandTypeComponent.propTypes = {
-    commandUsageType: PropTypes.string,
+
+  PermissionSettingsForEachCategoryComponent.propTypes = {
+    currentPermissionTypes: PropTypes.object,
+    usageType: PropTypes.string,
+    menuItem: PropTypes.object,
   };
   };
 
 
+  // Using i18n in allowedChannelsDescription will cause interpolation error
+  const menuMap = {
+    broadcastUse: {
+      title: 'Multiple GROWI',
+      description: t('admin:slack_integration.accordion.multiple_growi_command'),
+      defaultCommandsName: defaultSupportedCommandsNameForBroadcastUse,
+      updatePermissionsHandler: handleUpdateBroadcastUsePermissions,
+      updateChannelsHandler: handleUpdateBroadcastUseChannels,
+      allowedChannelsDescription: 'admin:slack_integration.accordion.allowed_channels_description',
+    },
+    singleUse: {
+      title: 'Single GROWI',
+      description: t('admin:slack_integration.accordion.single_growi_command'),
+      defaultCommandsName: defaultSupportedCommandsNameForSingleUse,
+      updatePermissionsHandler: handleUpdateSingleUsePermissions,
+      updateChannelsHandler: handleUpdateSingleUseChannels,
+      allowedChannelsDescription: 'admin:slack_integration.accordion.allowed_channels_description',
+    },
+    linkSharing: {
+      defaultCommandsName: defaultSupportedSlackEventActions,
+      updatePermissionsHandler: handleUpdateEventsPermissions,
+      updateChannelsHandler: handleUpdateEventsChannels,
+      singleCommandDescription: t('admin:slack_integration.accordion.unfurl_description'),
+      allowedChannelsDescription: 'admin:slack_integration.accordion.unfurl_allowed_channels_description',
+    },
+  };
 
 
   return (
   return (
     <div className="py-4 px-5">
     <div className="py-4 px-5">
-      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.growi_commands')}</p>
       <div className="row d-flex flex-column align-items-center">
       <div className="row d-flex flex-column align-items-center">
+        <div className="col-8">
+          {Object.values(CommandUsageTypes).map(commandUsageType => (
+            <PermissionSettingsForEachCategoryComponent
+              key={commandUsageType}
+              currentPermissionTypes={currentPermissionTypes}
+              usageType={commandUsageType}
+              menuItem={menuMap[commandUsageType]}
+            />
+          ))}
+        </div>
+      </div>
 
 
+      <p className="mb-4 font-weight-bold">Events</p>
+      <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
         <div className="col-8">
-          {Object.values(CommandUsageTypes).map((commandUsageType) => {
-            return <PermissionSettingsForEachCommandTypeComponent key={commandUsageType} commandUsageType={commandUsageType} />;
-          })}
+          {Object.values(EventTypes).map(EventType => (
+            <PermissionSettingsForEachCategoryComponent
+              key={EventType}
+              currentPermissionTypes={currentPermissionTypes}
+              usageType={EventType}
+              menuItem={menuMap[EventType]}
+            />
+          ))}
         </div>
         </div>
       </div>
       </div>
+
       <div className="row">
       <div className="row">
         <button
         <button
           type="submit"
           type="submit"
           className="btn btn-primary mx-auto"
           className="btn btn-primary mx-auto"
-          onClick={updateCommandsHandler}
+          onClick={updateSettingsHandler}
         >
         >
           { t('Update') }
           { t('Update') }
         </button>
         </button>
@@ -290,6 +405,7 @@ ManageCommandsProcess.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
+  permissionsForSlackEventActions: PropTypes.object.isRequired,
 };
 };
 
 
 export default ManageCommandsProcess;
 export default ManageCommandsProcess;

+ 56 - 23
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx

@@ -1,7 +1,7 @@
 import React, { useCallback, useEffect, useState } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse, defaultSupportedSlackEventActions } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -49,7 +49,7 @@ const getUpdatedPermissionSettings = (commandPermissionObj, commandName, value)
 };
 };
 
 
 
 
-const PermissionSettingForEachCommandComponent = ({
+const SinglePermissionSettingComponent = ({
   commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
   commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -144,7 +144,7 @@ const PermissionSettingForEachCommandComponent = ({
   );
   );
 };
 };
 
 
-PermissionSettingForEachCommandComponent.propTypes = {
+SinglePermissionSettingComponent.propTypes = {
   commandName: PropTypes.string,
   commandName: PropTypes.string,
   editingCommandPermission: PropTypes.object,
   editingCommandPermission: PropTypes.object,
   onPermissionTypeClicked: PropTypes.func,
   onPermissionTypeClicked: PropTypes.func,
@@ -153,18 +153,10 @@ PermissionSettingForEachCommandComponent.propTypes = {
 
 
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
+const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission, eventActionsPermission }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [editingCommandPermission, setEditingCommandPermission] = useState({});
   const [editingCommandPermission, setEditingCommandPermission] = useState({});
-
-  const updatePermissionsCommandsState = useCallback((e) => {
-    const { target } = e;
-    const { name: commandName, value } = target;
-
-    // update state
-    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
-  }, []);
-
+  const [editingEventActionsPermission, setEditingEventActionsPermission] = useState({});
 
 
   useEffect(() => {
   useEffect(() => {
     if (commandPermission == null) {
     if (commandPermission == null) {
@@ -174,21 +166,43 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
     setEditingCommandPermission(updatedState);
     setEditingCommandPermission(updatedState);
   }, [commandPermission]);
   }, [commandPermission]);
 
 
-  const updateChannelsListState = useCallback((e) => {
+  useEffect(() => {
+    if (eventActionsPermission == null) {
+      return;
+    }
+    const updatedState = { ...eventActionsPermission };
+    setEditingEventActionsPermission(updatedState);
+  }, [eventActionsPermission]);
+
+  const updatePermissionsCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
+  }, []);
+
+  const updatePermissionsEventsState = useCallback((e) => {
+    const { target } = e;
+    const { name: actionName, value } = target;
+    setEditingEventActionsPermission(eventActionPermissionObj => getUpdatedPermissionSettings(eventActionPermissionObj, actionName, value));
+  }, []);
+
+  const updateCommandsChannelsListState = useCallback((e) => {
     const { target } = e;
     const { target } = e;
     const { name: commandName, value } = target;
     const { name: commandName, value } = target;
-    // update state
-    setEditingCommandPermission((commandPermissionObj) => {
-      return {
-        ...getUpdatedChannelsList(commandPermissionObj, commandName, value),
-      };
-    });
+    setEditingCommandPermission(commandPermissionObj => ({ ...getUpdatedChannelsList(commandPermissionObj, commandName, value) }));
+  }, []);
+
+  const updateEventsChannelsListState = useCallback((e) => {
+    const { target } = e;
+    const { name: actionName, value } = target;
+    setEditingEventActionsPermission(eventActionPermissionObj => ({ ...getUpdatedChannelsList(eventActionPermissionObj, actionName, value) }));
   }, []);
   }, []);
 
 
   const updateCommandsHandler = async(e) => {
   const updateCommandsHandler = async(e) => {
     try {
     try {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
         commandPermission: editingCommandPermission,
         commandPermission: editingCommandPermission,
+        eventActionsPermission: editingEventActionsPermission,
       });
       });
       toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
       toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
     }
     }
@@ -200,7 +214,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
 
 
   return (
   return (
     <div className="py-4 px-5">
     <div className="py-4 px-5">
-      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.growi_commands')}</p>
       <div className="row d-flex flex-column align-items-center">
       <div className="row d-flex flex-column align-items-center">
         <div className="col-8">
         <div className="col-8">
           <div className="custom-control custom-checkbox">
           <div className="custom-control custom-checkbox">
@@ -208,12 +222,12 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
               { defaultCommandsName.map((commandName) => {
               { defaultCommandsName.map((commandName) => {
                 // eslint-disable-next-line max-len
                 // eslint-disable-next-line max-len
                 return (
                 return (
-                  <PermissionSettingForEachCommandComponent
+                  <SinglePermissionSettingComponent
                     key={`${commandName}-component`}
                     key={`${commandName}-component`}
                     commandName={commandName}
                     commandName={commandName}
                     editingCommandPermission={editingCommandPermission}
                     editingCommandPermission={editingCommandPermission}
                     onPermissionTypeClicked={updatePermissionsCommandsState}
                     onPermissionTypeClicked={updatePermissionsCommandsState}
-                    onPermissionListChanged={updateChannelsListState}
+                    onPermissionListChanged={updateCommandsChannelsListState}
                   />
                   />
                 );
                 );
               })}
               })}
@@ -221,6 +235,24 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
+      <p className="mb-4 font-weight-bold">Events</p>
+      <div className="row d-flex flex-column align-items-center">
+        <div className="col-8">
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5 d-block">
+              { defaultSupportedSlackEventActions.map(actionName => (
+                <SinglePermissionSettingComponent
+                  key={`${actionName}-component`}
+                  commandName={actionName}
+                  editingCommandPermission={editingEventActionsPermission}
+                  onPermissionTypeClicked={updatePermissionsEventsState}
+                  onPermissionListChanged={updateEventsChannelsListState}
+                />
+              ))}
+            </div>
+          </div>
+        </div>
+      </div>
       <div className="row">
       <div className="row">
         <button
         <button
           type="submit"
           type="submit"
@@ -237,6 +269,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
 ManageCommandsProcessWithoutProxy.propTypes = {
 ManageCommandsProcessWithoutProxy.propTypes = {
   apiv3Put: PropTypes.func,
   apiv3Put: PropTypes.func,
   commandPermission: PropTypes.object,
   commandPermission: PropTypes.object,
+  eventActionsPermission: PropTypes.object,
 };
 };
 
 
 export default ManageCommandsProcessWithoutProxy;
 export default ManageCommandsProcessWithoutProxy;

+ 2 - 1
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -95,7 +95,7 @@ const OfficialBotSettings = (props) => {
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
           const {
-            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
           } = slackAppIntegration;
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
@@ -118,6 +118,7 @@ const OfficialBotSettings = (props) => {
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
+                permissionsForSlackEventActions={permissionsForSlackEventActions}
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

+ 11 - 1
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -28,6 +28,7 @@ const SlackIntegration = (props) => {
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [commandPermission, setCommandPermission] = useState(null);
   const [commandPermission, setCommandPermission] = useState(null);
+  const [eventActionsPermission, setEventActionsPermission] = useState(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
@@ -41,7 +42,14 @@ const SlackIntegration = (props) => {
     try {
     try {
       const { data } = await appContainer.apiv3.get('/slack-integration-settings');
       const { data } = await appContainer.apiv3.get('/slack-integration-settings');
       const {
       const {
-        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri, commandPermission,
+        slackSigningSecret,
+        slackBotToken,
+        slackSigningSecretEnvVars,
+        slackBotTokenEnvVars,
+        slackAppIntegrations,
+        proxyServerUri,
+        commandPermission,
+        eventActionsPermission,
       } = data.settings;
       } = data.settings;
 
 
       setErrorMsg(data.errorMsg);
       setErrorMsg(data.errorMsg);
@@ -55,6 +63,7 @@ const SlackIntegration = (props) => {
       setSlackAppIntegrations(slackAppIntegrations);
       setSlackAppIntegrations(slackAppIntegrations);
       setProxyServerUri(proxyServerUri);
       setProxyServerUri(proxyServerUri);
       setCommandPermission(commandPermission);
       setCommandPermission(commandPermission);
+      setEventActionsPermission(eventActionsPermission);
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -154,6 +163,7 @@ const SlackIntegration = (props) => {
           onUpdatedSecretToken={changeSecretAndToken}
           onUpdatedSecretToken={changeSecretAndToken}
           connectionStatuses={connectionStatuses}
           connectionStatuses={connectionStatuses}
           commandPermission={commandPermission}
           commandPermission={commandPermission}
+          eventActionsPermission={eventActionsPermission}
         />
         />
       );
       );
       break;
       break;

+ 5 - 2
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -340,12 +340,13 @@ const WithProxyAccordions = (props) => {
       />,
       />,
     },
     },
     '③': {
     '③': {
-      title: 'manage_commands',
+      title: 'manage_permission',
       content: <ManageCommandsProcess
       content: <ManageCommandsProcess
         apiv3Put={props.appContainer.apiv3.put}
         apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
       />,
       />,
     },
     },
     '④': {
     '④': {
@@ -384,12 +385,13 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
       content: <RegisteringProxyUrlProcess />,
     },
     },
     '⑤': {
     '⑤': {
-      title: 'manage_commands',
+      title: 'manage_permission',
       content: <ManageCommandsProcess
       content: <ManageCommandsProcess
         apiv3Put={props.appContainer.apiv3.put}
         apiv3Put={props.appContainer.apiv3.put}
         slackAppIntegrationId={props.slackAppIntegrationId}
         slackAppIntegrationId={props.slackAppIntegrationId}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
         permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+        permissionsForSlackEventActions={props.permissionsForSlackEventActions}
       />,
       />,
     },
     },
     '⑥': {
     '⑥': {
@@ -443,6 +445,7 @@ WithProxyAccordions.propTypes = {
   tokenGtoP: PropTypes.string,
   tokenGtoP: PropTypes.string,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
   permissionsForSingleUseCommands: PropTypes.object.isRequired,
+  permissionsForSlackEventActions: PropTypes.object.isRequired,
 };
 };
 
 
 export default WithProxyAccordionsWrapper;
 export default WithProxyAccordionsWrapper;

+ 148 - 0
packages/app/src/components/CompleteUserRegistrationForm.tsx

@@ -0,0 +1,148 @@
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '../client/util/apiNotification';
+
+interface Props {
+  messageErrors?: any,
+  inputs?: any,
+  email: string,
+  token: string,
+}
+
+const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
+
+  const { t } = useTranslation();
+  const {
+    messageErrors,
+    email,
+    token,
+  } = props;
+
+  const [usernameAvailable, setUsernameAvailable] = useState(true);
+  const [username, setUsername] = useState('');
+  const [name, setName] = useState('');
+  const [password, setPassword] = useState('');
+  const [disableForm, setDisableForm] = useState(false);
+
+  useEffect(() => {
+    const delayDebounceFn = setTimeout(async() => {
+      try {
+        const { data } = await apiv3Get('/check_username', { username });
+        if (data.ok) {
+          setUsernameAvailable(data.valid);
+        }
+      }
+      catch (error) {
+        toastError(error, 'Error occurred when checking username');
+      }
+    }, 500);
+
+    return () => clearTimeout(delayDebounceFn);
+  }, [username]);
+
+  async function submitRegistration() {
+    setDisableForm(true);
+    try {
+      await apiv3Post('/complete-registration', {
+        username, name, password, token,
+      });
+      toastSuccess('Registration succeed');
+      window.location.href = '/login';
+    }
+    catch (err) {
+      toastError(err, 'Registration failed');
+      setDisableForm(false);
+    }
+  }
+
+  return (
+    <>
+      <div id="register-form-errors">
+        {messageErrors && (
+          <div className="alert alert-danger">
+            { messageErrors }
+          </div>
+        )}
+      </div>
+      <div id="register-dialog">
+
+        <fieldset id="registration-form" disabled={disableForm}>
+          <input type="hidden" name="token" value={token} />
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-envelope"></i></span>
+            </div>
+            <input type="text" className="form-control" disabled value={email} />
+          </div>
+          <div className="input-group" id="input-group-username">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-user"></i></span>
+            </div>
+            <input
+              type="text"
+              className="form-control"
+              placeholder={t('User ID')}
+              name="username"
+              onChange={e => setUsername(e.target.value)}
+              required
+            />
+          </div>
+          {!usernameAvailable && (
+            <p className="form-text text-red">
+              <span id="help-block-username"><i className="icon-fw icon-ban"></i>{t('installer.unavaliable_user_id')}</span>
+            </p>
+          )}
+
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-tag"></i></span>
+            </div>
+            <input
+              type="text"
+              className="form-control"
+              placeholder={t('Name')}
+              name="name"
+              value={name}
+              onChange={e => setName(e.target.value)}
+              required
+            />
+          </div>
+
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-lock"></i></span>
+            </div>
+            <input
+              type="password"
+              className="form-control"
+              placeholder={t('Password')}
+              name="password"
+              value={password}
+              onChange={e => setPassword(e.target.value)}
+              required
+            />
+          </div>
+
+          <div className="input-group justify-content-center d-flex mt-5">
+            <button type="button" onClick={submitRegistration} className="btn btn-fill" id="register">
+              <div className="eff"></div>
+              <span className="btn-label"><i className="icon-user-follow"></i></span>
+              <span className="btn-label-text">{t('Create')}</span>
+            </button>
+          </div>
+
+          <div className="input-group mt-5 d-flex justify-content-center">
+            <a href="https://growi.org" className="link-growi-org">
+              <span className="growi">GROWI</span>.<span className="org">ORG</span>
+            </a>
+          </div>
+
+        </fieldset>
+      </div>
+    </>
+  );
+
+};
+
+export default CompleteUserRegistrationForm;

+ 6 - 7
packages/app/src/components/ContentLinkButtons.jsx

@@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
@@ -20,7 +20,7 @@ const WIKI_HEADER_LINK = 120;
  */
  */
 const ContentLinkButtons = (props) => {
 const ContentLinkButtons = (props) => {
 
 
-  const { appContainer, navigationContainer, pageContainer } = props;
+  const { appContainer, pageContainer } = props;
   const { pageUser, path } = pageContainer.state;
   const { pageUser, path } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
   const { isSharedUser } = appContainer;
   const { isSharedUser } = appContainer;
@@ -39,7 +39,7 @@ const ContentLinkButtons = (props) => {
         <button
         <button
           type="button"
           type="button"
           className="btn btn-outline-secondary btn-sm btn-block"
           className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => navigationContainer.smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
         >
         >
           <i className="mr-2 icon-fw icon-bubbles"></i>
           <i className="mr-2 icon-fw icon-bubbles"></i>
           <span>Comments</span>
           <span>Comments</span>
@@ -53,7 +53,7 @@ const ContentLinkButtons = (props) => {
       <button
       <button
         type="button"
         type="button"
         className="btn btn-outline-secondary btn-sm px-2"
         className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
       >
       >
         <i className="mr-2 icon-star"></i>
         <i className="mr-2 icon-star"></i>
         <span>Bookmarks</span>
         <span>Bookmarks</span>
@@ -67,7 +67,7 @@ const ContentLinkButtons = (props) => {
       <button
       <button
         type="button"
         type="button"
         className="btn btn-outline-secondary btn-sm px-3"
         className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
       >
       >
         <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
         <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
         <span>Recently Created</span>
         <span>Recently Created</span>
@@ -90,8 +90,7 @@ const ContentLinkButtons = (props) => {
 
 
 ContentLinkButtons.propTypes = {
 ContentLinkButtons.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, NavigationContainer, PageContainer]);
+export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 40 - 0
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -0,0 +1,40 @@
+import { FC, useCallback, useEffect } from 'react';
+
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
+
+const HashChanged: FC<void> = () => {
+  const { data: isEditable } = useIsEditable();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const hashchangeHandler = useCallback(() => {
+    const { hash } = window.location;
+
+    if (hash == null) {
+      return;
+    }
+
+    if (hash === '#edit') {
+      mutateEditorMode(EditorMode.Editor);
+    }
+  }, [mutateEditorMode]);
+
+  // setup effect
+  useEffect(() => {
+    if (!isEditable) {
+      return;
+    }
+
+    window.addEventListener('hashchange', hashchangeHandler);
+
+    // return remove handler
+    return function cleanup() {
+      window.removeEventListener('hashchange', hashchangeHandler);
+    };
+
+  }, [hashchangeHandler, isEditable, mutateEditorMode]);
+
+  return null;
+};
+
+export default HashChanged;

+ 9 - 6
packages/app/src/components/Fab.jsx

@@ -5,7 +5,9 @@ import loggerFactory from '~/utils/logger';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { usePageCreateModalOpened } from '~/stores/ui';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
@@ -13,9 +15,11 @@ import ReturnTopIcon from './Icons/ReturnTopIcon';
 const logger = loggerFactory('growi:cli:Fab');
 const logger = loggerFactory('growi:cli:Fab');
 
 
 const Fab = (props) => {
 const Fab = (props) => {
-  const { navigationContainer, appContainer } = props;
+  const { appContainer } = props;
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
 
 
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
   const [buttonClasses, setButtonClasses] = useState('');
 
 
@@ -52,7 +56,7 @@ const Fab = (props) => {
           <button
           <button
             type="button"
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
-            onClick={navigationContainer.openPageCreateModal}
+            onClick={() => mutatePageCreateModalOpened(true)}
           >
           >
             <CreatePageIcon />
             <CreatePageIcon />
           </button>
           </button>
@@ -68,7 +72,7 @@ const Fab = (props) => {
         <button
         <button
           type="button"
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
-          onClick={() => navigationContainer.smoothScrollIntoView()}
+          onClick={() => smoothScrollIntoView()}
         >
         >
           <ReturnTopIcon />
           <ReturnTopIcon />
         </button>
         </button>
@@ -80,7 +84,6 @@ const Fab = (props) => {
 
 
 Fab.propTypes = {
 Fab.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(Fab, [AppContainer, NavigationContainer]);
+export default withUnstatedContainers(Fab, [AppContainer]);

+ 9 - 11
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,31 +1,29 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
 
-const CreatePage = (props) => {
+const CreatePage = React.memo((props) => {
+
+  const { mutate } = usePageCreateModalOpened();
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {
-    props.navigationContainer.openPageCreateModal();
+    mutate(true);
 
 
     // remove this
     // remove this
     props.onDeleteRender(this);
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutate, props]);
 
 
   return <></>;
   return <></>;
-};
+});
 
 
 CreatePage.propTypes = {
 CreatePage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 };
 
 
-const CreatePageWrapper = withUnstatedContainers(CreatePage, [NavigationContainer]);
-
-CreatePageWrapper.getHotkeyStrokes = () => {
+CreatePage.getHotkeyStrokes = () => {
   return [['c']];
   return [['c']];
 };
 };
 
 
-export default CreatePageWrapper;
+export default CreatePage;

+ 13 - 10
packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,36 +1,39 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
 
 
 const EditPage = (props) => {
 const EditPage = (props) => {
+  const { data: isEditable } = useIsEditable();
+  const { mutate: mutateEditorMode } = useEditorMode();
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {
+    if (!isEditable) {
+      return;
+    }
+
     // ignore when dom that has 'modal in' classes exists
     // ignore when dom that has 'modal in' classes exists
     if (document.getElementsByClassName('modal in').length > 0) {
     if (document.getElementsByClassName('modal in').length > 0) {
       return;
       return;
     }
     }
 
 
-    props.navigationContainer.setEditorMode('edit');
+    mutateEditorMode(EditorMode.Editor);
 
 
     // remove this
     // remove this
     props.onDeleteRender(this);
     props.onDeleteRender(this);
-  }, [props]);
+  }, [isEditable, mutateEditorMode, props]);
 
 
-  return <></>;
+  return null;
 };
 };
 
 
 EditPage.propTypes = {
 EditPage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 };
 
 
-const EditPageWrapper = withUnstatedContainers(EditPage, [NavigationContainer]);
-
-EditPageWrapper.getHotkeyStrokes = () => {
+EditPage.getHotkeyStrokes = () => {
   return [['e']];
   return [['e']];
 };
 };
 
 
-export default EditPageWrapper;
+export default EditPage;

+ 3 - 3
packages/app/src/components/Icons/GrowiLogo.jsx

@@ -1,6 +1,6 @@
-import React from 'react';
+import React, { memo } from 'react';
 
 
-const GrowiLogo = () => (
+const GrowiLogo = memo(() => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     width="32"
     width="32"
@@ -29,6 +29,6 @@ const GrowiLogo = () => (
     >
     >
     </path>
     </path>
   </svg>
   </svg>
-);
+));
 
 
 export default GrowiLogo;
 export default GrowiLogo;

+ 59 - 27
packages/app/src/components/LoginForm.jsx

@@ -148,6 +148,7 @@ class LoginForm extends React.Component {
     const {
     const {
       t,
       t,
       appContainer,
       appContainer,
+      isEmailAuthenticationEnabled,
       username,
       username,
       name,
       name,
       email,
       email,
@@ -155,6 +156,15 @@ class LoginForm extends React.Component {
       registrationWhiteList,
       registrationWhiteList,
     } = this.props;
     } = this.props;
 
 
+    const { isMailerSetup } = appContainer.config;
+    let registerAction = '/register';
+
+    let submitText = t('Sign up');
+    if (isEmailAuthenticationEnabled) {
+      registerAction = '/user-activation/register';
+      submitText = t('page_register.send_email');
+    }
+
     return (
     return (
       <React.Fragment>
       <React.Fragment>
         {registrationMode === 'Restricted' && (
         {registrationMode === 'Restricted' && (
@@ -164,27 +174,44 @@ class LoginForm extends React.Component {
             {t('page_register.notice.restricted_defail')}
             {t('page_register.notice.restricted_defail')}
           </p>
           </p>
         )}
         )}
-        <form role="form" action="/register" method="post" id="register-form">
-          <div className="input-group" id="input-group-username">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-user"></i>
-              </span>
-            </div>
-            <input type="text" className="form-control rounded-0" placeholder={t('User ID')} name="registerForm[username]" defaultValue={username} required />
-          </div>
-          <p className="form-text text-danger">
-            <span id="help-block-username"></span>
+        { (!isMailerSetup && isEmailAuthenticationEnabled) && (
+          <p className="alert alert-danger">
+            <span>{t('security_setting.Local.please_enable_mailer')}</span>
           </p>
           </p>
+        )}
 
 
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-tag"></i>
-              </span>
+        <form role="form" action={registerAction} method="post" id="register-form">
+
+          {!isEmailAuthenticationEnabled && (
+            <div>
+              <div className="input-group" id="input-group-username">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-user"></i>
+                  </span>
+                </div>
+                <input
+                  type="text"
+                  className="form-control rounded-0"
+                  placeholder={t('User ID')}
+                  name="registerForm[username]"
+                  defaultValue={username}
+                  required
+                />
+              </div>
+              <p className="form-text text-danger">
+                <span id="help-block-username"></span>
+              </p>
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-tag"></i>
+                  </span>
+                </div>
+                <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
+              </div>
             </div>
             </div>
-            <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
-          </div>
+          )}
 
 
           <div className="input-group">
           <div className="input-group">
             <div className="input-group-prepend">
             <div className="input-group-prepend">
@@ -210,23 +237,27 @@ class LoginForm extends React.Component {
             </>
             </>
           )}
           )}
 
 
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-lock"></i>
-              </span>
+          {!isEmailAuthenticationEnabled && (
+            <div>
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-lock"></i>
+                  </span>
+                </div>
+                <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
+              </div>
             </div>
             </div>
-            <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
-          </div>
+          )}
 
 
           <div className="input-group justify-content-center my-4">
           <div className="input-group justify-content-center my-4">
             <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
             <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
-            <button type="submit" className="btn btn-fill rounded-0" id="register">
+            <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
               <div className="eff"></div>
               <div className="eff"></div>
               <span className="btn-label">
               <span className="btn-label">
                 <i className="icon-user-follow"></i>
                 <i className="icon-user-follow"></i>
               </span>
               </span>
-              <span className="btn-label-text">{t('Sign up')}</span>
+              <span className="btn-label-text">{submitText}</span>
             </button>
             </button>
           </div>
           </div>
         </form>
         </form>
@@ -314,6 +345,7 @@ LoginForm.propTypes = {
   registrationMode: PropTypes.string,
   registrationMode: PropTypes.string,
   registrationWhiteList: PropTypes.array,
   registrationWhiteList: PropTypes.array,
   isPasswordResetEnabled: PropTypes.bool,
   isPasswordResetEnabled: PropTypes.bool,
+  isEmailAuthenticationEnabled: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   objOfIsExternalAuthEnableds: PropTypes.object,
   objOfIsExternalAuthEnableds: PropTypes.object,

+ 0 - 46
packages/app/src/components/Navbar/DrawerToggler.jsx

@@ -1,46 +0,0 @@
-import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-const DrawerToggler = (props) => {
-
-  const { navigationContainer } = props;
-
-  const clickHandler = useCallback(() => {
-    navigationContainer.toggleDrawer();
-  }, [navigationContainer]);
-
-  const iconClass = props.iconClass || 'icon-menu';
-
-  return (
-    <button
-      className="grw-drawer-toggler btn btn-secondary"
-      type="button"
-      aria-expanded="false"
-      aria-label="Toggle navigation"
-      onClick={clickHandler}
-    >
-      <i className={iconClass}></i>
-    </button>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const DrawerTogglerWrapper = withUnstatedContainers(DrawerToggler, [NavigationContainer]);
-
-
-DrawerToggler.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  iconClass: PropTypes.string,
-};
-
-export default withTranslation()(DrawerTogglerWrapper);

+ 28 - 0
packages/app/src/components/Navbar/DrawerToggler.tsx

@@ -0,0 +1,28 @@
+import React, { FC } from 'react';
+import { useDrawerOpened } from '~/stores/ui';
+
+type Props = {
+  iconClass?: string,
+}
+
+const DrawerToggler: FC<Props> = (props: Props) => {
+
+  const { data: isOpened, mutate } = useDrawerOpened();
+
+  const iconClass = props.iconClass || 'icon-menu';
+
+  return (
+    <button
+      className="grw-drawer-toggler btn btn-secondary"
+      type="button"
+      aria-expanded="false"
+      aria-label="Toggle navigation"
+      onClick={() => mutate(!isOpened)}
+    >
+      <i className={iconClass}></i>
+    </button>
+  );
+
+};
+
+export default DrawerToggler;

+ 1 - 3
packages/app/src/components/Navbar/GlobalSearch.jsx

@@ -4,7 +4,6 @@ import { withTranslation } from 'react-i18next';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 
 
 import SearchForm from '../SearchForm';
 import SearchForm from '../SearchForm';
 
 
@@ -97,7 +96,6 @@ class GlobalSearch extends React.Component {
 GlobalSearch.propTypes = {
 GlobalSearch.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 
 
   dropup: PropTypes.bool,
   dropup: PropTypes.bool,
 };
 };
@@ -105,6 +103,6 @@ GlobalSearch.propTypes = {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer, NavigationContainer]);
+const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer]);
 
 
 export default withTranslation()(GlobalSearchWrapper);
 export default withTranslation()(GlobalSearchWrapper);

+ 0 - 120
packages/app/src/components/Navbar/GrowiNavbar.jsx

@@ -1,120 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { UncontrolledTooltip } from 'reactstrap';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-import AppContainer from '~/client/services/AppContainer';
-import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
-
-
-import GrowiLogo from '../Icons/GrowiLogo';
-
-import PersonalDropdown from './PersonalDropdown';
-import GlobalSearch from './GlobalSearch';
-
-class GrowiNavbar extends React.Component {
-
-  renderNavbarRight() {
-    const { t, appContainer, navigationContainer } = this.props;
-    const { currentUser } = appContainer;
-
-    // render login button
-    if (currentUser == null) {
-      return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
-    }
-
-    return (
-      <>
-        <li className="nav-item">
-          <InAppNotificationDropdown />
-        </li>
-
-        <li className="nav-item d-none d-md-block">
-          <button className="px-md-2 nav-link btn-create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
-            <i className="icon-pencil mr-2"></i>
-            <span className="d-none d-lg-block">{ t('New') }</span>
-          </button>
-        </li>
-
-        <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
-          <PersonalDropdown />
-        </li>
-      </>
-    );
-  }
-
-  renderConfidential() {
-    const { appContainer } = this.props;
-    const { crowi } = appContainer.config;
-
-    return (
-      <li className="nav-item confidential text-light">
-        <i id="confidentialTooltip" className="icon-info d-md-none" />
-        <span className="d-none d-md-inline">
-          {crowi.confidential}
-        </span>
-        <UncontrolledTooltip
-          placement="bottom"
-          target="confidentialTooltip"
-          className="d-md-none"
-        >
-          {crowi.confidential}
-        </UncontrolledTooltip>
-      </li>
-    );
-  }
-
-  render() {
-    const { appContainer, navigationContainer } = this.props;
-    const { crowi, isSearchServiceConfigured } = appContainer.config;
-    const { isDeviceSmallerThanMd } = navigationContainer.state;
-
-    return (
-      <>
-
-        {/* Brand Logo  */}
-        <div className="navbar-brand mr-0">
-          <a className="grw-logo d-block" href="/">
-            <GrowiLogo />
-          </a>
-        </div>
-
-        <div className="grw-app-title d-none d-md-block">
-          {crowi.title}
-        </div>
-
-
-        {/* Navbar Right  */}
-        <ul className="navbar-nav ml-auto">
-          {this.renderNavbarRight()}
-          {crowi.confidential != null && this.renderConfidential()}
-        </ul>
-
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
-          <div className="grw-global-search grw-global-search-top position-absolute">
-            <GlobalSearch />
-          </div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer, NavigationContainer]);
-
-
-GrowiNavbar.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withTranslation()(GrowiNavbarWrapper);

+ 134 - 0
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -0,0 +1,134 @@
+import React, { FC, memo } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '~/interfaces/user';
+import { useIsDeviceSmallerThanMd, usePageCreateModalOpened } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import GrowiLogo from '../Icons/GrowiLogo';
+
+import PersonalDropdown from './PersonalDropdown';
+import GlobalSearch from './GlobalSearch';
+import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
+
+
+type NavbarRightProps = {
+  currentUser: IUser,
+}
+const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
+  const { t } = useTranslation();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
+  const { currentUser } = props;
+
+  // render login button
+  if (currentUser == null) {
+    return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
+  }
+
+  return (
+    <>
+      <li className="nav-item">
+        <InAppNotificationDropdown />
+      </li>
+
+      <li className="nav-item d-none d-md-block">
+        <button
+          className="px-md-2 nav-link btn-create-page border-0 bg-transparent"
+          type="button"
+          onClick={() => mutatePageCreateModalOpened(true)}
+        >
+          <i className="icon-pencil mr-2"></i>
+          <span className="d-none d-lg-block">{ t('New') }</span>
+        </button>
+      </li>
+
+      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
+        <PersonalDropdown />
+      </li>
+    </>
+  );
+});
+
+type ConfidentialProps = {
+  confidential?: string,
+}
+const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
+  const { confidential } = props;
+
+  if (confidential == null) {
+    return null;
+  }
+
+  return (
+    <li className="nav-item confidential text-light">
+      <i id="confidentialTooltip" className="icon-info d-md-none" />
+      <span className="d-none d-md-inline">
+        {confidential}
+      </span>
+      <UncontrolledTooltip
+        placement="bottom"
+        target="confidentialTooltip"
+        className="d-md-none"
+      >
+        {confidential}
+      </UncontrolledTooltip>
+    </li>
+  );
+});
+
+
+const GrowiNavbar = (props) => {
+
+  const { appContainer } = props;
+  const { currentUser } = appContainer;
+  const { crowi, isSearchServiceConfigured } = appContainer.config;
+
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
+  return (
+    <>
+      {/* Brand Logo  */}
+      <div className="navbar-brand mr-0">
+        <a className="grw-logo d-block" href="/">
+          <GrowiLogo />
+        </a>
+      </div>
+
+      <div className="grw-app-title d-none d-md-block">
+        {crowi.title}
+      </div>
+
+
+      {/* Navbar Right  */}
+      <ul className="navbar-nav ml-auto">
+        <NavbarRight currentUser={currentUser}></NavbarRight>
+        <Confidential confidential={crowi.confidential}></Confidential>
+      </ul>
+
+      { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+        <div className="grw-global-search grw-global-search-top position-absolute">
+          <GlobalSearch />
+        </div>
+      ) }
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer]);
+
+
+GrowiNavbar.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default GrowiNavbarWrapper;

+ 7 - 12
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -1,17 +1,15 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
 
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';
 
 
 const GrowiNavbarBottom = (props) => {
 const GrowiNavbarBottom = (props) => {
 
 
-  const {
-    navigationContainer,
-  } = props;
-  const { isDrawerOpened, isDeviceSmallerThanMd } = navigationContainer.state;
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
 
   const additionalClasses = ['grw-navbar-bottom'];
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
   if (isDrawerOpened) {
@@ -36,7 +34,7 @@ const GrowiNavbarBottom = (props) => {
             <a
             <a
               role="button"
               role="button"
               className="nav-link btn-lg"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.toggleDrawer()}
+              onClick={() => mutateDrawerOpened(true)}
             >
             >
               <i className="icon-menu"></i>
               <i className="icon-menu"></i>
             </a>
             </a>
@@ -55,7 +53,7 @@ const GrowiNavbarBottom = (props) => {
             <a
             <a
               role="button"
               role="button"
               className="nav-link btn-lg"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.openPageCreateModal()}
+              onClick={() => mutatePageCreateModalOpened(true)}
             >
             >
               <i className="icon-pencil"></i>
               <i className="icon-pencil"></i>
             </a>
             </a>
@@ -67,8 +65,5 @@ const GrowiNavbarBottom = (props) => {
   );
   );
 };
 };
 
 
-GrowiNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
 
 
-export default withUnstatedContainers(GrowiNavbarBottom, [NavigationContainer]);
+export default GrowiNavbarBottom;

+ 13 - 12
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,16 +1,16 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
-
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
 
 
 import CopyDropdown from '../Page/CopyDropdown';
 import CopyDropdown from '../Page/CopyDropdown';
 import TagLabels from '../Page/TagLabels';
 import TagLabels from '../Page/TagLabels';
@@ -67,21 +67,24 @@ const PagePathNav = ({
 };
 };
 
 
 const GrowiSubNavigation = (props) => {
 const GrowiSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+
   const {
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, pageContainer, isCompactMode,
   } = props;
   } = props;
-  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
     pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
   } = pageContainer.state;
 
 
   const { isGuestUser } = appContainer;
   const { isGuestUser } = appContainer;
-  const isEditorMode = editorMode !== 'view';
+  const isEditorMode = editorMode !== EditorMode.View;
   // Tags cannot be edited while the new page and editorMode is view
   // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
+  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
 
 
   function onPageEditorModeButtonClicked(viewType) {
   function onPageEditorModeButtonClicked(viewType) {
-    navigationContainer.setEditorMode(viewType);
+    mutateEditorMode(viewType);
   }
   }
 
 
   return (
   return (
@@ -145,16 +148,14 @@ const GrowiSubNavigation = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
 
 
 
 
 GrowiSubNavigation.propTypes = {
 GrowiSubNavigation.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
 
   isCompactMode: PropTypes.bool,
   isCompactMode: PropTypes.bool,
 };
 };
 
 
-export default withTranslation()(GrowiSubNavigationWrapper);
+export default GrowiSubNavigationWrapper;

+ 17 - 15
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -1,10 +1,12 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 /* eslint-disable react/prop-types */
 /* eslint-disable react/prop-types */
 const PageEditorModeButtonWrapper = React.memo(({
 const PageEditorModeButtonWrapper = React.memo(({
@@ -36,14 +38,17 @@ const PageEditorModeButtonWrapper = React.memo(({
 
 
 function PageEditorModeManager(props) {
 function PageEditorModeManager(props) {
   const {
   const {
-    t, appContainer,
-    editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+    appContainer,
+    editorMode, onPageEditorModeButtonClicked, isBtnDisabled,
   } = props;
   } = props;
 
 
+  const { t } = useTranslation();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
   const isAdmin = appContainer.isAdmin;
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== 'hackmd';
+  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
     if (isBtnDisabled) {
     if (isBtnDisabled) {
@@ -62,32 +67,32 @@ function PageEditorModeManager(props) {
         aria-label="page-editor-mode-manager"
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
       >
       >
-        {(!isDeviceSmallerThanMd || editorMode !== 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode !== EditorMode.View) && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="view"
+            targetMode={EditorMode.View}
             icon={<i className="icon-control-play" />}
             icon={<i className="icon-control-play" />}
             label={t('view')}
             label={t('view')}
           />
           />
         )}
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="edit"
+            targetMode={EditorMode.Editor}
             icon={<i className="icon-note" />}
             icon={<i className="icon-note" />}
             label={t('Edit')}
             label={t('Edit')}
           />
           />
         )}
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && showHackmdBtn && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="hackmd"
+            targetMode={EditorMode.HackMD}
             icon={<i className="fa fa-file-text-o" />}
             icon={<i className="fa fa-file-text-o" />}
             label={t('hackmd.hack_md')}
             label={t('hackmd.hack_md')}
             id="grw-page-editor-mode-manager-hackmd-button"
             id="grw-page-editor-mode-manager-hackmd-button"
@@ -110,18 +115,15 @@ function PageEditorModeManager(props) {
 }
 }
 
 
 PageEditorModeManager.propTypes = {
 PageEditorModeManager.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
   onPageEditorModeButtonClicked: PropTypes.func,
   onPageEditorModeButtonClicked: PropTypes.func,
   isBtnDisabled: PropTypes.bool,
   isBtnDisabled: PropTypes.bool,
   editorMode: PropTypes.string,
   editorMode: PropTypes.string,
-  isDeviceSmallerThanMd: PropTypes.bool,
 };
 };
 
 
 PageEditorModeManager.defaultProps = {
 PageEditorModeManager.defaultProps = {
   isBtnDisabled: false,
   isBtnDisabled: false,
-  isDeviceSmallerThanMd: false,
 };
 };
 
 
 /**
 /**
@@ -129,4 +131,4 @@ PageEditorModeManager.defaultProps = {
  */
  */
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 
 
-export default withTranslation()(PageEditorModeManagerWrapper);
+export default PageEditorModeManagerWrapper;

+ 21 - 21
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
@@ -6,9 +6,12 @@ import { withTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 
 import {
 import {
   isUserPreferenceExists,
   isUserPreferenceExists,
@@ -28,12 +31,15 @@ import SunIcon from '../Icons/SunIcon';
 
 
 const PersonalDropdown = (props) => {
 const PersonalDropdown = (props) => {
 
 
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
   const user = appContainer.currentUser || {};
   const user = appContainer.currentUser || {};
 
 
   const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
   const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
   const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
   const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
 
 
+  const { data: isPreferDrawerMode, mutate: mutatePreferDrawerMode } = usePreferDrawerModeByUser();
+  const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
+
   const logoutHandler = () => {
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
     const { interceptorManager } = appContainer;
 
 
@@ -46,13 +52,15 @@ const PersonalDropdown = (props) => {
     window.location.href = '/logout';
     window.location.href = '/logout';
   };
   };
 
 
-  const preferDrawerModeSwitchModifiedHandler = (bool) => {
-    navigationContainer.setDrawerModePreference(bool);
-  };
+  const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
+    mutatePreferDrawerMode(bool);
+    scheduleToPutUserUISettings({ preferDrawerModeByUser: bool });
+  }, [mutatePreferDrawerMode]);
 
 
-  const preferDrawerModeOnEditSwitchModifiedHandler = (bool) => {
-    navigationContainer.setDrawerModePreferenceOnEdit(bool);
-  };
+  const preferDrawerModeOnEditSwitchModifiedHandler = useCallback((bool) => {
+    mutatePreferDrawerModeOnEdit(bool);
+    scheduleToPutUserUISettings({ preferDrawerModeOnEditByUser: bool });
+  }, [mutatePreferDrawerModeOnEdit]);
 
 
   const followOsCheckboxModifiedHandler = (bool) => {
   const followOsCheckboxModifiedHandler = (bool) => {
     if (bool) {
     if (bool) {
@@ -77,13 +85,6 @@ const PersonalDropdown = (props) => {
   };
   };
 
 
 
 
-  /*
-   * render
-   */
-  const {
-    preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-  } = navigationContainer.state;
-
   /* eslint-disable react/prop-types */
   /* eslint-disable react/prop-types */
   const IconWithTooltip = ({
   const IconWithTooltip = ({
     id, label, children, additionalClasses,
     id, label, children, additionalClasses,
@@ -144,7 +145,7 @@ const PersonalDropdown = (props) => {
                   id="swSidebarMode"
                   id="swSidebarMode"
                   className="custom-control-input"
                   className="custom-control-input"
                   type="checkbox"
                   type="checkbox"
-                  checked={!preferDrawerModeByUser}
+                  checked={!isPreferDrawerMode}
                   onChange={e => preferDrawerModeSwitchModifiedHandler(!e.target.checked)}
                   onChange={e => preferDrawerModeSwitchModifiedHandler(!e.target.checked)}
                 />
                 />
                 <label className="custom-control-label" htmlFor="swSidebarMode"></label>
                 <label className="custom-control-label" htmlFor="swSidebarMode"></label>
@@ -169,7 +170,7 @@ const PersonalDropdown = (props) => {
                   id="swSidebarModeOnEditor"
                   id="swSidebarModeOnEditor"
                   className="custom-control-input"
                   className="custom-control-input"
                   type="checkbox"
                   type="checkbox"
-                  checked={!preferDrawerModeOnEditByUser}
+                  checked={!isPreferDrawerModeOnEdit}
                   onChange={e => preferDrawerModeOnEditSwitchModifiedHandler(!e.target.checked)}
                   onChange={e => preferDrawerModeOnEditSwitchModifiedHandler(!e.target.checked)}
                 />
                 />
                 <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
                 <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
@@ -236,13 +237,12 @@ const PersonalDropdown = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer, NavigationContainer]);
+const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer]);
 
 
 
 
 PersonalDropdown.propTypes = {
 PersonalDropdown.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(PersonalDropdownWrapper);
 export default withTranslation()(PersonalDropdownWrapper);

+ 6 - 6
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import BookmarkButton from '../BookmarkButton';
 import BookmarkButton from '../BookmarkButton';
@@ -12,9 +12,11 @@ import PageManagement from '../Page/PageManagement';
 
 
 const SubnavButtons = React.memo((props) => {
 const SubnavButtons = React.memo((props) => {
   const {
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, pageContainer, isCompactMode,
   } = props;
   } = props;
 
 
+  const { data: editorMode } = useEditorMode();
+
   /* eslint-disable react/prop-types */
   /* eslint-disable react/prop-types */
   const PageReactionButtons = ({ pageContainer }) => {
   const PageReactionButtons = ({ pageContainer }) => {
 
 
@@ -36,8 +38,7 @@ const SubnavButtons = React.memo((props) => {
   };
   };
   /* eslint-enable react/prop-types */
   /* eslint-enable react/prop-types */
 
 
-  const { editorMode } = navigationContainer.state;
-  const isViewMode = editorMode === 'view';
+  const isViewMode = editorMode === EditorMode.View;
 
 
   return (
   return (
     <>
     <>
@@ -54,12 +55,11 @@ const SubnavButtons = React.memo((props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, NavigationContainer, PageContainer]);
+const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, PageContainer]);
 
 
 
 
 SubnavButtons.propTypes = {
 SubnavButtons.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
 
   isCompactMode: PropTypes.bool,
   isCompactMode: PropTypes.bool,

+ 42 - 7
packages/app/src/components/Page.jsx

@@ -17,6 +17,13 @@ import DrawioModal from './PageEditor/DrawioModal';
 import mtu from './PageEditor/MarkdownTableUtil';
 import mtu from './PageEditor/MarkdownTableUtil';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 
 
+import { getOptionsToSave } from '~/client/util/editor';
+
+// TODO: remove this when omitting unstated is completed
+import { useEditorMode } from '~/stores/ui';
+import { useIsSlackEnabled } from '~/stores/editor';
+import { useSlackChannels } from '~/stores/context';
+
 const logger = loggerFactory('growi:Page');
 const logger = loggerFactory('growi:Page');
 
 
 class Page extends React.Component {
 class Page extends React.Component {
@@ -70,8 +77,10 @@ class Page extends React.Component {
   }
   }
 
 
   async saveHandlerForHandsontableModal(markdownTable) {
   async saveHandlerForHandsontableModal(markdownTable) {
-    const { pageContainer, editorContainer } = this.props;
-    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
 
 
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
       markdownTable,
@@ -85,7 +94,7 @@ class Page extends React.Component {
       editorContainer.disableUnsavedWarning();
       editorContainer.disableUnsavedWarning();
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
       logger.debug('success to save');
 
 
       pageContainer.showSuccessToastr();
       pageContainer.showSuccessToastr();
@@ -100,8 +109,10 @@ class Page extends React.Component {
   }
   }
 
 
   async saveHandlerForDrawioModal(drawioData) {
   async saveHandlerForDrawioModal(drawioData) {
-    const { pageContainer, editorContainer } = this.props;
-    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
 
 
     const newMarkdown = mdu.replaceDrawioInMarkdown(
     const newMarkdown = mdu.replaceDrawioInMarkdown(
       drawioData,
       drawioData,
@@ -115,7 +126,7 @@ class Page extends React.Component {
       editorContainer.disableUnsavedWarning();
       editorContainer.disableUnsavedWarning();
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
       logger.debug('success to save');
 
 
       pageContainer.showSuccessToastr();
       pageContainer.showSuccessToastr();
@@ -157,6 +168,30 @@ Page.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
+};
+
+const PageWrapper = (props) => {
+  const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
+
+  if (editorMode == null) {
+    return null;
+  }
+
+  return (
+    <Page
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
 };
 };
 
 
-export default withUnstatedContainers(Page, [AppContainer, PageContainer, EditorContainer]);
+export default withUnstatedContainers(PageWrapper, [AppContainer, PageContainer, EditorContainer]);

+ 30 - 17
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import React from 'react';
 import { TabContent, TabPane } from 'reactstrap';
 import { TabContent, TabPane } from 'reactstrap';
 import propTypes from 'prop-types';
 import propTypes from 'prop-types';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import Editor from '../PageEditor';
 import Editor from '../PageEditor';
 import Page from '../Page';
 import Page from '../Page';
 import UserInfo from '../User/UserInfo';
 import UserInfo from '../User/UserInfo';
@@ -12,19 +14,25 @@ import ContentLinkButtons from '../ContentLinkButtons';
 import PageAccessories from '../PageAccessories';
 import PageAccessories from '../PageAccessories';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+import { useIsEditable } from '~/stores/context';
 
 
 
 
 const DisplaySwitcher = (props) => {
 const DisplaySwitcher = (props) => {
   const {
   const {
-    navigationContainer, pageContainer,
+    pageContainer,
   } = props;
   } = props;
-  const { editorMode } = navigationContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
 
 
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
   return (
     <>
     <>
       <TabContent activeTab={editorMode}>
       <TabContent activeTab={editorMode}>
-        <TabPane tabId="view">
+        <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
           <div className="d-flex flex-column flex-lg-row-reverse">
 
 
             <div className="grw-side-contents-container">
             <div className="grw-side-contents-container">
@@ -49,26 +57,31 @@ const DisplaySwitcher = (props) => {
 
 
           </div>
           </div>
         </TabPane>
         </TabPane>
-        <TabPane tabId="edit">
-          <div id="page-editor">
-            <Editor />
-          </div>
-        </TabPane>
-        <TabPane tabId="hackmd">
-          <div id="page-editor-with-hackmd">
-            <PageEditorByHackmd />
-          </div>
-        </TabPane>
+        { isEditable && (
+          <TabPane tabId={EditorMode.Editor}>
+            <div id="page-editor">
+              <Editor />
+            </div>
+          </TabPane>
+        ) }
+        { isEditable && (
+          <TabPane tabId={EditorMode.HackMD}>
+            <div id="page-editor-with-hackmd">
+              <PageEditorByHackmd />
+            </div>
+          </TabPane>
+        ) }
       </TabContent>
       </TabContent>
-      {editorMode !== 'view' && <EditorNavbarBottom /> }
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
     </>
     </>
   );
   );
 };
 };
 
 
 DisplaySwitcher.propTypes = {
 DisplaySwitcher.propTypes = {
-  navigationContainer: propTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: propTypes.instanceOf(PageContainer).isRequired,
   pageContainer: propTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
 
 
-export default withUnstatedContainers(DisplaySwitcher, [NavigationContainer, PageContainer]);
+export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 13 - 13
packages/app/src/components/Page/NotFoundAlert.jsx

@@ -1,24 +1,26 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 
 
 const NotFoundAlert = (props) => {
 const NotFoundAlert = (props) => {
-  const { t, isHidden, isGuestUserMode } = props;
-  function clickHandler(viewType) {
+  const { t } = useTranslation();
+  const { isHidden, isGuestUserMode } = props;
 
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const clickHandler = useCallback(() => {
     // check guest user,
     // check guest user,
     // disabled of button cannot be used for using tooltip.
     // disabled of button cannot be used for using tooltip.
     if (isGuestUserMode) {
     if (isGuestUserMode) {
       return;
       return;
     }
     }
 
 
-    if (props.onPageCreateClicked === null) {
-      return;
-    }
-    props.onPageCreateClicked(viewType);
-  }
+    mutateEditorMode(EditorMode.Editor);
+
+  }, [isGuestUserMode, mutateEditorMode]);
 
 
   if (isHidden) {
   if (isHidden) {
     return null;
     return null;
@@ -38,7 +40,7 @@ const NotFoundAlert = (props) => {
           <button
           <button
             type="button"
             type="button"
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={() => { clickHandler('edit') }}
+            onClick={clickHandler}
           >
           >
             <i className="icon-note icon-fw" />
             <i className="icon-note icon-fw" />
             {t('not_found_page.Create Page')}
             {t('not_found_page.Create Page')}
@@ -58,10 +60,8 @@ const NotFoundAlert = (props) => {
 
 
 
 
 NotFoundAlert.propTypes = {
 NotFoundAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  onPageCreateClicked: PropTypes.func,
   isHidden: PropTypes.bool.isRequired,
   isHidden: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
 };
 };
 
 
-export default withTranslation()(NotFoundAlert);
+export default NotFoundAlert;

+ 21 - 8
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -3,12 +3,13 @@ import PropTypes from 'prop-types';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { blinkElem } from '~/client/util/blink-section-header';
 
 
 import RevisionBody from './RevisionBody';
 import RevisionBody from './RevisionBody';
 
 
-class RevisionRenderer extends React.PureComponent {
+class LegacyRevisionRenderer extends React.PureComponent {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -35,7 +36,7 @@ class RevisionRenderer extends React.PureComponent {
 
 
   componentDidUpdate(prevProps) {
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords, navigationContainer } = this.props;
+    const { markdown, highlightKeywords } = this.props;
 
 
     // render only when props.markdown is updated
     // render only when props.markdown is updated
     if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
     if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
@@ -46,7 +47,7 @@ class RevisionRenderer extends React.PureComponent {
 
 
     const HeaderLink = document.getElementsByClassName('revision-head-link');
     const HeaderLink = document.getElementsByClassName('revision-head-link');
     const HeaderLinkArray = Array.from(HeaderLink);
     const HeaderLinkArray = Array.from(HeaderLink);
-    navigationContainer.addSmoothScrollEvent(HeaderLinkArray);
+    addSmoothScrollEvent(HeaderLinkArray, blinkElem);
 
 
     const { interceptorManager } = this.props.appContainer;
     const { interceptorManager } = this.props.appContainer;
 
 
@@ -117,18 +118,30 @@ class RevisionRenderer extends React.PureComponent {
 
 
 }
 }
 
 
+LegacyRevisionRenderer.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
+  markdown: PropTypes.string.isRequired,
+  highlightKeywords: PropTypes.string,
+  additionalClassName: PropTypes.string,
+};
+
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, NavigationContainer]);
+const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRenderer, [AppContainer]);
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const RevisionRenderer = (props) => {
+  return <LegacyRevisionRendererWrapper {...props} />;
+};
 
 
 RevisionRenderer.propTypes = {
 RevisionRenderer.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
   highlightKeywords: PropTypes.string,
   additionalClassName: PropTypes.string,
   additionalClassName: PropTypes.string,
 };
 };
 
 
-export default RevisionRendererWrapper;
+export default RevisionRenderer;

+ 4 - 3
packages/app/src/components/Page/TagLabels.jsx

@@ -11,6 +11,7 @@ import EditorContainer from '~/client/services/EditorContainer';
 
 
 import RenderTagLabels from './RenderTagLabels';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 import TagEditModal from './TagEditModal';
+import { EditorMode } from '~/stores/ui';
 
 
 class TagLabels extends React.Component {
 class TagLabels extends React.Component {
 
 
@@ -33,7 +34,7 @@ class TagLabels extends React.Component {
    */
    */
   getTagData() {
   getTagData() {
     const { editorContainer, pageContainer, editorMode } = this.props;
     const { editorContainer, pageContainer, editorMode } = this.props;
-    return (editorMode === 'edit') ? editorContainer.state.tags : pageContainer.state.tags;
+    return (editorMode === EditorMode.Editor) ? editorContainer.state.tags : pageContainer.state.tags;
   }
   }
 
 
   openEditorModal() {
   openEditorModal() {
@@ -52,7 +53,7 @@ class TagLabels extends React.Component {
     const { pageId } = pageContainer.state;
     const { pageId } = pageContainer.state;
 
 
     // It will not be reflected in the DB until the page is refreshed
     // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === 'edit') {
+    if (editorMode === EditorMode.Editor) {
       return editorContainer.setState({ tags: newTags });
       return editorContainer.setState({ tags: newTags });
     }
     }
 
 
@@ -116,7 +117,7 @@ TagLabels.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
 
-  editorMode: PropTypes.string.isRequired,
+  editorMode: PropTypes.string,
 };
 };
 
 
 export default withTranslation()(TagLabelsWrapper);
 export default withTranslation()(TagLabelsWrapper);

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

@@ -17,7 +17,7 @@ import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import Editor from '../PageEditor/Editor';
 import Editor from '../PageEditor/Editor';
-import SlackNotification from '../SlackNotification';
+import { SlackNotification } from '../SlackNotification';
 
 
 import CommentPreview from './CommentPreview';
 import CommentPreview from './CommentPreview';
 import NotAvailableForGuest from '../NotAvailableForGuest';
 import NotAvailableForGuest from '../NotAvailableForGuest';

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

@@ -6,6 +6,7 @@ import AuthorInfo from './Navbar/AuthorInfo';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
+import { usePath } from '~/stores/context';
 
 
 const PageContentFooter = (props) => {
 const PageContentFooter = (props) => {
   const { pageContainer } = props;
   const { pageContainer } = props;

+ 8 - 7
packages/app/src/components/PageCreateModal.jsx

@@ -11,9 +11,9 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 
@@ -22,7 +22,9 @@ const {
 } = pagePathUtils;
 } = pagePathUtils;
 
 
 const PageCreateModal = (props) => {
 const PageCreateModal = (props) => {
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
+
+  const { data: isPageCreateModalOpened, mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
 
   const config = appContainer.getConfig();
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
@@ -264,12 +266,12 @@ const PageCreateModal = (props) => {
   return (
   return (
     <Modal
     <Modal
       size="lg"
       size="lg"
-      isOpen={navigationContainer.state.isPageCreateModalShown}
-      toggle={navigationContainer.closePageCreateModal}
+      isOpen={isPageCreateModalOpened}
+      toggle={() => mutatePageCreateModalOpened(false)}
       className="grw-create-page"
       className="grw-create-page"
       autoFocus={false}
       autoFocus={false}
     >
     >
-      <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={() => mutatePageCreateModalOpened(false)} className="bg-primary text-light">
         {t('New Page')}
         {t('New Page')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -286,13 +288,12 @@ const PageCreateModal = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer, NavigationContainer]);
+const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
 
 
 
 
 PageCreateModal.propTypes = {
 PageCreateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(ModalControlWrapper);
 export default withTranslation()(ModalControlWrapper);

+ 48 - 6
packages/app/src/components/PageEditor.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 
 
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
-import { envUtils } from 'growi-commons';
+import { envUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
@@ -15,6 +15,13 @@ import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 
 
+import { getOptionsToSave } from '~/client/util/editor';
+
+// TODO: remove this when omitting unstated is completed
+import { useEditorMode } from '~/stores/ui';
+import { useIsEditable, useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
+
 const logger = loggerFactory('growi:PageEditor');
 const logger = loggerFactory('growi:PageEditor');
 
 
 class PageEditor extends React.Component {
 class PageEditor extends React.Component {
@@ -124,15 +131,18 @@ class PageEditor extends React.Component {
    * save and update state of containers
    * save and update state of containers
    */
    */
   async onSaveWithShortcut() {
   async onSaveWithShortcut() {
-    const { pageContainer, editorContainer } = this.props;
-    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+    const {
+      isSlackEnabled, slackChannels, editorContainer, pageContainer,
+    } = this.props;
+
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
       editorContainer.disableUnsavedWarning();
       editorContainer.disableUnsavedWarning();
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(this.state.markdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(this.state.markdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
       logger.debug('success to save');
 
 
       pageContainer.showSuccessToastr();
       pageContainer.showSuccessToastr();
@@ -186,7 +196,7 @@ class PageEditor extends React.Component {
       // when if created newly
       // when if created newly
       if (res.pageCreated) {
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
         logger.info('Page is created', res.page._id);
-        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision);
+        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, this.props.editorMode);
         editorContainer.setState({ grant: res.page.grant });
         editorContainer.setState({ grant: res.page.grant });
       }
       }
     }
     }
@@ -306,6 +316,10 @@ class PageEditor extends React.Component {
   }
   }
 
 
   render() {
   render() {
+    if (!this.props.isEditable) {
+      return null;
+    }
+
     const config = this.props.appContainer.getConfig();
     const config = this.props.appContainer.getConfig();
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
@@ -347,12 +361,40 @@ class PageEditor extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+
+const PageEditorWrapper = (props) => {
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
+
+  if (isEditable == null || editorMode == null) {
+    return null;
+  }
+
+  return (
+    <PageEditorHOCWrapper
+      {...props}
+      isEditable={isEditable}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
+};
 
 
 PageEditor.propTypes = {
 PageEditor.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isEditable: PropTypes.bool.isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 };
 
 
 export default PageEditorWrapper;
 export default PageEditorWrapper;

+ 32 - 3
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -66,6 +66,7 @@ require('codemirror/addon/display/placeholder');
 require('codemirror/addon/lint/lint');
 require('codemirror/addon/lint/lint');
 require('codemirror/addon/lint/lint.css');
 require('codemirror/addon/lint/lint.css');
 require('~/client/util/codemirror/autorefresh.ext');
 require('~/client/util/codemirror/autorefresh.ext');
+require('~/client/util/codemirror/drawio-fold.ext');
 require('~/client/util/codemirror/gfm-growi.mode');
 require('~/client/util/codemirror/gfm-growi.mode');
 // import modes to highlight
 // import modes to highlight
 require('codemirror/mode/clike/clike');
 require('codemirror/mode/clike/clike');
@@ -149,6 +150,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
+
+    this.foldDrawioSection = this.foldDrawioSection.bind(this);
+    this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
   }
   }
 
 
   init() {
   init() {
@@ -185,6 +189,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
     // set keymap
     // set keymap
     const keymapMode = this.props.editorOptions.keymapMode;
     const keymapMode = this.props.editorOptions.keymapMode;
     this.setKeymapMode(keymapMode);
     this.setKeymapMode(keymapMode);
+
+    // fold drawio section
+    this.foldDrawioSection();
   }
   }
 
 
   componentWillReceiveProps(nextProps) {
   componentWillReceiveProps(nextProps) {
@@ -195,6 +202,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
     // set keymap
     // set keymap
     const keymapMode = nextProps.editorOptions.keymapMode;
     const keymapMode = nextProps.editorOptions.keymapMode;
     this.setKeymapMode(keymapMode);
     this.setKeymapMode(keymapMode);
+
+    // fold drawio section
+    this.foldDrawioSection();
   }
   }
 
 
   async initializeTextlint() {
   async initializeTextlint() {
@@ -264,7 +274,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const linePosition = Math.max(0, line);
     const linePosition = Math.max(0, line);
 
 
     editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
     editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
-    this.setScrollTopByLine(linePosition);
+
+    setTimeout(() => {
+      this.setScrollTopByLine(linePosition);
+    }, 100);
   }
   }
 
 
   /**
   /**
@@ -277,7 +290,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     const editor = this.getCodeMirror();
     const editor = this.getCodeMirror();
     // get top position of the line
     // get top position of the line
-    const top = editor.charCoords({ line, ch: 0 }, 'local').top;
+    const top = editor.charCoords({ line: line - 1, ch: 0 }, 'local').top;
     editor.scrollTo(null, top);
     editor.scrollTo(null, top);
   }
   }
 
 
@@ -738,6 +751,22 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
     this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
   }
   }
 
 
+  // fold draw.io section (::: drawio ~ :::)
+  foldDrawioSection() {
+    const editor = this.getCodeMirror();
+    const lineNumbers = mdu.findAllDrawioSection(editor);
+    lineNumbers.forEach((lineNumber) => {
+      editor.foldCode({ line: lineNumber, ch: 0 }, { scanUp: false }, 'fold');
+    });
+  }
+
+  onSaveForDrawio(drawioData) {
+    const range = mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData);
+    // Fold the section after the drawio section (:::drawio) has been updated.
+    this.foldDrawioSection();
+    return range;
+  }
+
   getNavbarItems() {
   getNavbarItems() {
     return [
     return [
       <Button
       <Button
@@ -971,7 +1000,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         />
         />
         <DrawioModal
         <DrawioModal
           ref={this.drawioModal}
           ref={this.drawioModal}
-          onSave={(drawioData) => { return mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData) }}
+          onSave={this.onSaveForDrawio}
         />
         />
 
 
       </React.Fragment>
       </React.Fragment>

+ 36 - 30
packages/app/src/components/PageEditor/EditorNavbarBottom.jsx → packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,48 +1,57 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { Collapse, Button } from 'reactstrap';
 import { Collapse, Button } from 'reactstrap';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import SlackNotification from '../SlackNotification';
+import {
+  EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
+
+import { SlackNotification } from '../SlackNotification';
 import SlackLogo from '../SlackLogo';
 import SlackLogo from '../SlackLogo';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import SavePageControls from '../SavePageControls';
 import SavePageControls from '../SavePageControls';
 
 
 import OptionsSelector from './OptionsSelector';
 import OptionsSelector from './OptionsSelector';
+import { useIsSlackEnabled } from '~/stores/editor';
+import { useSlackChannels } from '~/stores/context';
 
 
 const EditorNavbarBottom = (props) => {
 const EditorNavbarBottom = (props) => {
 
 
+  const { data: editorMode } = useEditorMode();
+
   const [isExpanded, setExpanded] = useState(false);
   const [isExpanded, setExpanded] = useState(false);
 
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
   const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
 
-  const {
-    navigationContainer,
-  } = props;
-  const { editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
-
+  const { mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels, mutate: mutateSlackChannels } = useSlackChannels();
   const additionalClasses = ['grw-editor-navbar-bottom'];
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
 
+  const isSlackEnabledToggleHandler = useCallback(
+    (bool: boolean) => mutateIsSlackEnabled(bool), [mutateIsSlackEnabled],
+  );
+
+  const slackChannelsChangedHandler = useCallback(
+    (slackChannels: string) => mutateSlackChannels(slackChannels), [mutateSlackChannels],
+  );
+
   const renderDrawerButton = () => (
   const renderDrawerButton = () => (
-    <button type="button" className="btn btn-outline-secondary border-0" onClick={() => navigationContainer.toggleDrawer()}>
+    <button
+      type="button"
+      className="btn btn-outline-secondary border-0"
+      onClick={() => mutateDrawerOpened(true)}
+    >
       <i className="icon-menu"></i>
       <i className="icon-menu"></i>
     </button>
     </button>
   );
   );
 
 
-  const slackEnabledFlagChangedHandler = (isSlackEnabled) => {
-    props.editorContainer.setState({ isSlackEnabled });
-  };
-
-  const slackChannelsChangedHandler = (slackChannels) => {
-    props.editorContainer.setState({ slackChannels });
-  };
-
-  // eslint-disable-next-line react/prop-types
   const renderExpandButton = () => (
   const renderExpandButton = () => (
     <div className="d-md-none ml-2">
     <div className="d-md-none ml-2">
       <button
       <button
@@ -55,22 +64,21 @@ const EditorNavbarBottom = (props) => {
     </div>
     </div>
   );
   );
 
 
-  const isOptionsSelectorEnabled = editorMode !== 'hackmd';
+  const isOptionsSelectorEnabled = editorMode !== EditorMode.HackMD;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
 
 
   return (
   return (
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
       {/* Collapsed SlackNotification */}
       {/* Collapsed SlackNotification */}
       {isSlackConfigured && (
       {isSlackConfigured && (
-        <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd}>
+        <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd === true}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
             <SlackNotification
             <SlackNotification
-              isSlackEnabled={props.editorContainer.state.isSlackEnabled}
-              slackChannels={props.editorContainer.state.slackChannels}
-              onEnabledFlagChange={slackEnabledFlagChangedHandler}
+              isSlackEnabled={isSlackEnabled ?? false}
+              slackChannels={slackChannels}
+              onEnabledFlagChange={isSlackEnabledToggleHandler}
               onChannelChange={slackChannelsChangedHandler}
               onChannelChange={slackChannelsChangedHandler}
               id="idForEditorNavbarBottomForMobile"
               id="idForEditorNavbarBottomForMobile"
-              popUp
             />
             />
           </nav>
           </nav>
         </Collapse>
         </Collapse>
@@ -97,12 +105,11 @@ const EditorNavbarBottom = (props) => {
           ) : (
           ) : (
             <div className="mr-2">
             <div className="mr-2">
               <SlackNotification
               <SlackNotification
-                isSlackEnabled={props.editorContainer.state.isSlackEnabled}
-                slackChannels={props.editorContainer.state.slackChannels}
-                onEnabledFlagChange={slackEnabledFlagChangedHandler}
+                isSlackEnabled={isSlackEnabled ?? false}
+                slackChannels={slackChannels}
+                onEnabledFlagChange={isSlackEnabledToggleHandler}
                 onChannelChange={slackChannelsChangedHandler}
                 onChannelChange={slackChannelsChangedHandler}
                 id="idForEditorNavbarBottom"
                 id="idForEditorNavbarBottom"
-                popUp={false}
               />
               />
             </div>
             </div>
           ))}
           ))}
@@ -127,9 +134,8 @@ const EditorNavbarBottom = (props) => {
 };
 };
 
 
 EditorNavbarBottom.propTypes = {
 EditorNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer, EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);

+ 2 - 1
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -162,7 +162,8 @@ class LinkEditModal extends React.PureComponent {
       const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
       const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
 
 
       try {
       try {
-        const { page } = await this.props.appContainer.apiGet('/pages.get', { path: pathWithoutFragment, page_id: pageId });
+        const { data } = await this.props.appContainer.apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { page } = data;
         markdown = page.revision.body;
         markdown = page.revision.body;
         permalink = page.id;
         permalink = page.id;
       }
       }

+ 16 - 0
packages/app/src/components/PageEditor/MarkdownDrawioUtil.js

@@ -155,6 +155,22 @@ class MarkdownDrawioUtil {
     return newMarkdown;
     return newMarkdown;
   }
   }
 
 
+  /**
+   * return an array of the starting line numbers of the drawio sections found in markdown
+   */
+  findAllDrawioSection(editor) {
+    const lineNumbers = [];
+    // refs: https://github.com/codemirror/CodeMirror/blob/5.64.0/addon/fold/foldcode.js#L106-L111
+    for (let i = editor.firstLine(), e = editor.lastLine(); i <= e; i++) {
+      const line = editor.getLine(i);
+      const match = this.lineBeginPartOfDrawioRE.exec(line);
+      if (match) {
+        lineNumbers.push(i);
+      }
+    }
+    return lineNumbers;
+  }
+
 }
 }
 
 
 // singleton pattern
 // singleton pattern

+ 1 - 1
packages/app/src/components/PageEditor/MarkdownTableInterceptor.js

@@ -1,4 +1,4 @@
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 
 import mtu from './MarkdownTableUtil';
 import mtu from './MarkdownTableUtil';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';

+ 36 - 0
packages/app/src/components/PageEditor/OptionsSelector.jsx

@@ -22,6 +22,7 @@ export const defaultEditorOptions = {
 
 
 export const defaultPreviewOptions = {
 export const defaultPreviewOptions = {
   renderMathJaxInRealtime: false,
   renderMathJaxInRealtime: false,
+  renderDrawioInRealtime: true,
 };
 };
 
 
 class OptionsSelector extends React.Component {
 class OptionsSelector extends React.Component {
@@ -54,6 +55,7 @@ class OptionsSelector extends React.Component {
     this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
     this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
     this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
     this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
+    this.onClickRenderDrawioInRealtime = this.onClickRenderDrawioInRealtime.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
     this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
     this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
     this.confirmEnableTextlintHandler = this.confirmEnableTextlintHandler.bind(this);
     this.confirmEnableTextlintHandler = this.confirmEnableTextlintHandler.bind(this);
@@ -108,6 +110,17 @@ class OptionsSelector extends React.Component {
     editorContainer.saveOptsToLocalStorage();
     editorContainer.saveOptsToLocalStorage();
   }
   }
 
 
+  onClickRenderDrawioInRealtime(event) {
+    const { editorContainer } = this.props;
+
+    const newValue = !editorContainer.state.previewOptions.renderDrawioInRealtime;
+    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderDrawioInRealtime: newValue });
+    editorContainer.setState({ previewOptions: newOpts });
+
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
+  }
+
   onClickMarkdownTableAutoFormatting(event) {
   onClickMarkdownTableAutoFormatting(event) {
     const { editorContainer } = this.props;
     const { editorContainer } = this.props;
 
 
@@ -249,6 +262,7 @@ class OptionsSelector extends React.Component {
           <DropdownMenu>
           <DropdownMenu>
             {this.renderActiveLineMenuItem()}
             {this.renderActiveLineMenuItem()}
             {this.renderRealtimeMathJaxMenuItem()}
             {this.renderRealtimeMathJaxMenuItem()}
+            {this.renderRealtimeDrawioMenuItem()}
             {this.renderMarkdownTableAutoFormattingMenuItem()}
             {this.renderMarkdownTableAutoFormattingMenuItem()}
             {this.renderIsTextlintEnabledMenuItem()}
             {this.renderIsTextlintEnabledMenuItem()}
             {/* <DropdownItem divider /> */}
             {/* <DropdownItem divider /> */}
@@ -308,6 +322,28 @@ class OptionsSelector extends React.Component {
     );
     );
   }
   }
 
 
+  renderRealtimeDrawioMenuItem() {
+    const { editorContainer } = this.props;
+
+    const isActive = editorContainer.state.previewOptions.renderDrawioInRealtime;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={this.onClickRenderDrawioInRealtime}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
+          <span className="menuitem-label">draw.io Rendering</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }
+
   renderMarkdownTableAutoFormattingMenuItem() {
   renderMarkdownTableAutoFormattingMenuItem() {
     const { t, editorContainer } = this.props;
     const { t, editorContainer } = this.props;
     // Auto-formatting was enabled before optionalizing, so we made it a disabled option(ignoreMarkdownTableAutoFormatting).
     // Auto-formatting was enabled before optionalizing, so we made it a disabled option(ignoreMarkdownTableAutoFormatting).

+ 1 - 1
packages/app/src/components/PageEditor/PreventMarkdownListInterceptor.js

@@ -1,4 +1,4 @@
-import { BasicInterceptor } from 'growi-commons';
+import { BasicInterceptor } from '@growi/core';
 
 
 import mlu from './MarkdownListUtil';
 import mlu from './MarkdownListUtil';
 
 

+ 37 - 4
packages/app/src/components/PageEditorByHackmd.jsx

@@ -11,6 +11,13 @@ import EditorContainer from '~/client/services/EditorContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
 
+import { getOptionsToSave } from '~/client/util/editor';
+
+// TODO: remove this when omitting unstated is completed
+import { useEditorMode } from '~/stores/ui';
+import { useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
+
 const logger = loggerFactory('growi:PageEditorByHackmd');
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
 
 class PageEditorByHackmd extends React.Component {
 class PageEditorByHackmd extends React.Component {
@@ -163,15 +170,17 @@ class PageEditorByHackmd extends React.Component {
    * @param {string} markdown
    * @param {string} markdown
    */
    */
   async onSaveWithShortcut(markdown) {
   async onSaveWithShortcut(markdown) {
-    const { pageContainer, editorContainer } = this.props;
-    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
       editorContainer.disableUnsavedWarning();
       editorContainer.disableUnsavedWarning();
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(markdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(markdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
       logger.debug('success to save');
 
 
       pageContainer.showSuccessToastr();
       pageContainer.showSuccessToastr();
@@ -417,7 +426,26 @@ class PageEditorByHackmd extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PageEditorByHackmdWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
+const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
+
+const PageEditorByHackmdWrapper = (props) => {
+  const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
+
+  if (editorMode == null) {
+    return null;
+  }
+
+  return (
+    <PageEditorByHackmdHOCWrapper
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
+};
 
 
 PageEditorByHackmd.propTypes = {
 PageEditorByHackmd.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
@@ -425,6 +453,11 @@ PageEditorByHackmd.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(PageEditorByHackmdWrapper);
 export default withTranslation()(PageEditorByHackmdWrapper);

+ 49 - 6
packages/app/src/components/SavePageControls.jsx

@@ -17,6 +17,13 @@ import EditorContainer from '~/client/services/EditorContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import GrantSelector from './SavePageControls/GrantSelector';
 import GrantSelector from './SavePageControls/GrantSelector';
 
 
+import { getOptionsToSave } from '~/client/util/editor';
+
+// TODO: remove this when omitting unstated is completed
+import { useEditorMode } from '~/stores/ui';
+import { useIsEditable, useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
+
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
 class SavePageControls extends React.Component {
 class SavePageControls extends React.Component {
@@ -31,6 +38,7 @@ class SavePageControls extends React.Component {
 
 
     this.save = this.save.bind(this);
     this.save = this.save.bind(this);
     this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
     this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
+
   }
   }
 
 
   updateGrantHandler(data) {
   updateGrantHandler(data) {
@@ -38,13 +46,16 @@ class SavePageControls extends React.Component {
   }
   }
 
 
   async save() {
   async save() {
-    const { pageContainer, editorContainer } = this.props;
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
     // disable unsaved warning
     // disable unsaved warning
     editorContainer.disableUnsavedWarning();
     editorContainer.disableUnsavedWarning();
 
 
     try {
     try {
       // save
       // save
-      await pageContainer.saveAndReload(editorContainer.getCurrentOptionsToSave());
+      const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+      await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
@@ -53,14 +64,17 @@ class SavePageControls extends React.Component {
   }
   }
 
 
   saveAndOverwriteScopesOfDescendants() {
   saveAndOverwriteScopesOfDescendants() {
-    const { pageContainer, editorContainer } = this.props;
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
     // disable unsaved warning
     // disable unsaved warning
     editorContainer.disableUnsavedWarning();
     editorContainer.disableUnsavedWarning();
     // save
     // save
-    const optionsToSave = Object.assign(editorContainer.getCurrentOptionsToSave(), {
+    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+    const optionsToSave = Object.assign(currentOptionsToSave, {
       overwriteScopesOfDescendants: true,
       overwriteScopesOfDescendants: true,
     });
     });
-    pageContainer.saveAndReload(optionsToSave);
+    pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
   }
   }
 
 
   render() {
   render() {
@@ -107,7 +121,31 @@ class SavePageControls extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SavePageControlsWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+
+const SavePageControlsWrapper = (props) => {
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
+
+  if (isEditable == null || editorMode == null) {
+    return null;
+  }
+
+  if (!isEditable) {
+    return null;
+  }
+
+  return (
+    <SavePageControlsHOCWrapper
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
+};
 
 
 SavePageControls.propTypes = {
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
@@ -115,6 +153,11 @@ SavePageControls.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(SavePageControlsWrapper);
 export default withTranslation()(SavePageControlsWrapper);

+ 0 - 242
packages/app/src/components/Sidebar.jsx

@@ -1,242 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  withNavigationUIController,
-  LayoutManager,
-  NavigationProvider,
-  ThemeProvider,
-} from '@atlaskit/navigation-next';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-import DrawerToggler from './Navbar/DrawerToggler';
-
-import SidebarNav from './Sidebar/SidebarNav';
-import SidebarContents from './Sidebar/SidebarContents';
-import StickyStretchableScroller from './StickyStretchableScroller';
-
-const sidebarDefaultWidth = 320;
-
-class Sidebar extends React.Component {
-
-  static propTypes = {
-    appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-    navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-    navigationUIController: PropTypes.any.isRequired,
-    isDrawerModeOnInit: PropTypes.bool,
-  };
-
-  componentWillMount() {
-    this.hackUIController();
-  }
-
-  componentDidUpdate(prevProps, prevState) {
-    this.toggleDrawerMode(this.isDrawerMode);
-  }
-
-  /**
-   * hack and override UIController.storeState
-   *
-   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
-   */
-  hackUIController() {
-    const { navigationUIController } = this.props;
-
-    // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
-    const orgStoreState = navigationUIController.storeState;
-    navigationUIController.storeState = async(state) => {
-      await navigationUIController.setState(state);
-      orgStoreState(state);
-    };
-  }
-
-  /**
-   * return whether drawer mode or not
-   */
-  get isDrawerMode() {
-    let isDrawerMode = this.props.navigationContainer.state.isDrawerMode;
-    if (isDrawerMode == null) {
-      isDrawerMode = this.props.isDrawerModeOnInit;
-    }
-    return isDrawerMode;
-  }
-
-  toggleDrawerMode(bool) {
-    const { navigationUIController } = this.props;
-
-    const isStateModified = navigationUIController.state.isResizeDisabled !== bool;
-    if (!isStateModified) {
-      return;
-    }
-
-    // Drawer <-- Dock
-    if (bool) {
-      // cache state
-      this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
-      this.sidebarWidthCached = navigationUIController.state.productNavWidth;
-
-      // clear transition temporary
-      if (this.sidebarCollapsedCached) {
-        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
-      }
-
-      navigationUIController.disableResize();
-
-      // fix width
-      navigationUIController.setState({ productNavWidth: sidebarDefaultWidth });
-    }
-    // Drawer --> Dock
-    else {
-      // clear transition temporary
-      if (this.sidebarCollapsedCached) {
-        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
-      }
-
-      navigationUIController.enableResize();
-
-      // restore width
-      if (this.sidebarWidthCached != null) {
-        navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
-      }
-    }
-  }
-
-  get sidebarElem() {
-    return document.querySelector('.grw-sidebar');
-  }
-
-  addCssClassTemporary(className) {
-    // clear
-    this.sidebarElem.classList.add(className);
-
-    // restore after 300ms
-    setTimeout(() => {
-      this.sidebarElem.classList.remove(className);
-    }, 300);
-  }
-
-  backdropClickedHandler = () => {
-    const { navigationContainer } = this.props;
-    navigationContainer.toggleDrawer();
-  }
-
-  itemSelectedHandler = (contentsId) => {
-    const { navigationContainer, navigationUIController } = this.props;
-    const { sidebarContentsId } = navigationContainer.state;
-
-    // already selected
-    if (sidebarContentsId === contentsId) {
-      navigationUIController.toggleCollapse();
-    }
-    // switch and expand
-    else {
-      navigationUIController.expand();
-    }
-  }
-
-  calcViewHeight() {
-    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
-    return window.innerHeight - scrollTargetElem.getBoundingClientRect().top;
-  }
-
-  renderGlobalNavigation = () => (
-    <SidebarNav onItemSelected={this.itemSelectedHandler} />
-  );
-
-  renderSidebarContents = () => {
-    const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
-
-    return (
-      <>
-        <StickyStretchableScroller
-          scrollTargetSelector={scrollTargetSelector}
-          contentsElemSelector="#grw-sidebar-content-container"
-          stickyElemSelector=".grw-sidebar"
-          calcViewHeightFunc={this.calcViewHeight}
-        />
-
-        <div id="grw-sidebar-contents-scroll-target">
-          <div id="grw-sidebar-content-container">
-            <SidebarContents
-              isSharedUser={this.props.appContainer.isSharedUser}
-            />
-          </div>
-        </div>
-
-        <DrawerToggler iconClass="icon-arrow-left" />
-      </>
-    );
-  };
-
-  render() {
-    const { isDrawerOpened } = this.props.navigationContainer.state;
-
-    return (
-      <>
-        <div className={`grw-sidebar d-print-none ${this.isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
-          <ThemeProvider
-            theme={theme => ({
-              ...theme,
-              context: 'product',
-            })}
-          >
-            <LayoutManager
-              globalNavigation={this.renderGlobalNavigation}
-              productNavigation={() => null}
-              containerNavigation={this.renderSidebarContents}
-              experimental_hideNavVisuallyOnCollapse
-              experimental_flyoutOnHover
-              experimental_alternateFlyoutBehaviour
-              experimental_fullWidthFlyout
-              shouldHideGlobalNavShadow
-              showContextualNavigation
-            >
-            </LayoutManager>
-          </ThemeProvider>
-        </div>
-
-        { isDrawerOpened && (
-          <div className="grw-sidebar-backdrop modal-backdrop show" onClick={this.backdropClickedHandler}></div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-
-const SidebarWithNavigationUIController = withNavigationUIController(Sidebar);
-
-/**
- * Wrapper component for using unstated
- */
-
-const SidebarWithNavigation = (props) => {
-  const { preferDrawerModeByUser: isDrawerModeOnInit } = props.navigationContainer.state;
-
-  const initUICForDrawerMode = isDrawerModeOnInit
-    // generate initialUIController for Drawer mode
-    ? {
-      isCollapsed: false,
-      isResizeDisabled: true,
-      productNavWidth: sidebarDefaultWidth,
-    }
-    // set undefined (should be initialized by cache)
-    : undefined;
-
-  return (
-    <NavigationProvider initialUIController={initUICForDrawerMode}>
-      <SidebarWithNavigationUIController {...props} isDrawerModeOnInit={isDrawerModeOnInit} />
-    </NavigationProvider>
-  );
-};
-
-SidebarWithNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withUnstatedContainers(SidebarWithNavigation, [AppContainer, NavigationContainer]);

+ 282 - 0
packages/app/src/components/Sidebar.tsx

@@ -0,0 +1,282 @@
+import React, {
+  FC, useCallback, useEffect, useRef, useState,
+} from 'react';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import {
+  useDrawerMode, useDrawerOpened,
+  useSidebarCollapsed,
+  useCurrentSidebarContents,
+  useCurrentProductNavWidth,
+  useSidebarResizeDisabled,
+} from '~/stores/ui';
+
+import DrawerToggler from './Navbar/DrawerToggler';
+
+import SidebarNav from './Sidebar/SidebarNav';
+import SidebarContents from './Sidebar/SidebarContents';
+import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
+import StickyStretchableScroller from './StickyStretchableScroller';
+
+const sidebarMinWidth = 240;
+const sidebarMinimizeWidth = 20;
+
+const GlobalNavigation = () => {
+  const { data: currentContents } = useCurrentSidebarContents();
+  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
+
+  const itemSelectedHandler = useCallback((selectedContents) => {
+
+    let newValue = false;
+
+    // already selected
+    if (currentContents === selectedContents) {
+      // toggle collapsed
+      newValue = !isCollapsed;
+    }
+
+    mutateSidebarCollapsed(newValue, false);
+    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+
+  }, [currentContents, isCollapsed, mutateSidebarCollapsed]);
+
+  return <SidebarNav onItemSelected={itemSelectedHandler} />;
+};
+
+const SidebarContentsWrapper = () => {
+  const [resetKey, setResetKey] = useState(0);
+
+  const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
+
+  const calcViewHeight = useCallback(() => {
+    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
+    return scrollTargetElem != null
+      ? window.innerHeight - scrollTargetElem?.getBoundingClientRect().top
+      : window.innerHeight;
+  }, []);
+
+  return (
+    <>
+      <StickyStretchableScroller
+        scrollTargetSelector={scrollTargetSelector}
+        contentsElemSelector="#grw-sidebar-content-container"
+        stickyElemSelector=".grw-sidebar"
+        calcViewHeightFunc={calcViewHeight}
+        resetKey={resetKey}
+      />
+
+      <div id="grw-sidebar-contents-scroll-target" style={{ minHeight: '100%' }}>
+        <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
+          <SidebarContents />
+        </div>
+      </div>
+
+      <DrawerToggler iconClass="icon-arrow-left" />
+    </>
+  );
+};
+
+
+type Props = {
+}
+
+const Sidebar: FC<Props> = (props: Props) => {
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
+  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
+  const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
+
+  const [isTransitionEnabled, setTransitionEnabled] = useState(false);
+
+  const [isHover, setHover] = useState(false);
+  const [isDragging, setDrag] = useState(false);
+
+  const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
+
+  const toggleDrawerMode = useCallback((bool) => {
+    const isStateModified = isResizeDisabled !== bool;
+    if (!isStateModified) {
+      return;
+    }
+
+    // Drawer <-- Dock
+    if (bool) {
+      // disable resize
+      mutateSidebarResizeDisabled(true, false);
+    }
+    // Drawer --> Dock
+    else {
+      // enable resize
+      mutateSidebarResizeDisabled(false, false);
+    }
+  }, [isResizeDisabled, mutateSidebarResizeDisabled]);
+
+  const backdropClickedHandler = useCallback(() => {
+    mutateDrawerOpened(false, false);
+  }, [mutateDrawerOpened]);
+
+  useEffect(() => {
+    setTimeout(() => {
+      setTransitionEnabled(true);
+    }, 1000);
+  }, []);
+
+  useEffect(() => {
+    toggleDrawerMode(isDrawerMode);
+  }, [isDrawerMode, toggleDrawerMode]);
+
+  const resizableContainer = useRef<HTMLDivElement>(null);
+  const setContentWidth = useCallback((newWidth) => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+    resizableContainer.current.style.width = `${newWidth}px`;
+  }, []);
+
+  const hoverOnResizableContainerHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHover(true);
+    setContentWidth(currentProductNavWidth);
+  }, [isCollapsed, isDrawerMode, isDragging, setContentWidth, currentProductNavWidth]);
+
+  const hoverOutHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHover(false);
+    setContentWidth(sidebarMinimizeWidth);
+  }, [isCollapsed, isDragging, isDrawerMode, setContentWidth]);
+
+  const toggleNavigationBtnClickHandler = useCallback(() => {
+    const newValue = !isCollapsed;
+    mutateSidebarCollapsed(newValue, false);
+    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+  }, [isCollapsed, mutateSidebarCollapsed]);
+
+  useEffect(() => {
+    if (isCollapsed) {
+      setContentWidth(sidebarMinimizeWidth);
+    }
+    else {
+      setContentWidth(currentProductNavWidth);
+    }
+  }, [currentProductNavWidth, isCollapsed, setContentWidth]);
+
+  const draggableAreaMoveHandler = useCallback((event: MouseEvent) => {
+    event.preventDefault();
+
+    const newWidth = event.pageX - 60;
+    if (resizableContainer.current != null) {
+      setContentWidth(newWidth);
+      resizableContainer.current.classList.add('dragging');
+    }
+  }, [setContentWidth]);
+
+  const dragableAreaMouseUpHandler = useCallback(() => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+
+    setDrag(false);
+
+    if (resizableContainer.current.clientWidth < sidebarMinWidth) {
+      // force collapsed
+      mutateSidebarCollapsed(true);
+      mutateProductNavWidth(sidebarMinWidth, false);
+      scheduleToPutUserUISettings({ isSidebarCollapsed: true, currentProductNavWidth: sidebarMinWidth });
+    }
+    else {
+      const newWidth = resizableContainer.current.clientWidth;
+      mutateSidebarCollapsed(false);
+      mutateProductNavWidth(newWidth, false);
+      scheduleToPutUserUISettings({ isSidebarCollapsed: false, currentProductNavWidth: newWidth });
+    }
+
+    resizableContainer.current.classList.remove('dragging');
+
+  }, [mutateProductNavWidth, mutateSidebarCollapsed]);
+
+  const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
+    if (!isResizableByDrag) {
+      return;
+    }
+
+    event.preventDefault();
+
+    setDrag(true);
+
+    const removeEventListeners = () => {
+      document.removeEventListener('mousemove', draggableAreaMoveHandler);
+      document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
+      document.removeEventListener('mouseup', removeEventListeners);
+    };
+
+    document.addEventListener('mousemove', draggableAreaMoveHandler);
+    document.addEventListener('mouseup', dragableAreaMouseUpHandler);
+    document.addEventListener('mouseup', removeEventListeners);
+
+  }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
+
+  return (
+    <>
+      <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
+        <div className="data-layout-container">
+          <div className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`} onMouseLeave={hoverOutHandler}>
+            <div className="grw-navigation-wrap">
+              <div className="grw-global-navigation">
+                <GlobalNavigation></GlobalNavigation>
+              </div>
+              <div
+                ref={resizableContainer}
+                className="grw-contextual-navigation"
+                onMouseEnter={hoverOnResizableContainerHandler}
+                style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
+              >
+                <div className="grw-contextual-navigation-child">
+                  <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
+                    <SidebarContentsWrapper></SidebarContentsWrapper>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div className="grw-navigation-draggable">
+              { isResizableByDrag && (
+                <div
+                  className="grw-navigation-draggable-hitarea"
+                  onMouseDown={dragableAreaMouseDownHandler}
+                >
+                  <div className="grw-navigation-draggable-hitarea-child"></div>
+                </div>
+              ) }
+              <button
+                className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
+                type="button"
+                aria-expanded="true"
+                aria-label="Toggle navigation"
+                disabled={isDrawerMode}
+                onClick={toggleNavigationBtnClickHandler}
+              >
+                <span className="hexagon-container" role="presentation">
+                  <NavigationResizeHexagon />
+                </span>
+                <span className="hitarea" role="presentation"></span>
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      { isDrawerOpened && (
+        <div className="grw-sidebar-backdrop modal-backdrop show" onClick={backdropClickedHandler}></div>
+      ) }
+    </>
+  );
+
+};
+
+export default Sidebar;

+ 17 - 39
packages/app/src/components/Sidebar/CustomSidebar.jsx → packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -1,13 +1,12 @@
-import React, {
-  useState, useCallback, useEffect,
-} from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 
 
+import AppContainer from '~/client/services/AppContainer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { useSWRxPageByPath } from '~/stores/page';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import RevisionRenderer from '../Page/RevisionRenderer';
 import RevisionRenderer from '../Page/RevisionRenderer';
+import { IRevision } from '~/interfaces/revision';
 
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
 
@@ -22,55 +21,38 @@ const SidebarNotFound = () => {
   );
   );
 };
 };
 
 
-const CustomSidebar = (props) => {
-
-  const { appContainer } = props;
-  const { apiGet } = appContainer;
+type Props = {
+  appContainer: AppContainer,
+};
 
 
-  const [isMounted, setMounted] = useState(false);
-  const [markdown, setMarkdown] = useState();
+const CustomSidebar: FC<Props> = (props: Props) => {
 
 
-  const growiRenderer = appContainer.getRenderer('sidebar');
+  const { appContainer } = props;
 
 
-  // TODO: refactor with SWR
-  const fetchDataAndRenderHtml = useCallback(async() => {
-    let page = null;
-    try {
-      const result = await apiGet('/pages.get', { path: '/Sidebar' });
-      page = result.page;
-    }
-    catch (e) {
-      logger.warn(e.message);
-      return;
-    }
-    finally {
-      setMounted(true);
-    }
+  const renderer = appContainer.getRenderer('sidebar');
 
 
-    setMarkdown(page.revision.body);
-  }, [apiGet]);
+  const { data: page, mutate } = useSWRxPageByPath('/Sidebar');
 
 
-  useEffect(() => {
-    fetchDataAndRenderHtml();
-  }, [fetchDataAndRenderHtml]);
+  const isLoading = page === undefined;
+  const markdown = (page?.revision as IRevision | undefined)?.body;
 
 
   return (
   return (
     <>
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
       <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0 text-nowrap">
+        <h3 className="mb-0">
           Custom Sidebar
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={fetchDataAndRenderHtml}>
+        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={() => mutate()}>
           <i className="icon icon-reload"></i>
           <i className="icon icon-reload"></i>
         </button>
         </button>
       </div>
       </div>
-      { isMounted && markdown == null && <SidebarNotFound /> }
+      { !isLoading && markdown == null && <SidebarNotFound /> }
       {/* eslint-disable-next-line react/no-danger */}
       {/* eslint-disable-next-line react/no-danger */}
       { markdown != null && (
       { markdown != null && (
         <div className="p-3">
         <div className="p-3">
           <RevisionRenderer
           <RevisionRenderer
-            growiRenderer={growiRenderer}
+            growiRenderer={renderer}
             markdown={markdown}
             markdown={markdown}
             additionalClassName="grw-custom-sidebar-content"
             additionalClassName="grw-custom-sidebar-content"
           />
           />
@@ -81,10 +63,6 @@ const CustomSidebar = (props) => {
 
 
 };
 };
 
 
-CustomSidebar.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */

+ 20 - 0
packages/app/src/components/Sidebar/NavigationResizeHexagon.tsx

@@ -0,0 +1,20 @@
+import React, { FC } from 'react';
+
+type Props = {
+
+};
+
+export const NavigationResizeHexagon: FC<Props> = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 27.691 23.999"
+  >
+    <g className="background" transform="translate(0 0)">
+      <path d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z" transform="translate(0)"></path>
+    </g>
+    <g className="icon" transform="translate(10 6)">
+      { /* eslint-disable-next-line max-len */ }
+      <path d="M2.124,9.114l5.28,5.34a.647.647,0,0,0,.922,0l.616-.623a.665.665,0,0,0,0-.932L4.759,8.648,8.943,4.4a.665.665,0,0,0,0-.932l-.616-.623a.647.647,0,0,0-.922,0l-5.28,5.34A.665.665,0,0,0,2.124,9.114Z" transform="translate(-1.933 -2.648)"></path>
+    </g>
+  </svg>
+);

+ 6 - 100
packages/app/src/components/Sidebar/RecentChanges.jsx → packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -1,16 +1,15 @@
 import React, {
 import React, {
+  FC,
   useCallback, useEffect, useState,
   useCallback, useEffect, useState,
 } from 'react';
 } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { useTranslation, withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
 import { useSWRxRecentlyUpdated } from '~/stores/page';
 import { useSWRxRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -124,14 +123,10 @@ SmallPageItem.propTypes = {
 };
 };
 
 
 
 
-const RecentChanges = () => {
+const RecentChanges: FC<void> = () => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: pages, error, mutate } = useSWRxRecentlyUpdated();
-
-  if (error != null) {
-    toastError(error, 'Error occurred in updating History');
-  }
+  const { data: pages, mutate } = useSWRxRecentlyUpdated();
 
 
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
 
 
@@ -139,7 +134,7 @@ const RecentChanges = () => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
       setIsRecentChangesSidebarSmall(true);
     }
     }
-  });
+  }, []);
 
 
   const changeSizeHandler = useCallback((e) => {
   const changeSizeHandler = useCallback((e) => {
     setIsRecentChangesSidebarSmall(e.target.checked);
     setIsRecentChangesSidebarSmall(e.target.checked);
@@ -184,93 +179,4 @@ const RecentChanges = () => {
 
 
 };
 };
 
 
-// export default RecentChanges;
-
-
-class DeprecatedRecentChanges extends React.Component {
-
-  static propTypes = {
-    t: PropTypes.func.isRequired, // i18next
-  };
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      isRecentChangesSidebarSmall: false,
-      recentlyUpdatedPages: [],
-    };
-    this.reloadData = this.reloadData.bind(this);
-  }
-
-  componentWillMount() {
-    this.retrieveSizePreferenceFromLocalStorage();
-  }
-
-  async componentDidMount() {
-    this.reloadData();
-  }
-
-  async reloadData() {
-    try {
-      const { data } = await apiv3Get('/pages/recent');
-      this.setState({ recentlyUpdatedPages: data.pages });
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      toastError(error, 'Error occurred in updating History');
-    }
-  }
-
-  retrieveSizePreferenceFromLocalStorage() {
-    if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
-      this.setState({
-        isRecentChangesSidebarSmall: true,
-      });
-    }
-  }
-
-  changeSizeHandler = (e) => {
-    this.setState({
-      isRecentChangesSidebarSmall: e.target.checked,
-    });
-    window.localStorage.setItem('isRecentChangesSidebarSmall', e.target.checked);
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <>
-        <div className="grw-sidebar-content-header p-3 d-flex">
-          <h3 className="mb-0">{t('Recent Changes')}</h3>
-          {/* <h3 className="mb-0">{t('Recent Created')}</h3> */} {/* TODO: impl switching */}
-          <button type="button" className="btn btn-sm ml-auto grw-btn-reload-rc" onClick={this.reloadData}>
-            <i className="icon icon-reload"></i>
-          </button>
-          <div className="grw-recent-changes-resize-button custom-control custom-switch ml-2">
-            <input
-              id="recentChangesResize"
-              className="custom-control-input"
-              type="checkbox"
-              checked={this.state.isRecentChangesSidebarSmall}
-              onChange={this.changeSizeHandler}
-            />
-            <label className="custom-control-label" htmlFor="recentChangesResize">
-            </label>
-          </div>
-        </div>
-        <div className="grw-sidebar-content-body grw-recent-changes p-3">
-          <ul className="list-group list-group-flush">
-            {this.state.recentlyUpdatedPages.map(page => (this.state.isRecentChangesSidebarSmall
-              ? <SmallPageItem key={page._id} page={page} />
-              : <LargePageItem key={page._id} page={page} />))}
-          </ul>
-        </div>
-      </>
-    );
-  }
-
-}
-
-
-export default withTranslation()(DeprecatedRecentChanges);
+export default RecentChanges;

+ 0 - 49
packages/app/src/components/Sidebar/SidebarContents.jsx

@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-import RecentChanges from './RecentChanges';
-import CustomSidebar from './CustomSidebar';
-
-const SidebarContents = (props) => {
-  const { navigationContainer, isSharedUser } = props;
-
-  if (isSharedUser) {
-    return null;
-  }
-
-  let Contents;
-  switch (navigationContainer.state.sidebarContentsId) {
-    case 'recent':
-      Contents = RecentChanges;
-      break;
-    default:
-      Contents = CustomSidebar;
-  }
-
-  return (
-    <Contents />
-  );
-
-};
-
-SidebarContents.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  isSharedUser: PropTypes.bool,
-};
-
-SidebarContents.defaultProps = {
-  isSharedUser: false,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SidebarContentsWrapper = withUnstatedContainers(SidebarContents, [NavigationContainer]);
-
-export default withTranslation()(SidebarContentsWrapper);

+ 31 - 0
packages/app/src/components/Sidebar/SidebarContents.tsx

@@ -0,0 +1,31 @@
+import React, { FC } from 'react';
+
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+import RecentChanges from './RecentChanges';
+import CustomSidebar from './CustomSidebar';
+
+type Props = {
+};
+
+const SidebarContents: FC<Props> = (props: Props) => {
+
+  const { data: currentSidebarContents } = useCurrentSidebarContents();
+
+  let Contents;
+  switch (currentSidebarContents) {
+    case SidebarContentsType.RECENT:
+      Contents = RecentChanges;
+      break;
+    default:
+      Contents = CustomSidebar;
+  }
+
+  return (
+    <Contents />
+  );
+
+};
+
+export default SidebarContents;

+ 0 - 94
packages/app/src/components/Sidebar/SidebarNav.jsx

@@ -1,94 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-
-class SidebarNav extends React.Component {
-
-  static propTypes = {
-    onItemSelected: PropTypes.func,
-  };
-
-  state = {
-  };
-
-  itemSelectedHandler = (contentsId) => {
-    const { navigationContainer, onItemSelected } = this.props;
-    if (onItemSelected != null) {
-      onItemSelected(contentsId);
-    }
-
-    navigationContainer.selectSidebarContents(contentsId);
-  }
-
-  PrimaryItem = ({ id, label, iconName }) => {
-    const { sidebarContentsId } = this.props.navigationContainer.state;
-    const isSelected = sidebarContentsId === id;
-
-    return (
-      <button
-        type="button"
-        className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
-        onClick={() => this.itemSelectedHandler(id)}
-      >
-        <i className="material-icons">{iconName}</i>
-      </button>
-    );
-  }
-
-  SecondaryItem({
-    label, iconName, href, isBlank,
-  }) {
-    return (
-      <a href={href} className="d-block btn btn-primary" target={`${isBlank ? '_blank' : ''}`}>
-        <i className="material-icons">{iconName}</i>
-      </a>
-    );
-  }
-
-  generateIconFactory(classNames) {
-    return () => <i className={classNames}></i>;
-  }
-
-  render() {
-    const { isAdmin, currentUsername, isSharedUser } = this.props.appContainer;
-    const isLoggedIn = currentUsername != null;
-
-    const { PrimaryItem, SecondaryItem } = this;
-
-    return (
-      <div className="grw-sidebar-nav">
-        <div className="grw-sidebar-nav-primary-container">
-          {!isSharedUser && <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />}
-          {!isSharedUser && <PrimaryItem id="recent" label="Recent Changes" iconName="update" />}
-          {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
-          {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
-        </div>
-        <div className="grw-sidebar-nav-secondary-container">
-          {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
-          {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
-          <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
-          <SecondaryItem label="Trash" iconName="delete" href="/trash" />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-SidebarNav.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SidebarNavWrapper = withUnstatedContainers(SidebarNav, [AppContainer, NavigationContainer]);
-
-export default withTranslation()(SidebarNavWrapper);

+ 96 - 0
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -0,0 +1,96 @@
+import React, { FC, memo, useCallback } from 'react';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentUser, useIsSharedUser } from '~/stores/context';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+
+type PrimaryItemProps = {
+  contents: SidebarContentsType,
+  label: string,
+  iconName: string,
+  onItemSelected: (contents: SidebarContentsType) => void,
+}
+
+const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
+  const {
+    contents, iconName, onItemSelected,
+  } = props;
+
+  const { data: currentContents, mutate } = useCurrentSidebarContents();
+
+  const isSelected = contents === currentContents;
+
+  const itemSelectedHandler = useCallback(() => {
+    if (onItemSelected != null) {
+      onItemSelected(contents);
+    }
+
+    mutate(contents, false);
+    scheduleToPutUserUISettings({ currentSidebarContents: contents });
+  }, [contents, mutate, onItemSelected]);
+
+  return (
+    <button
+      type="button"
+      className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
+      onClick={itemSelectedHandler}
+    >
+      <i className="material-icons">{iconName}</i>
+    </button>
+  );
+};
+
+type SecondaryItemProps = {
+  label: string,
+  href: string,
+  iconName: string,
+  isBlank?: boolean,
+}
+
+const SecondaryItem: FC<SecondaryItemProps> = memo((props: SecondaryItemProps) => {
+  const { iconName, href, isBlank } = props;
+
+  return (
+    <a href={href} className="d-block btn btn-primary" target={`${isBlank ? '_blank' : ''}`}>
+      <i className="material-icons">{iconName}</i>
+    </a>
+  );
+});
+
+
+type Props = {
+  onItemSelected: (contents: SidebarContentsType) => void,
+}
+
+const SidebarNav: FC<Props> = (props: Props) => {
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: currentUser } = useCurrentUser();
+
+  const isAdmin = currentUser?.admin;
+  const isLoggedIn = currentUser != null;
+
+  const { onItemSelected } = props;
+
+  return (
+    <div className="grw-sidebar-nav">
+      <div className="grw-sidebar-nav-primary-container">
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />}
+        {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
+        {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
+      </div>
+      <div className="grw-sidebar-nav-secondary-container">
+        {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
+        {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
+        <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
+        <SecondaryItem label="Trash" iconName="delete" href="/trash" />
+      </div>
+    </div>
+  );
+
+};
+
+export default SidebarNav;

+ 0 - 87
packages/app/src/components/SlackNotification.jsx

@@ -1,87 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import { UncontrolledPopover, PopoverHeader, PopoverBody } from 'reactstrap';
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class SlackNotification
- * @extends {React.Component}
- */
-
-class SlackNotification extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.idForSlackPopover = `${this.props.id}ForSlackPopover`;
-    this.updateCheckboxHandler = this.updateCheckboxHandler.bind(this);
-    this.updateSlackChannelsHandler = this.updateSlackChannelsHandler.bind(this);
-  }
-
-  updateCheckboxHandler(event) {
-    const value = event.target.checked;
-    if (this.props.onEnabledFlagChange != null) {
-      this.props.onEnabledFlagChange(value);
-    }
-  }
-
-  updateSlackChannelsHandler(event) {
-    const value = event.target.value;
-    if (this.props.onChannelChange != null) {
-      this.props.onChannelChange(value);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="grw-slack-notification w-100">
-        <div className="grw-input-group-slack-notification input-group extended-setting">
-          <label className="input-group-addon">
-            <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
-              <input
-                type="checkbox"
-                className="custom-control-input border-0"
-                id={this.props.id}
-                checked={this.props.isSlackEnabled}
-                onChange={this.updateCheckboxHandler}
-              />
-              <label className="custom-control-label align-center" htmlFor={this.props.id}>
-              </label>
-            </div>
-          </label>
-          <input
-            className="grw-form-control-slack-notification form-control align-top pl-0"
-            id={this.idForSlackPopover}
-            type="text"
-            value={this.props.slackChannels}
-            placeholder="Input channels"
-            onChange={this.updateSlackChannelsHandler}
-          />
-          <UncontrolledPopover trigger="focus" placement="top" target={this.idForSlackPopover}>
-            <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
-            <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
-          </UncontrolledPopover>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-SlackNotification.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  popUp: PropTypes.bool.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  onEnabledFlagChange: PropTypes.func,
-  onChannelChange: PropTypes.func,
-  id: PropTypes.string.isRequired,
-};
-
-export default withTranslation()(SlackNotification);

+ 67 - 0
packages/app/src/components/SlackNotification.tsx

@@ -0,0 +1,67 @@
+/* eslint-disable react/prop-types */
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PopoverBody, PopoverHeader, UncontrolledPopover } from 'reactstrap';
+
+
+type SlackNotificationProps = {
+  id: string;
+  isSlackEnabled: boolean;
+  slackChannels: string;
+  onEnabledFlagChange?: (isSlackEnabled: boolean) => void;
+  onChannelChange?: (value: string) => void;
+};
+
+export const SlackNotification: FC<SlackNotificationProps> = ({
+  id, isSlackEnabled, slackChannels, onEnabledFlagChange, onChannelChange,
+}) => {
+  const { t } = useTranslation();
+  const idForSlackPopover = `${id}ForSlackPopover`;
+
+  const updateCheckboxHandler = (event: { target: { checked: boolean }; }) => {
+    const value = event.target.checked;
+    if (onEnabledFlagChange != null) {
+      onEnabledFlagChange(value);
+    }
+  };
+
+  const updateSlackChannelsHandler = (event: { target: { value: string } }) => {
+    const value = event.target.value;
+    if (onChannelChange != null) {
+      onChannelChange(value);
+    }
+  };
+
+
+  return (
+    <div className="grw-slack-notification w-100">
+      <div className="grw-input-group-slack-notification input-group extended-setting">
+        <label className="input-group-addon">
+          <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
+            <input
+              type="checkbox"
+              className="custom-control-input border-0"
+              id={id}
+              checked={isSlackEnabled}
+              onChange={updateCheckboxHandler}
+            />
+            <label className="custom-control-label align-center" htmlFor={id}></label>
+          </div>
+        </label>
+        <input
+          className="grw-form-control-slack-notification form-control align-top pl-0"
+          id={idForSlackPopover}
+          type="text"
+          value={slackChannels}
+          placeholder="Input channels"
+          onChange={updateSlackChannelsHandler}
+        />
+        <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>
+          <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
+          <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
+        </UncontrolledPopover>
+      </div>
+    </div>
+
+  );
+};

+ 9 - 16
packages/app/src/components/StickyStretchableScroller.jsx

@@ -5,9 +5,6 @@ import { debounce } from 'throttle-debounce';
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
-
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 
 
 
 
@@ -49,9 +46,9 @@ const StickyStretchableScroller = (props) => {
 
 
   let { scrollTargetSelector } = props;
   let { scrollTargetSelector } = props;
   const {
   const {
-    navigationContainer,
     children, contentsElemSelector, stickyElemSelector,
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
     calcViewHeightFunc, calcContentsHeightFunc,
+    resetKey,
   } = props;
   } = props;
 
 
   if (scrollTargetSelector == null && children == null) {
   if (scrollTargetSelector == null && children == null) {
@@ -105,7 +102,7 @@ const StickyStretchableScroller = (props) => {
 
 
   const stickyChangeHandler = useCallback((event) => {
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
     logger.debug('StickyEvents.CHANGE detected');
-    resetScrollbar();
+    setTimeout(resetScrollbar, 100);
   }, [resetScrollbar]);
   }, [resetScrollbar]);
 
 
   // setup effect by sticky event
   // setup effect by sticky event
@@ -141,17 +138,12 @@ const StickyStretchableScroller = (props) => {
     };
     };
   }, [resetScrollbarDebounced]);
   }, [resetScrollbarDebounced]);
 
 
-  // setup effect by isScrollTop
+  // setup effect on init
   useEffect(() => {
   useEffect(() => {
-    if (navigationContainer.state.isScrollTop) {
-      resetScrollbar();
+    if (resetKey != null) {
+      resetScrollbarDebounced();
     }
     }
-  }, [navigationContainer.state.isScrollTop, resetScrollbar]);
-
-  // setup effect by update props
-  useEffect(() => {
-    resetScrollbarDebounced();
-  }, [resetScrollbarDebounced]);
+  }, [resetKey, resetScrollbarDebounced]);
 
 
   return (
   return (
     <>
     <>
@@ -161,15 +153,16 @@ const StickyStretchableScroller = (props) => {
 };
 };
 
 
 StickyStretchableScroller.propTypes = {
 StickyStretchableScroller.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   contentsElemSelector: PropTypes.string.isRequired,
   contentsElemSelector: PropTypes.string.isRequired,
 
 
   children: PropTypes.node,
   children: PropTypes.node,
   scrollTargetSelector: PropTypes.string,
   scrollTargetSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
 
 
+  resetKey: PropTypes.any,
+
   calcViewHeightFunc: PropTypes.func,
   calcViewHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
 };
 };
 
 
-export default withUnstatedContainers(StickyStretchableScroller, [NavigationContainer]);
+export default StickyStretchableScroller;

+ 6 - 6
packages/app/src/components/TableOfContents.jsx

@@ -5,7 +5,8 @@ import loggerFactory from '~/utils/logger';
 
 
 
 
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { blinkElem } from '~/client/util/blink-section-header';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
@@ -20,7 +21,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
  */
 const TableOfContents = (props) => {
 const TableOfContents = (props) => {
 
 
-  const { t, pageContainer, navigationContainer } = props;
+  const { t, pageContainer } = props;
   const { pageUser } = pageContainer.state;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
   const isUserPage = pageUser != null;
 
 
@@ -50,8 +51,8 @@ const TableOfContents = (props) => {
   useEffect(() => {
   useEffect(() => {
     const tocDom = document.getElementById('revision-toc-content');
     const tocDom = document.getElementById('revision-toc-content');
     const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
     const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
-    navigationContainer.addSmoothScrollEvent(anchorsInToc);
-  }, [tocHtml, navigationContainer]);
+    addSmoothScrollEvent(anchorsInToc, blinkElem);
+  }, [tocHtml]);
 
 
   return (
   return (
     <StickyStretchableScroller
     <StickyStretchableScroller
@@ -85,13 +86,12 @@ const TableOfContents = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer, NavigationContainer]);
+const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer]);
 
 
 TableOfContents.propTypes = {
 TableOfContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
 
 
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(TableOfContentsWrapper);
 export default withTranslation()(TableOfContentsWrapper);

+ 7 - 0
packages/app/src/interfaces/common.ts

@@ -0,0 +1,7 @@
+/*
+ * Common types and interfaces
+ */
+
+
+// Foreign key field
+export type Ref<T> = string | T;

+ 14 - 0
packages/app/src/interfaces/named-query.ts

@@ -0,0 +1,14 @@
+import { IUser } from './user';
+
+
+export const SearchDelegatorName = {
+  DEFAULT: 'FullTextSearch',
+} as const;
+export type SearchDelegatorName = typeof SearchDelegatorName[keyof typeof SearchDelegatorName];
+
+export interface INamedQuery {
+  name: string
+  aliasOf?: string
+  delegatorName?: SearchDelegatorName
+  creator?: IUser
+}

+ 26 - 4
packages/app/src/interfaces/page.ts

@@ -1,14 +1,36 @@
+import { Ref } from './common';
 import { IUser } from './user';
 import { IUser } from './user';
 import { IRevision } from './revision';
 import { IRevision } from './revision';
 import { ITag } from './tag';
 import { ITag } from './tag';
+import { HasObjectId } from './has-object-id';
+
 
 
 export type IPage = {
 export type IPage = {
   path: string,
   path: string,
   status: string,
   status: string,
-  revision: IRevision,
-  tags: ITag[],
-  creator: IUser,
+  revision: Ref<IRevision>,
+  tags: Ref<ITag>[],
+  creator: Ref<IUser>,
   createdAt: Date,
   createdAt: Date,
   updatedAt: Date,
   updatedAt: Date,
-  seenUsers: string[]
+  seenUsers: Ref<IUser>[],
+  parent: Ref<IPage> | null,
+  isEmpty: boolean,
+  redirectTo: string,
+  grant: number,
+  grantedUsers: Ref<IUser>[],
+  grantedGroup: Ref<any>,
+  lastUpdateUser: Ref<IUser>,
+  liker: Ref<IUser>[],
+  commentCount: number
+  slackChannels: string,
+  pageIdOnHackmd: string,
+  revisionHackmdSynced: Ref<IRevision>,
+  hasDraftOnHackmd: boolean,
+  deleteUser: Ref<IUser>,
+  deletedAt: Date,
 }
 }
+
+export type IPageHasId = IPage & HasObjectId;
+
+export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;

+ 31 - 0
packages/app/src/interfaces/search.ts

@@ -0,0 +1,31 @@
+import { IPageHasId } from './page';
+
+export enum CheckboxType {
+  NONE_CHECKED = 'noneChecked',
+  INDETERMINATE = 'indeterminate',
+  ALL_CHECKED = 'allChecked',
+}
+
+export type IPageSearchResultData = {
+  pageData: IPageHasId,
+  pageMeta: {
+    bookmarkCount?: number,
+    elasticSearchResult?: {
+      snippet: string,
+      highlightedPath: string,
+    },
+  },
+}
+
+export const SORT_AXIS = {
+  RELATION_SCORE: 'relationScore',
+  CREATED_AT: 'createdAt',
+  UPDATED_AT: 'updatedAt',
+} as const;
+export type SORT_AXIS = typeof SORT_AXIS[keyof typeof SORT_AXIS];
+
+export const SORT_ORDER = {
+  DESC: 'desc',
+  ASC: 'asc',
+} as const;
+export type SORT_ORDER = typeof SORT_ORDER[keyof typeof SORT_ORDER];

Некоторые файлы не были показаны из-за большого количества измененных файлов