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

Merge branch 'master' into feat/77515-display-search-result-with-snippet

yuto-oweseek 4 лет назад
Родитель
Сommit
7c6e9cd87d
91 измененных файлов с 2157 добавлено и 594 удалено
  1. 12 9
      .devcontainer/devcontainer.json
  2. 0 4
      .devcontainer/docker-compose.yml
  3. 39 1
      CHANGELOG.md
  4. 12 1
      SECURITY.md
  5. 1 1
      lerna.json
  6. 1 1
      package.json
  7. 3 0
      packages/app/config/webpack.common.js
  8. 2 2
      packages/app/docker/README.md
  9. 8 7
      packages/app/package.json
  10. 1 0
      packages/app/resource/locales/en_US/translation.json
  11. 7 1
      packages/app/resource/locales/ja_JP/admin/admin.json
  12. 7 1
      packages/app/resource/locales/zh_CN/admin/admin.json
  13. 8 3
      packages/app/src/client/app.jsx
  14. 19 96
      packages/app/src/client/services/AppContainer.js
  15. 1 3
      packages/app/src/client/services/CommentContainer.js
  16. 56 25
      packages/app/src/client/services/PageContainer.js
  17. 47 0
      packages/app/src/client/util/apiv1-client.ts
  18. 0 12
      packages/app/src/client/util/apiv1ErrorHandler.js
  19. 56 0
      packages/app/src/client/util/apiv3-client.ts
  20. 0 21
      packages/app/src/client/util/apiv3ErrorHandler.js
  21. 6 0
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  22. 2 2
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  23. 1 1
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  24. 35 13
      packages/app/src/components/LikeButtons.jsx
  25. 5 6
      packages/app/src/components/Navbar/SubNavButtons.jsx
  26. 1 5
      packages/app/src/components/PageComment/Comment.jsx
  27. 2 2
      packages/app/src/components/Sidebar/CustomSidebar.jsx
  28. 79 18
      packages/app/src/components/Sidebar/RecentChanges.jsx
  29. 4 2
      packages/app/src/components/User/SeenUserInfo.jsx
  30. 7 0
      packages/app/src/interfaces/page-tag-relation.ts
  31. 14 0
      packages/app/src/interfaces/page.ts
  32. 9 0
      packages/app/src/interfaces/revision.ts
  33. 4 0
      packages/app/src/interfaces/tag.ts
  34. 18 0
      packages/app/src/interfaces/user.ts
  35. 15 6
      packages/app/src/migrations/20200901034313-update-mail-transmission.js
  36. 0 33
      packages/app/src/migrations/20200901034314-update-mail-transmission-fix.js
  37. 0 1
      packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  38. 1 1
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  39. 84 0
      packages/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js
  40. 97 0
      packages/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js
  41. 14 6
      packages/app/src/server/middlewares/http-error-handler.js
  42. 0 10
      packages/app/src/server/models/comment.js
  43. 19 48
      packages/app/src/server/routes/apiv3/page.js
  44. 3 3
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  45. 179 66
      packages/app/src/server/routes/apiv3/slack-integration.js
  46. 28 17
      packages/app/src/server/routes/comment.js
  47. 2 2
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  48. 15 7
      packages/app/src/server/service/slack-command-handler/help.js
  49. 229 0
      packages/app/src/server/service/slack-command-handler/keep.js
  50. 12 12
      packages/app/src/server/service/slack-command-handler/note.js
  51. 38 39
      packages/app/src/server/service/slack-command-handler/search.js
  52. 12 6
      packages/app/src/server/service/slack-integration.ts
  53. 5 0
      packages/app/src/server/util/slack-integration.ts
  54. 0 4
      packages/app/src/server/views/widget/page_content.html
  55. 13 0
      packages/app/src/stores/page.tsx
  56. 1 5
      packages/app/src/styles/_recent-changes.scss
  57. 3 1
      packages/app/src/styles/_sidebar.scss
  58. 0 1
      packages/app/src/styles/_variables.scss
  59. 2 2
      packages/app/src/styles/_wiki.scss
  60. 3 7
      packages/app/src/styles/theme/_apply-colors.scss
  61. 115 0
      packages/app/src/styles/theme/blackboard.scss
  62. 209 0
      packages/app/src/styles/theme/fire-red.scss
  63. 209 0
      packages/app/src/styles/theme/jade-green.scss
  64. 6 6
      packages/app/src/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts
  65. 9 0
      packages/app/src/utils/swr-utils.ts
  66. 1 1
      packages/codemirror-textlint/package.json
  67. 1 1
      packages/core/package.json
  68. 1 1
      packages/plugin-attachment-refs/package.json
  69. 1 1
      packages/plugin-lsx/package.json
  70. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  71. 3 2
      packages/slack/package.json
  72. 7 5
      packages/slack/src/index.ts
  73. 9 7
      packages/slack/src/interfaces/request-between-growi-and-proxy.ts
  74. 8 0
      packages/slack/src/interfaces/respond-util.ts
  75. 22 0
      packages/slack/src/utils/interaction-payload-accessor.ts
  76. 6 6
      packages/slack/src/utils/reshape-contents-body.test.ts
  77. 2 2
      packages/slack/src/utils/reshape-contents-body.ts
  78. 71 0
      packages/slack/src/utils/respond-util-factory.ts
  79. 0 21
      packages/slack/src/utils/welcome-message.ts
  80. 2 2
      packages/slackbot-proxy/package.json
  81. 48 8
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  82. 30 12
      packages/slackbot-proxy/src/controllers/slack.ts
  83. 24 0
      packages/slackbot-proxy/src/entities/system-information.ts
  84. 23 0
      packages/slackbot-proxy/src/repositories/system-information.ts
  85. 4 0
      packages/slackbot-proxy/src/services/RelationsService.ts
  86. 1 1
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  87. 47 0
      packages/slackbot-proxy/src/services/SystemInformationService.ts
  88. 4 2
      packages/slackbot-proxy/src/services/UnregisterService.ts
  89. 38 0
      packages/slackbot-proxy/src/utils/welcome-message.ts
  90. 1 1
      packages/ui/package.json
  91. 12 0
      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

+ 39 - 1
CHANGELOG.md

@@ -1,9 +1,47 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.7...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.4.9...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [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

+ 12 - 1
SECURITY.md

@@ -14,9 +14,20 @@
 If you believe you have found a security vulnerability in any GROWI related repository, please report it to us using one of the methods described below.
 
   * [Join our Slack team](https://growi-slackin.weseek.co.jp/) and send DM to `@yuki` who is the lead developer
-  * Report to JPCERT/CC ([en](https://www.jpcert.or.jp/english/ir/form.html)/[ja](https://www.jpcert.or.jp/form/))
+  * Report to JPCERT/CC[^jpcertcc]
+    * [[PDF] JPCERT/CC Vulnerability Coordination and Disclosure Policy](https://www.jpcert.or.jp/english/vh/vul-coordination-disclosure-policy_2019.pdf)
 
 ## Preferred Languages
 
 Communication in English and Japanese is possible.  
 In Japanese, we can reply more quickly. 
+
+
+
+
+Some long sentence. 
+
+[^jpcertcc]: JPCERT/CC is a National CSIRT in Japan and a coordination center
+who coordinates cyber security incidents and products vulnerabilities
+with network service providers, product vendors (software/IoT/ICS etc.),
+security vendors, government agencies, as well as the industry associations.

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.4.8-RC.0",
+  "version": "4.4.10-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.9`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.9/docker/Dockerfile)
+* [`4.4.9-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.9/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)
 

+ 8 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.4.8-RC.0",
+  "version": "4.4.10-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -57,11 +57,11 @@
     "@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.10-RC.0",
+    "@growi/plugin-attachment-refs": "^4.4.10-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.10-RC.0",
+    "@growi/plugin-lsx": "^4.4.10-RC.0",
+    "@growi/slack": "^4.4.10-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -157,7 +157,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.10-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -232,6 +232,7 @@
     "stylelint": "^13.2.0",
     "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 - 0
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",

+ 7 - 1
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": "サイト名",

+ 7 - 1
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": "网站名称 ",

+ 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,

+ 19 - 96
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,9 +29,6 @@ export default class AppContainer extends Container {
 
     this.state = {
       preferDarkModeByMediaQuery: false,
-
-      // stetes for contents
-      recentlyUpdatedPages: [],
     };
 
     const body = document.querySelector('body');
@@ -60,17 +58,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 +281,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 +301,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 });
-  }
-
 }

+ 1 - 3
packages/app/src/client/services/CommentContainer.js

@@ -132,11 +132,9 @@ export default class CommentContainer extends Container {
     return this.appContainer.apiPost('/comments.update', {
       commentForm: {
         comment,
-        page_id: pageId,
-        revision_id: revisionId,
         is_markdown: isMarkdown,
+        revision_id: revisionId,
         comment_id: commentId,
-        author,
       },
     })
       .then((res) => {

+ 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', { _id: 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() {

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

@@ -0,0 +1,47 @@
+import * as urljoin from 'url-join';
+
+import axios from '~/utils/axios';
+
+const apiv1Root = '/_api';
+
+
+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 });
+}
+
+export async function apiPost(path: string, params: unknown = {}): Promise<unknown> {
+  return apiRequest('post', path, params);
+}
+
+export async function apiDelete(path: string, params: unknown = {}): Promise<unknown> {
+  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;

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

@@ -0,0 +1,56 @@
+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');
+
+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: unknown = {}): Promise<AxiosResponse<T>> {
+  return apiv3Request('post', path, params);
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3Put<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+  return apiv3Request('put', path, params);
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3Delete<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+  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 - 2
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -72,8 +72,8 @@ const ManageCommandsProcess = ({
     search: permissionsForBroadcastUseCommands.search,
   });
   const [permissionsForSingleUseCommandsState, setPermissionsForSingleUseCommandsState] = useState({
-    create: permissionsForSingleUseCommands.create,
-    togetter: permissionsForSingleUseCommands.togetter,
+    note: permissionsForSingleUseCommands.note,
+    keep: permissionsForSingleUseCommands.keep,
   });
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
     const initialState = {};

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

@@ -190,7 +190,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
         commandPermission: editingCommandPermission,
       });
-      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+      toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
     }
     catch (err) {
       toastError(err);

+ 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} />}
         </>
       )}
     </>

+ 1 - 5
packages/app/src/components/PageComment/Comment.jsx

@@ -73,10 +73,6 @@ class Comment extends React.PureComponent {
     interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
   }
 
-  checkPermissionToControlComment() {
-    return this.props.appContainer.isAdmin || this.isCurrentUserEqualsToAuthor();
-  }
-
   isCurrentUserEqualsToAuthor() {
     const { creator } = this.props.comment;
     if (creator == null) {
@@ -210,7 +206,7 @@ class Comment extends React.PureComponent {
                   </UncontrolledTooltip>
                 </span>
               </div>
-              {this.checkPermissionToControlComment() && (
+              {this.isCurrentUserEqualsToAuthor() && (
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}

+ 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[]
+}

+ 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
-  },
-};

+ 0 - 1
packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js

@@ -1,6 +1,5 @@
 import mongoose from 'mongoose';
 
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
 import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 

+ 1 - 1
packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -5,7 +5,7 @@ import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 
-const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
+const logger = loggerFactory('growi:migrate:migrate-slack-app-integration-schema');
 
 // create default data
 const defaultDataForBroadcastUse = {};

+ 84 - 0
packages/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js

@@ -0,0 +1,84 @@
+import mongoose from 'mongoose';
+
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:migrate:slack-app-integration-rename-keys');
+
+module.exports = {
+  async up(db) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    const slackAppIntegrations = await SlackAppIntegration.find();
+
+    if (slackAppIntegrations.length === 0) return;
+
+    // create operations
+    const operations = slackAppIntegrations.map((doc) => {
+      const permissionsForSingleUseCommands = doc._doc.permissionsForSingleUseCommands;
+      const createValue = permissionsForSingleUseCommands.get('create', false);
+      const togetterValue = permissionsForSingleUseCommands.get('togetter', false);
+
+      const newPermissionsForSingleUseCommands = {
+        note: createValue,
+        keep: togetterValue,
+      };
+
+      return {
+        updateOne: {
+          filter: { _id: doc._id },
+          update: {
+            $set: {
+              permissionsForSingleUseCommands: newPermissionsForSingleUseCommands,
+            },
+          },
+        },
+      };
+    });
+
+    await db.collection('slackappintegrations').bulkWrite(operations);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, next) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    const slackAppIntegrations = await SlackAppIntegration.find();
+
+    if (slackAppIntegrations.length === 0) return next();
+
+    // create operations
+    const operations = slackAppIntegrations.map((doc) => {
+      const permissionsForSingleUseCommands = doc._doc.permissionsForSingleUseCommands;
+      const noteValue = permissionsForSingleUseCommands.get('note', false);
+      const keepValue = permissionsForSingleUseCommands.get('keep', false);
+
+      const newPermissionsForSingleUseCommands = {
+        create: noteValue,
+        togetter: keepValue,
+      };
+
+      return {
+        updateOne: {
+          filter: { _id: doc._id },
+          update: {
+            $set: {
+              permissionsForSingleUseCommands: newPermissionsForSingleUseCommands,
+            },
+          },
+        },
+      };
+    });
+
+    await db.collection('slackappintegrations').bulkWrite(operations);
+
+    next();
+    logger.info('Migration rollback has successfully applied');
+  },
+};

+ 97 - 0
packages/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js

@@ -0,0 +1,97 @@
+import mongoose from 'mongoose';
+
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+import Config from '~/server/models/config';
+
+
+const logger = loggerFactory('growi:migrate:slack-app-integration-rename-keys');
+
+module.exports = {
+  async up(db) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const isExist = (await Config.count({ key: 'slackbot:withoutProxy:commandPermission' })) > 0;
+    if (!isExist) return;
+
+    const commandPermissionValue = await Config.findOne({ key: 'slackbot:withoutProxy:commandPermission' });
+    // do nothing if data is 'null' or null
+    if (commandPermissionValue._doc.value === 'null' || commandPermissionValue._doc.value == null) return;
+
+    const commandPermission = JSON.parse(commandPermissionValue._doc.value);
+
+    const newCommandPermission = {
+      note: false,
+      keep: false,
+    };
+    Object.entries(commandPermission).forEach((entry) => {
+      const [key, value] = entry;
+      switch (key) {
+        case 'create':
+          newCommandPermission.note = value;
+          break;
+        case 'togetter':
+          newCommandPermission.keep = value;
+          break;
+        default:
+          newCommandPermission[key] = value;
+          break;
+      }
+    });
+
+    await Config.findOneAndUpdate(
+      { key: 'slackbot:withoutProxy:commandPermission' },
+      {
+        $set: {
+          value: JSON.stringify(newCommandPermission),
+        },
+      },
+    );
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, next) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const isExist = (await Config.count({ key: 'slackbot:withoutProxy:commandPermission' })) > 0;
+    if (!isExist) return next();
+
+    const commandPermissionValue = await Config.findOne({ key: 'slackbot:withoutProxy:commandPermission' });
+    // do nothing if data is 'null' or null
+    if (commandPermissionValue._doc.value === 'null' || commandPermissionValue._doc.value == null) return next();
+
+    const commandPermission = JSON.parse(commandPermissionValue._doc.value);
+
+    const newCommandPermission = {
+      create: false,
+      togetter: false,
+    };
+    Object.entries(commandPermission).forEach((entry) => {
+      const [key, value] = entry;
+      switch (key) {
+        case 'note':
+          newCommandPermission.create = value;
+          break;
+        case 'keep':
+          newCommandPermission.togetter = value;
+          break;
+        default:
+          newCommandPermission[key] = value;
+          break;
+      }
+    });
+
+    await Config.findOneAndUpdate(
+      { key: 'slackbot:withoutProxy:commandPermission' },
+      {
+        $set: {
+          value: JSON.stringify(newCommandPermission),
+        },
+      },
+    );
+
+    next();
+    logger.info('Migration rollback has successfully applied');
+  },
+};

+ 14 - 6
packages/app/src/server/middlewares/http-error-handler.js

@@ -1,4 +1,7 @@
 import { HttpError } from 'http-errors';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middleware:htto-error-handler');
 
 const isHttpError = (val) => {
   if (!val || typeof val !== 'object') {
@@ -20,12 +23,17 @@ module.exports = async(err, req, res, next) => {
   if (isHttpError(err)) {
     const httpError = err;
 
-    return res
-      .status(httpError.status)
-      .send({
-        status: httpError.status,
-        message: httpError.message,
-      });
+    try {
+      return res
+        .status(httpError.status)
+        .send({
+          status: httpError.status,
+          message: httpError.message,
+        });
+    }
+    catch (err) {
+      logger.error('Cannot call res.send() twice:', err);
+    }
   }
 
   next(err);

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

@@ -65,16 +65,6 @@ module.exports = function(crowi) {
     }));
   };
 
-  commentSchema.statics.updateCommentsByPageId = function(comment, isMarkdown, commentId) {
-    const Comment = this;
-
-    return Comment.findOneAndUpdate(
-      { _id: commentId },
-      { $set: { comment, isMarkdown } },
-    );
-
-  };
-
   commentSchema.statics.removeCommentsByPageId = function(pageId) {
     const Comment = this;
 

+ 19 - 48
packages/app/src/server/routes/apiv3/page.js

@@ -112,17 +112,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          bool:
  *            type: boolean
  *            description: boolean for like status
- *
- *      LikeInfo:
- *        description: LikeInfo
- *        type: object
- *        properties:
- *          sumOfLikers:
- *            type: number
- *            description: how many people liked the page
- *          isLiked:
- *            type: boolean
- *            description: Whether the request user liked (will be returned if the user is included in the request)
  */
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
@@ -141,9 +130,6 @@ module.exports = (crowi) => {
       body('pageId').isString(),
       body('bool').isBoolean(),
     ],
-    likeInfo: [
-      query('_id').isMongoId(),
-    ],
     export: [
       query('format').isString().isIn(['md', 'pdf']),
       query('revisionId').isString(),
@@ -222,50 +208,35 @@ module.exports = (crowi) => {
     }
   });
 
-  /**
-   * @swagger
-   *
-   *    /page/like-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
-   *        responses:
-   *          200:
-   *            description: Succeeded to get bookmark info.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/LikeInfo'
-   */
-  router.get('/like-info', loginRequired, validator.likeInfo, apiV3FormValidator, async(req, res) => {
-    const pageId = req.query._id;
-
-    const responsesParams = {};
+  router.get(('/info', loginRequired), async(req, res) => {
 
     try {
+      const pageId = req.query._id;
       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);
     }
+
   });
 
   /**

+ 3 - 3
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -174,7 +174,7 @@ module.exports = (crowi) => {
       settings.slackBotTokenEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:withoutProxy:botToken');
       settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
       settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
-      settings.commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+      settings.commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
     }
     else {
       settings.proxyServerUri = slackIntegrationService.proxyUriForCurrentType;
@@ -251,7 +251,7 @@ module.exports = (crowi) => {
         commandPermission[commandName] = true;
       });
 
-      const requestParams = { 'slackbot:withoutProxy:commandPermission': JSON.stringify(commandPermission) };
+      const requestParams = { 'slackbot:withoutProxy:commandPermission': commandPermission };
       try {
         await updateSlackBotSettings(requestParams);
         crowi.slackIntegrationService.publishUpdatedMessage();
@@ -398,7 +398,7 @@ module.exports = (crowi) => {
 
     const { commandPermission } = req.body;
     const requestParams = {
-      'slackbot:withoutProxy:commandPermission': JSON.stringify(commandPermission),
+      'slackbot:withoutProxy:commandPermission': commandPermission,
     };
     try {
       await updateSlackBotSettings(requestParams);

+ 179 - 66
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -1,5 +1,9 @@
-import { markdownSectionBlock, InvalidGrowiCommandError } from '@growi/slack';
+import {
+  markdownSectionBlock, InvalidGrowiCommandError, generateRespondUtil, supportedGrowiCommands,
+} from '@growi/slack';
+import createError from 'http-errors';
 import loggerFactory from '~/utils/logger';
+import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 
 const express = require('express');
 const mongoose = require('mongoose');
@@ -27,7 +31,7 @@ module.exports = (crowi) => {
     if (tokenPtoG == null) {
       const message = 'The value of header \'x-growi-ptog-tokens\' must not be empty.';
       logger.warn(message, { body: req.body });
-      return res.status(400).send({ message });
+      return next(createError(400, message));
     }
 
     const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
@@ -57,12 +61,37 @@ module.exports = (crowi) => {
     return { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands };
   }
 
-  // REFACTORIMG THIS MIDDLEWARE GW-7441
+  // TODO: move this middleware to each controller
+  // no res.send() is allowed after this middleware
   async function checkCommandsPermission(req, res, next) {
-    let { growiCommand } = req.body;
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    // for without proxy
+    res.send();
+
+    let growiCommand;
+    try {
+      growiCommand = getGrowiCommand(req.body);
+    }
+    catch (err) {
+      logger.error(err.message);
+      return next(err);
+    }
 
-    // when /relation-test or from proxy
-    if (req.body.text == null && growiCommand == null) return next();
+    // not supported commands
+    if (!supportedGrowiCommands.includes(growiCommand.growiCommandType)) {
+      const options = {
+        respondBody: {
+          text: 'Command is not supported',
+          blocks: [
+            markdownSectionBlock('*Command is not supported*'),
+            // eslint-disable-next-line max-len
+            markdownSectionBlock(`\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`),
+          ],
+        },
+      };
+      return next(new SlackCommandHandlerError('Command type is not specified', options));
+    }
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
@@ -75,29 +104,38 @@ module.exports = (crowi) => {
       commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
       const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
       if (isPermitted) return next();
-      return res.status(403).send(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`);
+
+      return next(createError(403, `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`));
     }
 
     // without proxy
-    growiCommand = parseSlashCommand(req.body);
-    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+    commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
 
     const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
     if (isPermitted) {
       return next();
     }
     // show ephemeral error message if not permitted
-    res.json({
-      response_type: 'ephemeral',
-      text: 'Command forbidden',
-      blocks: [
-        markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
-      ],
-    });
+    const options = {
+      respondBody: {
+        text: 'Command forbidden',
+        blocks: [
+          markdownSectionBlock('*Command is not supported*'),
+          markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
+        ],
+      },
+    };
+    return next(new SlackCommandHandlerError('Command type is not specified', options));
   }
 
-  // REFACTORIMG THIS MIDDLEWARE GW-7441
+  // TODO: move this middleware to each controller
+  // no res.send() is allowed after this middleware
   async function checkInteractionsPermission(req, res, next) {
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    // for without proxy
+    res.send();
+
     const { interactionPayload, interactionPayloadAccessor } = req;
     const siteUrl = crowi.appService.getSiteUrl();
 
@@ -114,24 +152,27 @@ module.exports = (crowi) => {
       const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
       if (isPermitted) return next();
 
-      return res.status(403).send(`This interaction is forbidden on this GROWI: ${siteUrl}`);
+      return next(createError(403, `This interaction is forbidden on this GROWI: ${siteUrl}`));
     }
 
     // without proxy
-    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+    commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
 
     const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
     if (isPermitted) {
       return next();
     }
     // show ephemeral error message if not permitted
-    res.json({
-      response_type: 'ephemeral',
-      text: 'Interaction forbidden',
-      blocks: [
-        markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
-      ],
-    });
+    const options = {
+      respondBody: {
+        text: 'Interaction forbidden',
+        blocks: [
+          markdownSectionBlock('*Interaction forbidden*'),
+          markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
+        ],
+      },
+    };
+    return next(new SlackCommandHandlerError('Interaction forbidden', options));
   }
 
   const addSigningSecretToReq = (req, res, next) => {
@@ -150,103 +191,164 @@ module.exports = (crowi) => {
     return next();
   };
 
-  async function handleCommands(req, res, client) {
-    const { body } = req;
-    let { growiCommand } = body;
+  function getRespondUtil(responseUrl) {
+    const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType; // can be null
 
+    const appSiteUrl = crowi.appService.getSiteUrl();
+    if (appSiteUrl == null || appSiteUrl === '') {
+      logger.error('App site url must exist.');
+      throw SlackCommandHandlerError('App site url must exist.');
+    }
+
+    return generateRespondUtil(responseUrl, proxyUri, appSiteUrl);
+  }
+
+  function getGrowiCommand(body) {
+    let { growiCommand } = body;
     if (growiCommand == null) {
       try {
         growiCommand = parseSlashCommand(body);
       }
       catch (err) {
         if (err instanceof InvalidGrowiCommandError) {
-          res.json({
-            blocks: [
-              markdownSectionBlock('*Command type is not specified.*'),
-              markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
-            ],
-          });
+          const options = {
+            respondBody: {
+              text: 'Command type is not specified',
+              blocks: [
+                markdownSectionBlock('*Command type is not specified.*'),
+                markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
+              ],
+            },
+          };
+          throw new SlackCommandHandlerError('Command type is not specified', options);
         }
-        logger.error(err.message);
-        return;
+        throw err;
       }
     }
+    return growiCommand;
+  }
 
-    const { text } = growiCommand;
+  async function handleCommands(body, res, client, responseUrl) {
+    let growiCommand;
+    let respondUtil;
+    try {
+      growiCommand = getGrowiCommand(body);
+      respondUtil = getRespondUtil(responseUrl);
+    }
+    catch (err) {
+      logger.error(err.message);
+      return handleError(err, responseUrl);
+    }
 
+    const { text } = growiCommand;
 
     if (text == null) {
       return 'No text.';
     }
 
-    /*
-     * TODO: use parseSlashCommand
-     */
-
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.json({
-      response_type: 'ephemeral',
-      text: 'Processing your request ...',
-    });
-
+    const appSiteUrl = crowi.appService.getSiteUrl();
+    try {
+      await respondUtil.respond({
+        text: 'Processing your request ...',
+        blocks: [
+          markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${appSiteUrl} ...`),
+        ],
+      });
+    }
+    catch (err) {
+      logger.error('Error occurred while request via axios:', err);
+    }
 
     try {
-      await crowi.slackIntegrationService.handleCommandRequest(growiCommand, client, body);
+      await crowi.slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
     }
     catch (err) {
-      await handleError(err, growiCommand.responseUrl);
+      return handleError(err, responseUrl);
     }
 
   }
 
-  // TODO: do investigation and fix if needed GW-7519
+  // TODO: this method will be a middleware when typescriptize in the future
+  function getResponseUrl(req) {
+    const { body } = req;
+    const responseUrl = body?.growiCommand?.responseUrl;
+    if (responseUrl == null) {
+      return body.response_url; // may be null
+    }
+    return responseUrl;
+  }
+
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
-    const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-    return handleCommands(req, res, client);
+    const { body } = req;
+    const responseUrl = getResponseUrl(req);
+
+    let client;
+    try {
+      client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+    }
+    catch (err) {
+      logger.error(err.message);
+      return handleError(err, responseUrl);
+    }
+
+    return handleCommands(body, res, client, responseUrl);
   });
 
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
+  // when relation test
+  router.post('/proxied/verify', verifyAccessTokenFromProxy, async(req, res) => {
     const { body } = req;
+
     // eslint-disable-next-line max-len
     // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
     if (body.type === 'url_verification') {
       return res.send({ challenge: body.challenge });
     }
+  });
+
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
+    const { body } = req;
+    const responseUrl = getResponseUrl(req);
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-    const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-    return handleCommands(req, res, client);
+
+    let client;
+    try {
+      client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+    }
+    catch (err) {
+      return handleError(err, responseUrl);
+    }
+
+    return handleCommands(body, res, client, responseUrl);
   });
 
   async function handleInteractionsRequest(req, res, client) {
 
-    // Send response immediately to avoid opelation_timeout error
-    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
-
     const { interactionPayload, interactionPayloadAccessor } = req;
     const { type } = interactionPayload;
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
     try {
+      const respondUtil = getRespondUtil(responseUrl);
       switch (type) {
         case 'block_actions':
-          await crowi.slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor);
+          await crowi.slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
           break;
         case 'view_submission':
-          await crowi.slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor);
+          await crowi.slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
           break;
         default:
           break;
       }
     }
-    catch (error) {
-      logger.error(error);
-      await handleError(error, interactionPayloadAccessor.getResponseUrl());
+    catch (err) {
+      logger.error(err);
+      return handleError(err, responseUrl);
     }
   }
 
-  // TODO: do investigation and fix if needed GW-7519
   router.post('/interactions', addSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleInteractionsRequest(req, res, client);
@@ -255,7 +357,6 @@ module.exports = (crowi) => {
   router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-
     return handleInteractionsRequest(req, res, client);
   });
 
@@ -267,5 +368,17 @@ module.exports = (crowi) => {
     return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
   });
 
+  // error handler
+  router.use(async(err, req, res, next) => {
+    const responseUrl = getResponseUrl(req);
+    if (responseUrl == null) {
+      // pass err to global error handler
+      return next(err);
+    }
+
+    await handleError(err, responseUrl);
+    return;
+  });
+
   return router;
 };

+ 28 - 17
packages/app/src/server/routes/comment.js

@@ -310,10 +310,10 @@ module.exports = function(crowi, app) {
    *                            $ref: '#/components/schemas/Page/properties/_id'
    *                          revision_id:
    *                            $ref: '#/components/schemas/Revision/properties/_id'
+   *                          comment_id:
+   *                            $ref: '#/components/schemas/Comment/properties/_id'
    *                          comment:
    *                            $ref: '#/components/schemas/Comment/properties/comment'
-   *                          comment_position:
-   *                            $ref: '#/components/schemas/Comment/properties/commentPosition'
    *                required:
    *                  - form
    *        responses:
@@ -340,13 +340,12 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const { commentForm } = req.body;
 
-    const pageId = commentForm.page_id;
-    const comment = commentForm.comment;
+    const commentStr = commentForm.comment;
     const isMarkdown = commentForm.is_markdown;
     const commentId = commentForm.comment_id;
-    const author = commentForm.author;
+    const revision = commentForm.revision_id;
 
-    if (comment === '') {
+    if (commentStr === '') {
       return res.json(ApiResponse.error('Comment text is required'));
     }
 
@@ -354,19 +353,28 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('\'comment_id\' is undefined'));
     }
 
-    if (author !== req.user.username) {
-      return res.json(ApiResponse.error('Only the author can edit'));
-    }
-
-    // check whether accessible
-    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
-    if (!isAccessible) {
-      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
-    }
-
     let updatedComment;
     try {
-      updatedComment = await Comment.updateCommentsByPageId(comment, isMarkdown, commentId);
+      const comment = await Comment.findById(commentId).exec();
+
+      if (comment == null) {
+        throw new Error('This comment does not exist.');
+      }
+
+      // check whether accessible
+      const pageId = comment.page;
+      const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+      if (!isAccessible) {
+        throw new Error('Current user is not accessible to this page.');
+      }
+      if (req.user.id !== comment.creator.toString()) {
+        throw new Error('Current user is not operatable to this comment.');
+      }
+
+      updatedComment = await Comment.findOneAndUpdate(
+        { _id: commentId },
+        { $set: { comment: commentStr, isMarkdown, revision } },
+      );
     }
     catch (err) {
       logger.error(err);
@@ -438,6 +446,9 @@ module.exports = function(crowi, app) {
       if (!isAccessible) {
         throw new Error('Current user is not accessible to this page.');
       }
+      if (req.user.id !== comment.creator.toString()) {
+        throw new Error('Current user is not operatable to this comment.');
+      }
 
       await comment.removeWithReplies();
       await Page.updateCommentCount(comment.page);

+ 2 - 2
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -11,7 +11,7 @@ class CreatePageService {
     this.crowi = crowi;
   }
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil) {
     const Page = this.crowi.model('Page');
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
 
@@ -25,7 +25,7 @@ class CreatePageService {
 
     // Send a message when page creation is complete
     const growiUri = this.crowi.appService.getSiteUrl();
-    await respond(interactionPayloadAccessor.getResponseUrl(), {
+    await respondUtil.respond({
       text: 'Page has been created',
       blocks: [
         markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),

+ 15 - 7
packages/app/src/server/service/slack-command-handler/help.js

@@ -1,18 +1,26 @@
-const { markdownSectionBlock, respond } = require('@growi/slack');
+/*
+ * !!help command and its message text must exist only in growi app package!!
+ * the help message should vary depending on the growi version
+ */
 
-module.exports = () => {
+const { markdownSectionBlock } = require('@growi/slack');
+
+module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
-  handler.handleCommand = (growiCommand, client, body) => {
+  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
+    const appTitle = crowi.appService.getAppTitle();
+    const appSiteUrl = crowi.appService.getSiteUrl();
     // adjust spacing
-    let message = '*Help*\n\n';
+    let message = `*Help* (*${appTitle}* at ${appSiteUrl})\n\n`;
     message += 'Usage:     `/growi [command] [args]`\n\n';
     message += 'Commands:\n\n';
-    message += '`/growi create`                          Create new page\n\n';
+    message += '`/growi note`                          Take a note on GROWI\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
-    message += '`/growi togetter`                      Create new page with existing slack conversations (Alpha)\n\n';
-    await respond(growiCommand.responseUrl, {
+    message += '`/growi keep`                          Create new page with existing slack conversations (Alpha)\n\n';
+
+    await respondUtil.respond({
       text: 'Help',
       blocks: [
         markdownSectionBlock(message),

+ 229 - 0
packages/app/src/server/service/slack-command-handler/keep.js

@@ -0,0 +1,229 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:SlackBotService:keep');
+const {
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
+} = require('@growi/slack');
+const { parse, format } = require('date-fns');
+const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
+
+module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
+  const BaseSlackCommandHandler = require('./slack-command-handler');
+  const handler = new BaseSlackCommandHandler();
+
+  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+    await respondUtil.respond({
+      text: 'Select messages to use.',
+      blocks: this.keepMessageBlocks(body.channel_name),
+    });
+    return;
+  };
+
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
+  };
+
+  handler.cancel = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    await respondUtil.deleteOriginal();
+  };
+
+  handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    let result = [];
+    const channelId = payload.channel.id; // this must exist since the type is always block_actions
+    const userChannelId = payload.user.id;
+
+    // validate form
+    const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
+    // get messages
+    result = await this.keepGetMessages(client, channelId, newest, oldest);
+    // clean messages
+    const cleanedContents = await this.keepCleanMessages(result.messages);
+
+    const contentsBody = cleanedContents.join('');
+    // create and send url message
+    await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody, respondUtil);
+  };
+
+  handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) {
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
+    let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
+    let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
+
+    if (oldest == null || newest == null || path == null) {
+      throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
+    }
+
+    /**
+     * RegExp for datetime yyyy/MM/dd-HH:mm
+     * @see https://regex101.com/r/XbxdNo/1
+     */
+    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+
+    if (!regexpDatetime.test(oldest.trim())) {
+      throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
+    }
+    if (!regexpDatetime.test(newest.trim())) {
+      throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
+    }
+    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
+    newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+
+    if (oldest > newest) {
+      throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
+    }
+
+    return { path, oldest, newest };
+  };
+
+  async function retrieveHistory(client, channelId, newest, oldest) {
+    return client.conversations.history({
+      channel: channelId,
+      newest,
+      oldest,
+      limit: 100,
+      inclusive: true,
+    });
+  }
+
+  handler.keepGetMessages = async function(client, channelId, newest, oldest) {
+    let result;
+
+    // first attempt
+    try {
+      result = await retrieveHistory(client, channelId, newest, oldest);
+    }
+    catch (err) {
+      const errorCode = err.data?.errorCode;
+
+      if (errorCode === 'not_in_channel') {
+        // join and retry
+        await client.conversations.join({
+          channel: channelId,
+        });
+        result = await retrieveHistory(client, channelId, newest, oldest);
+      }
+      else if (errorCode === 'channel_not_found') {
+
+        const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
+          + '\nPlease add GROWI bot to this channel.'
+          + '\n';
+        throw new SlackCommandHandlerError(message, {
+          respondBody: {
+            text: message,
+            blocks: [
+              markdownSectionBlock(message),
+              {
+                type: 'image',
+                image_url: 'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png',
+                alt_text: 'Add app to this channel',
+              },
+            ],
+          },
+        });
+      }
+      else {
+        throw err;
+      }
+    }
+
+    // return if no message found
+    if (result.messages.length === 0) {
+      throw new SlackCommandHandlerError('No message found from keep command. Try different datetime.');
+    }
+    return result;
+  };
+
+  handler.keepCleanMessages = async function(messages) {
+    const cleanedContents = [];
+    let lastMessage = {};
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    messages
+      .sort((a, b) => {
+        return a.ts - b.ts;
+      })
+      .forEach((message) => {
+        // increment contentsBody while removing the same headers
+        // exclude header
+        const lastMessageTs = Math.floor(lastMessage.ts / 60);
+        const messageTs = Math.floor(message.ts / 60);
+        if (lastMessage.user === message.user && lastMessageTs === messageTs) {
+          cleanedContents.push(`${message.text}\n`);
+        }
+        // include header
+        else {
+          const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
+          const time = format(new Date(ts), 'h:mm a');
+          cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
+          lastMessage = message;
+        }
+      });
+    return cleanedContents;
+  };
+
+  handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody, respondUtil) {
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil);
+
+    // TODO: contentsBody text characters must be less than 3001
+    // send preview to dm
+    // await client.chat.postMessage({
+    //   channel: userChannelId,
+    //   text: 'Preview from keep command',
+    //   blocks: [
+    //     markdownSectionBlock('*Preview*'),
+    //     divider(),
+    //     markdownSectionBlock(contentsBody),
+    //     divider(),
+    //   ],
+    // });
+
+    // dismiss
+    await respondUtil.deleteOriginal();
+  };
+
+  handler.keepMessageBlocks = function(channelName) {
+    const tzDateSec = new Date().getTime();
+    const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
+
+    const now = tzDateSec - grwTzoffset;
+    const oldest = now - 60 * 60 * 1000;
+    const newest = now;
+
+    const initialOldest = format(oldest, 'yyyy/MM/dd-HH:mm');
+    const initialNewest = format(newest, 'yyyy/MM/dd-HH:mm');
+    const initialPagePath = `/slack/keep/${channelName}/${format(oldest, 'yyyyMMdd-HH:mm')} - ${format(newest, 'yyyyMMdd-HH:mm')}`;
+
+    return [
+      markdownSectionBlock('*The keep command is in alpha.*'),
+      markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
+      inputBlock({
+        type: 'plain_text_input',
+        action_id: 'oldest',
+        initial_value: initialOldest,
+      }, 'oldest', 'Oldest datetime'),
+      inputBlock({
+        type: 'plain_text_input',
+        action_id: 'newest',
+        initial_value: initialNewest,
+      }, 'newest', 'Newest datetime'),
+      inputBlock({
+        type: 'plain_text_input',
+        placeholder: {
+          type: 'plain_text',
+          text: 'Input page path to create.',
+        },
+        initial_value: initialPagePath,
+        action_id: 'page_path',
+      }, 'page_path', 'Page path'),
+      actionsBlock(
+        buttonElement({ text: 'Cancel', actionId: 'keep:cancel' }),
+        buttonElement({ text: 'Create page', actionId: 'keep:createPage', style: 'primary' }),
+      ),
+    ];
+  };
+
+  return handler;
+};

+ 12 - 12
packages/app/src/server/service/slack-command-handler/create.js → packages/app/src/server/service/slack-command-handler/note.js

@@ -1,10 +1,10 @@
 import loggerFactory from '~/utils/logger';
 
 const {
-  markdownSectionBlock, inputSectionBlock, respond, inputBlock,
+  markdownSectionBlock, inputSectionBlock, inputBlock,
 } = require('@growi/slack');
 
-const logger = loggerFactory('growi:service:SlackCommandHandler:create');
+const logger = loggerFactory('growi:service:SlackCommandHandler:note');
 
 module.exports = (crowi) => {
   const CreatePageService = require('./create-page-service');
@@ -18,16 +18,16 @@ module.exports = (crowi) => {
     default_to_current_conversation: true,
   };
 
-  handler.handleCommand = async(growiCommand, client, body) => {
+  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
     await client.views.open({
       trigger_id: body.trigger_id,
 
       view: {
         type: 'modal',
-        callback_id: 'create:createPage',
+        callback_id: 'note:createPage',
         title: {
           type: 'plain_text',
-          text: 'Create Page',
+          text: 'Take a note',
         },
         submit: {
           type: 'plain_text',
@@ -38,9 +38,9 @@ module.exports = (crowi) => {
           text: 'Cancel',
         },
         blocks: [
-          markdownSectionBlock('Create new page.'),
+          markdownSectionBlock('Take a note on GROWI'),
           inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
-          inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
+          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 }),
@@ -48,15 +48,15 @@ module.exports = (crowi) => {
     });
   };
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
   };
 
-  handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor) {
+  handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) {
     const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value;
     const privateMetadata = interactionPayloadAccessor.getViewPrivateMetaData();
     if (privateMetadata == null) {
-      await respond(interactionPayloadAccessor.getResponseUrl(), {
+      await respondUtil.respond({
         text: 'Error occurred',
         blocks: [
           markdownSectionBlock('Failed to create a page.'),
@@ -65,7 +65,7 @@ module.exports = (crowi) => {
       return;
     }
     const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil);
   };
 
   return handler;

+ 38 - 39
packages/app/src/server/service/slack-command-handler/search.js

@@ -3,7 +3,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 const {
-  markdownSectionBlock, divider, respond, respondInChannel, replaceOriginal, deleteOriginal,
+  markdownSectionBlock, divider,
 } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 
@@ -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: {
@@ -229,27 +231,26 @@ module.exports = (crowi) => {
   }
 
 
-  handler.handleCommand = async function(growiCommand, client, body) {
-    const { responseUrl, growiCommandArgs } = growiCommand;
+  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+    const { growiCommandArgs } = growiCommand;
 
     const respondBody = await buildRespondBody(growiCommandArgs);
-    await respond(responseUrl, respondBody);
+    await respondUtil.respond(respondBody);
   };
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
   };
 
-  handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor) {
+  handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor, respondUtil) {
     const { user } = payload;
-    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
     const appUrl = crowi.appService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
 
     const value = interactionPayloadAccessor.firstAction()?.value; // shareSinglePage action must have button action
     if (value == null) {
-      await respond(responseUrl, {
+      await respondUtil.respond({
         text: 'Error occurred',
         blocks: [
           markdownSectionBlock('Failed to share the result.'),
@@ -258,13 +259,15 @@ module.exports = (crowi) => {
       return;
     }
 
+    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
+
     // restore page data from value
-    const { page, href, pathname } = JSON.parse(value);
+    const { page, href, pathname } = parsedValue;
     const { updatedAt, commentCount } = page;
 
     // share
     const now = new Date();
-    return respondInChannel(responseUrl, {
+    return respondUtil.respondInChannel({
       blocks: [
         { type: 'divider' },
         markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
@@ -283,12 +286,11 @@ module.exports = (crowi) => {
     });
   };
 
-  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true) {
-    const responseUrl = interactionPayloadAccessor.getResponseUrl();
+  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true, respondUtil) {
 
     const value = interactionPayloadAccessor.firstAction()?.value;
     if (value == null) {
-      await respond(responseUrl, {
+      await respondUtil.respond({
         text: 'Error occurred',
         blocks: [
           markdownSectionBlock('Failed to show the next results.'),
@@ -296,7 +298,8 @@ module.exports = (crowi) => {
       });
       return;
     }
-    const parsedValue = JSON.parse(value);
+
+    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
 
     const { growiCommandArgs, offset: offsetNum } = parsedValue;
     const newOffsetNum = isNext
@@ -305,23 +308,19 @@ module.exports = (crowi) => {
 
     const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum);
 
-    await replaceOriginal(responseUrl, buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
+    await respondUtil.replaceOriginal(buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
   }
 
-  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor) {
-    return showPrevOrNextResults(interactionPayloadAccessor, false);
+  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil);
   };
 
-  handler.showNextResults = async function(client, payload, interactionPayloadAccessor) {
-    return showPrevOrNextResults(interactionPayloadAccessor, true);
+  handler.showNextResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil);
   };
 
-  handler.dismissSearchResults = async function(client, payload) {
-    const { response_url: responseUrl } = payload;
-
-    return deleteOriginal(responseUrl, {
-      delete_original: true,
-    });
+  handler.dismissSearchResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return respondUtil.deleteOriginal();
   };
 
   return handler;

+ 12 - 6
packages/app/src/server/service/slack-integration.ts

@@ -5,6 +5,7 @@ import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 
 import {
   generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, SlackbotType,
+  RespondUtil,
 } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
@@ -238,7 +239,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   /**
    * Handle /commands endpoint
    */
-  async handleCommandRequest(growiCommand: GrowiCommand, client, body): Promise<void> {
+  async handleCommandRequest(growiCommand: GrowiCommand, client, body, respondUtil: RespondUtil): Promise<void> {
     const { growiCommandType } = growiCommand;
     const module = `./slack-command-handler/${growiCommandType}`;
 
@@ -248,6 +249,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     }
     catch (err) {
       const text = `*No command.*\n \`command: ${growiCommand.text}\``;
+      logger.error(err);
       throw new SlackCommandHandlerError(text, {
         respondBody: {
           text,
@@ -259,10 +261,12 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     }
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
-    return handler.handleCommand(growiCommand, client, body);
+    return handler.handleCommand(growiCommand, client, body, respondUtil);
   }
 
-  async handleBlockActionsRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+  async handleBlockActionsRequest(
+      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+  ): Promise<void> {
     const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = actionId.split(':')[0];
     const handlerMethodName = actionId.split(':')[1];
@@ -278,10 +282,12 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     }
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
-    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
+    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
   }
 
-  async handleViewSubmissionRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+  async handleViewSubmissionRequest(
+      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+  ): Promise<void> {
     const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = callbackId.split(':')[0];
     const handlerMethodName = callbackId.split(':')[1];
@@ -297,7 +303,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     }
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
-    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
+    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
   }
 
 }

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

@@ -7,6 +7,11 @@ export const checkPermission = (
 ):boolean => {
   let isPermitted = false;
 
+  // help
+  if (commandOrActionIdOrCallbackId === 'help') {
+    return true;
+  }
+
   Object.entries(commandPermission).forEach((entry) => {
     const [command, value] = entry;
     const permission = value;

+ 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 %}"
   >

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

@@ -0,0 +1,13 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IPage } from '~/interfaces/page';
+
+// 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),
+  );
+};

+ 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;

+ 2 - 2
packages/app/src/styles/_wiki.scss

@@ -239,14 +239,14 @@ div.body {
     }
   }
 
-  .grw-togetter {
+  .grw-keep {
     padding: 7%;
     padding-bottom: 3%;
     margin: 0 7%;
     background-color: rgba(200, 200, 200, 0.2);
     border-radius: 10px;
 
-    .grw-togetter-time {
+    .grw-keep-time {
       float: right;
       font-size: 0.8em;
       font-weight: normal;

+ 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%));
+    }
+  }
+}

+ 6 - 6
packages/app/src/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts

@@ -87,8 +87,8 @@ describe('migrate-slack-app-integration-schema', () => {
       },
       permissionsForSingleUseCommands: {
         bar: true,
-        create: false,
-        togetter: false,
+        note: false,
+        keep: false,
       },
     });
     expect(fixedDoc2).toStrictEqual({
@@ -101,8 +101,8 @@ describe('migrate-slack-app-integration-schema', () => {
       },
       permissionsForSingleUseCommands: {
         bar: true,
-        create: false,
-        togetter: false,
+        note: false,
+        keep: false,
       },
     });
     expect(fixedDoc3).toStrictEqual({
@@ -113,8 +113,8 @@ describe('migrate-slack-app-integration-schema', () => {
         search: true,
       },
       permissionsForSingleUseCommands: {
-        create: true,
-        togetter: true,
+        note: true,
+        keep: true,
       },
     });
   });

+ 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,
+};

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "4.4.8-RC.0",
+  "version": "4.4.10-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.10-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.10-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.10-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
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.10-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 3 - 2
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "4.4.8-RC.0",
+  "version": "4.4.10-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
@@ -19,7 +19,8 @@
     "bunyan": "^1.8.15",
     "extensible-custom-error": "^0.0.7",
     "http-errors": "^1.8.0",
-    "universal-bunyan": "^0.9.2"
+    "universal-bunyan": "^0.9.2",
+    "url-join": "^4.0.0"
   },
   "devDependencies": {
     "@types/express": "^4.17.11",

+ 7 - 5
packages/slack/src/index.ts

@@ -8,8 +8,8 @@ export const supportedSlackCommands: string[] = [
 
 export const supportedGrowiCommands: string[] = [
   'search',
-  'create',
-  'togetter',
+  'note',
+  'keep',
   'help',
 ];
 
@@ -18,8 +18,8 @@ export const defaultSupportedCommandsNameForBroadcastUse: string[] = [
 ];
 
 export const defaultSupportedCommandsNameForSingleUse: string[] = [
-  'create',
-  'togetter',
+  'note',
+  'keep',
 ];
 
 export * from './interfaces/growi-command-processor';
@@ -29,6 +29,8 @@ export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
 export * from './interfaces/response-url';
 export * from './interfaces/slackbot-types';
+export * from './interfaces/response-url';
+export * from './interfaces/respond-util';
 export * from './models/errors';
 export * from './middlewares/parse-slack-interaction-request';
 export * from './middlewares/verify-growi-to-slack-request';
@@ -42,7 +44,7 @@ export * from './utils/reshape-contents-body';
 export * from './utils/response-url';
 export * from './utils/slash-command-parser';
 export * from './utils/webclient-factory';
-export * from './utils/welcome-message';
 export * from './utils/required-scopes';
 export * from './utils/interaction-payload-accessor';
 export * from './utils/payload-interaction-id-helpers';
+export * from './utils/respond-util-factory';

+ 9 - 7
packages/slack/src/interfaces/request-between-growi-and-proxy.ts

@@ -1,17 +1,19 @@
 import { Request } from 'express';
 
-export type RequestFromGrowi = Request & {
-  // appended by GROWI
-  headers:{'x-growi-gtop-tokens'?:string},
-
-  // will be extracted from header
-  tokenGtoPs: string[],
-
+export interface BlockKitRequest {
   // Block Kit properties
   body: {
     view?: string,
     blocks?: string
   },
+}
+
+export type RequestFromGrowi = Request & BlockKitRequest & {
+  // appended by GROWI
+  headers:{'x-growi-gtop-tokens'?:string},
+
+  // will be extracted from header
+  tokenGtoPs: string[],
 };
 
 export type RequestFromProxy = Request & {

+ 8 - 0
packages/slack/src/interfaces/respond-util.ts

@@ -0,0 +1,8 @@
+import { RespondBodyForResponseUrl } from './response-url';
+
+export interface IRespondUtil {
+  respond(body: RespondBodyForResponseUrl): Promise<void>,
+  respondInChannel(body: RespondBodyForResponseUrl): Promise<void>,
+  replaceOriginal(body: RespondBodyForResponseUrl): Promise<void>,
+  deleteOriginal(): Promise<void>,
+}

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

@@ -1,5 +1,8 @@
 import assert from 'assert';
 import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
+import loggerFactory from './logger';
+
+const logger = loggerFactory('@growi/slack:utils:interaction-payload-accessor');
 
 
 export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
@@ -80,4 +83,23 @@ export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
     return null;
   }
 
+  getOriginalData(): any | null {
+    const value = this.firstAction()?.value;
+    if (value == null) return null;
+
+    const { originalData } = JSON.parse(value);
+    if (originalData == null) return JSON.parse(value);
+
+    let parsedOriginalData;
+    try {
+      parsedOriginalData = JSON.parse(originalData);
+    }
+    catch (err) {
+      logger.error('Failed to parse original data:\n', err);
+      return null;
+    }
+
+    return parsedOriginalData;
+  }
+
 }

+ 6 - 6
packages/slack/src/utils/reshape-contents-body.test.ts

@@ -41,9 +41,9 @@ some messages...
 some messages...`;
 
       const output = `
-<div class="grw-togetter">
+<div class="grw-keep">
 
-## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+## **taichi-m**<span class="grw-keep-time">  12:23 PM</span>
 \u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
@@ -72,9 +72,9 @@ some messages...
 some messages...`;
 
       const output = `
-<div class="grw-togetter">
+<div class="grw-keep">
 
-## **taichi-m**<span class="grw-togetter-time">  12:23</span>
+## **taichi-m**<span class="grw-keep-time">  12:23</span>
 \u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
@@ -99,9 +99,9 @@ taichi-m  12:23 PM
 some messages...`;
 
       const output = `some messages...
-<div class="grw-togetter">
+<div class="grw-keep">
 
-## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+## **taichi-m**<span class="grw-keep-time">  12:23 PM</span>
 \u0020\u0020
 some messages...\u0020\u0020
 </div>\u0020\u0020

+ 2 - 2
packages/slack/src/utils/reshape-contents-body.ts

@@ -64,7 +64,7 @@ export const reshapeContentsBody = (str: string): string => {
       }
       // ##*username*  HH:mm AM
       copyline = '\n## **'.concat(copyline);
-      copyline = copyline.replace(regexpTime, '**<span class="grw-togetter-time">'.concat(time, '</span>\n'));
+      copyline = copyline.replace(regexpTime, '**<span class="grw-keep-time">'.concat(time, '</span>\n'));
     }
     // Check 3: Is this line a short time(HH:mm)?
     else if (regexpShortTime.test(copyline)) {
@@ -82,7 +82,7 @@ export const reshapeContentsBody = (str: string): string => {
   // remove all blanks
   const blanksRemoved = reshapedArray.filter(line => line !== '');
   // add <div> to the first line & add </div> to the last line
-  blanksRemoved[0] = '\n<div class="grw-togetter">\n'.concat(blanksRemoved[0]);
+  blanksRemoved[0] = '\n<div class="grw-keep">\n'.concat(blanksRemoved[0]);
   blanksRemoved.push('</div>');
   // Add 2 spaces and 1 enter to all lines
   const completedArray = blanksRemoved.map(line => line.concat('  \n'));

+ 71 - 0
packages/slack/src/utils/respond-util-factory.ts

@@ -0,0 +1,71 @@
+import axios from 'axios';
+import urljoin from 'url-join';
+import { RespondBodyForResponseUrl } from '../interfaces/response-url';
+import { IRespondUtil } from '../interfaces/respond-util';
+
+type AxiosOptions = {
+  headers?: {
+    [header:string]: string,
+  }
+}
+
+function getResponseUrlForProxy(proxyUri: string, responseUrl: string): string {
+  return urljoin(proxyUri, `/g2s/respond?response_url=${responseUrl}`);
+}
+
+function getUrl(responseUrl: string, proxyUri: string | null): string {
+  return proxyUri == null ? responseUrl : getResponseUrlForProxy(proxyUri, responseUrl);
+}
+
+export class RespondUtil implements IRespondUtil {
+
+  url!: string;
+
+  options!: AxiosOptions;
+
+  constructor(responseUrl: string, proxyUri: string | null, appSiteUrl: string) {
+    this.url = getUrl(responseUrl, proxyUri);
+
+    this.options = {
+      headers: {
+        'x-growi-app-site-url': appSiteUrl,
+      },
+    };
+  }
+
+  async respond(body: RespondBodyForResponseUrl): Promise<void> {
+    return axios.post(this.url, {
+      replace_original: false,
+      text: body.text,
+      blocks: body.blocks,
+    }, this.options);
+  }
+
+  async respondInChannel(body: RespondBodyForResponseUrl): Promise<void> {
+    return axios.post(this.url, {
+      response_type: 'in_channel',
+      replace_original: false,
+      text: body.text,
+      blocks: body.blocks,
+    }, this.options);
+  }
+
+  async replaceOriginal(body: RespondBodyForResponseUrl): Promise<void> {
+    return axios.post(this.url, {
+      replace_original: true,
+      text: body.text,
+      blocks: body.blocks,
+    }, this.options);
+  }
+
+  async deleteOriginal(): Promise<void> {
+    return axios.post(this.url, {
+      delete_original: true,
+    }, this.options);
+  }
+
+}
+
+export function generateRespondUtil(responseUrl: string, proxyUri: string | null, appSiteUrl: string): RespondUtil {
+  return new RespondUtil(responseUrl, proxyUri, appSiteUrl);
+}

+ 0 - 21
packages/slack/src/utils/welcome-message.ts

@@ -1,21 +0,0 @@
-import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
-
-export const postWelcomeMessage = (client: WebClient, userId: string): Promise<ChatPostMessageResponse> => {
-  return client.chat.postMessage({
-    channel: userId,
-    user: userId,
-    blocks: [
-      {
-        type: 'section',
-        text: {
-          type: 'mrkdwn',
-          text: ':tada: You have successfully installed GROWI Official bot on this Slack workspace.\n'
-            + 'At first you do `/growi register` in the channel that you want to use.\n'
-            + 'Looking for additional help?'
-            // eslint-disable-next-line max-len
-            + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.',
-        },
-      },
-    ],
-  });
-};

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "4.4.8-slackbot-proxy.0",
+  "version": "4.4.10-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.10-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 48 - 8
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -1,5 +1,5 @@
 import {
-  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put,
+  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put, QueryParams,
 } from '@tsed/common';
 import axios from 'axios';
 import createError from 'http-errors';
@@ -8,7 +8,7 @@ import { addHours } from 'date-fns';
 import { ErrorCode, WebAPICallResult } from '@slack/web-api';
 
 import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient,
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient, BlockKitRequest,
 } from '@growi/slack';
 
 import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
@@ -27,6 +27,14 @@ import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/Sect
 
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 
+export type RespondReqFromGrowi = Req & BlockKitRequest & {
+  // appended by GROWI
+  headers:{ 'x-growi-app-site-url'?: string },
+
+  // will be extracted from header
+  appSiteUrl: string,
+}
+
 @Controller('/g2s')
 export class GrowiToSlackCtrl {
 
@@ -51,8 +59,8 @@ export class GrowiToSlackCtrl {
   @Inject()
   sectionBlockPayloadDelegator: SectionBlockPayloadDelegator;
 
-  async requestToGrowi(growiUrl:string, tokenPtoG:string):Promise<void> {
-    const url = new URL('/_api/v3/slack-integration/proxied/commands', growiUrl);
+  async urlVerificationRequestToGrowi(growiUrl:string, tokenPtoG:string):Promise<void> {
+    const url = new URL('/_api/v3/slack-integration/proxied/verify', growiUrl);
     await axios.post(url.toString(), {
       type: 'url_verification',
       challenge: 'this_is_my_challenge_token',
@@ -141,7 +149,7 @@ export class GrowiToSlackCtrl {
       }
 
       try {
-        await this.requestToGrowi(relation.growiUri, relation.tokenPtoG);
+        await this.urlVerificationRequestToGrowi(relation.growiUri, relation.tokenPtoG);
       }
       catch (err) {
         logger.error(err);
@@ -170,7 +178,7 @@ export class GrowiToSlackCtrl {
 
     // Access the GROWI URL saved in the Order record and check if the GtoP token is valid.
     try {
-      await this.requestToGrowi(order.growiUrl, order.tokenPtoG);
+      await this.urlVerificationRequestToGrowi(order.growiUrl, order.tokenPtoG);
     }
     catch (err) {
       logger.error(err);
@@ -217,7 +225,7 @@ export class GrowiToSlackCtrl {
     return res.send({ relation: generatedRelation, slackBotToken: token });
   }
 
-  injectGrowiUri(req: GrowiReq, growiUri: string): void {
+  injectGrowiUri(req: BlockKitRequest, growiUri: string): void {
     if (req.body.view == null && req.body.blocks == null) {
       return;
     }
@@ -231,7 +239,7 @@ export class GrowiToSlackCtrl {
       }
     }
     else if (req.body.blocks != null) {
-      const parsedElement = JSON.parse(req.body.blocks);
+      const parsedElement = (typeof req.body.blocks === 'string') ? JSON.parse(req.body.blocks) : req.body.blocks;
       // delegate to ActionsBlockPayloadDelegator
       if (this.actionsBlockPayloadDelegator.shouldHandleToInject(parsedElement)) {
         this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
@@ -245,6 +253,38 @@ export class GrowiToSlackCtrl {
     }
   }
 
+  @Post('/respond')
+  async respondUsingResponseUrl(
+    @QueryParams('response_url') responseUrl: string, @Req() req: RespondReqFromGrowi, @Res() res: WebclientRes,
+  ): Promise<WebclientRes> {
+
+    // get growi url from header
+    const growiUri = req.headers['x-growi-app-site-url'];
+
+    if (growiUri == null) {
+      logger.error('Request to this endpoint requires the x-growi-app-site-url header.');
+      return res.status(400).send('Failed to respond.');
+    }
+
+    try {
+      this.injectGrowiUri(req, growiUri);
+    }
+    catch (err) {
+      logger.error('Error occurred while injecting GROWI uri:\n', err);
+
+      return res.status(400).send('Failed to respond.');
+    }
+
+    try {
+      await axios.post(responseUrl, req.body);
+    }
+    catch (err) {
+      logger.error('Error occurred while request via axios:', err);
+      return res.status(502).send(err.message);
+    }
+    return res.send();
+  }
+
   @Post('/:method')
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   async callSlackApi(

+ 30 - 12
packages/slackbot-proxy/src/controllers/slack.ts

@@ -10,9 +10,9 @@ import { Installation } from '@slack/oauth';
 
 import {
   markdownSectionBlock, GrowiCommand, parseSlashCommand, respondRejectedErrors, generateWebClient,
-  InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, REQUEST_TIMEOUT_FOR_PTOG,
+  InvalidGrowiCommandError, requiredScopes, REQUEST_TIMEOUT_FOR_PTOG,
   parseSlackInteractionRequest, verifySlackRequest,
-  respond,
+  respond, supportedGrowiCommands,
 } from '@growi/slack';
 
 import { Relation } from '~/entities/relation';
@@ -32,6 +32,7 @@ import { RegisterService } from '~/services/RegisterService';
 import { RelationsService } from '~/services/RelationsService';
 import { UnregisterService } from '~/services/UnregisterService';
 import loggerFactory from '~/utils/logger';
+import { postInstallSuccessMessage, postWelcomeMessageOnce } from '~/utils/welcome-message';
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
@@ -177,6 +178,7 @@ export class SlackCtrl {
       return this.unregisterService.processCommand(growiCommand, authorizeResult);
     }
 
+    // get relations
     const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
@@ -204,11 +206,22 @@ export class SlackCtrl {
       });
     }
 
-    await respond(growiCommand.responseUrl, {
-      blocks: [
-        markdownSectionBlock(`Processing your request *"/growi ${growiCommand.text}"* ...`),
-      ],
-    });
+    // not supported commands
+    if (!supportedGrowiCommands.includes(growiCommand.growiCommandType)) {
+      return respond(growiCommand.responseUrl, {
+        text: 'Command is not supported',
+        blocks: [
+          markdownSectionBlock('*Command is not supported*'),
+          // eslint-disable-next-line max-len
+          markdownSectionBlock(`\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`),
+        ],
+      });
+    }
+
+    // help
+    if (growiCommand.growiCommandType === 'help') {
+      return this.sendCommand(growiCommand, relations, body);
+    }
 
     const allowedRelationsForSingleUse:Relation[] = [];
     const allowedRelationsForBroadcastUse:Relation[] = [];
@@ -282,7 +295,7 @@ export class SlackCtrl {
     logger.debug('receive interaction', req.body);
 
     const {
-      body, authorizeResult, interactionPayload, interactionPayloadAccessor,
+      body, authorizeResult, interactionPayload, interactionPayloadAccessor, growiUri,
     } = req;
 
     // pass
@@ -316,6 +329,7 @@ export class SlackCtrl {
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
     const relations = await this.relationRepository.createQueryBuilder('relation')
       .where('relation.installationId = :id', { id: installation?.id })
+      .andWhere('relation.growiUri = :uri', { uri: growiUri })
       .leftJoinAndSelect('relation.installation', 'installation')
       .getMany();
 
@@ -374,12 +388,16 @@ export class SlackCtrl {
   @Post('/events')
   @UseBefore(UrlVerificationMiddleware, AuthorizeEventsMiddleware)
   async handleEvent(@Req() req: SlackOauthReq): Promise<void> {
-
     const { authorizeResult } = req;
     const client = generateWebClient(authorizeResult.botToken);
 
     if (req.body.event.type === 'app_home_opened') {
-      await postWelcomeMessage(client, req.body.event.channel);
+      try {
+        await postWelcomeMessageOnce(client, req.body.event.channel);
+      }
+      catch (err) {
+        logger.error('Failed to post welcome message', err);
+      }
     }
 
     return;
@@ -434,9 +452,9 @@ export class SlackCtrl {
 
         await Promise.all([
           // post message
-          postWelcomeMessage(client, userId),
+          postInstallSuccessMessage(client, userId),
           // publish home
-          // TODO When Home tab show off, use bellow.
+          // TODO: When Home tab show off, use bellow.
           // publishInitialHomeView(client, userId),
         ]);
       }

+ 24 - 0
packages/slackbot-proxy/src/entities/system-information.ts

@@ -0,0 +1,24 @@
+import {
+  Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn,
+} from 'typeorm';
+
+@Entity()
+export class SystemInformation {
+
+  @PrimaryGeneratedColumn()
+  readonly id: number;
+
+  @Column({ nullable: false })
+  version: string;
+
+  @CreateDateColumn()
+  readonly createdAt: Date;
+
+  @UpdateDateColumn()
+  readonly updatedAt: Date;
+
+  setVersion(version: string): void {
+    this.version = version;
+  }
+
+}

+ 23 - 0
packages/slackbot-proxy/src/repositories/system-information.ts

@@ -0,0 +1,23 @@
+import {
+  Repository, EntityRepository,
+} from 'typeorm';
+
+import { SystemInformation } from '~/entities/system-information';
+
+@EntityRepository(SystemInformation)
+export class SystemInformationRepository extends Repository<SystemInformation> {
+
+  async createOrUpdateUniqueRecordWithVersion(systemInfo: SystemInformation | undefined, proxyVersion: string): Promise<void> {
+    // update the version if it exists
+    if (systemInfo != null) {
+      systemInfo.setVersion(proxyVersion);
+      await this.save(systemInfo);
+      return;
+    }
+    // create new system information object if it didn't exist
+    const newSystemInfo = new SystemInformation();
+    newSystemInfo.setVersion(proxyVersion);
+    await this.save(newSystemInfo);
+  }
+
+}

+ 4 - 0
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -30,6 +30,10 @@ export class RelationsService {
   @Inject()
   relationRepository: RelationRepository;
 
+  async resetAllExpiredAtCommands(): Promise<void> {
+    await this.relationRepository.update({}, { expiredAtCommands: new Date('2000-01-01') });
+  }
+
   private async getSupportedGrowiCommands(relation:Relation):Promise<any> {
     // generate API URL
     const url = new URL('/_api/v3/slack-integration/supported-commands', relation.growiUri);

+ 1 - 1
packages/slackbot-proxy/src/services/SelectGrowiService.ts

@@ -170,7 +170,7 @@ export class SelectGrowiService implements GrowiCommandProcessor<SelectGrowiComm
     await replaceOriginal(responseUrl, {
       text: `Accepted ${growiCommand.growiCommandType} command.`,
       blocks: [
-        markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${growiUri} ...`),
+        markdownSectionBlock(`Forwarding your request *"/growi ${growiCommand.growiCommandType}"* on GROWI to ${growiUri} ...`),
       ],
     });
 

+ 47 - 0
packages/slackbot-proxy/src/services/SystemInformationService.ts

@@ -0,0 +1,47 @@
+import { Inject, Service } from '@tsed/di';
+
+import readPkgUp from 'read-pkg-up';
+
+import { SystemInformation } from '~/entities/system-information';
+import { SystemInformationRepository } from '~/repositories/system-information';
+import { RelationsService } from './RelationsService';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('slackbot-proxy:services:SystemInformationService');
+
+@Service()
+export class SystemInformationService {
+
+  @Inject()
+  private readonly repository: SystemInformationRepository;
+
+  @Inject()
+  relationsService: RelationsService;
+
+  async $onInit(): Promise<void> {
+    await this.onInitCheckVersion();
+  }
+
+  /*
+   * updates version or create new system information record
+   * make all relations expired if the previous version was <= 4.4.8
+   */
+  async onInitCheckVersion(): Promise<void> {
+    const readPkgUpResult = await readPkgUp();
+    const proxyVersion = readPkgUpResult?.packageJson.version;
+    if (proxyVersion == null) return logger.error('version is null');
+
+    const systemInfo: SystemInformation | undefined = await this.repository.findOne();
+
+    // return if the version didn't change
+    if (systemInfo != null && systemInfo.version === proxyVersion) {
+      return;
+    }
+
+    await this.repository.createOrUpdateUniqueRecordWithVersion(systemInfo, proxyVersion);
+
+    // make relations expired
+    await this.relationsService.resetAllExpiredAtCommands();
+  }
+
+}

+ 4 - 2
packages/slackbot-proxy/src/services/UnregisterService.ts

@@ -50,7 +50,7 @@ export class UnregisterService implements GrowiCommandProcessor, GrowiInteractio
     }
 
     const staticSelectElement: MultiStaticSelect = {
-      action_id: 'selectedGrowiUris',
+      action_id: 'unregister:selectedGrowiUris',
       type: 'multi_static_select',
       placeholder: {
         type: 'plain_text',
@@ -106,6 +106,8 @@ export class UnregisterService implements GrowiCommandProcessor, GrowiInteractio
       case 'unregister:cancel':
         interactionHandledResult.result = await this.handleUnregisterCancelInteraction(interactionPayloadAccessor);
         break;
+      case 'unregister:selectedGrowiUris':
+        break;
       default:
         logger.error('This unregister interaction is not implemented.');
         break;
@@ -122,7 +124,7 @@ export class UnregisterService implements GrowiCommandProcessor, GrowiInteractio
   ):Promise<void> {
     const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
-    const selectedOptions = interactionPayloadAccessor.getStateValues()?.growiUris?.selectedGrowiUris?.selected_options;
+    const selectedOptions = interactionPayloadAccessor.getStateValues()?.growiUris?.['unregister:selectedGrowiUris']?.selected_options;
     if (!Array.isArray(selectedOptions)) {
       logger.error('Unregisteration failed: Mulformed object was detected\n');
       await respond(responseUrl, {

+ 38 - 0
packages/slackbot-proxy/src/utils/welcome-message.ts

@@ -0,0 +1,38 @@
+import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
+import { markdownSectionBlock } from '@growi/slack';
+
+export const postWelcomeMessageOnce = async(client: WebClient, channel: string): Promise<void|ChatPostMessageResponse> => {
+  const history = await client.conversations.history({
+    channel,
+    limit: 1,
+  });
+
+  // skip posting on the second time or later
+  if (history.messages != null && history.messages.length > 0) {
+    return;
+  }
+
+  return client.chat.postMessage({
+    channel,
+    blocks: [
+      markdownSectionBlock('Hi! This is GROWI bot.\n'
+        + 'You can invoke any feature with `/growi [command]` in any channel. Type `/growi help` to check the available features.'),
+      markdownSectionBlock('Looking for additional help? '
+        // eslint-disable-next-line max-len
+        + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.'),
+    ],
+  });
+};
+
+export const postInstallSuccessMessage = async(client: WebClient, userId: string): Promise<ChatPostMessageResponse> => {
+  return client.chat.postMessage({
+    channel: userId,
+    blocks: [
+      markdownSectionBlock(':tada: You have successfully installed GROWI bot on this Slack workspace.\n'
+      + 'At first you do `/growi register` in the channel that you want to use.'),
+      markdownSectionBlock('Looking for additional help? '
+        // eslint-disable-next-line max-len
+        + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.'),
+    ],
+  });
+};

+ 1 - 1
packages/ui/package.json

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

+ 12 - 0
yarn.lock

@@ -7222,6 +7222,11 @@ deprecation@^2.0.0, deprecation@^2.3.1:
   resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
   integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
 
+dequal@2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.2.tgz#85ca22025e3a87e65ef75a7a437b35284a7e319d"
+  integrity sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==
+
 des.js@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc"
@@ -20127,6 +20132,13 @@ swig-templates@^2.0.2:
     optimist "~0.6"
     uglify-js "2.6.0"
 
+swr@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/swr/-/swr-1.0.1.tgz#15f62846b87ee000e52fa07812bb65eb62d79483"
+  integrity sha512-EPQAxSjoD4IaM49rpRHK0q+/NzcwoT8c0/Ylu/u3/6mFj/CWnQVjNJ0MV2Iuw/U+EJSd2TX5czdAwKPYZIG0YA==
+  dependencies:
+    dequal "2.0.2"
+
 symbol-observable@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4"