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

Merge pull request #4717 from weseek/master

Release @growi/slackbot-proxy v4.4.14-slackbot-proxy.0
Yuki Takei 4 лет назад
Родитель
Сommit
5b708993f2
83 измененных файлов с 1757 добавлено и 870 удалено
  1. 12 9
      .devcontainer/devcontainer.json
  2. 0 4
      .devcontainer/docker-compose.yml
  3. 104 1
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 3 0
      packages/app/config/webpack.common.js
  7. 2 2
      packages/app/docker/README.md
  8. 16 14
      packages/app/package.json
  9. 1 1
      packages/app/resource/locales/en_US/admin/admin.json
  10. 4 2
      packages/app/resource/locales/en_US/translation.json
  11. 8 2
      packages/app/resource/locales/ja_JP/admin/admin.json
  12. 4 2
      packages/app/resource/locales/ja_JP/translation.json
  13. 8 2
      packages/app/resource/locales/zh_CN/admin/admin.json
  14. 4 3
      packages/app/resource/locales/zh_CN/translation.json
  15. 14 0
      packages/app/resource/search/mappings.json
  16. 8 3
      packages/app/src/client/app.jsx
  17. 21 97
      packages/app/src/client/services/AppContainer.js
  18. 56 25
      packages/app/src/client/services/PageContainer.js
  19. 63 0
      packages/app/src/client/util/apiv1-client.ts
  20. 0 12
      packages/app/src/client/util/apiv1ErrorHandler.js
  21. 74 0
      packages/app/src/client/util/apiv3-client.ts
  22. 0 21
      packages/app/src/client/util/apiv3ErrorHandler.js
  23. 6 0
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  24. 2 1
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  25. 47 17
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  26. 35 13
      packages/app/src/components/LikeButtons.jsx
  27. 5 6
      packages/app/src/components/Navbar/SubNavButtons.jsx
  28. 8 1
      packages/app/src/components/PageEditor/DrawioModal.jsx
  29. 17 25
      packages/app/src/components/PageList.jsx
  30. 2 2
      packages/app/src/components/PageTimeline.jsx
  31. 2 2
      packages/app/src/components/Sidebar/CustomSidebar.jsx
  32. 79 18
      packages/app/src/components/Sidebar/RecentChanges.jsx
  33. 4 2
      packages/app/src/components/User/SeenUserInfo.jsx
  34. 7 0
      packages/app/src/interfaces/page-tag-relation.ts
  35. 14 0
      packages/app/src/interfaces/page.ts
  36. 5 0
      packages/app/src/interfaces/paging-result.ts
  37. 9 0
      packages/app/src/interfaces/revision.ts
  38. 4 0
      packages/app/src/interfaces/tag.ts
  39. 18 0
      packages/app/src/interfaces/user.ts
  40. 15 6
      packages/app/src/migrations/20200901034313-update-mail-transmission.js
  41. 0 33
      packages/app/src/migrations/20200901034314-update-mail-transmission-fix.js
  42. 1 0
      packages/app/src/server/crowi/index.js
  43. 17 0
      packages/app/src/server/events/comment.ts
  44. 18 0
      packages/app/src/server/models/comment.js
  45. 1 1
      packages/app/src/server/models/editor-settings.ts
  46. 73 31
      packages/app/src/server/routes/apiv3/page.js
  47. 5 2
      packages/app/src/server/routes/apiv3/slack-integration.js
  48. 8 0
      packages/app/src/server/routes/comment.js
  49. 0 6
      packages/app/src/server/service/config-manager.ts
  50. 16 3
      packages/app/src/server/service/passport.ts
  51. 36 4
      packages/app/src/server/service/search-delegator/elasticsearch.js
  52. 5 0
      packages/app/src/server/service/search.js
  53. 1 3
      packages/app/src/server/service/slack-command-handler/help.js
  54. 23 38
      packages/app/src/server/service/slack-command-handler/note.js
  55. 15 13
      packages/app/src/server/service/slack-command-handler/search.js
  56. 15 5
      packages/app/src/server/util/slack-integration.ts
  57. 0 4
      packages/app/src/server/views/widget/page_content.html
  58. 33 0
      packages/app/src/stores/page.tsx
  59. 4 1
      packages/app/src/styles/_layout.scss
  60. 1 5
      packages/app/src/styles/_recent-changes.scss
  61. 3 1
      packages/app/src/styles/_sidebar.scss
  62. 0 1
      packages/app/src/styles/_variables.scss
  63. 3 7
      packages/app/src/styles/theme/_apply-colors.scss
  64. 115 0
      packages/app/src/styles/theme/blackboard.scss
  65. 209 0
      packages/app/src/styles/theme/fire-red.scss
  66. 209 0
      packages/app/src/styles/theme/jade-green.scss
  67. 9 0
      packages/app/src/utils/swr-utils.ts
  68. 4 1
      packages/app/tsconfig.base.json
  69. 1 1
      packages/codemirror-textlint/package.json
  70. 1 1
      packages/core/package.json
  71. 1 1
      packages/plugin-attachment-refs/package.json
  72. 1 1
      packages/plugin-lsx/package.json
  73. 2 2
      packages/plugin-pukiwiki-like-linker/package.json
  74. 2 2
      packages/slack/package.json
  75. 1 0
      packages/slack/src/index.ts
  76. 6 0
      packages/slack/src/interfaces/channel.ts
  77. 4 4
      packages/slack/src/utils/interaction-payload-accessor.ts
  78. 3 3
      packages/slackbot-proxy/package.json
  79. 15 5
      packages/slackbot-proxy/src/controllers/slack.ts
  80. 34 15
      packages/slackbot-proxy/src/services/RelationsService.ts
  81. 7 4
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  82. 1 1
      packages/ui/package.json
  83. 171 376
      yarn.lock

+ 12 - 9
.devcontainer/devcontainer.json

@@ -9,20 +9,23 @@
 
 	// Set *default* container specific settings.json values on container create.
 	"settings": {
-		"terminal.integrated.shell.linux": "/bin/bash"
+		"terminal.integrated.defaultProfile.linux": "bash"
 	},
 
 	// Add the IDs of extensions you want installed when the container is created.
 	"extensions": [
-		"dbaeumer.vscode-eslint",
-		"eamodio.gitlens",
+    "dbaeumer.vscode-eslint",
+    "mhutchie.git-graph",
+    "eamodio.gitlens",
+    "github.vscode-pull-request-github",
+    "cschleiden.vscode-github-actions",
     "firsttris.vscode-jest-runner",
-		"msjsdiag.debugger-for-chrome",
-		"firefox-devtools.vscode-firefox-debug",
-		"editorconfig.editorconfig",
-		"esbenp.prettier-vscode",
-		"shinnn.stylelint",
-		"hex-ci.stylelint-plus",
+    "msjsdiag.debugger-for-chrome",
+    "firefox-devtools.vscode-firefox-debug",
+    "editorconfig.editorconfig",
+    "esbenp.prettier-vscode",
+    "shinnn.stylelint",
+    "hex-ci.stylelint-plus",
 	],
 
 	// Uncomment the next line if you want start specific services in your Docker Compose config.

+ 0 - 4
.devcontainer/docker-compose.yml

@@ -17,10 +17,6 @@ services:
       context: .
       dockerfile: Dockerfile
 
-    ports:
-      - 3000:3000
-      - 3001:3001 # for browser-sync
-
     volumes:
       - ..:/workspace/growi:delegated
       - node_modules:/workspace/growi/node_modules

+ 104 - 1
CHANGELOG.md

@@ -1,9 +1,112 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.7...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.4.13...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [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
+
+### 🐛 Bug Fixes
+
+- fix: Cannot use HackMD (#4667)
+
+### 🧰 Maintenance
+
+- ci(deps): Downgrade passport to 0.4.0 (#4669) @mudana-grune
+
+## [v4.4.11](https://github.com/weseek/growi/compare/v4.4.10...v4.4.11) - 2021-11-12
+
+### 🚀 Improvement
+
+- imprv: SAML settings by DB (#4656) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Unescape Attribute-based Login Control field value (#4651) @haruhikonyan
+- fix: Slack Integration 'note' command causes expired_trigger_id error (#4629) @stevenfukase
+- fix: Timeline was broken (#4639) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Bump mpath with mongoose (#4638) @yuki-takei
+- ci(deps): bump passport-oauth2 from 1.4.0 to 1.6.1 (#4599) @dependabot
+- ci(deps): bump passport from 0.4.0 to 0.5.0 (#4582) @dependabot
+- ci(deps): bump axios from 0.21.1 to 0.24.0 (#4604) @dependabot
+- ci(deps): bump tar from 4.4.13 to 4.4.19 (#4601) @dependabot
+
+## [v4.4.10](https://github.com/weseek/growi/compare/v4.4.9...v4.4.10) - 2021-11-08
+
+### 🚀 Improvement
+
+- imprv: Sidebar content header style (#4526) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: /pages/info API is broken (#4602) @yuki-takei
+- fix: blackboard theme location in theme list (#4506) @ayaka0417
+
+### 🧰 Maintenance
+
+- support: Use SWR (#4487) @yuki-takei
+- support: Replaced PageList with SWR (#4498) @takayuki-t
+- support: Improve devcontainer (#4510) @yuki-takei
+- support: Update passport-ldpauth from ^2.0.0 to ^3.0.1 (#4578) @LuqmanHakim-Grune
+- ci(deps): bump validator from 13.6.0 to 13.7.0 (#4588) @dependabot
+- ci(deps-dev): bump stylelint from 13.2.0 to 14.0.1 (#4583) @dependabot
+- Bump browserslist from 4.0.1 to 4.16.6 (#3776) @dependabot
+- ci(deps-dev): bump colors from 1.2.5 to 1.4.0 (#4365) @dependabot
+- ci(deps-dev): bump on-headers from 1.0.1 to 1.0.2 (#4366) @dependabot
+- ci(deps-dev): bump jquery-ui from 1.12.1 to 1.13.0 (#4549) @dependabot
+- docs(page): Add docs to /page/info api (#4531) @Mxchaeltrxn
+
+## [v4.4.9](https://github.com/weseek/growi/compare/v4.4.8...v4.4.9) - 2021-10-18
+
+### 💎 Features
+
+- feat: blackboard theme (#4501) @ayaka0417
+- feat: jade-green theme (#4500) @ayaka0417
+- feat: fire-red theme (#4499) @ayaka0417
+- feat: Add user list for like button (#4346) @Mxchaeltrxn
+
+### 🚀 Improvement
+
+- imprv: GROWI slackbot help message (#4488) @hakumizuki
+
+### 🐛 Bug Fixes
+
+- fix: Migration update-mail-transmission (#4482) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Localize Copy bug report button (#4436) @AbiFirmandhani-Grune
+
+## [v4.4.8](https://github.com/weseek/growi/compare/v4.4.7...v4.4.8) - 2021-10-08
+
+### 🚀 Improvement
+
+- imprv: Permissions to operate comment (#4466) @yuki-takei
+- imprv: Show modal when enabling Textlint (#4373) @stevenfukase
+- imprv: Slackbot reaction to user (#4442) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Redirected to apiv3 endpoint when guest mode is enabled (#4443) @stevenfukase
+- fix: Unnecessary extra JSON.stringify for configurations for slackbot without proxy (#4467) @hakumizuki
+- fix: Migration for slackbot configurations without proxy (#4465) @hakumizuki
+- fix: Slackbot error/command handling (#4463) @hakumizuki
+- fix(slackbot): Respond bad gateway error & improved help message (#4470) @hakumizuki
+- fix(slackbot): Stop auto-join to channels with middlewarer (#4424) @yuki-takei
+
 ## [v4.4.7](https://github.com/weseek/growi/compare/v4.4.6...v4.4.7) - 2021-09-29
 
 ### 🚀 Improvement

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -45,6 +45,9 @@ module.exports = (options) => {
       'styles/theme-antarctic':       './src/styles/theme/antarctic.scss',
       'styles/theme-spring':          './src/styles/theme/spring.scss',
       'styles/theme-hufflepuff':      './src/styles/theme/hufflepuff.scss',
+      'styles/theme-fire-red':      './src/styles/theme/fire-red.scss',
+      'styles/theme-jade-green':      './src/styles/theme/jade-green.scss',
+      'styles/theme-blackboard':      './src/styles/theme/blackboard.scss',
       // styles for external services
       'styles/style-hackmd':          './src/styles-hackmd/style.scss',
     }, options.entry || {}), // Merge with env dependent settings

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.4.7`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.7/docker/Dockerfile)
-* [`4.4.7-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.7/docker/Dockerfile)
+* [`4.4.13`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
+* [`4.4.13-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/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)
 

+ 16 - 14
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.4.8-RC.0",
+  "version": "4.4.14-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -33,7 +33,7 @@
     "predev:ci": "run-p resources:*",
     "lint:typecheck": "npx tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
-    "lint:styles": "stylelint src/**/*.scss",
+    "lint:styles": "stylelint src/**/*.scss --custom-syntax postcss-scss",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
@@ -51,17 +51,18 @@
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
+    "mongoose": "5.13.13 causes an error like 't.versions.node is undefined' about 'browser.umd.js' on browser",
     "string-width": "5.0.0 or above exports only ESM."
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.4.8-RC.0",
-    "@growi/plugin-attachment-refs": "^4.4.8-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.4.8-RC.0",
-    "@growi/plugin-lsx": "^4.4.8-RC.0",
-    "@growi/slack": "^4.4.8-RC.0",
+    "@growi/codemirror-textlint": "^4.4.14-RC.0",
+    "@growi/plugin-attachment-refs": "^4.4.14-RC.0",
+    "@growi/plugin-lsx": "^4.4.14-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.14-RC.0",
+    "@growi/slack": "^4.4.14-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -72,7 +73,7 @@
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.88.0",
-    "axios": "^0.21.1",
+    "axios": "^0.24.0",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
@@ -111,7 +112,7 @@
     "method-override": "^3.0.0",
     "migrate-mongo": "^8.2.2",
     "mkdirp": "^1.0.3",
-    "mongoose": "5.12.13",
+    "mongoose": "=5.13.12",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
@@ -125,7 +126,7 @@
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-http": "^0.3.0",
-    "passport-ldapauth": "^2.0.0",
+    "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-saml": "^2.2.0",
     "passport-twitter": "^1.0.4",
@@ -157,7 +158,7 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.4.8-RC.0",
+    "@growi/ui": "^4.4.14-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -201,7 +202,6 @@
     "mini-css-extract-plugin": "^0.9.0",
     "morgan": "^1.10.0",
     "node-dev": "^4.0.0",
-    "node-sass": "^4.14.1",
     "normalize-path": "^3.0.0",
     "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",
@@ -223,15 +223,17 @@
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
-    "sass-loader": "^8.0.0",
+    "sass": "^1.43.4",
+    "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^4.2.0",
     "sticky-events": "^3.1.3",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
-    "stylelint": "^13.2.0",
+    "stylelint": "^14.0.1",
     "stylelint-config-recess-order": "^2.0.1",
     "swagger2openapi": "^5.3.1",
+    "swr": "^1.0.1",
     "terser-webpack-plugin": "^4.1.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",

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

@@ -342,7 +342,7 @@
       "manage_commands": "Manage GROWI commands",
       "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",
-      "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": "Enter the allowed channel ID or channel name for \"{{commandName}}\" command. Only channel IDs are allowed for private channels. Separate each channel with \",\" . Users will be able to use \"{{commandName}}\" command from channels written here.",
       "allow_all": "Allow all",
       "deny_all": "Deny all",
       "allow_specified": "Allow specified",

+ 4 - 2
packages/app/resource/locales/en_US/translation.json

@@ -57,6 +57,7 @@
   "Presentation Mode": "Presentation",
   "The end": "The end",
   "Not available for guest": "Not available for guest",
+  "No users have liked this yet.": "No users have liked this yet.",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",
   "Target page": "Target page",
@@ -694,8 +695,9 @@
       "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</code> is used.",
       "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
       "attr_based_login_control_detail": "Limit who can sign up by using <code>&lt;saml: Attribute&gt;</code> element included in <code>&lt;saml: AttributeStatement&gt;</code> element and its child element <code>&lt;saml: AttributeValue&gt;</code>.",
-      "attr_based_login_control_rule_detail": "See <a href=\"https://lucene.apache.org/core/2_9_4/queryparsersyntax.html\" target=\"_blank\">Apache Lucene - Query Parser Syntax</a>.<h6>Supported Queries:</h6><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h6>Unsupported Queries:</h6><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul>",
-      "attr_based_login_control_rule_example": "<h6>Example</h6>If a rule is <code>(Department: A || Department: B) && Position: Leader</code>, users who have either <code>Department: A</code> or <code>Department: B</code> and have <code>Position: Leader</code> <strong>can</strong> sign in.",
+      "attr_based_login_control_rule_help": "<h5>Supported Queries:</h5><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h5>Unsupported Queries:</h5><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul><h5>Escaping special characters</h5>It is needed to escape following special characters:<br><code>+ - && || ! ( ) { } [ ] ^ &quot; &tilde; * ? : &#92;</code> and <code>/</code>",
+      "attr_based_login_control_rule_example1": "<h5>Example for conditions</h5>If a rule is <code>(Department: A || Department: B) && Position: Leader</code>, users who have either <code>Department: A</code> or <code>Department: B</code> and have <code>Position: Leader</code> <strong>can</strong> sign in.",
+      "attr_based_login_control_rule_example2": "<h5>Example for escaping</h5>If you would like to use URL as a query value, escape the following:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
       "updated_saml": "Succeeded to update SAML setting"
     },
     "Basic": {

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

@@ -11,7 +11,13 @@
     "installed_version": "インストールされているバージョン",
     "list_of_env_vars": "サーバー側で設定されている環境変数一覧",
     "env_var_priority": "セキュリティに関する環境変数を除き、データベースの値が優先的に取得されます。",
-    "about_security": "セキュリティに関する環境変数は <a href='/admin/security'>セキュリティ設定画面</a> からご確認ください。"
+    "about_security": "セキュリティに関する環境変数は <a href='/admin/security'>セキュリティ設定画面</a> からご確認ください。",
+    "copy_prefilled_host_information": {
+      "default": "上記のホスト情報をコピー",
+      "done": "クリップボードにコピーしました!"
+    },
+    "bug_report": "バグを報告する",
+    "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
   },
   "app_setting": {
     "site_name": "サイト名",
@@ -335,7 +341,7 @@
       "manage_commands": "使用可能なGROWIコマンドを設定する",
       "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
       "single_growi_command": "一つのGROWIに対して送信できるコマンド",
-      "allowed_channels_description": "\"{{commandName}}\" コマンドの使用を許可するチャンネルを \",\" 区切りで入力してください。ユーザーはここに記入されているチャンネルから \"{{commandName}}\" コマンドを使用することができます。",
+      "allowed_channels_description": "\"{{commandName}}\" コマンドの使用を許可するチャンネルの ID またはチャンネル名を \",\" 区切りで入力してください。プライベートチャンネルの場合はチャンネル ID を入力する必要があります。ユーザーはここに記入されているチャンネルから \"{{commandName}}\" コマンドを使用することができます。",
       "allow_all": "全てのチャンネルを許可",
       "deny_all": "全てのチャンネルを拒否",
       "allow_specified": "特定のチャンネルを許可",

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

@@ -692,8 +692,10 @@
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{env}}</code> の値を利用します",
       "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
       "attr_based_login_control_detail": "SAMLの <code>&lt;saml:AttributeStatement&gt;</code> 要素に含まれる <code>&lt;saml:Attribute&gt;</code> 要素と、その子要素 <code>&lt;saml:AttributeValue&gt;</code> を利用してログインの可否を制御します。",
-      "attr_based_login_control_rule_detail": "See <a href=\"https://lucene.apache.org/core/2_9_4/queryparsersyntax.html\" target=\"_blank\">Apache Lucene - Query Parser Syntax</a>.<h6>利用可能なクエリ:</h6><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h6>利用不可なクエリ:</h6><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul>",
-      "attr_based_login_control_rule_example": "<h6>Example</h6>ルールに <code>(Department: A || Department: B) && Position: Leader</code> を指定した場合, <code>Department: A</code> または <code>Department: B</code> のどちらかに該当し、かつ <code>Position: Leader</code> を持つユーザーにログインを<strong>許可</strong>します。"
+      "attr_based_login_control_rule_help": "<h5>利用可能なクエリ:</h5><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h5>利用不可なクエリ:</h5><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul><h5>特殊文字のエスケープ</h5>次の特殊文字はエスケープする必要があります。<code>+ - && || ! ( ) { } [ ] ^ &quot; &tilde; * ? : &#92;</code> and <code>/</code>",
+      "attr_based_login_control_rule_example1": "<h5>条件式の例</h5>ルールに <code>(Department: A || Department: B) && Position: Leader</code> を指定した場合, <code>Department: A</code> または <code>Department: B</code> のどちらかに該当し、かつ <code>Position: Leader</code> を持つユーザーにログインを<strong>許可</strong>します。",
+      "attr_based_login_control_rule_exampl2": "<h5>エスケープの例</h5>ルールに URL を利用したい場合は、次のようにエスケープしてください:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
+      "updated_saml": "Succeeded to update SAML setting"
     },
     "Basic": {
       "enable_basic": "Basic を有効にする",

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

@@ -11,7 +11,13 @@
     "installed_version": "已安装版本",
     "list_of_env_vars": "环境变量列表",
     "env_var_priority": "对于安全性以外的环境变量,优先获取数据库的值。",
-    "about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。"
+    "about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。",
+    "copy_prefilled_host_information": {
+      "default": "复制预填的主机信息",
+      "done": "复制到剪贴板!"
+    },
+    "bug_report": "提交一个错误报告",
+    "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
   },
   "app_setting": {
     "site_name": "网站名称 ",
@@ -345,7 +351,7 @@
       "manage_commands": "管理 GROWI 命令",
       "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
       "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
-      "allowed_channels_description": "为 \"{{commandName}}\" 命令输入允许的通道。每个通道之间用 \",\" 隔开。用户可以从这里写入的通道中使用 \"{{commandName}}\"。",
+      "allowed_channels_description": "为 \"{{commandName}}\" 命令输入允许的频道ID或频道名称。对于私人频道,只允许输入频道ID。每个频道之间用\",\"隔开。用户可以从这里写的频道中使用 \"{{commandName}}\" 命令。",
       "allow_all": "允许所有",
       "deny_all": "拒绝所有",
       "allow_specified": "允许指定",

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

@@ -681,9 +681,10 @@
 			"Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</code> is used.",
 			"note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
 			"attr_based_login_control_detail": "Limit who can sign up by using <code>&lt;saml: Attribute&gt;</code> element included in <code>&lt;saml: AttributeStatement&gt;</code> element and its child element <code>&lt;saml: AttributeValue&gt;</code>.",
-			"attr_based_login_control_rule_detail": "See <a href=\"https://lucene.apache.org/core/2_9_4/queryparsersyntax.html\" target=\"_blank\">Apache Lucene - Query Parser Syntax</a>.<h6>Supported Queries:</h6><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h6>Unsupported Queries:</h6><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul>",
-			"attr_based_login_control_rule_example": "<h6>Example</h6>If a rule is <code>(Department: A || Department: B) && Position: Leader</code>, users who have either <code>Department: A</code> or <code>Department: B</code> and have <code>Position: Leader</code> <strong>can</strong> sign in.",
-			"updated_saml": "Succeeded to update SAML setting"
+			"attr_based_login_control_rule_help": "<h5>Supported Queries:</h5><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h5>Unsupported Queries:</h5><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul><h5>Escaping special characters</h5>It is needed to escape following special characters:<br><code>+ - && || ! ( ) { } [ ] ^ &quot; &tilde; * ? : &#92;</code> and <code>/</code>",
+			"attr_based_login_control_rule_example1": "<h5>Example for conditions</h5>If a rule is <code>(Department: A || Department: B) && Position: Leader</code>, users who have either <code>Department: A</code> or <code>Department: B</code> and have <code>Position: Leader</code> <strong>can</strong> sign in.",
+      "attr_based_login_control_rule_example2": "<h5>Example for escaping</h5>If you would like to use URL as a query value, escape the following:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
+      "updated_saml": "Succeeded to update SAML setting"
 		},
 		"Basic": {
 			"enable_basic": "Enable Basic",

+ 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": {
           "type": "keyword"
         },

+ 8 - 3
packages/app/src/client/app.jsx

@@ -3,7 +3,10 @@ import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 
+import { SWRConfig } from 'swr';
+
 import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 import ErrorBoundary from '../components/ErrorBoudary';
 import Sidebar from '../components/Sidebar';
@@ -156,9 +159,11 @@ Object.keys(componentMappings).forEach((key) => {
     ReactDOM.render(
       <I18nextProvider i18n={i18n}>
         <ErrorBoundary>
-          <Provider inject={injectableContainers}>
-            {componentMappings[key]}
-          </Provider>
+          <SWRConfig value={swrGlobalConfiguration}>
+            <Provider inject={injectableContainers}>
+              {componentMappings[key]}
+            </Provider>
+          </SWRConfig>
         </ErrorBoundary>
       </I18nextProvider>,
       elem,

+ 21 - 97
packages/app/src/client/services/AppContainer.js

@@ -1,10 +1,13 @@
 import { Container } from 'unstated';
 
-import urljoin from 'url-join';
-
-import axios from '~/utils/axios';
 import InterceptorManager from '~/services/interceptor-manager';
 
+import {
+  apiDelete, apiGet, apiPost, apiRequest,
+} from '../util/apiv1-client';
+import {
+  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+} from '../util/apiv3-client';
 import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
 import GrowiRenderer from '../util/GrowiRenderer';
 
@@ -12,10 +15,8 @@ import {
   mediaQueryListForDarkMode,
   applyColorScheme,
 } from '../util/color-scheme';
-import Apiv1ErrorHandler from '../util/apiv1ErrorHandler';
 
 import { i18nFactory } from '../util/i18n';
-import apiv3ErrorHandler from '../util/apiv3ErrorHandler';
 
 /**
  * Service container related to options for Application
@@ -28,13 +29,11 @@ export default class AppContainer extends Container {
 
     this.state = {
       preferDarkModeByMediaQuery: false,
-
-      // stetes for contents
-      recentlyUpdatedPages: [],
     };
 
+    // get csrf token from body element
+    // DO NOT REMOVE: uploading attachment data requires appContainer.csrfToken
     const body = document.querySelector('body');
-
     this.csrfToken = body.dataset.csrftoken;
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
@@ -60,17 +59,21 @@ export default class AppContainer extends Container {
     this.componentInstances = {};
     this.rendererInstances = {};
 
-    this.apiGet = this.apiGet.bind(this);
-    this.apiPost = this.apiPost.bind(this);
-    this.apiDelete = this.apiDelete.bind(this);
-    this.apiRequest = this.apiRequest.bind(this);
+    this.apiGet = apiGet;
+    this.apiPost = apiPost;
+    this.apiDelete = apiDelete;
+    this.apiRequest = apiRequest;
+
+    this.apiv3Get = apiv3Get;
+    this.apiv3Post = apiv3Post;
+    this.apiv3Put = apiv3Put;
+    this.apiv3Delete = apiv3Delete;
 
-    this.apiv3Root = '/_api/v3';
     this.apiv3 = {
-      get: this.apiv3Get.bind(this),
-      post: this.apiv3Post.bind(this),
-      put: this.apiv3Put.bind(this),
-      delete: this.apiv3Delete.bind(this),
+      get: apiv3Get,
+      post: apiv3Post,
+      put: apiv3Put,
+      delete: apiv3Delete,
     };
   }
 
@@ -279,11 +282,6 @@ export default class AppContainer extends Container {
     });
   }
 
-  async retrieveRecentlyUpdated() {
-    const { data } = await this.apiv3Get('/pages/recent');
-    this.setState({ recentlyUpdatedPages: data.pages });
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {
@@ -304,78 +302,4 @@ export default class AppContainer extends Container {
     targetComponent.launchDrawioModal(beginLineNumber, endLineNumber);
   }
 
-  async apiGet(path, params) {
-    return this.apiRequest('get', path, { params });
-  }
-
-  async apiPost(path, params) {
-    if (!params._csrf) {
-      params._csrf = this.csrfToken;
-    }
-
-    return this.apiRequest('post', path, params);
-  }
-
-  async apiDelete(path, params) {
-    if (!params._csrf) {
-      params._csrf = this.csrfToken;
-    }
-
-    return this.apiRequest('delete', path, { data: params });
-  }
-
-  async apiRequest(method, path, params) {
-    const res = await axios[method](`/_api${path}`, params);
-    if (res.data.ok) {
-      return res.data;
-    }
-
-    // Return error code if code is exist
-    if (res.data.code != null) {
-      const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
-      throw error;
-    }
-
-    throw new Error(res.data.error);
-  }
-
-  async apiv3Request(method, path, params) {
-    try {
-      const res = await axios[method](urljoin(this.apiv3Root, path), params);
-      return res.data;
-    }
-    catch (err) {
-      const errors = apiv3ErrorHandler(err);
-      throw errors;
-    }
-  }
-
-  async apiv3Get(path, params) {
-    return this.apiv3Request('get', path, { params });
-  }
-
-  async apiv3Post(path, params = {}) {
-    if (!params._csrf) {
-      params._csrf = this.csrfToken;
-    }
-
-    return this.apiv3Request('post', path, params);
-  }
-
-  async apiv3Put(path, params = {}) {
-    if (!params._csrf) {
-      params._csrf = this.csrfToken;
-    }
-
-    return this.apiv3Request('put', path, params);
-  }
-
-  async apiv3Delete(path, params = {}) {
-    if (!params._csrf) {
-      params._csrf = this.csrfToken;
-    }
-
-    return this.apiv3Request('delete', path, { params });
-  }
-
 }

+ 56 - 25
packages/app/src/client/services/PageContainer.js

@@ -51,15 +51,19 @@ export default class PageContainer extends Container {
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path,
       tocHtml: '',
-      isLiked: false,
+
       isBookmarked: false,
+      sumOfBookmarks: 0,
+
       seenUsers: [],
-      seenUserIds: mainContent.getAttribute('data-page-ids-of-seen-users'),
-      countOfSeenUsers: mainContent.getAttribute('data-page-count-of-seen-users'),
+      seenUserIds: [],
+      sumOfSeenUsers: [],
 
-      likerUsers: [],
+      isLiked: false,
+      likers: [],
+      likerIds: [],
       sumOfLikers: 0,
-      sumOfBookmarks: 0,
+
       createdAt: mainContent.getAttribute('data-page-created-at'),
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
       deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
@@ -109,7 +113,7 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
     this.initStateMarkdown();
-    this.checkAndUpdateImageUrlCached(this.state.likerUsers);
+    this.checkAndUpdateImageUrlCached(this.state.likers);
 
     const { isSharedUser } = this.appContainer;
 
@@ -117,8 +121,10 @@ export default class PageContainer extends Container {
     const isAbleToGetAttachedInformationAboutPages = this.state.isPageExist && !isSharedUser;
 
     if (isAbleToGetAttachedInformationAboutPages) {
-      this.retrieveSeenUsers();
-      this.retrieveLikeInfo();
+      // We don't retrieve bookmarks in the initial page load
+      // as it is stored in a separate collection to like and seen user
+      // data so it has a separate api endpoint.
+      this.initialPageLoad();
       this.retrieveBookmarkInfo();
     }
 
@@ -219,7 +225,7 @@ export default class PageContainer extends Container {
    * whether to like button
    * not displayed on user page
    */
-  get isAbleToShowLikeButton() {
+  get isAbleToShowLikeButtons() {
     const { isUserPage } = this.state;
     const { isSharedUser } = this.appContainer;
 
@@ -264,29 +270,54 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
   }
 
-  async retrieveSeenUsers() {
-    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: this.state.seenUserIds });
 
-    this.setState({ seenUsers: users });
-    this.checkAndUpdateImageUrlCached(users);
-  }
+  async initialPageLoad() {
+    {
+      const {
+        data: {
+          likerIds, sumOfLikers, isLiked, seenUserIds, sumOfSeenUsers, isSeen,
+        },
+      } = await this.appContainer.apiv3Get('/page/info', { pageId: this.state.pageId });
 
-  async retrieveLikeInfo() {
-    const res = await this.appContainer.apiv3Get('/page/like-info', { _id: this.state.pageId });
-    const { sumOfLikers, isLiked } = res.data;
+      await this.setState({
+        sumOfLikers,
+        isLiked,
+        likerIds,
+        seenUserIds,
+        sumOfSeenUsers,
+        isSeen,
+      });
+    }
 
-    this.setState({
-      sumOfLikers,
-      isLiked,
-    });
+    await this.retrieveLikersAndSeenUsers();
   }
 
   async toggleLike() {
-    const bool = !this.state.isLiked;
-    await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool });
-    this.setState({ isLiked: bool });
+    {
+      const toggledIsLiked = !this.state.isLiked;
+      await this.appContainer.apiv3Put('/page/likes', { pageId: this.state.pageId, bool: toggledIsLiked });
+
+      await this.setState(state => ({
+        isLiked: toggledIsLiked,
+        sumOfLikers: toggledIsLiked ? state.sumOfLikers + 1 : state.sumOfLikers - 1,
+        likerIds: toggledIsLiked
+          ? [...this.state.likerIds, this.appContainer.currentUserId]
+          : state.likerIds.filter(id => id !== this.appContainer.currentUserId),
+      }));
+    }
+
+    await this.retrieveLikersAndSeenUsers();
+  }
+
+  async retrieveLikersAndSeenUsers() {
+    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: [...this.state.likerIds, ...this.state.seenUserIds].join(',') });
 
-    return this.retrieveLikeInfo();
+    await this.setState({
+      likers: users.filter(({ id }) => this.state.likerIds.includes(id)).slice(0, 15),
+      seenUsers: users.filter(({ id }) => this.state.seenUserIds.includes(id)).slice(0, 15),
+    });
+
+    this.checkAndUpdateImageUrlCached(users);
   }
 
   async retrieveBookmarkInfo() {

+ 63 - 0
packages/app/src/client/util/apiv1-client.ts

@@ -0,0 +1,63 @@
+import * as urljoin from 'url-join';
+
+import axios from '~/utils/axios';
+
+const apiv1Root = '/_api';
+
+// get csrf token from body element
+const body = document.querySelector('body');
+const csrfToken = body?.dataset.csrftoken;
+
+
+type ParamWithCsrfKey = {
+  _csrf: string,
+}
+
+class Apiv1ErrorHandler extends Error {
+
+  code;
+
+  constructor(message = '', code = '') {
+    super();
+
+    this.message = message;
+    this.code = code;
+  }
+
+}
+
+export async function apiRequest(method: string, path: string, params: unknown): Promise<unknown> {
+  const res = await axios[method](urljoin(apiv1Root, path), params);
+
+  if (res.data.ok) {
+    return res.data;
+  }
+
+  // Return error code if code is exist
+  if (res.data.code != null) {
+    const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
+    throw error;
+  }
+
+  throw new Error(res.data.error);
+}
+
+export async function apiGet(path: string, params: unknown = {}): Promise<unknown> {
+  return apiRequest('get', path, { params });
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiPost(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
+  if (params._csrf == null) {
+    params._csrf = csrfToken;
+  }
+  return apiRequest('post', path, params);
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiDelete(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
+  if (params._csrf == null) {
+    params._csrf = csrfToken;
+  }
+  return apiRequest('delete', path, { data: params });
+}

+ 0 - 12
packages/app/src/client/util/apiv1ErrorHandler.js

@@ -1,12 +0,0 @@
-class Apiv1ErrorHandler extends Error {
-
-  constructor(message = '', code = '') {
-    super();
-
-    this.message = message;
-    this.code = code;
-  }
-
-}
-
-module.exports = Apiv1ErrorHandler;

+ 74 - 0
packages/app/src/client/util/apiv3-client.ts

@@ -0,0 +1,74 @@
+import * as urljoin from 'url-join';
+
+// eslint-disable-next-line no-restricted-imports
+import { AxiosResponse } from 'axios';
+
+import loggerFactory from '~/utils/logger';
+import axios from '~/utils/axios';
+import { toArrayIfNot } from '~/utils/array-utils';
+
+const apiv3Root = '/_api/v3';
+
+const logger = loggerFactory('growi:apiv3');
+
+// get csrf token from body element
+const body = document.querySelector('body');
+const csrfToken = body?.dataset.csrftoken;
+
+
+type ParamWithCsrfKey = {
+  _csrf: string,
+}
+
+const apiv3ErrorHandler = (_err) => {
+  // extract api errors from general 400 err
+  const err = _err.response ? _err.response.data.errors : _err;
+  const errs = toArrayIfNot(err);
+
+  for (const err of errs) {
+    logger.error(err.message);
+  }
+
+  return errs;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
+  try {
+    const res = await axios[method](urljoin(apiv3Root, path), params);
+    return res.data;
+  }
+  catch (err) {
+    const errors = apiv3ErrorHandler(err);
+    throw errors;
+  }
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3Get<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+  return apiv3Request('get', path, { params });
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3Post<T = any>(path: string, params: any & ParamWithCsrfKey = {}): Promise<AxiosResponse<T>> {
+  if (params._csrf == null) {
+    params._csrf = csrfToken;
+  }
+  return apiv3Request('post', path, params);
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3Put<T = any>(path: string, params: any & ParamWithCsrfKey = {}): Promise<AxiosResponse<T>> {
+  if (params._csrf == null) {
+    params._csrf = csrfToken;
+  }
+  return apiv3Request('put', path, params);
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3Delete<T = any>(path: string, params: any & ParamWithCsrfKey = {}): Promise<AxiosResponse<T>> {
+  if (params._csrf == null) {
+    params._csrf = csrfToken;
+  }
+  return apiv3Request('delete', path, { params });
+}

+ 0 - 21
packages/app/src/client/util/apiv3ErrorHandler.js

@@ -1,21 +0,0 @@
-// API v3 sends an array of errors in res.data.errors.
-// API v3 errors need to extracted from an error object in order to properly handle them.
-
-import loggerFactory from '~/utils/logger';
-import { toArrayIfNot } from '~/utils/array-utils';
-
-const logger = loggerFactory('growi:apiv3');
-
-const apiv3ErrorHandler = (_err, header = 'Error') => {
-  // extract api errors from general 400 err
-  const err = _err.response ? _err.response.data.errors : _err;
-  const errs = toArrayIfNot(err);
-
-  for (const err of errs) {
-    logger.error(err.message);
-  }
-
-  return errs;
-};
-
-export default apiv3ErrorHandler;

+ 6 - 0
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -22,6 +22,10 @@ class CustomizeThemeOptions extends React.Component {
       name: 'mono-blue',  bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', theme: '#00587A',
     }, {
       name: 'hufflepuff',  bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', theme: '#993439',
+    }, {
+      name: 'fire-red',  bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#EA5532',
+    }, {
+      name: 'jade-green',  bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#38B48B',
     }];
 
     const uniqueTheme = [{
@@ -42,6 +46,8 @@ class CustomizeThemeOptions extends React.Component {
       name: 'halloween',  bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
     }, {
       name: 'kibela',  bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
+    }, {
+      name: 'blackboard',  bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', theme: '#DA8506',
     }];
     /* eslint-enable no-multi-spaces */
 

+ 2 - 1
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({
         isRebuildingProcessing: false,
         isRebuildingCompleted: true,

+ 47 - 17
packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -3,6 +3,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import { Collapse } from 'reactstrap';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
@@ -15,6 +17,10 @@ class SamlSecurityManagementContents extends React.Component {
   constructor(props) {
     super(props);
 
+    this.state = {
+      isHelpOpened: false,
+    };
+
     this.onClickSubmit = this.onClickSubmit.bind(this);
   }
 
@@ -112,7 +118,7 @@ class SamlSecurityManagementContents extends React.Component {
               Basic Settings
             </h3>
 
-            <table className={`table settings-table ${adminSamlSecurityContainer.state.useOnlyEnvVars && 'use-only-env-vars'}`}>
+            <table className={`table settings-table ${useOnlyEnvVars && 'use-only-env-vars'}`}>
               <colgroup>
                 <col className="item-name" />
                 <col className="from-db" />
@@ -222,7 +228,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
               Attribute Mapping
             </h3>
 
-            <table className={`table settings-table ${adminSamlSecurityContainer.state.useOnlyEnvVars && 'use-only-env-vars'}`}>
+            <table className="table settings-table">
               <colgroup>
                 <col className="item-name" />
                 <col className="from-db" />
@@ -238,7 +244,6 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      readOnly={useOnlyEnvVars}
                       defaultValue={adminSamlSecurityContainer.state.samlAttrMapId}
                       onChange={e => adminSamlSecurityContainer.changeSamlAttrMapId(e.target.value)}
                     />
@@ -266,7 +271,6 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      readOnly={useOnlyEnvVars}
                       defaultValue={adminSamlSecurityContainer.state.samlAttrMapUsername}
                       onChange={e => adminSamlSecurityContainer.changeSamlAttrMapUserName(e.target.value)}
                     />
@@ -292,7 +296,6 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      readOnly={useOnlyEnvVars}
                       defaultValue={adminSamlSecurityContainer.state.samlAttrMapMail}
                       onChange={e => adminSamlSecurityContainer.changeSamlAttrMapMail(e.target.value)}
                     />
@@ -318,7 +321,6 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      readOnly={useOnlyEnvVars}
                       defaultValue={adminSamlSecurityContainer.state.samlAttrMapFirstName}
                       onChange={e => adminSamlSecurityContainer.changeSamlAttrMapFirstName(e.target.value)}
                     />
@@ -349,7 +351,6 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      readOnly={useOnlyEnvVars}
                       defaultValue={adminSamlSecurityContainer.state.samlAttrMapLastName}
                       onChange={e => adminSamlSecurityContainer.changeSamlAttrMapLastName(e.target.value)}
                     />
@@ -433,7 +434,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
               <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_detail') }} />
             </p>
 
-            <table className={`table settings-table ${useOnlyEnvVars && 'use-only-env-vars'}`}>
+            <table className="table settings-table">
               <colgroup>
                 <col className="item-name" />
                 <col className="from-db" />
@@ -448,22 +449,51 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     { t('security_setting.form_item_name.ABLCRule') }
                   </th>
                   <td>
-                    <input
+                    <textarea
                       className="form-control"
                       type="text"
                       defaultValue={adminSamlSecurityContainer.state.samlABLCRule || ''}
                       onChange={(e) => { adminSamlSecurityContainer.changeSamlABLCRule(e.target.value) }}
-                      readOnly={useOnlyEnvVars}
                     />
-                    <p className="form-text text-muted">
-                      <small>
-                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_rule_detail') }} />
-                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_rule_example') }} />
-                      </small>
-                    </p>
+                    <div className="mt-2">
+                      <p>
+                        See&nbsp;
+                        <a
+                          href="https://lucene.apache.org/core/2_9_4/queryparsersyntax.html"
+                          target="_blank"
+                          rel="noreferer noreferrer"
+                        >
+                          Apache Lucene - Query Parser Syntax <i className="icon-share-alt"></i>
+                        </a>.
+                      </p>
+                      <div className="accordion" id="accordionExample">
+                        <div className="card">
+                          <div className="card-header p-1">
+                            <h2 className="mb-0">
+                              <button
+                                className="btn btn-link btn-block text-left"
+                                type="button"
+                                onClick={() => this.setState({ isHelpOpened: !this.state.isHelpOpened })}
+                                aria-expanded="true"
+                                aria-controls="ablchelp"
+                              >
+                                <i className={`icon-fw ${this.state.isHelpOpened ? 'icon-arrow-down' : 'icon-arrow-right'} small`}></i> Show more...
+                              </button>
+                            </h2>
+                          </div>
+                          <Collapse isOpen={this.state.isHelpOpened}>
+                            <div className="card-body">
+                              <p dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_rule_help') }} />
+                              <p dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_rule_example1') }} />
+                              <p dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_rule_example2') }} />
+                            </div>
+                          </Collapse>
+                        </div>
+                      </div>
+                    </div>
                   </td>
                   <td>
-                    <input
+                    <textarea
                       className="form-control"
                       type="text"
                       value={adminSamlSecurityContainer.state.envABLCRule || ''}

+ 35 - 13
packages/app/src/components/LikeButton.jsx → packages/app/src/components/LikeButtons.jsx

@@ -1,22 +1,35 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { UncontrolledTooltip } from 'reactstrap';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
+import UserPictureList from './User/UserPictureList';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import { toastError } from '~/client/util/apiNotification';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 
-class LikeButton extends React.Component {
+class LikeButtons extends React.Component {
 
   constructor(props) {
     super(props);
 
+    this.state = {
+      isPopoverOpen: false,
+    };
+
+    this.togglePopover = this.togglePopover.bind(this);
     this.handleClick = this.handleClick.bind(this);
   }
 
+  togglePopover() {
+    this.setState(prevState => ({
+      ...prevState,
+      isPopoverOpen: !prevState.isPopoverOpen,
+    }));
+  }
+
   async handleClick() {
     const { appContainer, pageContainer } = this.props;
     const { isGuestUser } = appContainer;
@@ -33,31 +46,40 @@ class LikeButton extends React.Component {
     }
   }
 
-
   render() {
     const { appContainer, pageContainer, t } = this.props;
     const { isGuestUser } = appContainer;
+    const {
+      state: { likers, sumOfLikers, isLiked },
+    } = pageContainer;
 
     return (
-      <div>
+      <div className="btn-group" role="group" aria-label="Like buttons">
         <button
           type="button"
           id="like-button"
           onClick={this.handleClick}
           className={`btn btn-like border-0
-          ${pageContainer.state.isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
-          <i className="icon-like mr-3"></i>
-          <span className="total-likes">
-            {pageContainer.state.sumOfLikers}
-          </span>
+          <i className="icon-like"></i>
         </button>
-
         {isGuestUser && (
           <UncontrolledTooltip placement="top" target="like-button" fade={false}>
             {t('Not available for guest')}
           </UncontrolledTooltip>
         )}
+
+        <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+          {sumOfLikers}
+        </button>
+        <Popover placement="bottom" isOpen={this.state.isPopoverOpen} target="po-total-likes" toggle={this.togglePopover} trigger="legacy">
+          <PopoverBody className="seen-user-popover">
+            <div className="px-2 text-right user-list-content text-truncate text-muted">
+              {likers.length ? <UserPictureList users={likers} /> : t('No users have liked this yet.')}
+            </div>
+          </PopoverBody>
+        </Popover>
       </div>
     );
   }
@@ -67,9 +89,9 @@ class LikeButton extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const LikeButtonWrapper = withUnstatedContainers(LikeButton, [AppContainer, PageContainer]);
+const LikeButtonsWrapper = withUnstatedContainers(LikeButtons, [AppContainer, PageContainer]);
 
-LikeButton.propTypes = {
+LikeButtons.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
@@ -77,4 +99,4 @@ LikeButton.propTypes = {
   size: PropTypes.string,
 };
 
-export default withTranslation()(LikeButtonWrapper);
+export default withTranslation()(LikeButtonsWrapper);

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

@@ -6,7 +6,7 @@ import PageContainer from '~/client/services/PageContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import BookmarkButton from '../BookmarkButton';
-import LikeButton from '../LikeButton';
+import LikeButtons from '../LikeButtons';
 import PageManagement from '../Page/PageManagement';
 
 const SubnavButtons = (props) => {
@@ -21,15 +21,14 @@ const SubnavButtons = (props) => {
 
     return (
       <>
-        {pageContainer.isAbleToShowLikeButton && (
+        {pageContainer.isAbleToShowLikeButtons && (
           <span>
-            <LikeButton />
+            <LikeButtons />
           </span>
         )}
         <span>
           <BookmarkButton />
         </span>
-
       </>
     );
   };
@@ -42,8 +41,8 @@ const SubnavButtons = (props) => {
     <>
       {isViewMode && (
         <>
-          { pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
-          { pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} /> }
+          {pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} />}
+          {pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} />}
         </>
       )}
     </>

+ 8 - 1
packages/app/src/components/PageEditor/DrawioModal.jsx

@@ -135,7 +135,14 @@ class DrawioModal extends React.PureComponent {
 
   render() {
     return (
-      <Modal isOpen={this.state.show} toggle={this.cancel} className="drawio-modal" size="xl" keyboard={false}>
+      <Modal
+        isOpen={this.state.show}
+        toggle={this.cancel}
+        backdrop="static"
+        className="drawio-modal"
+        size="xl"
+        keyboard={false}
+      >
         <ModalBody className="p-0">
           {/* Loading spinner */}
           <div className="w-100 h-100 position-absolute d-flex">

+ 17 - 25
packages/app/src/components/PageList.jsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useCallback, useState } from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
@@ -8,39 +8,33 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 
+import { useSWRxPageList } from '~/stores/page';
+
 import PaginationWrapper from './PaginationWrapper';
 
 
 const PageList = (props) => {
   const { appContainer, pageContainer, t } = props;
   const { path } = pageContainer.state;
-  const [pages, setPages] = useState(null);
-  const [isLoading, setIsLoading] = useState(true);
 
   const [activePage, setActivePage] = useState(1);
-  const [totalPages, setTotalPages] = useState(0);
-  const [limit, setLimit] = useState(Infinity);
+
+  const { data: pagesListData, error: errors } = useSWRxPageList(path, activePage);
 
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
   }
 
-  const updatePageList = useCallback(async() => {
-    const page = activePage;
-    const res = await appContainer.apiv3Get('/pages/list', { path, page });
-
-    setPages(res.data.pages);
-    setIsLoading(false);
-    setTotalPages(res.data.totalCount);
-    setLimit(res.data.limit);
-  }, [appContainer, path, activePage]);
-
-  useEffect(() => {
-    updatePageList();
-  }, [updatePageList]);
-
+  if (errors != null) {
+    return (
+      <div className="my-5">
+        {/* eslint-disable-next-line react/no-array-index-key */}
+        {errors.map((error, index) => <div key={index} className="text-danger">{error.message}</div>)}
+      </div>
+    );
+  }
 
-  if (isLoading) {
+  if (pagesListData == null) {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
@@ -51,7 +45,7 @@ const PageList = (props) => {
   }
 
   const liClasses = props.liClasses.join(' ');
-  const pageList = pages.map(page => (
+  const pageList = pagesListData.items.map(page => (
     <li key={page._id} className={liClasses}>
       <Page page={page} />
     </li>
@@ -81,14 +75,12 @@ const PageList = (props) => {
       <PaginationWrapper
         activePage={activePage}
         changePage={setPageNumber}
-        totalItemsCount={totalPages}
-        pagingLimit={limit}
+        totalItemsCount={pagesListData.totalCount}
+        pagingLimit={pagesListData.limit}
         align="center"
       />
     </div>
   );
-
-
 };
 
 const PageListWrapper = withUnstatedContainers(PageList, [AppContainer, PageContainer]);

+ 2 - 2
packages/app/src/components/PageTimeline.jsx

@@ -76,14 +76,14 @@ class PageTimeline extends React.Component {
       <div>
         { pages.map((page) => {
           return (
-            <div className="timeline-body" key={`key-${page.id}`}>
+            <div className="timeline-body" key={`key-${page._id}`}>
               <div className="card card-timeline">
                 <div className="card-header"><a href={page.path}>{page.path}</a></div>
                 <div className="card-body">
                   <RevisionLoader
                     lazy
                     growiRenderer={this.growiRenderer}
-                    pageId={page.id}
+                    pageId={page._id}
                     revisionId={page.revision}
                   />
                 </div>

+ 2 - 2
packages/app/src/components/Sidebar/CustomSidebar.jsx

@@ -57,11 +57,11 @@ const CustomSidebar = (props) => {
   return (
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0">
+        <h3 className="mb-0 text-nowrap">
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload-cs" onClick={fetchDataAndRenderHtml}>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={fetchDataAndRenderHtml}>
           <i className="icon icon-reload"></i>
         </button>
       </div>

+ 79 - 18
packages/app/src/components/Sidebar/RecentChanges.jsx

@@ -1,20 +1,23 @@
-import React from 'react';
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
 import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
+import { useTranslation, withTranslation } from 'react-i18next';
 
 import { UserPicture } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
+
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/apiNotification';
+import { useSWRxRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import LinkedPagePath from '~/models/linked-page-path';
 
 import FootstampIcon from '../FootstampIcon';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastError } from '~/client/util/apiNotification';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
@@ -119,17 +122,82 @@ function SmallPageItem({ page }) {
 SmallPageItem.propTypes = {
   page: PropTypes.any,
 };
-class RecentChanges extends React.Component {
+
+
+const RecentChanges = () => {
+
+  const { t } = useTranslation();
+  const { data: pages, error, mutate } = useSWRxRecentlyUpdated();
+
+  if (error != null) {
+    toastError(error, 'Error occurred in updating History');
+  }
+
+  const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
+
+  const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
+    if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
+      setIsRecentChangesSidebarSmall(true);
+    }
+  });
+
+  const changeSizeHandler = useCallback((e) => {
+    setIsRecentChangesSidebarSmall(e.target.checked);
+    window.localStorage.setItem('isRecentChangesSidebarSmall', e.target.checked);
+  }, []);
+
+  // componentDidMount
+  useEffect(() => {
+    retrieveSizePreferenceFromLocalStorage();
+  }, [retrieveSizePreferenceFromLocalStorage]);
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
+          <i className="icon icon-reload"></i>
+        </button>
+        <div className="d-flex align-items-center">
+          <div className="grw-recent-changes-resize-button custom-control custom-switch ml-1">
+            <input
+              id="recentChangesResize"
+              className="custom-control-input"
+              type="checkbox"
+              checked={isRecentChangesSidebarSmall}
+              onChange={changeSizeHandler}
+            />
+            <label className="custom-control-label" htmlFor="recentChangesResize">
+            </label>
+          </div>
+        </div>
+      </div>
+      <div className="grw-sidebar-content-body grw-recent-changes p-3">
+        <ul className="list-group list-group-flush">
+          {(pages || []).map(page => (isRecentChangesSidebarSmall
+            ? <SmallPageItem key={page._id} page={page} />
+            : <LargePageItem key={page._id} page={page} />))}
+        </ul>
+      </div>
+    </>
+  );
+
+};
+
+// export default RecentChanges;
+
+
+class DeprecatedRecentChanges extends React.Component {
 
   static propTypes = {
     t: PropTypes.func.isRequired, // i18next
-    appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   };
 
   constructor(props) {
     super(props);
     this.state = {
       isRecentChangesSidebarSmall: false,
+      recentlyUpdatedPages: [],
     };
     this.reloadData = this.reloadData.bind(this);
   }
@@ -143,10 +211,9 @@ class RecentChanges extends React.Component {
   }
 
   async reloadData() {
-    const { appContainer } = this.props;
-
     try {
-      await appContainer.retrieveRecentlyUpdated();
+      const { data } = await apiv3Get('/pages/recent');
+      this.setState({ recentlyUpdatedPages: data.pages });
     }
     catch (error) {
       logger.error('failed to save', error);
@@ -171,7 +238,6 @@ class RecentChanges extends React.Component {
 
   render() {
     const { t } = this.props;
-    const { recentlyUpdatedPages } = this.props.appContainer.state;
 
     return (
       <>
@@ -195,7 +261,7 @@ class RecentChanges extends React.Component {
         </div>
         <div className="grw-sidebar-content-body grw-recent-changes p-3">
           <ul className="list-group list-group-flush">
-            {recentlyUpdatedPages.map(page => (this.state.isRecentChangesSidebarSmall
+            {this.state.recentlyUpdatedPages.map(page => (this.state.isRecentChangesSidebarSmall
               ? <SmallPageItem key={page._id} page={page} />
               : <LargePageItem key={page._id} page={page} />))}
           </ul>
@@ -206,10 +272,5 @@ class RecentChanges extends React.Component {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const RecentChangesWrapper = withUnstatedContainers(RecentChanges, [AppContainer]);
-
 
-export default withTranslation()(RecentChangesWrapper);
+export default withTranslation()(DeprecatedRecentChanges);

+ 4 - 2
packages/app/src/components/User/SeenUserInfo.jsx

@@ -22,8 +22,10 @@ const SeenUserInfo = (props) => {
   return (
     <div className="grw-seen-user-info">
       <Button id="po-seen-user" color="link" className="px-2">
-        <span className="mr-1 footstamp-icon"><FootstampIcon /></span>
-        <span className="seen-user-count">{pageContainer.state.countOfSeenUsers}</span>
+        <span className="mr-1 footstamp-icon">
+          <FootstampIcon />
+        </span>
+        <span className="seen-user-count">{pageContainer.state.sumOfSeenUsers}</span>
       </Button>
       <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy" disabled={disabled}>
         <PopoverBody className="seen-user-popover">

+ 7 - 0
packages/app/src/interfaces/page-tag-relation.ts

@@ -0,0 +1,7 @@
+import { IPage } from './page';
+import { ITag } from './tag';
+
+export type IPageTagRelation = {
+  relatedPage: IPage,
+  relatedTag: ITag,
+}

+ 14 - 0
packages/app/src/interfaces/page.ts

@@ -0,0 +1,14 @@
+import { IUser } from './user';
+import { IRevision } from './revision';
+import { ITag } from './tag';
+
+export type IPage = {
+  path: string,
+  status: string,
+  revision: IRevision,
+  tags: ITag[],
+  creator: IUser,
+  createdAt: Date,
+  updatedAt: Date,
+  seenUsers: string[]
+}

+ 5 - 0
packages/app/src/interfaces/paging-result.ts

@@ -0,0 +1,5 @@
+export type IPagingResult<T> = {
+  items: T[],
+  totalCount: number,
+  limit: number,
+}

+ 9 - 0
packages/app/src/interfaces/revision.ts

@@ -0,0 +1,9 @@
+import { IUser } from './user';
+
+export type IRevision = {
+  body: string,
+  author: IUser,
+  hasDiffToPrev: boolean;
+  createdAt: Date,
+  updatedAt: Date,
+}

+ 4 - 0
packages/app/src/interfaces/tag.ts

@@ -0,0 +1,4 @@
+export type ITag = {
+  name: string,
+  createdAt: Date;
+}

+ 18 - 0
packages/app/src/interfaces/user.ts

@@ -0,0 +1,18 @@
+export type IUser = {
+  name: string;
+  username: string;
+  imageUrlCached: string;
+  admin: boolean;
+}
+
+export type IUserGroupRelation = {
+  relatedGroup: IUserGroup,
+  relatedUser: IUser,
+  createdAt: Date,
+}
+
+export type IUserGroup = {
+  userGroupId:string;
+  name: string;
+  createdAt: Date;
+}

+ 15 - 6
packages/app/src/migrations/20200901034313-update-mail-transmission.js

@@ -11,17 +11,26 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const sesExist = await Config.findOne({
+    const sesAccessKeyId = await Config.findOne({
       ns: 'crowi',
       key: 'mail:sesAccessKeyId',
     });
+    const transmissionMethod = await Config.findOne({
+      ns: 'crowi',
+      key: 'mail:transmissionMethod',
+    });
 
-    if (sesExist == null) {
-      return logger.info('Document does not exist, value of transmission method will be set smtp automatically.');
+    if (sesAccessKeyId == null) {
+      return logger.info('The key \'mail:sesAccessKeyId\' does not exist, value of transmission method will be set smtp automatically.');
     }
-    const value = (
-      sesExist.value != null ? 'ses' : 'smtp'
-    );
+    if (transmissionMethod != null) {
+      return logger.info('The key \'mail:transmissionMethod\' already exists, there is no need to migrate.');
+    }
+
+    const value = sesAccessKeyId.value != null
+      ? JSON.stringify('ses')
+      : JSON.stringify('smtp');
+
     await Config.create({
       ns: 'crowi',
       key: 'mail:transmissionMethod',

+ 0 - 33
packages/app/src/migrations/20200901034314-update-mail-transmission-fix.js

@@ -1,33 +0,0 @@
-import { getMongoUri, mongoOptions } from '@growi/core';
-import loggerFactory from '~/utils/logger';
-
-import Config from '~/server/models/config';
-
-const logger = loggerFactory('growi:migrate:update-mail-transmission-fix');
-
-const mongoose = require('mongoose');
-
-module.exports = {
-  async up(db, client) {
-    logger.info('Apply migration');
-    mongoose.connect(getMongoUri(), mongoOptions);
-
-    const transmissionMethod = await Config.findOne({
-      ns: 'crowi',
-      key: 'mail:transmissionMethod',
-    });
-
-    if (transmissionMethod == null) {
-      return logger.info('No need to change.');
-    }
-
-    transmissionMethod.value = JSON.stringify(transmissionMethod.value);
-    await transmissionMethod.save();
-
-    logger.info('Migration has successfully applied');
-  },
-
-  async down(db, client) {
-    // do not rollback
-  },
-};

+ 1 - 0
packages/app/src/server/crowi/index.js

@@ -81,6 +81,7 @@ function Crowi() {
     user: new (require('../events/user'))(this),
     page: new (require('../events/page'))(this),
     bookmark: new (require('../events/bookmark'))(this),
+    comment: new (require('../events/comment'))(this),
     tag: new (require('../events/tag'))(this),
     admin: new (require('../events/admin'))(this),
   };

+ 17 - 0
packages/app/src/server/events/comment.ts

@@ -0,0 +1,17 @@
+
+import util from 'util';
+
+const events = require('events');
+
+function CommentEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(CommentEvent, events.EventEmitter);
+
+CommentEvent.prototype.onCreate = function(comment) {};
+CommentEvent.prototype.onUpdate = function(comment) {};
+CommentEvent.prototype.onDelete = function(comment) {};
+
+module.exports = CommentEvent;

+ 18 - 0
packages/app/src/server/models/comment.js

@@ -51,6 +51,24 @@ module.exports = function(crowi) {
     return this.find({ revision: id }).sort({ createdAt: -1 });
   };
 
+
+  /**
+   * @return {object} key: page._id, value: comments
+   */
+  commentSchema.statics.getPageIdToCommentMap = async function(pageIds) {
+    const results = await this.aggregate()
+      .match({ page: { $in: pageIds } })
+      .group({ _id: '$page', comments: { $push: '$comment' } });
+
+    // convert to map
+    const idToCommentMap = {};
+    results.forEach((result, i) => {
+      idToCommentMap[result._id] = result.comments;
+    });
+
+    return idToCommentMap;
+  };
+
   commentSchema.statics.countCommentByPageId = function(page) {
     const self = this;
 

+ 1 - 1
packages/app/src/server/models/editor-settings.ts

@@ -32,7 +32,7 @@ const textlintSettingsSchema = new Schema<ITextlintSettings>({
   },
 });
 
-const editorSettingsSchema = new Schema<IEditorSettings>({
+const editorSettingsSchema = new Schema<EditorSettingsDocument, EditorSettingsModel>({
   userId: { type: String },
   textlintSettings: textlintSettingsSchema,
 });

+ 73 - 31
packages/app/src/server/routes/apiv3/page.js

@@ -113,16 +113,51 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *            type: boolean
  *            description: boolean for like status
  *
- *      LikeInfo:
- *        description: LikeInfo
+ *      PageInfo:
+ *        description: PageInfo
  *        type: object
+ *        required:
+ *          - isSeen
+ *          - sumOfLikers
+ *          - likerIds
+ *          - sumOfSeenUsers
+ *          - seenUserIds
  *        properties:
- *          sumOfLikers:
- *            type: number
- *            description: how many people liked the page
+ *          isSeen:
+ *            type: boolean
+ *            description: Whether the page has ever been seen
  *          isLiked:
  *            type: boolean
- *            description: Whether the request user liked (will be returned if the user is included in the request)
+ *            description: Whether the page is liked by the logged in user
+ *          sumOfLikers:
+ *            type: number
+ *            description: Number of users who have liked the page
+ *          likerIds:
+ *            type: array
+ *            items:
+ *              type: string
+ *            description: Ids of users who have liked the page
+ *            example: ["5e07345972560e001761fa63"]
+ *          sumOfSeenUsers:
+ *            type: number
+ *            description: Number of users who have seen the page
+ *          seenUserIds:
+ *            type: array
+ *            items:
+ *              type: string
+ *            description: Ids of users who have seen the page
+ *            example: ["5e07345972560e001761fa63"]
+ *
+ *      PageParams:
+ *        description: PageParams
+ *        type: object
+ *        required:
+ *          - pageId
+ *        properties:
+ *          pageId:
+ *            type: string
+ *            description: page ID
+ *            example: 5e07345972560e001761fa63
  */
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
@@ -141,8 +176,8 @@ module.exports = (crowi) => {
       body('pageId').isString(),
       body('bool').isBoolean(),
     ],
-    likeInfo: [
-      query('_id').isMongoId(),
+    info: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     export: [
       query('format').isString().isIn(['md', 'pdf']),
@@ -225,47 +260,54 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /page/like-info:
+   *    /page/info:
    *      get:
    *        tags: [Page]
-   *        summary: /page/like-info
-   *        description: Get like info
-   *        operationId: getLikeInfo
-   *        parameters:
-   *          - name: _id
-   *            in: query
-   *            description: page id
-   *            schema:
-   *              type: string
+   *        summary: /page/info
+   *        description: Retrieve current page info
+   *        operationId: getPageInfo
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/PageParams'
    *        responses:
    *          200:
-   *            description: Succeeded to get bookmark info.
+   *            description: Successfully retrieved current page info.
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/LikeInfo'
+   *                  $ref: '#/components/schemas/PageInfo'
+   *          500:
+   *            description: Internal server error.
    */
-  router.get('/like-info', loginRequired, validator.likeInfo, apiV3FormValidator, async(req, res) => {
-    const pageId = req.query._id;
-
-    const responsesParams = {};
+  router.get('/info', loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.query;
 
     try {
       const page = await Page.findById(pageId);
-      responsesParams.sumOfLikers = page.liker.length;
 
-      // guest user return nothing
-      if (!req.user) {
-        return res.apiv3(responsesParams);
+      const guestUserResponse = {
+        sumOfLikers: page.liker.length,
+        likerIds: page.liker.slice(0, 15),
+        seenUserIds: page.seenUsers.slice(0, 15),
+        sumOfSeenUsers: page.seenUsers.length,
+        isSeen: page.seenUsers.length > 0,
+      };
+
+      const isGuestUser = !req.user;
+      if (isGuestUser) {
+        return res.apiv3(guestUserResponse);
       }
 
-      responsesParams.isLiked = page.liker.includes(req.user._id);
-      return res.apiv3(responsesParams);
+      const userResponse = { ...guestUserResponse, isLiked: page.isLiked(req.user) };
+      return res.apiv3(userResponse);
     }
     catch (err) {
-      logger.error('get-like-count-failed', err);
+      logger.error('get-page-info', err);
       return res.apiv3Err(err, 500);
     }
+
   });
 
   /**

+ 5 - 2
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -95,7 +95,10 @@ module.exports = (crowi) => {
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
-    const fromChannel = req.body.channel_name;
+    const fromChannel = {
+      id: req.body.channel_id,
+      name: req.body.channel_name,
+    };
     const siteUrl = crowi.appService.getSiteUrl();
 
     let commandPermission;
@@ -141,7 +144,7 @@ module.exports = (crowi) => {
 
     const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const callbacIdkOrActionId = callbackId || actionId;
-    const fromChannel = interactionPayloadAccessor.getChannelName();
+    const fromChannel = interactionPayloadAccessor.getChannel();
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);

+ 8 - 0
packages/app/src/server/routes/comment.js

@@ -231,6 +231,7 @@ module.exports = function(crowi, app) {
     const position = commentForm.comment_position || -1;
     const isMarkdown = commentForm.is_markdown;
     const replyTo = commentForm.replyTo;
+    const commentEvent = crowi.event('comment');
 
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
@@ -241,6 +242,7 @@ module.exports = function(crowi, app) {
     let createdComment;
     try {
       createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo);
+      commentEvent.emit('create', createdComment);
     }
     catch (err) {
       logger.error(err);
@@ -345,6 +347,8 @@ module.exports = function(crowi, app) {
     const commentId = commentForm.comment_id;
     const revision = commentForm.revision_id;
 
+    const commentEvent = crowi.event('comment');
+
     if (commentStr === '') {
       return res.json(ApiResponse.error('Comment text is required'));
     }
@@ -375,6 +379,7 @@ module.exports = function(crowi, app) {
         { _id: commentId },
         { $set: { comment: commentStr, isMarkdown, revision } },
       );
+      commentEvent.emit('create', updatedComment);
     }
     catch (err) {
       logger.error(err);
@@ -428,6 +433,8 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment_id Comment Id.
    */
   api.remove = async function(req, res) {
+    const commentEvent = crowi.event('comment');
+
     const commentId = req.body.comment_id;
     if (!commentId) {
       return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
@@ -452,6 +459,7 @@ module.exports = function(crowi, app) {
 
       await comment.removeWithReplies();
       await Page.updateCommentCount(comment.page);
+      commentEvent.emit('delete', comment);
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 0 - 6
packages/app/src/server/service/config-manager.ts

@@ -18,13 +18,7 @@ const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:isEnabled',
   'security:passport-saml:entryPoint',
   'security:passport-saml:issuer',
-  'security:passport-saml:attrMapId',
-  'security:passport-saml:attrMapUsername',
-  'security:passport-saml:attrMapMail',
-  'security:passport-saml:attrMapFirstName',
-  'security:passport-saml:attrMapLastName',
   'security:passport-saml:cert',
-  'security:passport-saml:ABLCRule',
 ];
 
 const KEYS_FOR_FIEL_UPLOAD_USE_ONLY_ENV_OPTION = [

+ 16 - 3
packages/app/src/server/service/passport.ts

@@ -810,15 +810,16 @@ class PassportService implements S2sMessageHandlable {
     }
 
     const { field, term } = luceneRule;
-    if (field === '<implicit>') {
+    const unescapedField = this.literalUnescape(field);
+    if (unescapedField === '<implicit>') {
       return attributes[term] != null;
     }
 
-    if (attributes[field] == null) {
+    if (attributes[unescapedField] == null) {
       return false;
     }
 
-    return attributes[field].includes(term);
+    return attributes[unescapedField].includes(term);
   }
 
   /**
@@ -973,6 +974,18 @@ class PassportService implements S2sMessageHandlable {
     return this.crowi.configManager.getConfig('crowi', key);
   }
 
+  literalUnescape(string: string) {
+    return string
+      .replace(/\\\\/g, '\\')
+      .replace(/\\\//g, '/')
+      .replace(/\\:/g, ':')
+      .replace(/\\"/g, '"')
+      .replace(/\\0/g, '\0')
+      .replace(/\\t/g, '\t')
+      .replace(/\\n/g, '\n')
+      .replace(/\\r/g, '\r');
+  }
+
 }
 
 module.exports = PassportService;

+ 36 - 4
packages/app/src/server/service/search-delegator/elasticsearch.js

@@ -314,6 +314,7 @@ class ElasticsearchDelegator {
       body: page.revision.body,
       // username: page.creator?.username, // available Node.js v14 and above
       username: page.creator != null ? page.creator.username : null,
+      comments: page.comments,
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
       like_count: page.liker.length || 0,
@@ -371,6 +372,7 @@ class ElasticsearchDelegator {
     const Page = mongoose.model('Page');
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark');
+    const Comment = mongoose.model('Comment');
     const PageTagRelation = mongoose.model('PageTagRelation');
 
     const socket = this.socketIoService.getAdminSocket();
@@ -431,6 +433,28 @@ class ElasticsearchDelegator {
       },
     });
 
+
+    const appendCommentStream = new Transform({
+      objectMode: true,
+      async transform(chunk, encoding, callback) {
+        const pageIds = chunk.map(doc => doc._id);
+
+        const idToCommentMap = await Comment.getPageIdToCommentMap(pageIds);
+        const idsHavingComment = Object.keys(idToCommentMap);
+
+        // append comments
+        chunk
+          .filter(doc => idsHavingComment.includes(doc._id.toString()))
+          .forEach((doc) => {
+            // append comments from idToCommentMap
+            doc.comments = idToCommentMap[doc._id.toString()];
+          });
+
+        this.push(chunk);
+        callback();
+      },
+    });
+
     const appendTagNamesStream = new Transform({
       objectMode: true,
       async transform(chunk, encoding, callback) {
@@ -503,6 +527,7 @@ class ElasticsearchDelegator {
       .pipe(thinOutStream)
       .pipe(batchStream)
       .pipe(appendBookmarkCountStream)
+      .pipe(appendCommentStream)
       .pipe(appendTagNamesStream)
       .pipe(writeStream);
 
@@ -579,7 +604,7 @@ class ElasticsearchDelegator {
   }
 
   createSearchQuerySortedByScore(option) {
-    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names', 'comments'];
     if (option) {
       fields = option.fields || fields;
     }
@@ -635,7 +660,7 @@ class ElasticsearchDelegator {
         multi_match: {
           query: parsedKeywords.match.join(' '),
           type: 'most_fields',
-          fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
+          fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
         },
       };
       query.body.query.bool.must.push(q);
@@ -645,7 +670,7 @@ class ElasticsearchDelegator {
       const q = {
         multi_match: {
           query: parsedKeywords.not_match.join(' '),
-          fields: ['path.ja', 'path.en', 'body.ja', 'body.en'],
+          fields: ['path.ja', 'path.en', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
           operator: 'or',
         },
       };
@@ -657,12 +682,13 @@ class ElasticsearchDelegator {
       parsedKeywords.phrase.forEach((phrase) => {
         phraseQueries.push({
           multi_match: {
-            query: phrase, // each phrase is quoteted words
+            query: phrase, // each phrase is quoteted words like "This is GROWI"
             type: 'phrase',
             fields: [
               // Not use "*.ja" fields here, because we want to analyze (parse) search words
               'path.raw^2',
               'body',
+              'comments',
             ],
           },
         });
@@ -1023,6 +1049,12 @@ class ElasticsearchDelegator {
     return this.updateOrInsertPageById(pageId);
   }
 
+  async syncCommentChanged(comment) {
+    logger.debug('SearchClient.syncCommentChanged', comment);
+
+    return this.updateOrInsertPageById(comment.page);
+  }
+
   async syncTagChanged(page) {
     logger.debug('SearchClient.syncTagChanged', page.path);
 

+ 5 - 0
packages/app/src/server/service/search.js

@@ -73,6 +73,11 @@ class SearchService {
     bookmarkEvent.on('create', this.delegator.syncBookmarkChanged.bind(this.delegator));
     bookmarkEvent.on('delete', this.delegator.syncBookmarkChanged.bind(this.delegator));
 
+    const commentEvent = this.crowi.event('comment');
+    commentEvent.on('create', this.delegator.syncCommentChanged.bind(this.delegator));
+    commentEvent.on('update', this.delegator.syncCommentChanged.bind(this.delegator));
+    commentEvent.on('delete', this.delegator.syncCommentChanged.bind(this.delegator));
+
     const tagEvent = this.crowi.event('tag');
     tagEvent.on('update', this.delegator.syncTagChanged.bind(this.delegator));
   }

+ 1 - 3
packages/app/src/server/service/slack-command-handler/help.js

@@ -13,9 +13,7 @@ module.exports = (crowi) => {
     const appTitle = crowi.appService.getAppTitle();
     const appSiteUrl = crowi.appService.getSiteUrl();
     // adjust spacing
-    let message = '*Help*\n\n';
-    message += `GROWI App Title: *${appTitle}*`;
-    message += `GROWI Url: ${appSiteUrl}`;
+    let message = `*Help* (*${appTitle}* at ${appSiteUrl})\n\n`;
     message += 'Usage:     `/growi [command] [args]`\n\n';
     message += 'Commands:\n\n';
     message += '`/growi note`                          Take a note on GROWI\n\n';

+ 23 - 38
packages/app/src/server/service/slack-command-handler/note.js

@@ -1,8 +1,9 @@
 import loggerFactory from '~/utils/logger';
 
 const {
-  markdownSectionBlock, inputSectionBlock, inputBlock,
+  markdownHeaderBlock, inputSectionBlock, inputBlock, actionsBlock, buttonElement,
 } = require('@growi/slack');
+const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:note');
 
@@ -14,58 +15,42 @@ module.exports = (crowi) => {
   const conversationsSelectElement = {
     action_id: 'conversation',
     type: 'conversations_select',
-    response_url_enabled: true,
     default_to_current_conversation: true,
   };
 
   handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
-    await client.views.open({
-      trigger_id: body.trigger_id,
-
-      view: {
-        type: 'modal',
-        callback_id: 'note:createPage',
-        title: {
-          type: 'plain_text',
-          text: 'Take a note',
-        },
-        submit: {
-          type: 'plain_text',
-          text: 'Submit',
-        },
-        close: {
-          type: 'plain_text',
-          text: 'Cancel',
-        },
-        blocks: [
-          markdownSectionBlock('Take a note on GROWI'),
-          inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
-          inputSectionBlock('path', 'Page path', 'path_input', false, '/path'),
-          inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
-        ],
-        private_metadata: JSON.stringify({ channelId: body.channel_id, channelName: body.channel_name }),
-      },
+    await respondUtil.respond({
+      text: 'Take a note on GROWI',
+      blocks: [
+        markdownHeaderBlock('Take a note on GROWI'),
+        inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
+        inputSectionBlock('path', 'Page path', 'path_input', false, '/path'),
+        inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
+        actionsBlock(
+          buttonElement({ text: 'Cancel', actionId: 'note:cancel' }),
+          buttonElement({ text: 'Create page', actionId: 'note:createPage', style: 'primary' }),
+        ),
+
+      ],
     });
   };
 
+  handler.cancel = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) {
+    await respondUtil.deleteOriginal();
+  };
+
   handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
     await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
   };
 
   handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) {
     const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value;
-    const privateMetadata = interactionPayloadAccessor.getViewPrivateMetaData();
-    if (privateMetadata == null) {
-      await respondUtil.respond({
-        text: 'Error occurred',
-        blocks: [
-          markdownSectionBlock('Failed to create a page.'),
-        ],
-      });
-      return;
-    }
     const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
+    if (path == null || contentsBody == null) {
+      throw new SlackCommandHandlerError('All parameters are required.');
+    }
     await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil);
+    await respondUtil.deleteOriginal();
   };
 
   return handler;

+ 15 - 13
packages/app/src/server/service/slack-command-handler/search.js

@@ -132,22 +132,24 @@ module.exports = (crowi) => {
 
     const actionBlocks = {
       type: 'actions',
-      elements: [
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Dismiss',
-          },
-          style: 'danger',
-          action_id: 'search:dismissSearchResults',
-        },
-      ],
+      elements: [],
     };
+    // add "Dismiss" button
+    actionBlocks.elements.push(
+      {
+        type: 'button',
+        text: {
+          type: 'plain_text',
+          text: 'Dismiss',
+        },
+        style: 'danger',
+        action_id: 'search:dismissSearchResults',
+      },
+    );
     // show "Prev" button if previous page exists
     // eslint-disable-next-line yoda
     if (0 < offset) {
-      actionBlocks.elements.unshift(
+      actionBlocks.elements.push(
         {
           type: 'button',
           text: {
@@ -161,7 +163,7 @@ module.exports = (crowi) => {
     }
     // show "Next" button if next page exists
     if (offset + PAGINGLIMIT < resultsTotal) {
-      actionBlocks.elements.unshift(
+      actionBlocks.elements.push(
         {
           type: 'button',
           text: {

+ 15 - 5
packages/app/src/server/util/slack-integration.ts

@@ -1,9 +1,9 @@
-import { getSupportedGrowiActionsRegExp } from '@growi/slack';
+import { getSupportedGrowiActionsRegExp, IChannelOptionalId } from '@growi/slack';
 
 type CommandPermission = { [key:string]: string[] | boolean }
 
 export const checkPermission = (
-    commandPermission:CommandPermission, commandOrActionIdOrCallbackId:string, fromChannel:string,
+    commandPermission: CommandPermission, commandOrActionIdOrCallbackId: string, fromChannel: IChannelOptionalId,
 ):boolean => {
   let isPermitted = false;
 
@@ -23,9 +23,19 @@ export const checkPermission = (
       isPermitted = true;
       return;
     }
-    if (Array.isArray(permission) && permission.includes(fromChannel)) {
-      isPermitted = true;
-      return;
+
+    if (Array.isArray(permission)) {
+      if (permission.includes(fromChannel.name)) {
+        isPermitted = true;
+        return;
+      }
+
+      if (fromChannel.id == null) return;
+
+      if (permission.includes(fromChannel.id)) {
+        isPermitted = true;
+        return;
+      }
     }
   });
 

+ 0 - 4
packages/app/src/server/views/widget/page_content.html

@@ -12,8 +12,6 @@
   data-page-grant="{{ grant }}"
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
-  data-page-is-liked="{% if user %}{{ page.isLiked(user) }}{% else %}false{% endif %}"
-  data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-deletable="{% if isDeletablePage() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
@@ -27,8 +25,6 @@
   data-page-deleted-at="{% if page && page.deletedAt %}{{ page.deletedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
-  data-page-ids-of-seen-users="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
-  data-page-count-of-seen-users="{{ page.seenUsers.length|default(0) }}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
   >

+ 33 - 0
packages/app/src/stores/page.tsx

@@ -0,0 +1,33 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IPage } from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxRecentlyUpdated = <Data, Error>(): SWRResponse<IPage[], Error> => {
+  return useSWR(
+    '/pages/recent',
+    endpoint => apiv3Get<{ pages: IPage[] }>(endpoint).then(response => response.data?.pages),
+  );
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxPageList = (
+    path: string,
+    pageNumber?: number,
+): SWRResponse<IPagingResult<IPage>, Error> => {
+  const page = pageNumber || 1;
+  return useSWR(
+    `/pages/list?path=${path}&page=${page}`,
+    endpoint => apiv3Get<{pages: IPage[], totalCount: number, limit: number}>(endpoint).then((response) => {
+      return {
+        items: response.data.pages,
+        totalCount: response.data.totalCount,
+        limit: response.data.limit,
+      };
+    }),
+  );
+};

+ 4 - 1
packages/app/src/styles/_layout.scss

@@ -99,11 +99,14 @@ body.growi-layout-fluid .grw-container-convertible {
 
 // printable style
 @media print {
-  padding: 30px;
+  body {
+    padding: 30px;
+  }
 
   a:after {
     display: none !important;
   }
+
   .main {
     header {
       border-bottom: solid 1px $secondary;

+ 1 - 5
packages/app/src/styles/_recent-changes.scss

@@ -1,12 +1,8 @@
 .grw-sidebar-content-header {
-  .grw-btn-reload-rc {
-    font-size: 18px;
-  }
-
   .grw-recent-changes-resize-button {
     font-size: 12px;
     line-height: normal;
-    transform: translateY(6px);
+    transform: translateY(-2px);
 
     .custom-control-label::before {
       padding-left: 16px;

+ 3 - 1
packages/app/src/styles/_sidebar.scss

@@ -135,7 +135,9 @@
   }
 
   .grw-sidebar-content-header {
-    min-width: $grw-sidebar-content-min-width + 20px;
+    .grw-btn-reload {
+      font-size: 18px;
+    }
   }
 }
 

+ 0 - 1
packages/app/src/styles/_variables.scss

@@ -25,7 +25,6 @@ $grw-navbar-bottom-height: 48px;
 $grw-editor-navbar-bottom-height: 48px;
 
 $grw-sidebar-nav-width: 64px; // !!DO NOT CHANGE!! 'margin-left' for '.css-teprsg' is hardcoded
-$grw-sidebar-content-min-width: 240px;
 
 $grw-logo-width: $grw-sidebar-nav-width;
 $grw-logomark-width: 36px;

+ 3 - 7
packages/app/src/styles/theme/_apply-colors.scss

@@ -15,8 +15,7 @@ $bordercolor-nav-tabs-hover: $gray-200 $gray-200 $bordercolor-nav-tabs !default;
 $color-nav-tabs-link-active: $gray-600 !default;
 $bordercolor-nav-tabs-active: $bordercolor-nav-tabs $bordercolor-nav-tabs $bgcolor-global !default;
 $color-seen-user: #549c79 !default;
-$reload-btn-rc-color: $gray-500;
-$reload-btn-cs-color: $gray-500;
+$color-btn-reload-in-sidebar: $gray-500;
 $bgcolor-keyword-highlighted: $grw-marker-yellow !default;
 
 // override bootstrap variables
@@ -268,11 +267,8 @@ ul.pagination {
   }
 
   .grw-sidebar-content-header {
-    .grw-btn-reload-rc {
-      color: $reload-btn-rc-color;
-    }
-    .grw-btn-reload-cs {
-      color: $reload-btn-cs-color;
+    .grw-btn-reload {
+      color: $color-btn-reload-in-sidebar;
     }
 
     .grw-recent-changes-resize-button {

+ 115 - 0
packages/app/src/styles/theme/blackboard.scss

@@ -0,0 +1,115 @@
+@import '../variables';
+@import '../override-bootstrap-variables';
+
+html[light],
+html[dark] {
+  // Theme colors
+  $themecolor: #da8506;
+  $themelight: #223729;
+  $accentcolor: #739aff;
+  $subthemecolor: #192a1f;
+
+  $primary: $themecolor;
+  $dark: #223729;
+
+  // Background colors
+  $bgcolor-global: $themelight;
+  $bgcolor-navbar: #563e23;
+  $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-card: darken($themelight, 5%);
+  $bgcolor-blinked-section: rgba($primary, 0.5);
+  $bgcolor-keyword-highlighted: darken($grw-marker-red, 30%);
+
+  // Font colors
+  $color-global: #ffffff;
+  $color-reversal: $gray-100;
+  $color-link: $accentcolor;
+  $color-link-hover: lighten($color-link, 12%);
+  $color-link-wiki: $accentcolor;
+  $color-link-wiki-hover: lighten($color-link-wiki, 12%);
+  $color-link-nabvar: $color-reversal;
+  $color-inline-code: $subthemecolor;
+  $color-inline-code: #c7254e; // optional
+  $color-search: $dark;
+
+  // List Group colors
+  // $color-list: $color-global;
+  $bgcolor-list: transparent;
+  $color-list-hover: $accentcolor;
+  // $bgcolor-list-hover: lighten($bgcolor-global, 3%);
+  // $color-list-active: $color-reversal;
+  // $bgcolor-list-active: $primary;
+
+  // Navbar
+  $bgcolor-navbar: #563e23;
+  $bgcolor-search-top-dropdown: $themecolor;
+  $border-image-navbar: linear-gradient(to right, #bebebe 0%, #d8d8d8 100%);
+
+  // Logo colors
+  $bgcolor-logo: $color-global;
+  $fillcolor-logo-mark: $color-global;
+  // $fillcolor-logo-mark: #4e5a60;
+
+  // Sidebar
+  $bgcolor-sidebar: #7b5932;
+  // $bgcolor-sidebar-nav-item-active: rgba(#, 0.3); // optional
+  $text-shadow-sidebar-nav-item-active: 0px 0px 10px $primary; // optional
+  // Sidebar resize button
+  $color-resize-button: $color-global;
+  $bgcolor-resize-button: $primary;
+  $color-resize-button-hover: $color-global;
+  $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
+  // Sidebar contents
+  $bgcolor-sidebar-context: $subthemecolor;
+  $color-sidebar-context: $color-global;
+  // Sidebar list group
+  // $bgcolor-sidebar-list-group: #; // optional
+
+  // Icon colors
+  $color-editor-icons: $color-global;
+
+  // Border colors
+  $border-color-theme: $color-global;
+  $bordercolor-inline-code: #4d4d4d; // optional
+
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $primary;
+  $color-dropdown-link-active: $color-global;
+  $color-dropdown-link-hover: $color-reversal;
+
+  // admin theme box
+  $color-theme-color-box: $primary;
+
+  @import 'apply-colors';
+  @import 'apply-colors-dark';
+
+  // Navs
+  .nav-tabs {
+    border-bottom: $border-color-theme 1px solid;
+    .nav-link {
+      &:hover {
+        border-color: lighten($border-color-theme, 10%);
+        border-bottom: none;
+      }
+      &.active {
+        color: $color-link;
+        background-color: transparent;
+        border-color: $border-color-theme;
+      }
+    }
+  }
+
+  // Table
+  .table {
+    color: white;
+    background-color: $themelight;
+    border-color: $border-color-theme;
+  }
+
+  // Button
+  .btn-group.grw-page-editor-mode-manager {
+    .btn.btn-outline-primary {
+      @include btn-page-editor-mode-manager(#ffffff, $primary, $primary, darken($primary, 20%));
+    }
+  }
+}

+ 209 - 0
packages/app/src/styles/theme/fire-red.scss

@@ -0,0 +1,209 @@
+@import '../variables';
+@import '../override-bootstrap-variables';
+
+html[light] {
+  // Theme colors
+  $themecolor: #ea5532;
+  $themelight: #ffffff;
+  $accentcolor: #bfbfbf;
+  $subthemecolor: #e6e6e6;
+
+  $primary: $themecolor;
+
+  // Background colors
+  $bgcolor-global: $themelight;
+  $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-card: $accentcolor;
+  $bgcolor-blinked-section: rgba($primary, 0.1);
+  //$bgcolor-keyword-highlighted: $grw-marker-yellow;
+
+  // Font colors
+  $color-global: #2c2c2c;
+  $color-reversal: $gray-100;
+  $color-link: $primary;
+  $color-link-hover: lighten($color-link, 12%);
+  $color-link-wiki: $primary;
+  $color-link-wiki-hover: lighten($color-link-wiki, 12%);
+  $color-link-nabvar: $color-reversal;
+  $color-inline-code: #c7254e; // optional
+  $color-search: $color-global;
+
+  // List Group colors
+  // $color-list: $color-global;
+  $bgcolor-list: transparent;
+  $color-list-hover: $color-search;
+  $bgcolor-list-hover: darken($bgcolor-global, 3%);
+  // $color-list-active: $color-reversal;
+  // $bgcolor-list-active: $primary;
+
+  // Navbar
+  $bgcolor-navbar: $color-global;
+  $bgcolor-search-top-dropdown: $themecolor;
+  $border-image-navbar: linear-gradient(to right, $primary 0%, darken($primary, 5%) 100%);
+
+  // Logo colors
+  $bgcolor-logo: $themelight;
+  $fillcolor-logo-mark: $themelight;
+
+  // Sidebar
+  $bgcolor-sidebar: $accentcolor;
+  // $bgcolor-sidebar-nav-item-active: rgba(#, 0.37); // optional
+  $text-shadow-sidebar-nav-item-active: 0px 0px 10px #ffffff; // optional
+  // Sidebar resize button
+  $color-resize-button: #ffffff;
+  $bgcolor-resize-button: $primary;
+  $color-resize-button-hover: $color-reversal;
+  $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
+  // Sidebar contents
+  $color-sidebar-context: $color-global;
+  $bgcolor-sidebar-context: #ebebeb;
+  // Sidebar list group
+  // $bgcolor-sidebar-list-group: #; // optional
+
+  // Icon colors
+  $color-editor-icons: $color-global;
+
+  // Border colors
+  $border-color-theme: $primary;
+  $bordercolor-inline-code: #ccc8c8; // optional
+
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $primary;
+  $color-dropdown-link-active: $color-reversal;
+
+  // admin theme box
+  $color-theme-color-box: $primary;
+
+  @import 'apply-colors';
+  @import 'apply-colors-light';
+
+  // Navs {
+  .nav-tabs {
+    border-bottom: $border-color-theme 1px solid;
+    .nav-link {
+      &:hover {
+        border-color: lighten($border-color-theme, 10%);
+        border-bottom: none;
+      }
+      &.active {
+        background-color: transparent;
+      }
+    }
+  }
+  // Button
+  .btn-group.grw-page-editor-mode-manager {
+    .btn.btn-outline-primary {
+      @include btn-page-editor-mode-manager(#ffffff, $primary, $primary, lighten($primary, 20%));
+    }
+  }
+}
+
+html[dark] {
+  // Theme colors
+  $themecolor: #ea5532;
+  $themedark: #333333;
+  $accentcolor: #212121;
+  $subthemecolor: #2e2e2e;
+
+  $primary: #ea5532;
+  $dark: #a7a7a7;
+
+  // Background colors
+  $bgcolor-global: $themedark;
+  $bgcolor-navbar: #2b2b2b;
+  $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-card: darken($themedark, 5%);
+  $bgcolor-blinked-section: rgba($primary, 0.5);
+  $bgcolor-keyword-highlighted: darken($grw-marker-red, 30%);
+
+  // Font colors
+  $color-global: #ffffff;
+  $color-reversal: $gray-100;
+  $color-link: $primary;
+  $color-link-hover: lighten($color-link, 12%);
+  $color-link-wiki: $primary;
+  $color-link-wiki-hover: lighten($color-link-wiki, 12%);
+  $color-link-nabvar: $color-reversal;
+  $color-inline-code: $subthemecolor;
+  $color-inline-code: #c7254e; // optional
+  $color-search: $dark;
+
+  // List Group colors
+  // $color-list: $color-global;
+  $bgcolor-list: transparent;
+  $color-list-hover: $accentcolor;
+  // $bgcolor-list-hover: lighten($bgcolor-global, 3%);
+  // $color-list-active: $color-reversal;
+  // $bgcolor-list-active: $primary;
+
+  // Navbar
+  $bgcolor-navbar: #2c2c2c;
+  $bgcolor-search-top-dropdown: $themecolor;
+  $border-image-navbar: linear-gradient(to right, #ea5532 0%, #c9171e 100%);
+
+  // Logo colors
+  $bgcolor-logo: #ffffff;
+  $fillcolor-logo-mark: #ffffff;
+  // $fillcolor-logo-mark: #4e5a60;
+
+  // Sidebar
+  $bgcolor-sidebar: $accentcolor;
+  // $bgcolor-sidebar-nav-item-active: rgba(#, 0.3); // optional
+  $text-shadow-sidebar-nav-item-active: 0px 0px 10px $primary; // optional
+  // Sidebar resize button
+  $color-resize-button: $color-global;
+  $bgcolor-resize-button: $primary;
+  $color-resize-button-hover: $color-global;
+  $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
+  // Sidebar contents
+  $bgcolor-sidebar-context: #2e2e2e;
+  $color-sidebar-context: $color-global;
+  // Sidebar list group
+  // $bgcolor-sidebar-list-group: #; // optional
+
+  // Icon colors
+  $color-editor-icons: $color-global;
+
+  // Border colors
+  $border-color-theme: $primary;
+  $bordercolor-inline-code: #4d4d4d; // optional
+
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $primary;
+  $color-dropdown-link-active: $color-global;
+  $color-dropdown-link-hover: $color-reversal;
+
+  // admin theme box
+  $color-theme-color-box: $primary;
+
+  @import 'apply-colors';
+  @import 'apply-colors-dark';
+
+  // Navs
+  .nav-tabs {
+    border-bottom: $border-color-theme 1px solid;
+    .nav-link {
+      &:hover {
+        border-color: lighten($border-color-theme, 10%);
+        border-bottom: none;
+      }
+      &.active {
+        color: $color-link;
+        background-color: transparent;
+        border-color: $border-color-theme;
+      }
+    }
+  }
+
+  // Table
+  .table {
+    color: white;
+  }
+
+  // Button
+  .btn-group.grw-page-editor-mode-manager {
+    .btn.btn-outline-primary {
+      @include btn-page-editor-mode-manager(#ffffff, $primary, $primary, darken($primary, 20%));
+    }
+  }
+}

+ 209 - 0
packages/app/src/styles/theme/jade-green.scss

@@ -0,0 +1,209 @@
+@import '../variables';
+@import '../override-bootstrap-variables';
+
+html[light] {
+  // Theme colors
+  $themecolor: #38b48b;
+  $themelight: #ffffff;
+  $accentcolor: #bfbfbf;
+  $subthemecolor: #e6e6e6;
+
+  $primary: $themecolor;
+
+  // Background colors
+  $bgcolor-global: $themelight;
+  $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-card: $accentcolor;
+  $bgcolor-blinked-section: rgba($primary, 0.1);
+  //$bgcolor-keyword-highlighted: $grw-marker-yellow;
+
+  // Font colors
+  $color-global: #2c2c2c;
+  $color-reversal: $gray-100;
+  $color-link: $primary;
+  $color-link-hover: lighten($color-link, 12%);
+  $color-link-wiki: $primary;
+  $color-link-wiki-hover: lighten($color-link-wiki, 12%);
+  $color-link-nabvar: $color-reversal;
+  $color-inline-code: #c7254e; // optional
+  $color-search: $color-global;
+
+  // List Group colors
+  // $color-list: $color-global;
+  $bgcolor-list: transparent;
+  $color-list-hover: $color-search;
+  $bgcolor-list-hover: darken($bgcolor-global, 3%);
+  // $color-list-active: $color-reversal;
+  // $bgcolor-list-active: $primary;
+
+  // Navbar
+  $bgcolor-navbar: $color-global;
+  $bgcolor-search-top-dropdown: $themecolor;
+  $border-image-navbar: linear-gradient(to right, $primary 0%, darken($primary, 5%) 100%);
+
+  // Logo colors
+  $bgcolor-logo: $themelight;
+  $fillcolor-logo-mark: $themelight;
+
+  // Sidebar
+  $bgcolor-sidebar: $accentcolor;
+  // $bgcolor-sidebar-nav-item-active: rgba(#, 0.37); // optional
+  $text-shadow-sidebar-nav-item-active: 0px 0px 10px #ffffff; // optional
+  // Sidebar resize button
+  $color-resize-button: #ffffff;
+  $bgcolor-resize-button: $primary;
+  $color-resize-button-hover: $color-reversal;
+  $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
+  // Sidebar contents
+  $color-sidebar-context: $color-global;
+  $bgcolor-sidebar-context: #ebebeb;
+  // Sidebar list group
+  // $bgcolor-sidebar-list-group: #; // optional
+
+  // Icon colors
+  $color-editor-icons: $color-global;
+
+  // Border colors
+  $border-color-theme: $primary;
+  $bordercolor-inline-code: #ccc8c8; // optional
+
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $primary;
+  $color-dropdown-link-active: $color-reversal;
+
+  // admin theme box
+  $color-theme-color-box: $primary;
+
+  @import 'apply-colors';
+  @import 'apply-colors-light';
+
+  // Navs {
+  .nav-tabs {
+    border-bottom: $border-color-theme 1px solid;
+    .nav-link {
+      &:hover {
+        border-color: lighten($border-color-theme, 10%);
+        border-bottom: none;
+      }
+      &.active {
+        background-color: transparent;
+      }
+    }
+  }
+  // Button
+  .btn-group.grw-page-editor-mode-manager {
+    .btn.btn-outline-primary {
+      @include btn-page-editor-mode-manager(#ffffff, $primary, $primary, lighten($primary, 20%));
+    }
+  }
+}
+
+html[dark] {
+  // Theme colors
+  $themecolor: #38b48b;
+  $themedark: #333333;
+  $accentcolor: #212121;
+  $subthemecolor: #2e2e2e;
+
+  $primary: #38b48b;
+  $dark: #a7a7a7;
+
+  // Background colors
+  $bgcolor-global: $themedark;
+  $bgcolor-navbar: #2b2b2b;
+  $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-card: darken($themedark, 5%);
+  $bgcolor-blinked-section: rgba($primary, 0.5);
+  $bgcolor-keyword-highlighted: darken($grw-marker-red, 30%);
+
+  // Font colors
+  $color-global: #ffffff;
+  $color-reversal: $gray-100;
+  $color-link: $primary;
+  $color-link-hover: lighten($color-link, 12%);
+  $color-link-wiki: $primary;
+  $color-link-wiki-hover: lighten($color-link-wiki, 12%);
+  $color-link-nabvar: $color-reversal;
+  $color-inline-code: $subthemecolor;
+  $color-inline-code: #c7254e; // optional
+  $color-search: $dark;
+
+  // List Group colors
+  // $color-list: $color-global;
+  $bgcolor-list: transparent;
+  $color-list-hover: $accentcolor;
+  // $bgcolor-list-hover: lighten($bgcolor-global, 3%);
+  // $color-list-active: $color-reversal;
+  // $bgcolor-list-active: $primary;
+
+  // Navbar
+  $bgcolor-navbar: #2c2c2c;
+  $bgcolor-search-top-dropdown: $themecolor;
+  $border-image-navbar: linear-gradient(to right, $primary 0%, darken($primary, 5%) 100%);
+
+  // Logo colors
+  $bgcolor-logo: #ffffff;
+  $fillcolor-logo-mark: #ffffff;
+  // $fillcolor-logo-mark: #4e5a60;
+
+  // Sidebar
+  $bgcolor-sidebar: $accentcolor;
+  // $bgcolor-sidebar-nav-item-active: rgba(#, 0.3); // optional
+  $text-shadow-sidebar-nav-item-active: 0px 0px 10px $primary; // optional
+  // Sidebar resize button
+  $color-resize-button: $color-global;
+  $bgcolor-resize-button: $primary;
+  $color-resize-button-hover: $color-global;
+  $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
+  // Sidebar contents
+  $bgcolor-sidebar-context: #2e2e2e;
+  $color-sidebar-context: $color-global;
+  // Sidebar list group
+  // $bgcolor-sidebar-list-group: #; // optional
+
+  // Icon colors
+  $color-editor-icons: $color-global;
+
+  // Border colors
+  $border-color-theme: $primary;
+  $bordercolor-inline-code: #4d4d4d; // optional
+
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $primary;
+  $color-dropdown-link-active: $color-global;
+  $color-dropdown-link-hover: $color-reversal;
+
+  // admin theme box
+  $color-theme-color-box: $primary;
+
+  @import 'apply-colors';
+  @import 'apply-colors-dark';
+
+  // Navs
+  .nav-tabs {
+    border-bottom: $border-color-theme 1px solid;
+    .nav-link {
+      &:hover {
+        border-color: lighten($border-color-theme, 10%);
+        border-bottom: none;
+      }
+      &.active {
+        color: $color-link;
+        background-color: transparent;
+        border-color: $border-color-theme;
+      }
+    }
+  }
+
+  // Table
+  .table {
+    color: white;
+  }
+
+  // Button
+  .btn-group.grw-page-editor-mode-manager {
+    .btn.btn-outline-primary {
+      @include btn-page-editor-mode-manager(#ffffff, $primary, $primary, darken($primary, 20%));
+    }
+  }
+}

+ 9 - 0
packages/app/src/utils/swr-utils.ts

@@ -0,0 +1,9 @@
+import { SWRConfiguration } from 'swr';
+
+import axios from './axios';
+
+export const swrGlobalConfiguration: SWRConfiguration = {
+  fetcher: url => axios.get(url).then(res => res.data),
+  revalidateOnFocus: false,
+  errorRetryCount: 1,
+};

+ 4 - 1
packages/app/tsconfig.base.json

@@ -2,5 +2,8 @@
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
     "jsx": "react"
-  }
+  },
+  "include": [
+    "src/**/*"
+  ],
 }

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

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

+ 1 - 1
packages/core/package.json

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

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

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

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

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

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "4.4.8-RC.0",
+  "version": "4.4.14-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [
@@ -22,7 +22,7 @@
   },
   "devDependencies": {
     "browser-bunyan": "^1.6.3",
-    "stylelint": "^13.2.0",
+    "stylelint": "^14.0.1",
     "stylelint-config-recess-order": "^2.0.1",
     "tsc-alias": "^1.2.9"
   }

+ 2 - 2
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "4.4.8-RC.0",
+  "version": "4.4.14-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
@@ -14,7 +14,7 @@
   },
   "dependencies": {
     "@slack/oauth": "^2.0.1",
-    "axios": "^0.21.1",
+    "axios": "^0.24.0",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "extensible-custom-error": "^0.0.7",

+ 1 - 0
packages/slack/src/index.ts

@@ -22,6 +22,7 @@ export const defaultSupportedCommandsNameForSingleUse: string[] = [
   'keep',
 ];
 
+export * from './interfaces/channel';
 export * from './interfaces/growi-command-processor';
 export * from './interfaces/growi-interaction-processor';
 export * from './interfaces/growi-command';

+ 6 - 0
packages/slack/src/interfaces/channel.ts

@@ -0,0 +1,6 @@
+export type IChannel = {
+  id: string,
+  name: string,
+}
+
+export type IChannelOptionalId = Omit<IChannel, 'id'> & Partial<IChannel>;

+ 4 - 4
packages/slack/src/utils/interaction-payload-accessor.ts

@@ -1,5 +1,6 @@
 import assert from 'assert';
 import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
+import { IChannel } from '../interfaces/channel';
 import loggerFactory from './logger';
 
 const logger = loggerFactory('@growi/slack:utils:interaction-payload-accessor');
@@ -68,16 +69,15 @@ export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
     return { actionId, callbackId };
   }
 
-  getChannelName(): string | null {
+  getChannel(): IChannel | null {
     // private_metadata should have the channelName parameter when view_submission
     const privateMetadata = this.getViewPrivateMetaData();
     if (privateMetadata != null && privateMetadata.channelName != null) {
-      return privateMetadata.channelName;
+      throw new Error('PrivateMetaDatas are not implemented after removal of modal from slash commands. Use payload instead.');
     }
-
     const channel = this.payload.channel;
     if (channel != null) {
-      return this.payload.channel.name;
+      return channel;
     }
 
     return null;

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "4.4.8-slackbot-proxy.1",
+  "version": "4.4.14-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^4.4.8-RC.0",
+    "@growi/slack": "^4.4.14-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
@@ -33,7 +33,7 @@
     "@tsed/platform-express": "^6.43.0",
     "@tsed/swagger": "^6.43.0",
     "@tsed/typeorm": "^6.43.0",
-    "axios": "^0.21.1",
+    "axios": "^0.24.0",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "compression": "^1.7.4",

+ 15 - 5
packages/slackbot-proxy/src/controllers/slack.ts

@@ -12,7 +12,7 @@ import {
   markdownSectionBlock, GrowiCommand, parseSlashCommand, respondRejectedErrors, generateWebClient,
   InvalidGrowiCommandError, requiredScopes, REQUEST_TIMEOUT_FOR_PTOG,
   parseSlackInteractionRequest, verifySlackRequest,
-  respond, supportedGrowiCommands,
+  respond, supportedGrowiCommands, IChannelOptionalId,
 } from '@growi/slack';
 
 import { Relation } from '~/entities/relation';
@@ -227,16 +227,21 @@ export class SlackCtrl {
     const allowedRelationsForBroadcastUse:Relation[] = [];
     const disallowedGrowiUrls: Set<string> = new Set();
 
+    const channel: IChannelOptionalId = {
+      id: body.channel_id,
+      name: body.channel_name,
+    };
+
     // check permission
     await Promise.all(relations.map(async(relation) => {
       const isSupportedForSingleUse = await this.relationsService.isPermissionsForSingleUseCommands(
-        relation, growiCommand.growiCommandType, body.channel_name,
+        relation, growiCommand.growiCommandType, channel,
       );
 
       let isSupportedForBroadcastUse = false;
       if (!isSupportedForSingleUse) {
         isSupportedForBroadcastUse = await this.relationsService.isPermissionsUseBroadcastCommands(
-          relation, growiCommand.growiCommandType, body.channel_name,
+          relation, growiCommand.growiCommandType, channel,
         );
       }
 
@@ -346,8 +351,13 @@ export class SlackCtrl {
 
     const privateMeta = interactionPayloadAccessor.getViewPrivateMetaData();
 
-    const channelName = interactionPayload.channel?.name || privateMeta?.body?.channel_name || privateMeta?.channelName;
-    const permission = await this.relationsService.checkPermissionForInteractions(relations, actionId, callbackId, channelName);
+    const channelFromMeta = {
+      name: privateMeta?.body?.channel_name || privateMeta?.channelName,
+    };
+
+    const channel: IChannelOptionalId = interactionPayload.channel || channelFromMeta;
+    const permission = await this.relationsService.checkPermissionForInteractions(relations, actionId, callbackId, channel);
+
     const {
       allowedRelations, disallowedGrowiUrls, commandName, rejectedResults,
     } = permission;

+ 34 - 15
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -3,7 +3,7 @@ import { Inject, Service } from '@tsed/di';
 import axios from 'axios';
 import { addHours } from 'date-fns';
 
-import { REQUEST_TIMEOUT_FOR_PTOG, getSupportedGrowiActionsRegExp } from '@growi/slack';
+import { REQUEST_TIMEOUT_FOR_PTOG, getSupportedGrowiActionsRegExp, IChannelOptionalId } from '@growi/slack';
 import { Relation, PermissionSettingsInterface } from '~/entities/relation';
 import { RelationRepository } from '~/repositories/relation';
 
@@ -24,6 +24,7 @@ type CheckEachRelationResult = {
   eachRelationCommandName:string,
 }
 
+
 @Service()
 export class RelationsService {
 
@@ -76,7 +77,7 @@ export class RelationsService {
     return relation;
   }
 
-  private isPermitted(permissionSettings: PermissionSettingsInterface, growiCommandType: string, channelName: string): boolean {
+  private isPermitted(permissionSettings: PermissionSettingsInterface, growiCommandType: string, channel: IChannelOptionalId): boolean {
     // TODO assert (permissionSettings != null)
 
     const permissionForCommand = permissionSettings[growiCommandType];
@@ -86,13 +87,23 @@ export class RelationsService {
     }
 
     if (Array.isArray(permissionForCommand)) {
-      return permissionForCommand.includes(channelName);
+      if (permissionForCommand.includes(channel.name)) {
+        return true;
+      }
+
+      if (channel.id == null) {
+        return false;
+      }
+
+      if (permissionForCommand.includes(channel.id)) {
+        return true;
+      }
     }
+    return permissionForCommand as boolean;
 
-    return permissionForCommand;
   }
 
-  async isPermissionsForSingleUseCommands(relation: Relation, growiCommandType: string, channelName: string): Promise<boolean> {
+  async isPermissionsForSingleUseCommands(relation: Relation, growiCommandType: string, channel: IChannelOptionalId): Promise<boolean> {
     // TODO assert (relation != null)
     if (relation == null) {
       return false;
@@ -110,10 +121,10 @@ export class RelationsService {
 
     // TODO assert (relationToEval.permissionsForSingleUseCommands != null) because syncRelation success
 
-    return this.isPermitted(relationToEval.permissionsForSingleUseCommands, growiCommandType, channelName);
+    return this.isPermitted(relationToEval.permissionsForSingleUseCommands, growiCommandType, channel);
   }
 
-  async isPermissionsUseBroadcastCommands(relation: Relation, growiCommandType: string, channelName: string):Promise<boolean> {
+  async isPermissionsUseBroadcastCommands(relation: Relation, growiCommandType: string, channel: IChannelOptionalId):Promise<boolean> {
     // TODO assert (relation != null)
     if (relation == null) {
       return false;
@@ -131,11 +142,11 @@ export class RelationsService {
 
     // TODO assert (relationToEval.permissionsForSingleUseCommands != null) because syncRelation success
 
-    return this.isPermitted(relationToEval.permissionsForBroadcastUseCommands, growiCommandType, channelName);
+    return this.isPermitted(relationToEval.permissionsForBroadcastUseCommands, growiCommandType, channel);
   }
 
   async checkPermissionForInteractions(
-      relations:Relation[], actionId:string, callbackId:string, channelName:string,
+      relations: Relation[], actionId: string, callbackId: string, channel: IChannelOptionalId,
   ):Promise<CheckPermissionForInteractionsResults> {
 
     const allowedRelations:Relation[] = [];
@@ -143,7 +154,7 @@ export class RelationsService {
     let commandName = '';
 
     const results = await Promise.allSettled(relations.map((relation) => {
-      const relationResult = this.checkEachRelation(relation, actionId, callbackId, channelName);
+      const relationResult = this.checkEachRelation(relation, actionId, callbackId, channel);
       const { allowedRelation, disallowedGrowiUrl, eachRelationCommandName } = relationResult;
 
       if (allowedRelation != null) {
@@ -164,8 +175,7 @@ export class RelationsService {
     };
   }
 
-  checkEachRelation(relation:Relation, actionId:string, callbackId:string, channelName:string):CheckEachRelationResult {
-
+  checkEachRelation(relation:Relation, actionId:string, callbackId:string, channel: IChannelOptionalId): CheckEachRelationResult {
     let allowedRelation:Relation|null = null;
     let disallowedGrowiUrl:string|null = null;
     let eachRelationCommandName = '';
@@ -198,9 +208,18 @@ export class RelationsService {
       }
 
       // check permission at channel level
-      if (Array.isArray(permissionForInteractions) && permissionForInteractions.includes(channelName)) {
-        allowedRelation = relation;
-        return;
+      if (Array.isArray(permissionForInteractions)) {
+        if (permissionForInteractions.includes(channel.name)) {
+          allowedRelation = relation;
+          return;
+        }
+
+        if (channel.id == null) return;
+
+        if (permissionForInteractions.includes(channel.id)) {
+          allowedRelation = relation;
+          return;
+        }
       }
 
       disallowedGrowiUrl = relation.growiUri;

+ 7 - 4
packages/slackbot-proxy/src/services/SelectGrowiService.ts

@@ -28,6 +28,8 @@ type SendCommandBody = {
   // eslint-disable-next-line camelcase
   trigger_id: string,
   // eslint-disable-next-line camelcase
+  channel_id: string,
+  // eslint-disable-next-line camelcase
   channel_name: string,
 }
 
@@ -209,9 +211,9 @@ export class SelectGrowiService implements GrowiCommandProcessor<SelectGrowiComm
     }
 
     // increment sendCommandBody
-    const channelName = interactionPayloadAccessor.getChannelName();
-    if (channelName == null) {
-      logger.error('Growi command failed: channelName not found.');
+    const channel = interactionPayloadAccessor.getChannel();
+    if (channel == null) {
+      logger.error('Growi command failed: channel not found.');
       await respond(responseUrl, {
         text: 'Growi command failed',
         blocks: [
@@ -222,7 +224,8 @@ export class SelectGrowiService implements GrowiCommandProcessor<SelectGrowiComm
     }
     const sendCommandBody: SendCommandBody = {
       trigger_id: interactionPayload.trigger_id,
-      channel_name: channelName,
+      channel_id: channel.id,
+      channel_name: channel.name,
     };
 
     return {

+ 1 - 1
packages/ui/package.json

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

Разница между файлами не показана из-за своего большого размера
+ 171 - 376
yarn.lock


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