Explorar o código

Merge pull request #5008 from weseek/master

Release v4.5.5
Yuki Takei %!s(int64=4) %!d(string=hai) anos
pai
achega
480c791188
Modificáronse 97 ficheiros con 3103 adicións e 562 borrados
  1. 1 1
      .github/workflows/list-unhealthy-branches.yml
  2. 3 3
      README_JP.md
  3. 1 1
      lerna.json
  4. 2 2
      package.json
  5. 3 3
      packages/app/config/webpack.prod.js
  6. 14 12
      packages/app/package.json
  7. 16 0
      packages/app/resource/locales/en_US/translation.json
  8. 16 0
      packages/app/resource/locales/ja_JP/translation.json
  9. 16 0
      packages/app/resource/locales/zh_CN/translation.json
  10. 2 0
      packages/app/src/client/admin.jsx
  11. 2 0
      packages/app/src/client/app.jsx
  12. 3 0
      packages/app/src/client/interfaces/in-app-notification-openable.ts
  13. 8 1
      packages/app/src/client/services/ContextExtractor.tsx
  14. 4 29
      packages/app/src/client/services/EditorContainer.js
  15. 26 2
      packages/app/src/client/services/PageContainer.js
  16. 18 3
      packages/app/src/client/util/editor.ts
  17. 3 1
      packages/app/src/components/FormattedDistanceDate.jsx
  18. 102 0
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  19. 154 0
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  20. 42 0
      packages/app/src/components/InAppNotification/InAppNotificationList.tsx
  21. 151 0
      packages/app/src/components/InAppNotification/InAppNotificationPage.tsx
  22. 58 0
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  23. 111 0
      packages/app/src/components/Me/InAppNotificationSettings.tsx
  24. 7 0
      packages/app/src/components/Me/PersonalSettings.jsx
  25. 7 1
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  26. 2 2
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  27. 6 2
      packages/app/src/components/Navbar/SubNavButtons.jsx
  28. 17 5
      packages/app/src/components/Page.jsx
  29. 5 9
      packages/app/src/components/Page/TagLabels.jsx
  30. 20 5
      packages/app/src/components/PageEditor.jsx
  31. 14 3
      packages/app/src/components/PageEditorByHackmd.jsx
  32. 23 29
      packages/app/src/components/PaginationWrapper.tsx
  33. 34 10
      packages/app/src/components/SavePageControls.jsx
  34. 10 7
      packages/app/src/components/Sidebar/RecentChanges.tsx
  35. 4 0
      packages/app/src/components/Sidebar/SidebarContents.tsx
  36. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  37. 44 0
      packages/app/src/components/Sidebar/Tag.tsx
  38. 78 0
      packages/app/src/components/SubscribeButton.tsx
  39. 38 0
      packages/app/src/components/TagCloudBox.tsx
  40. 44 18
      packages/app/src/components/TagsList.jsx
  41. 60 0
      packages/app/src/interfaces/in-app-notification.ts
  42. 1 0
      packages/app/src/interfaces/ui.ts
  43. 69 0
      packages/app/src/migrations/20210921173042-add-is-trashed-field.js
  44. 18 0
      packages/app/src/models/serializers/in-app-notification-snapshot/page.ts
  45. 3 0
      packages/app/src/server/crowi/express-init.js
  46. 43 1
      packages/app/src/server/crowi/index.js
  47. 20 0
      packages/app/src/server/events/activity.ts
  48. 13 4
      packages/app/src/server/events/comment.ts
  49. 106 0
      packages/app/src/server/models/activity.ts
  50. 26 27
      packages/app/src/server/models/comment.js
  51. 1 0
      packages/app/src/server/models/config.ts
  52. 20 0
      packages/app/src/server/models/in-app-notification-settings.ts
  53. 105 0
      packages/app/src/server/models/in-app-notification.ts
  54. 45 25
      packages/app/src/server/models/page-tag-relation.js
  55. 10 0
      packages/app/src/server/models/page.js
  56. 5 0
      packages/app/src/server/models/revision.js
  57. 91 0
      packages/app/src/server/models/subscription.ts
  58. 8 0
      packages/app/src/server/routes/all-in-app-notifications.ts
  59. 4 0
      packages/app/src/server/routes/apiv3/bookmarks.js
  60. 117 0
      packages/app/src/server/routes/apiv3/in-app-notification.ts
  61. 3 0
      packages/app/src/server/routes/apiv3/index.js
  62. 96 0
      packages/app/src/server/routes/apiv3/page.js
  63. 21 1
      packages/app/src/server/routes/apiv3/pages.js
  64. 78 1
      packages/app/src/server/routes/apiv3/personal-setting.js
  65. 2 2
      packages/app/src/server/routes/comment.js
  66. 3 0
      packages/app/src/server/routes/index.js
  67. 2 0
      packages/app/src/server/routes/page.js
  68. 16 22
      packages/app/src/server/routes/tag.js
  69. 38 0
      packages/app/src/server/service/activity.ts
  70. 107 0
      packages/app/src/server/service/comment.ts
  71. 18 0
      packages/app/src/server/service/config-loader.ts
  72. 179 0
      packages/app/src/server/service/in-app-notification.ts
  73. 117 11
      packages/app/src/server/service/page.js
  74. 122 61
      packages/app/src/server/service/passport.ts
  75. 2 2
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  76. 8 2
      packages/app/src/server/service/search.ts
  77. 52 0
      packages/app/src/server/util/activityDefine.ts
  78. 21 0
      packages/app/src/server/views/me/all-in-app-notifications.html
  79. 2 5
      packages/app/src/server/views/tags.html
  80. 1 1
      packages/app/src/server/views/widget/alert_siteurl_undefined.html
  81. 24 0
      packages/app/src/stores/in-app-notification.ts
  82. 18 0
      packages/app/src/stores/page.tsx
  83. 16 4
      packages/app/src/stores/ui.tsx
  84. 6 0
      packages/app/src/styles/_navbar.scss
  85. 8 4
      packages/app/src/styles/_subnav.scss
  86. 11 0
      packages/app/src/styles/atoms/_buttons.scss
  87. 9 0
      packages/app/src/styles/theme/_apply-colors.scss
  88. 8 10
      packages/app/src/test/integration/service/page.test.js
  89. 1 1
      packages/codemirror-textlint/package.json
  90. 1 1
      packages/core/package.json
  91. 1 1
      packages/plugin-attachment-refs/package.json
  92. 1 1
      packages/plugin-lsx/package.json
  93. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  94. 1 1
      packages/slack/package.json
  95. 2 2
      packages/slackbot-proxy/package.json
  96. 1 1
      packages/ui/package.json
  97. 231 220
      yarn.lock

+ 1 - 1
.github/workflows/list-unhealthy-branches.yml

@@ -2,7 +2,7 @@ name: List Unhealthy Branches
 
 on:
   schedule:
-    - cron: '0 6 * * wed'
+    - cron: '0 3 * * fri'
 
 jobs:
   list:

+ 3 - 3
README_JP.md

@@ -38,15 +38,15 @@
 # 機能紹介
 
 - **主な機能**
-  - マークダウンを使用してページを階層構造で作成することが可能です。 -> 5 分間チュートリアルは[こちら](https://docs.growi.org/ja/guide/getting-started/five_minutes.html))
-  - HackMD(CodiMd)[https://hackmd.io/] と連携することで同時多人数編集が可能です。
+  - マークダウンを使用してページを階層構造で作成することが可能です。 -> 5 分間チュートリアルは[こちら](https://docs.growi.org/ja/guide/getting-started/five_minutes.html)。
+  - [HackMD(CodiMd)](https://hackmd.io/) と連携することで同時多人数編集が可能です。
     - [GROWI Docs: HackMD(CodiMD) 連携](https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html)
   - LDAP / Active Direcotry , OAuth 認証をサポートしています。
   - SAML を用いた Single Sign On が可能です。
   - Slack / Mattermost, IFTTT と連携することが可能です。
   - [GROWI Docs: 機能紹介](https://docs.growi.org/ja/guide/features/page_layout.html)
 - **プラグイン**
-  - [npm](https://www.npmjs.com/browse/keyword/growi-plugin) または [github](https://github.com/search?q=topic%3Agrowi-plugin) から 便利なプラグインを見つけることができます。
+  - [npm](https://www.npmjs.com/browse/keyword/growi-plugin) または [GitHub](https://github.com/search?q=topic%3Agrowi-plugin) から 便利なプラグインを見つけることができます。
 - **[Docker の準備][dockerhub]**
 - **[Docker Compose の準備][docker-compose]**
   - [GROWI Docs: 複数の GROWI を起動](https://docs.growi.org/ja/admin-guide/admin-cookbook/multi-app.html)

+ 1 - 1
lerna.json

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

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.5.4",
+  "version": "4.5.5-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -68,7 +68,7 @@
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
     "rewire": "^5.0.0",
-    "shipjs": "^0.23.3",
+    "shipjs": "^0.24.1",
     "ts-jest": "^27.0.4",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",

+ 3 - 3
packages/app/config/webpack.prod.js

@@ -35,10 +35,10 @@ module.exports = require('./webpack.common')({
             loader: 'postcss-loader',
             options: {
               sourceMap: false,
-              plugins: () => {
-                return [
+              postcssOptions: {
+                plugins: [
                   require('autoprefixer')(),
-                ];
+                ],
               },
             },
           },

+ 14 - 12
packages/app/package.json

@@ -1,12 +1,12 @@
 {
   "name": "@growi/app",
-  "version": "4.5.4",
+  "version": "4.5.5-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
     "start": "yarn build && yarn server",
     "build": "run-p build:*",
-    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
+    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
@@ -51,18 +51,17 @@
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
-    "mongoose": "5.13.13 causes an error like 't.versions.node is undefined' about 'browser.umd.js' on browser",
     "string-width": "5.0.0 or above exports only ESM."
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.5.4",
-    "@growi/plugin-attachment-refs": "^4.5.4",
-    "@growi/plugin-lsx": "^4.5.4",
-    "@growi/plugin-pukiwiki-like-linker": "^4.5.4",
-    "@growi/slack": "^4.5.4",
+    "@growi/codemirror-textlint": "^4.5.5-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.5-RC.0",
+    "@growi/plugin-lsx": "^4.5.5-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.5-RC.0",
+    "@growi/slack": "^4.5.5-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -78,6 +77,7 @@
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
+    "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
     "connect-redis": "^4.0.4",
@@ -98,6 +98,7 @@
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
+    "got": "^8.3.2",
     "graceful-fs": "^4.1.11",
     "helmet": "^4.6.0",
     "http-errors": "~1.8.0",
@@ -129,10 +130,12 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
+    "p-retry": "^4.0.0",
     "prom-client": "^13.0.0",
     "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
+    "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
@@ -156,7 +159,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^4.5.4",
+    "@growi/ui": "^4.5.5-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -241,10 +244,9 @@
     "tsc-alias": "^1.2.9",
     "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",
-    "webpack": "^4.39.3",
+    "webpack": "^4.46.0",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.9.0",
-    "webpack-cli": "^3.3.7",
-    "webpack-merge": "^4.2.2"
+    "webpack-cli": "^4.9.1"
   }
 }

+ 16 - 0
packages/app/resource/locales/en_US/translation.json

@@ -138,6 +138,7 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
+  "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
   "Load latest": "Load latest",
@@ -257,6 +258,21 @@
       "This tree": "Only children of this tree"
     }
   },
+  "in_app_notification": {
+    "notification_list": "In-App Notification List",
+    "see_all": "See All",
+    "no_notification": "You don't have any notificatios.",
+    "all": "All",
+    "unopend": "Unread",
+    "mark_all_as_read": "Mark all as read"
+  },
+  "in_app_notification_settings": {
+    "in_app_notification_settings": "In-App Notification Settings",
+    "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",
+    "default_subscribe_rules": {
+      "page_create": "Subscribe to the page when you create it."
+    }
+  },
   "editor_settings": {
     "editor_settings": "Editor Settings",
     "common_settings": {

+ 16 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -137,6 +137,7 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
+  "Check All tags": "全てのタグをチェックする",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
@@ -259,6 +260,21 @@
       "This tree": "この階層下の子ページのみ"
     }
   },
+  "in_app_notification": {
+    "notification_list": "アプリ内通知一覧",
+    "see_all": "通知一覧を見る",
+    "no_notification": "通知はありません",
+    "all": "全て",
+    "unopend": "未読",
+    "mark_all_as_read": "全て既読にする"
+  },
+  "in_app_notification_settings": {
+    "in_app_notification_settings": "アプリ内通知設定",
+    "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",
+    "default_subscribe_rules": {
+      "page_create": "ページを作成した時にそのページをサブスクライブします。"
+    }
+  },
   "editor_settings": {
     "editor_settings": "エディター設定",
     "common_settings": {

+ 16 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -146,6 +146,7 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
+  "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
 	"Load latest": "家在最新",
@@ -238,6 +239,21 @@
 			"This tree": "当前分支以下内容"
 		}
   },
+  "in_app_notification": {
+    "notification_list": "应用内通知列表",
+    "see_all": "查看通知列表",
+    "no_notification": "您没有任何通知",
+    "all": "全部",
+    "unopend": "未读",
+    "mark_all_as_read" : "标记为已读"
+  },
+  "in_app_notification_settings": {
+    "in_app_notification_settings": "在应用程序通知设置",
+    "subscribe_settings": "自动订阅(接收通知)页面的设置",
+    "default_subscribe_rules": {
+      "page_create": "创建页面时订阅页面。"
+    }
+  },
   "editor_settings": {
     "editor_settings": "编辑器设置",
     "common_settings": {

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

@@ -66,6 +66,7 @@ const adminNotificationContainer = new AdminNotificationContainer(appContainer);
 const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
+const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const injectableContainers = [
   appContainer,
   adminAppContainer,
@@ -79,6 +80,7 @@ const injectableContainers = [
   adminSlackIntegrationLegacyContainer,
   adminMarkDownContainer,
   adminUserGroupDetailContainer,
+  socketIoContainer,
 ];
 
 logger.info('unstated containers have been initialized');

+ 2 - 0
packages/app/src/client/app.jsx

@@ -8,6 +8,7 @@ import { SWRConfig } from 'swr';
 import loggerFactory from '~/utils/logger';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
+import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import ErrorBoundary from '../components/ErrorBoudary';
 import Sidebar from '../components/Sidebar';
 import SearchPage from '../components/SearchPage';
@@ -85,6 +86,7 @@ Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
 
   'search-page': <SearchPage crowi={appContainer} />,
+  'all-in-app-notifications': <InAppNotificationPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,

+ 3 - 0
packages/app/src/client/interfaces/in-app-notification-openable.ts

@@ -0,0 +1,3 @@
+export interface IInAppNotificationOpenable {
+  open: () => void,
+}

+ 8 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -7,10 +7,11 @@ import {
   usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser,
   useSlackChannels,
-} from '../../stores/context';
+} from '~/stores/context';
 import {
   useIsDeviceSmallerThanMd,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+  useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
@@ -68,6 +69,9 @@ const ContextExtractorOnce: FC = () => {
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
+  const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
+  const grantGroupId = mainContent?.getAttribute('data-page-grant-group') || null;
+  const grantGroupName = mainContent?.getAttribute('data-page-grant-group-name') || null;
   /*
    * use static swr
    */
@@ -116,6 +120,9 @@ const ContextExtractorOnce: FC = () => {
 
   // Editor
   useSlackChannels(slackChannels);
+  useSelectedGrant(grant);
+  useSelectedGrantGroupId(grantGroupId);
+  useSelectedGrantGroupName(grantGroupName);
 
   return null;
 };

+ 4 - 29
packages/app/src/client/services/EditorContainer.js

@@ -27,10 +27,6 @@ export default class EditorContainer extends Container {
     this.state = {
       tags: null,
 
-      grant: 1, // default: public
-      grantGroupId: null,
-      grantGroupName: null,
-
       editorOptions: {},
       previewOptions: {},
 
@@ -43,7 +39,6 @@ export default class EditorContainer extends Container {
 
     this.isSetBeforeunloadEventHandler = false;
 
-    this.initStateGrant();
     this.initDrafts();
 
     this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
@@ -57,26 +52,6 @@ export default class EditorContainer extends Container {
     return 'EditorContainer';
   }
 
-  /**
-   * initialize state for page permission
-   */
-  initStateGrant() {
-    const mainContent = document.getElementById('content-main');
-
-    if (mainContent == null) {
-      logger.debug('#content-main element is not exists');
-      return;
-    }
-
-    this.state.grant = +mainContent.getAttribute('data-page-grant');
-
-    const grantGroupId = mainContent.getAttribute('data-page-grant-group');
-    if (grantGroupId != null && grantGroupId.length > 0) {
-      this.state.grantGroupId = grantGroupId;
-      this.state.grantGroupName = mainContent.getAttribute('data-page-grant-group-name');
-    }
-  }
-
   /**
    * initialize state for drafts
    */
@@ -145,13 +120,13 @@ export default class EditorContainer extends Container {
     const opt = {
       // isSlackEnabled: this.state.isSlackEnabled,
       // slackChannels: this.state.slackChannels,
-      grant: this.state.grant,
+      // grant: this.state.grant,
       pageTags: this.state.tags,
     };
 
-    if (this.state.grantGroupId != null) {
-      opt.grantUserGroupId = this.state.grantGroupId;
-    }
+    // if (this.state.grantGroupId != null) {
+    //   opt.grantUserGroupId = this.state.grantGroupId;
+    // }
 
     return opt;
   }

+ 26 - 2
packages/app/src/client/services/PageContainer.js

@@ -377,6 +377,7 @@ export default class PageContainer extends Container {
       revisionId: revision._id,
       revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
       remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
       revisionIdHackmdSynced: page.revisionHackmdSynced,
       hasDraftOnHackmd: page.hasDraftOnHackmd,
       markdown: revision.body,
@@ -404,8 +405,31 @@ export default class PageContainer extends Container {
       }
     }
 
-    // hidden input
-    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * update page meta data
+   * @param {object} page Page instance
+   * @param {object} revision Revision instance
+   * @param {String[]} tags Array of Tag
+   */
+  updatePageMetaData(page, revision, tags) {
+
+    const newState = {
+      revisionId: revision._id,
+      revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
+      remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      updatedAt: page.updatedAt,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+
+    this.setState(newState);
+
   }
 
   /**

+ 18 - 3
packages/app/src/client/util/editor.ts

@@ -5,11 +5,26 @@ type OptionsToSave = {
   slackChannels: string;
   grant: number;
   pageTags: string[];
-  grantUserGroupId?: string;
+  grantUserGroupId: string | null;
+  grantUserGroupName: string | null;
 };
 
 // TODO: Remove editorContainer upon migration to SWR
-export const getOptionsToSave = (isSlackEnabled: boolean, slackChannels: string, editorContainer: EditorContainer): OptionsToSave => {
+export const getOptionsToSave = (
+    isSlackEnabled: boolean,
+    slackChannels: string,
+    grant: number,
+    grantUserGroupId: string | null,
+    grantUserGroupName: string | null,
+    editorContainer: EditorContainer,
+): OptionsToSave => {
   const optionsToSave = editorContainer.getCurrentOptionsToSave();
-  return { ...optionsToSave, isSlackEnabled, slackChannels };
+  return {
+    ...optionsToSave,
+    isSlackEnabled,
+    slackChannels,
+    grant,
+    grantUserGroupId,
+    grantUserGroupName,
+  };
 };

+ 3 - 1
packages/app/src/components/FormattedDistanceDate.jsx

@@ -23,7 +23,7 @@ const FormattedDistanceDate = (props) => {
   return (
     <>
       <span id={elemId}>{formatDistanceStrict(date, baseDate)}</span>
-      <UncontrolledTooltip placement="bottom" fade={false} target={elemId}>{dateFormatted}</UncontrolledTooltip>
+      {props.isShowTooltip && <UncontrolledTooltip placement="bottom" fade={false} target={elemId}>{dateFormatted}</UncontrolledTooltip>}
     </>
   );
 };
@@ -34,9 +34,11 @@ FormattedDistanceDate.propTypes = {
   baseDate: PropTypes.instanceOf(Date),
   // the number(sec) from 'baseDate' to avoid format
   differenceForAvoidingFormat: PropTypes.number,
+  isShowTooltip: PropTypes.bool,
 };
 FormattedDistanceDate.defaultProps = {
   differenceForAvoidingFormat: 86400 * 3,
+  isShowTooltip: true,
 };
 
 export default FormattedDistanceDate;

+ 102 - 0
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -0,0 +1,102 @@
+import React, {
+  useState, useEffect, FC, useCallback,
+} from 'react';
+import {
+  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import loggerFactory from '~/utils/logger';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { withUnstatedContainers } from '../UnstatedUtils';
+import InAppNotificationList from './InAppNotificationList';
+import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+
+import { toastError } from '~/client/util/apiNotification';
+
+const logger = loggerFactory('growi:InAppNotificationDropdown');
+
+type Props = {
+  socketIoContainer: SocketIoContainer,
+};
+
+const InAppNotificationDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const [isOpen, setIsOpen] = useState(false);
+  const limit = 6;
+  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(limit);
+  const { data: inAppNotificationUnreadStatusCount, mutate: mutateInAppNotificationUnreadStatusCount } = useSWRxInAppNotificationStatus();
+
+
+  const initializeSocket = useCallback((props) => {
+    const socket = props.socketIoContainer.getSocket();
+    socket.on('notificationUpdated', () => {
+      mutateInAppNotificationUnreadStatusCount();
+    });
+  }, [mutateInAppNotificationUnreadStatusCount]);
+
+  const updateNotificationStatus = async() => {
+    try {
+      await apiv3Post('/in-app-notification/read');
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+  useEffect(() => {
+    initializeSocket(props);
+  }, [initializeSocket, props]);
+
+
+  const toggleDropdownHandler = async() => {
+    if (!isOpen && inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
+      await updateNotificationStatus();
+      mutateInAppNotificationUnreadStatusCount();
+    }
+
+    const newIsOpenState = !isOpen;
+    if (newIsOpenState) {
+      mutateInAppNotificationData();
+    }
+    setIsOpen(newIsOpenState);
+  };
+
+  let badge;
+  if (inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
+    badge = <span className="badge badge-pill badge-danger grw-notification-badge">{inAppNotificationUnreadStatusCount}</span>;
+  }
+  else {
+    badge = '';
+  }
+
+  return (
+    <Dropdown className="notification-wrapper" isOpen={isOpen} toggle={toggleDropdownHandler}>
+      <DropdownToggle tag="a" className="px-3 nav-link border-0 bg-transparent waves-effect waves-light">
+        <i className="icon-bell" /> {badge}
+      </DropdownToggle>
+      <DropdownMenu right>
+        { inAppNotificationData != null && inAppNotificationData.docs.length === 0
+          // no items
+          ? <DropdownItem disabled>{t('in_app_notification.mark_all_as_read')}</DropdownItem>
+          // render DropdownItem
+          : <InAppNotificationList type="dropdown-item" inAppNotificationData={inAppNotificationData} />
+        }
+        <DropdownItem divider />
+        <DropdownItem tag="a" href="/me/all-in-app-notifications">
+          { t('in_app_notification.see_all') }
+        </DropdownItem>
+      </DropdownMenu>
+    </Dropdown>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const InAppNotificationDropdownWrapper = withUnstatedContainers(InAppNotificationDropdown, [SocketIoContainer]);
+
+export default InAppNotificationDropdownWrapper;

+ 154 - 0
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -0,0 +1,154 @@
+import React, {
+  FC, useRef,
+} from 'react';
+import { DropdownItem } from 'reactstrap';
+
+import { UserPicture } from '@growi/ui';
+import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { HasObjectId } from '~/interfaces/has-object-id';
+
+// Change the display for each targetmodel
+import PageModelNotification from './PageNotification/PageModelNotification';
+import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+interface Props {
+  notification: IInAppNotification & HasObjectId
+  elemClassName?: string,
+  type?: 'button' | 'dropdown-item',
+}
+
+
+const InAppNotificationElm: FC<Props> = (props: Props) => {
+
+  const { notification } = props;
+
+  const notificationRef = useRef<IInAppNotificationOpenable>(null);
+
+  const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
+    if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
+      // set notification status "OPEND"
+      await apiv3Post('/in-app-notification/open', { id: notification._id });
+    }
+
+    const currentInstance = notificationRef.current;
+    if (currentInstance != null) {
+      currentInstance.open();
+    }
+  };
+
+  const getActionUsers = () => {
+    const latestActionUsers = notification.actionUsers.slice(0, 3);
+    const latestUsers = latestActionUsers.map((user) => {
+      return `@${user.name}`;
+    });
+
+    let actionedUsers = '';
+    const latestUsersCount = latestUsers.length;
+    if (latestUsersCount === 1) {
+      actionedUsers = latestUsers[0];
+    }
+    else if (notification.actionUsers.length >= 4) {
+      actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
+    }
+    else {
+      actionedUsers = latestUsers.join(', ');
+    }
+
+    return actionedUsers;
+  };
+
+  const renderActionUserPictures = (): JSX.Element => {
+    const actionUsers = notification.actionUsers;
+
+    if (actionUsers.length < 1) {
+      return <></>;
+    }
+    if (actionUsers.length === 1) {
+      return <UserPicture user={actionUsers[0]} size="md" noTooltip />;
+    }
+    return (
+      <div className="position-relative">
+        <UserPicture user={actionUsers[0]} size="md" noTooltip />
+        <div className="position-absolute" style={{ top: 10, left: 10 }}>
+          <UserPicture user={actionUsers[1]} size="md" noTooltip />
+        </div>
+
+      </div>
+    );
+  };
+
+  const actionUsers = getActionUsers();
+
+  const actionType: string = notification.action;
+  let actionMsg: string;
+  let actionIcon: string;
+
+  switch (actionType) {
+    case 'PAGE_LIKE':
+      actionMsg = 'liked';
+      actionIcon = 'icon-like';
+      break;
+    case 'PAGE_BOOKMARK':
+      actionMsg = 'bookmarked on';
+      actionIcon = 'icon-star';
+      break;
+    case 'PAGE_UPDATE':
+      actionMsg = 'updated on';
+      actionIcon = 'ti-agenda';
+      break;
+    case 'PAGE_RENAME':
+      actionMsg = 'renamed';
+      actionIcon = 'icon-action-redo';
+      break;
+    case 'PAGE_DELETE':
+      actionMsg = 'deleted';
+      actionIcon = 'icon-trash';
+      break;
+    case 'PAGE_DELETE_COMPLETELY':
+      actionMsg = 'completely deleted';
+      actionIcon = 'icon-fire';
+      break;
+    case 'COMMENT_CREATE':
+      actionMsg = 'commented on';
+      actionIcon = 'icon-bubble';
+      break;
+    default:
+      actionMsg = '';
+      actionIcon = '';
+  }
+
+  const isDropdownItem = props.type === 'dropdown-item';
+
+  // determine tag
+  const TagElem = isDropdownItem
+    ? DropdownItem
+    // eslint-disable-next-line react/prop-types
+    : props => <button type="button" {...props}>{props.children}</button>;
+
+  return (
+    <TagElem className={props.elemClassName} onClick={() => clickHandler(notification)}>
+      <div className="d-flex align-items-center">
+        <span
+          className={`${notification.status === InAppNotificationStatuses.STATUS_UNOPENED
+            ? 'grw-unopend-notification'
+            : 'ml-2'
+          } rounded-circle mr-3`}
+        >
+        </span>
+        {renderActionUserPictures()}
+        {notification.targetModel === 'Page' && (
+          <PageModelNotification
+            ref={notificationRef}
+            notification={notification}
+            actionMsg={actionMsg}
+            actionIcon={actionIcon}
+            actionUsers={actionUsers}
+          />
+        )}
+      </div>
+    </TagElem>
+  );
+};
+
+export default InAppNotificationElm;

+ 42 - 0
packages/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -0,0 +1,42 @@
+import React, { FC } from 'react';
+
+import { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
+import { HasObjectId } from '~/interfaces/has-object-id';
+
+import InAppNotificationElm from './InAppNotificationElm';
+
+
+type Props = {
+  inAppNotificationData?: PaginateResult<IInAppNotification>,
+  elemClassName?: string,
+  type?: 'button' | 'dropdown-item',
+};
+
+const InAppNotificationList: FC<Props> = (props: Props) => {
+  const { inAppNotificationData } = props;
+
+  if (inAppNotificationData == null) {
+    return (
+      <div className="wiki">
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  const notifications = inAppNotificationData.docs;
+
+  return (
+    <>
+      { notifications.map((notification: IInAppNotification & HasObjectId) => {
+        return (
+          <InAppNotificationElm key={notification._id} notification={notification} type={props.type} elemClassName={props.elemClassName} />
+        );
+      }) }
+    </>
+  );
+};
+
+
+export default InAppNotificationList;

+ 151 - 0
packages/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -0,0 +1,151 @@
+import React, {
+  FC, useState, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import AppContainer from '~/client/services/AppContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+import InAppNotificationList from './InAppNotificationList';
+import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '../../stores/in-app-notification';
+import PaginationWrapper from '../PaginationWrapper';
+import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:InAppNotificationPage');
+
+
+type Props = {
+  appContainer: AppContainer
+}
+
+const InAppNotificationPageBody: FC<Props> = (props) => {
+  const { appContainer } = props;
+  const limit = appContainer.config.pageLimitationXL;
+  const { t } = useTranslation();
+  const { mutate } = useSWRxInAppNotificationStatus();
+
+  const updateNotificationStatus = useCallback(async() => {
+    try {
+      await apiv3Post('/in-app-notification/read');
+      mutate();
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }, [mutate]);
+
+  useEffect(() => {
+    updateNotificationStatus();
+  }, [updateNotificationStatus]);
+
+  const InAppNotificationCategoryByStatus = (status?: InAppNotificationStatuses) => {
+    const [activePage, setActivePage] = useState(1);
+    const offset = (activePage - 1) * limit;
+
+    let categoryStatus;
+
+    switch (status) {
+      case InAppNotificationStatuses.STATUS_UNOPENED:
+        categoryStatus = InAppNotificationStatuses.STATUS_UNOPENED;
+        break;
+      default:
+    }
+
+    const { data: notificationData, mutate: mutateNotificationData } = useSWRxInAppNotifications(limit, offset, categoryStatus);
+    const { mutate: mutateAllNotificationData } = useSWRxInAppNotifications(limit, offset, undefined);
+
+    const setAllNotificationPageNumber = (selectedPageNumber): void => {
+      setActivePage(selectedPageNumber);
+    };
+
+
+    if (notificationData == null) {
+      return (
+        <div className="wiki">
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+          </div>
+        </div>
+      );
+    }
+
+    const updateUnopendNotificationStatusesToOpened = async() => {
+      await apiv3Put('/in-app-notification/all-statuses-open');
+      // mutate notification statuses in 'UNREAD' Category
+      mutateNotificationData();
+      // mutate notification statuses in 'ALL' Category
+      mutateAllNotificationData();
+    };
+
+
+    return (
+      <>
+        {(status === InAppNotificationStatuses.STATUS_UNOPENED && notificationData.totalDocs > 0)
+      && (
+        <div className="mb-2 d-flex justify-content-end">
+          <button
+            type="button"
+            className="btn btn-outline-primary"
+            onClick={updateUnopendNotificationStatusesToOpened}
+          >
+            {t('in_app_notification.mark_all_as_read')}
+          </button>
+        </div>
+      )}
+        { notificationData != null && notificationData.docs.length === 0
+          // no items
+          ? t('in_app_notification.mark_all_as_read')
+          // render list-group
+          : (
+            <div className="list-group">
+              <InAppNotificationList inAppNotificationData={notificationData} type="button" elemClassName="list-group-item list-group-item-action" />
+            </div>
+          )
+        }
+
+        {notificationData.totalDocs > 0 && (
+          <div className="mt-4">
+            <PaginationWrapper
+              activePage={activePage}
+              changePage={setAllNotificationPageNumber}
+              totalItemsCount={notificationData.totalDocs}
+              pagingLimit={notificationData.limit}
+              align="center"
+              size="sm"
+            />
+          </div>
+        ) }
+      </>
+    );
+  };
+
+  const navTabMapping = {
+    user_infomation: {
+      Icon: () => <></>,
+      Content: () => InAppNotificationCategoryByStatus(),
+      i18n: t('in_app_notification.all'),
+      index: 0,
+    },
+    external_accounts: {
+      Icon: () => <></>,
+      Content: () => InAppNotificationCategoryByStatus(InAppNotificationStatuses.STATUS_UNOPENED),
+      i18n: t('in_app_notification.unopend'),
+      index: 1,
+    },
+  };
+
+  return (
+    <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['mt-4']} />
+  );
+};
+
+const InAppNotificationPage = withUnstatedContainers(InAppNotificationPageBody, [AppContainer]);
+export default InAppNotificationPage;
+
+InAppNotificationPageBody.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};

+ 58 - 0
packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -0,0 +1,58 @@
+import React, {
+  forwardRef, ForwardRefRenderFunction, useImperativeHandle,
+} from 'react';
+import { PagePathLabel } from '@growi/ui';
+
+import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { IInAppNotification } from '~/interfaces/in-app-notification';
+import { HasObjectId } from '~/interfaces/has-object-id';
+
+import FormattedDistanceDate from '../../FormattedDistanceDate';
+import { parseSnapshot } from '../../../models/serializers/in-app-notification-snapshot/page';
+
+interface Props {
+  notification: IInAppNotification & HasObjectId
+  actionMsg: string
+  actionIcon: string
+  actionUsers: string
+}
+
+const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
+
+  const {
+    notification, actionMsg, actionIcon, actionUsers,
+  } = props;
+
+  const snapshot = parseSnapshot(notification.snapshot);
+  const pagePath = { path: snapshot.path };
+
+  // publish open()
+  useImperativeHandle(ref, () => ({
+    open() {
+      if (notification.target != null) {
+        // jump to target page
+        const targetPagePath = notification.target.path;
+        if (targetPagePath != null) {
+          window.location.href = targetPagePath;
+        }
+      }
+    },
+  }));
+
+  return (
+    <div className="p-2">
+      <div>
+        <b>{actionUsers}</b> {actionMsg} <PagePathLabel page={pagePath} />
+      </div>
+      <i className={`${actionIcon} mr-2`} />
+      <FormattedDistanceDate
+        id={notification._id}
+        date={notification.createdAt}
+        isShowTooltip={false}
+        differenceForAvoidingFormat={Number.POSITIVE_INFINITY}
+      />
+    </div>
+  );
+};
+
+export default forwardRef(PageModelNotification);

+ 111 - 0
packages/app/src/components/Me/InAppNotificationSettings.tsx

@@ -0,0 +1,111 @@
+import React, {
+  FC, useState, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { pullAllBy } from 'lodash';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { subscribeRuleNames, SubscribeRuleDescriptions } from '~/interfaces/in-app-notification';
+
+type SubscribeRule = {
+  name: string,
+  isEnabled: boolean,
+}
+
+const subscribeRulesMenuItems = [
+  {
+    name: subscribeRuleNames.PAGE_CREATE,
+    description: SubscribeRuleDescriptions.PAGE_CREATE,
+  },
+];
+
+const isCheckedRule = (ruleName: string, subscribeRules: SubscribeRule[]) => (
+  subscribeRules.find(stateRule => (
+    stateRule.name === ruleName
+  ))?.isEnabled || false
+);
+
+const updateIsEnabled = (subscribeRules: SubscribeRule[], ruleName: string, isChecked: boolean) => {
+  const target = [{ name: ruleName, isEnabled: isChecked }];
+  return pullAllBy(subscribeRules, target, 'name').concat(target);
+};
+
+
+const InAppNotificationSettings: FC = () => {
+  const { t } = useTranslation();
+  const [subscribeRules, setSubscribeRules] = useState<SubscribeRule[]>([]);
+
+  const initializeInAppNotificationSettings = useCallback(async() => {
+    const { data } = await apiv3Get('/personal-setting/in-app-notification-settings');
+    const retrievedRules: SubscribeRule[] | null = data?.subscribeRules;
+
+    if (retrievedRules != null && retrievedRules.length > 0) {
+      setSubscribeRules(retrievedRules);
+    }
+  }, []);
+
+  const ruleCheckboxHandler = useCallback((ruleName: string, isChecked: boolean) => {
+    setSubscribeRules(prevState => updateIsEnabled(prevState, ruleName, isChecked));
+  }, []);
+
+  const updateSettingsHandler = useCallback(async() => {
+    try {
+      const { data } = await apiv3Put('/personal-setting/in-app-notification-settings', { subscribeRules });
+      setSubscribeRules(data.subscribeRules);
+      toastSuccess(t('toaster.update_successed', { target: 'InAppNotification Settings' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [subscribeRules, setSubscribeRules, t]);
+
+  useEffect(() => {
+    initializeInAppNotificationSettings();
+  }, [initializeInAppNotificationSettings]);
+
+  return (
+    <>
+      <h2 className="border-bottom my-4">{t('in_app_notification_settings.subscribe_settings')}</h2>
+
+      <div className="form-group row">
+        <div className="offset-md-3 col-md-6 text-left">
+          {subscribeRulesMenuItems.map(rule => (
+            <div
+              key={rule.name}
+              className="custom-control custom-switch custom-checkbox-success"
+            >
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id={rule.name}
+                checked={isCheckedRule(rule.name, subscribeRules)}
+                onChange={e => ruleCheckboxHandler(rule.name, e.target.checked)}
+              />
+              <label className="custom-control-label" htmlFor={rule.name}>
+                <strong>{rule.name}</strong>
+              </label>
+              <p className="form-text text-muted small">
+                {t(rule.description)}
+              </p>
+            </div>
+          ))}
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            type="button"
+            className="btn btn-primary"
+            onClick={updateSettingsHandler}
+          >
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default InAppNotificationSettings;

+ 7 - 0
packages/app/src/components/Me/PersonalSettings.jsx

@@ -9,6 +9,7 @@ import PasswordSettings from './PasswordSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import ApiSettings from './ApiSettings';
 import { EditorSettings } from './EditorSettings';
+import InAppNotificationSettings from './InAppNotificationSettings';
 
 const PersonalSettings = (props) => {
 
@@ -46,6 +47,12 @@ const PersonalSettings = (props) => {
         i18n: t('editor_settings.editor_settings'),
         index: 4,
       },
+      in_app_notification_settings: {
+        Icon: () => <i className="icon-fw icon-bell"></i>,
+        Content: InAppNotificationSettings,
+        i18n: t('in_app_notification_settings.in_app_notification_settings'),
+        index: 5,
+      },
     };
   }, [t]);
 

+ 7 - 1
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -14,6 +14,8 @@ import GrowiLogo from '../Icons/GrowiLogo';
 
 import PersonalDropdown from './PersonalDropdown';
 import GlobalSearch from './GlobalSearch';
+import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
+
 
 type NavbarRightProps = {
   currentUser: IUser,
@@ -31,9 +33,13 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
 
   return (
     <>
+      <li className="nav-item">
+        <InAppNotificationDropdown />
+      </li>
+
       <li className="nav-item d-none d-md-block">
         <button
-          className="px-md-2 nav-link btn-create-page border-0 bg-transparent"
+          className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           type="button"
           onClick={() => mutatePageCreateModalOpened(true)}
         >

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

@@ -101,8 +101,8 @@ const PersonalDropdown = (props) => {
       {/* Button */}
       {/* remove .dropdown-toggle for hide caret */}
       {/* See https://stackoverflow.com/a/44577512/13183572 */}
-      <a className="px-md-2 nav-link waves-effect waves-light" data-toggle="dropdown">
-        <UserPicture user={user} noLink noTooltip /><span className="d-none d-lg-inline-block">&nbsp;{user.name}</span>
+      <a className="px-md-3 nav-link waves-effect waves-light" data-toggle="dropdown">
+        <UserPicture user={user} noLink noTooltip /><span className="ml-1 d-none d-lg-inline-block">&nbsp;{user.name}</span>
       </a>
 
       {/* Menu */}

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

@@ -7,9 +7,10 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 
 import BookmarkButton from '../BookmarkButton';
 import LikeButtons from '../LikeButtons';
+import SubscribeButton from '../SubscribeButton';
 import PageManagement from '../Page/PageManagement';
 
-const SubnavButtons = (props) => {
+const SubnavButtons = React.memo((props) => {
   const {
     appContainer, pageContainer, isCompactMode,
   } = props;
@@ -21,6 +22,9 @@ const SubnavButtons = (props) => {
 
     return (
       <>
+        <span>
+          <SubscribeButton pageId={pageContainer.state.pageId} />
+        </span>
         {pageContainer.isAbleToShowLikeButtons && (
           <span>
             <LikeButtons />
@@ -46,7 +50,7 @@ const SubnavButtons = (props) => {
       )}
     </>
   );
-};
+});
 
 /**
  * Wrapper component for using unstated

+ 17 - 5
packages/app/src/components/Page.jsx

@@ -20,7 +20,9 @@ import mdu from './PageEditor/MarkdownDrawioUtil';
 import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
-import { useEditorMode } from '~/stores/ui';
+import {
+  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
 import { useIsSlackEnabled } from '~/stores/editor';
 import { useSlackChannels } from '~/stores/context';
 
@@ -78,9 +80,9 @@ class Page extends React.Component {
 
   async saveHandlerForHandsontableModal(markdownTable) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName,
     } = this.props;
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
 
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
@@ -110,9 +112,9 @@ class Page extends React.Component {
 
   async saveHandlerForDrawioModal(drawioData) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName,
     } = this.props;
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
 
     const newMarkdown = mdu.replaceDrawioInMarkdown(
       drawioData,
@@ -173,12 +175,19 @@ Page.propTypes = {
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
+  grant: PropTypes.number.isRequired,
+  grantGroupId: PropTypes.string,
+  grantGroupName: PropTypes.string,
 };
 
 const PageWrapper = (props) => {
   const { data: editorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannels } = useSlackChannels();
+  const { data: grant } = useSelectedGrant();
+  const { data: grantGroupId } = useSelectedGrantGroupId();
+  const { data: grantGroupName } = useSelectedGrantGroupName();
+
 
   if (editorMode == null) {
     return null;
@@ -190,6 +199,9 @@ const PageWrapper = (props) => {
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannels}
+      grant={grant}
+      grantGroupId={grantGroupId}
+      grantGroupName={grantGroupName}
     />
   );
 };

+ 5 - 9
packages/app/src/components/Page/TagLabels.jsx

@@ -50,21 +50,17 @@ class TagLabels extends React.Component {
       appContainer, editorContainer, pageContainer, editorMode,
     } = this.props;
 
-    const { pageId } = pageContainer.state;
-
+    const { pageId, revisionId } = pageContainer.state;
     // It will not be reflected in the DB until the page is refreshed
     if (editorMode === EditorMode.Editor) {
       return editorContainer.setState({ tags: newTags });
     }
-
     try {
-      const { tags } = await appContainer.apiPost('/tags.update', { pageId, tags: newTags });
-
-      // update pageContainer.state
-      pageContainer.setState({ tags });
-      // update editorContainer.state
+      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
+        pageId, tags: newTags, revisionId,
+      });
       editorContainer.setState({ tags });
-
+      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
       toastSuccess('updated tags successfully');
     }
     catch (err) {

+ 20 - 5
packages/app/src/components/PageEditor.jsx

@@ -18,7 +18,9 @@ import EditorContainer from '~/client/services/EditorContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
-import { useEditorMode } from '~/stores/ui';
+import {
+  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
 import { useIsEditable, useSlackChannels } from '~/stores/context';
 import { useIsSlackEnabled } from '~/stores/editor';
 
@@ -132,10 +134,10 @@ class PageEditor extends React.Component {
    */
   async onSaveWithShortcut() {
     const {
-      isSlackEnabled, slackChannels, editorContainer, pageContainer,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer, pageContainer,
     } = this.props;
 
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
 
     try {
       // disable unsaved warning
@@ -161,7 +163,9 @@ class PageEditor extends React.Component {
    * @param {any} file
    */
   async onUpload(file) {
-    const { appContainer, pageContainer, editorContainer } = this.props;
+    const {
+      appContainer, pageContainer, mutateGrant,
+    } = this.props;
 
     try {
       let res = await appContainer.apiGet('/attachments.limit', {
@@ -197,7 +201,7 @@ class PageEditor extends React.Component {
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
         pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, this.props.editorMode);
-        editorContainer.setState({ grant: res.page.grant });
+        mutateGrant(res.page.grant);
       }
     }
     catch (e) {
@@ -368,6 +372,9 @@ const PageEditorWrapper = (props) => {
   const { data: editorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannels } = useSlackChannels();
+  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
+  const { data: grantGroupId } = useSelectedGrantGroupId();
+  const { data: grantGroupName } = useSelectedGrantGroupName();
 
   if (isEditable == null || editorMode == null) {
     return null;
@@ -380,6 +387,10 @@ const PageEditorWrapper = (props) => {
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannels}
+      grant={grant}
+      grantGroupId={grantGroupId}
+      grantGroupName={grantGroupName}
+      mutateGrant={mutateGrant}
     />
   );
 };
@@ -395,6 +406,10 @@ PageEditor.propTypes = {
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
+  grant: PropTypes.number.isRequired,
+  grantGroupId: PropTypes.string,
+  grantGroupName: PropTypes.string,
+  mutateGrant: PropTypes.func,
 };
 
 export default PageEditorWrapper;

+ 14 - 3
packages/app/src/components/PageEditorByHackmd.jsx

@@ -14,7 +14,9 @@ import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
-import { useEditorMode } from '~/stores/ui';
+import {
+  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
 import { useSlackChannels } from '~/stores/context';
 import { useIsSlackEnabled } from '~/stores/editor';
 
@@ -171,9 +173,9 @@ class PageEditorByHackmd extends React.Component {
    */
   async onSaveWithShortcut(markdown) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName,
     } = this.props;
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
 
     try {
       // disable unsaved warning
@@ -432,6 +434,9 @@ const PageEditorByHackmdWrapper = (props) => {
   const { data: editorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannels } = useSlackChannels();
+  const { data: grant } = useSelectedGrant();
+  const { data: grantGroupId } = useSelectedGrantGroupId();
+  const { data: grantGroupName } = useSelectedGrantGroupName();
 
   if (editorMode == null) {
     return null;
@@ -443,6 +448,9 @@ const PageEditorByHackmdWrapper = (props) => {
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannels}
+      grant={grant}
+      grantGroupId={grantGroupId}
+      grantGroupName={grantGroupName}
     />
   );
 };
@@ -458,6 +466,9 @@ PageEditorByHackmd.propTypes = {
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
+  grant: PropTypes.number.isRequired,
+  grantGroupId: PropTypes.string,
+  grantGroupName: PropTypes.string,
 };
 
 export default withTranslation()(PageEditorByHackmdWrapper);

+ 23 - 29
packages/app/src/components/PaginationWrapper.jsx → packages/app/src/components/PaginationWrapper.tsx

@@ -1,18 +1,21 @@
-import React, { useCallback, useMemo } from 'react';
-import PropTypes from 'prop-types';
+import React, {
+  FC, memo, useCallback, useMemo,
+} from 'react';
 
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
-/**
- *
- * @author Mikitaka Itizawa <itizawa@weseek.co.jp>
- *
- * @export
- * @class PaginationWrapper
- * @extends {React.Component}
- */
 
-const PaginationWrapper = React.memo((props) => {
+type Props = {
+  activePage: number,
+  changePage?: (number) => void,
+  totalItemsCount: number,
+  pagingLimit?: number,
+  align?: string,
+  size?: string,
+};
+
+
+const PaginationWrapper: FC<Props> = memo((props: Props) => {
   const {
     activePage, changePage, totalItemsCount, pagingLimit, align,
   } = props;
@@ -59,14 +62,14 @@ const PaginationWrapper = React.memo((props) => {
    * this function set << & <
    */
   const generateFirstPrev = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     if (activePage !== 1) {
       paginationItems.push(
         <PaginationItem key="painationItemFirst">
-          <PaginationLink first onClick={() => { return changePage(1) }} />
+          <PaginationLink first onClick={() => { return changePage != null && changePage(1) }} />
         </PaginationItem>,
         <PaginationItem key="painationItemPrevious">
-          <PaginationLink previous onClick={() => { return changePage(activePage - 1) }} />
+          <PaginationLink previous onClick={() => { return changePage != null && changePage(activePage - 1) }} />
         </PaginationItem>,
       );
     }
@@ -89,11 +92,11 @@ const PaginationWrapper = React.memo((props) => {
    * this function set  numbers
    */
   const generatePaginations = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
       paginationItems.push(
         <PaginationItem key={`paginationItem-${number}`} active={number === activePage}>
-          <PaginationLink onClick={() => { return changePage(number) }}>
+          <PaginationLink onClick={() => { return changePage != null && changePage(number) }}>
             {number}
           </PaginationLink>
         </PaginationItem>,
@@ -108,14 +111,14 @@ const PaginationWrapper = React.memo((props) => {
    * this function set > & >>
    */
   const generateNextLast = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     if (totalPage !== activePage) {
       paginationItems.push(
         <PaginationItem key="painationItemNext">
-          <PaginationLink next onClick={() => { return changePage(activePage + 1) }} />
+          <PaginationLink next onClick={() => { return changePage != null && changePage(activePage + 1) }} />
         </PaginationItem>,
         <PaginationItem key="painationItemLast">
-          <PaginationLink last onClick={() => { return changePage(totalPage) }} />
+          <PaginationLink last onClick={() => { return changePage != null && changePage(totalPage) }} />
         </PaginationItem>,
       );
     }
@@ -133,7 +136,7 @@ const PaginationWrapper = React.memo((props) => {
   }, [activePage, changePage, totalPage]);
 
   const getListClassName = useMemo(() => {
-    const listClassNames = [];
+    const listClassNames: string[] = [];
 
     if (align === 'center') {
       listClassNames.push('justify-content-center');
@@ -157,15 +160,6 @@ const PaginationWrapper = React.memo((props) => {
 
 });
 
-PaginationWrapper.propTypes = {
-  activePage: PropTypes.number.isRequired,
-  changePage: PropTypes.func.isRequired,
-  totalItemsCount: PropTypes.number.isRequired,
-  pagingLimit: PropTypes.number,
-  align: PropTypes.string,
-  size: PropTypes.string,
-};
-
 PaginationWrapper.defaultProps = {
   align: 'left',
   size: 'md',

+ 34 - 10
packages/app/src/components/SavePageControls.jsx

@@ -20,7 +20,9 @@ import GrantSelector from './SavePageControls/GrantSelector';
 import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
-import { useEditorMode } from '~/stores/ui';
+import {
+  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
 import { useIsEditable, useSlackChannels } from '~/stores/context';
 import { useIsSlackEnabled } from '~/stores/editor';
 
@@ -42,19 +44,23 @@ class SavePageControls extends React.Component {
   }
 
   updateGrantHandler(data) {
-    this.props.editorContainer.setState(data);
+    const { mutateGrant, mutateGrantGroupId, mutateGrantGroupName } = this.props;
+
+    mutateGrant(data.grant);
+    mutateGrantGroupId(data.grantGroupId);
+    mutateGrantGroupName(data.grantGroupName);
   }
 
   async save() {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer,
     } = this.props;
     // disable unsaved warning
     editorContainer.disableUnsavedWarning();
 
     try {
       // save
-      const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+      const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
       await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
     }
     catch (error) {
@@ -65,12 +71,12 @@ class SavePageControls extends React.Component {
 
   saveAndOverwriteScopesOfDescendants() {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer,
     } = this.props;
     // disable unsaved warning
     editorContainer.disableUnsavedWarning();
     // save
-    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
     const optionsToSave = Object.assign(currentOptionsToSave, {
       overwriteScopesOfDescendants: true,
     });
@@ -79,7 +85,9 @@ class SavePageControls extends React.Component {
 
   render() {
 
-    const { t, pageContainer, editorContainer } = this.props;
+    const {
+      t, pageContainer, grant, grantGroupId, grantGroupName,
+    } = this.props;
 
     const isRootPage = pageContainer.state.path === '/';
     const labelSubmitButton = pageContainer.state.pageId == null ? t('Create') : t('Update');
@@ -93,9 +101,9 @@ class SavePageControls extends React.Component {
             <div className="mr-2">
               <GrantSelector
                 disabled={isRootPage}
-                grant={editorContainer.state.grant}
-                grantGroupId={editorContainer.state.grantGroupId}
-                grantGroupName={editorContainer.state.grantGroupName}
+                grant={grant}
+                grantGroupId={grantGroupId}
+                grantGroupName={grantGroupName}
                 onUpdateGrant={this.updateGrantHandler}
               />
             </div>
@@ -128,6 +136,10 @@ const SavePageControlsWrapper = (props) => {
   const { data: editorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannels } = useSlackChannels();
+  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
+  const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
+  const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
+
 
   if (isEditable == null || editorMode == null) {
     return null;
@@ -143,6 +155,12 @@ const SavePageControlsWrapper = (props) => {
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannels}
+      grant={grant}
+      grantGroupId={grantGroupId}
+      grantGroupName={grantGroupName}
+      mutateGrant={mutateGrant}
+      mutateGrantGroupId={mutateGrantGroupId}
+      mutateGrantGroupName={mutateGrantGroupName}
     />
   );
 };
@@ -158,6 +176,12 @@ SavePageControls.propTypes = {
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
+  grant: PropTypes.number.isRequired,
+  grantGroupId: PropTypes.string,
+  grantGroupName: PropTypes.string,
+  mutateGrant: PropTypes.func,
+  mutateGrantGroupId: PropTypes.func,
+  mutateGrantGroupName: PropTypes.func,
 };
 
 export default withTranslation()(SavePageControlsWrapper);

+ 10 - 7
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -56,13 +56,16 @@ function LargePageItem({ page }) {
   }
 
   const tags = page.tags;
-  const tagElements = tags.map((tag) => {
-    return (
-      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
-        {tag.name}
-      </a>
-    );
-  });
+  // when tag document is deleted from database directly tags includes null
+  const tagElements = tags.includes(null)
+    ? <></>
+    : tags.map((tag) => {
+      return (
+        <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+          {tag.name}
+        </a>
+      );
+    });
 
   return (
     <li className="list-group-item py-3 px-0">

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

@@ -5,6 +5,7 @@ import { useCurrentSidebarContents } from '~/stores/ui';
 
 import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
+import Tag from './Tag';
 
 type Props = {
 };
@@ -18,6 +19,9 @@ const SidebarContents: FC<Props> = (props: Props) => {
     case SidebarContentsType.RECENT:
       Contents = RecentChanges;
       break;
+    case SidebarContentsType.TAG:
+      Contents = Tag;
+      break;
     default:
       Contents = CustomSidebar;
   }

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -79,7 +79,7 @@ const SidebarNav: FC<Props> = (props: Props) => {
       <div className="grw-sidebar-nav-primary-container">
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />}
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />}
-        {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} /> }
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
       </div>
       <div className="grw-sidebar-nav-secondary-container">

+ 44 - 0
packages/app/src/components/Sidebar/Tag.tsx

@@ -0,0 +1,44 @@
+import React, { FC, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import TagsList from '../TagsList';
+
+const Tag: FC = () => {
+  const { t } = useTranslation('');
+  const [isOnReload, setIsOnReload] = useState<boolean>(false);
+
+  useEffect(() => {
+    setIsOnReload(false);
+  }, [isOnReload]);
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0">{t('Tags')}</h3>
+        <button
+          type="button"
+          className="btn btn-sm ml-auto grw-btn-reload-rc"
+          onClick={() => {
+            setIsOnReload(true);
+          }}
+        >
+          <i className="icon icon-reload"></i>
+        </button>
+      </div>
+      <div className="d-flex justify-content-center">
+        <button
+          className="btn btn-primary my-4"
+          type="button"
+          onClick={() => { window.location.href = '/tags' }}
+        >
+          {t('Check All tags')}
+        </button>
+      </div>
+      <div className="grw-container-convertible mb-5 pb-5">
+        <TagsList isOnReload={isOnReload} />
+      </div>
+    </>
+  );
+
+};
+
+export default Tag;

+ 78 - 0
packages/app/src/components/SubscribeButton.tsx

@@ -0,0 +1,78 @@
+import React, { FC } from 'react';
+
+
+import { Types } from 'mongoose';
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+import { useSWRxSubscriptionStatus } from '../stores/page';
+
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useIsGuestUser } from '~/stores/context';
+
+type Props = {
+  pageId: Types.ObjectId,
+};
+
+const SubscribeButton: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { pageId } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: subscriptionData, mutate } = useSWRxSubscriptionStatus(pageId);
+
+  let isSubscribed;
+
+  switch (subscriptionData?.status) {
+    case true:
+      isSubscribed = true;
+      break;
+    case false:
+      isSubscribed = false;
+      break;
+    default:
+      isSubscribed = null;
+  }
+
+  const buttonClass = `${isSubscribed ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
+  const iconClass = isSubscribed || isSubscribed == null ? 'fa fa-eye' : 'fa fa-eye-slash';
+
+  const handleClick = async() => {
+    if (isGuestUser) {
+      return;
+    }
+
+    try {
+      const res = await apiv3Put('/page/subscribe', { pageId, status: !isSubscribed });
+      if (res) {
+        mutate();
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <>
+      <button
+        type="button"
+        id="subscribe-button"
+        onClick={handleClick}
+        className={`btn btn-subscribe border-0 ${buttonClass}`}
+      >
+        <i className={iconClass}></i>
+      </button>
+
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+    </>
+  );
+
+};
+
+export default SubscribeButton;

+ 38 - 0
packages/app/src/components/TagCloudBox.tsx

@@ -0,0 +1,38 @@
+import React, { FC } from 'react';
+
+import { TagCloud } from 'react-tagcloud';
+
+type Tag = {
+  _id: string,
+  name: string,
+  count: number,
+}
+
+type Props = {
+  tags:Tag[],
+  minSize?: number,
+  maxSize?: number,
+}
+
+const MIN_FONT_SIZE = 12;
+const MAX_FONT_SIZE = 36;
+
+const TagCloudBox: FC<Props> = (props:Props) => {
+  return (
+    <>
+      <TagCloud
+        minSize={props.minSize || MIN_FONT_SIZE}
+        maxSize={props.maxSize || MAX_FONT_SIZE}
+        tags={props.tags.map((tag) => {
+          return { value: tag.name, count: tag.count };
+        })}
+        style={{ cursor: 'pointer' }}
+        className="simple-cloud"
+        onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
+      />
+    </>
+  );
+
+};
+
+export default TagCloudBox;

+ 44 - 18
packages/app/src/components/TagsList.jsx

@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import PaginationWrapper from './PaginationWrapper';
+import TagCloudBox from './TagCloudBox';
+import { apiGet } from '../client/util/apiv1-client';
+import { toastError } from '../client/util/apiNotification';
 
 class TagsList extends React.Component {
 
@@ -25,6 +28,12 @@ class TagsList extends React.Component {
     await this.getTagList(1);
   }
 
+  async componentDidUpdate() {
+    if (this.props.isOnReload) {
+      await this.getTagList(this.state.activePage);
+    }
+  }
+
   async handlePage(selectedPage) {
     await this.getTagList(selectedPage);
   }
@@ -32,7 +41,14 @@ class TagsList extends React.Component {
   async getTagList(selectPageNumber) {
     const limit = this.state.pagingLimit;
     const offset = (selectPageNumber - 1) * limit;
-    const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
+    let res;
+
+    try {
+      res = await apiGet('/tags.list', { limit, offset });
+    }
+    catch (error) {
+      toastError(error);
+    }
 
     const totalTags = res.totalCount;
     const tagData = res.data;
@@ -67,34 +83,44 @@ class TagsList extends React.Component {
     const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
 
     return (
-      <div className="text-center">
-        <div className="tag-list">
-          <ul className="list-group text-left">
-            {this.generateTagList(this.state.tagData)}
-          </ul>
-          {messageForNoTag}
-        </div>
-        <div className="tag-list-pagination">
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePage}
-            totalItemsCount={this.state.totalTags}
-            pagingLimit={this.state.pagingLimit}
-            size="sm"
-          />
+      <>
+        <header className="py-0">
+          <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${this.state.totalTags})`}</h1>
+        </header>
+        <div className="row text-center">
+          <div className="col-12 mb-5 px-5">
+            <TagCloudBox tags={this.state.tagData} minSize={20} />
+          </div>
+          <div className="col-12 tag-list mb-4">
+            <ul className="list-group text-left">
+              {this.generateTagList(this.state.tagData)}
+            </ul>
+            {messageForNoTag}
+          </div>
+          <div className="col-12 tag-list-pagination">
+            <PaginationWrapper
+              activePage={this.state.activePage}
+              changePage={this.handlePage}
+              totalItemsCount={this.state.totalTags}
+              pagingLimit={this.state.pagingLimit}
+              align="center"
+              size="md"
+            />
+          </div>
         </div>
-      </div>
+      </>
     );
   }
 
 }
 
 TagsList.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  isOnReload: PropTypes.bool,
   t: PropTypes.func.isRequired, // i18next
 };
 
 TagsList.defaultProps = {
+  isOnReload: false,
 };
 
 export default withTranslation()(TagsList);

+ 60 - 0
packages/app/src/interfaces/in-app-notification.ts

@@ -0,0 +1,60 @@
+import { Types } from 'mongoose';
+import { IUser } from './user';
+import { IPage } from './page';
+
+export enum InAppNotificationStatuses {
+  STATUS_UNREAD = 'UNREAD',
+  STATUS_UNOPENED = 'UNOPENED',
+  STATUS_OPENED = 'OPENED',
+}
+
+export interface IInAppNotification {
+  user: IUser
+  targetModel: 'Page'
+  target: IPage
+  action: 'COMMENT' | 'LIKE'
+  status: InAppNotificationStatuses
+  actionUsers: IUser[]
+  createdAt: Date
+  snapshot: string
+}
+
+/*
+* Note:
+* Need to use mongoose PaginateResult as a type after upgrading mongoose v6.0.0.
+* Until then, use the original "PaginateResult".
+*/
+export interface PaginateResult<T> {
+  docs: T[];
+  hasNextPage: boolean;
+  hasPrevPage: boolean;
+  limit: number;
+  nextPage: number | null;
+  offset: number;
+  page: number;
+  pagingCounter: number;
+  prevPage: number | null;
+  totalDocs: number;
+  totalPages: number;
+}
+
+/*
+* In App Notification Settings
+*/
+
+export enum subscribeRuleNames {
+  PAGE_CREATE = 'PAGE_CREATE'
+}
+
+export enum SubscribeRuleDescriptions {
+  PAGE_CREATE = 'in_app_notification_settings.default_subscribe_rules.page_create',
+}
+
+export interface ISubscribeRule {
+  name: subscribeRuleNames;
+  isEnabled: boolean;
+}
+export interface IInAppNotificationSettings {
+  userId: Types.ObjectId;
+  subscribeRules: ISubscribeRule[];
+}

+ 1 - 0
packages/app/src/interfaces/ui.ts

@@ -1,6 +1,7 @@
 export const SidebarContentsType = {
   CUSTOM: 'custom',
   RECENT: 'recent',
+  TAG: 'tag',
 } as const;
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
 export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];

+ 69 - 0
packages/app/src/migrations/20210921173042-add-is-trashed-field.js

@@ -0,0 +1,69 @@
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:add-column-is-trashed');
+const Page = require('~/server/models/page')();
+
+const LIMIT = 1000;
+
+/**
+ * set isPageTrashed of pagetagrelations included in updateIdList as true
+ */
+const updateIsPageTrashed = async(db, updateIdList) => {
+  await db.collection('pagetagrelations').updateMany(
+    { relatedPage: { $in: updateIdList } },
+    { $set: { isPageTrashed: true } },
+  );
+};
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    let updateDeletedPageIds = [];
+
+    // set isPageTrashed as false temporarily
+    await db.collection('pagetagrelations').updateMany(
+      {},
+      { $set: { isPageTrashed: false } },
+    );
+
+    for await (const deletedPage of Page.find({ status: Page.STATUS_DELETED }).select('_id').cursor()) {
+      updateDeletedPageIds.push(deletedPage._id);
+      // excute updateMany by one thousand ids
+      if (updateDeletedPageIds.length === LIMIT) {
+        await updateIsPageTrashed(db, updateDeletedPageIds);
+        updateDeletedPageIds = [];
+      }
+    }
+
+    // use ids that have not been updated
+    if (updateDeletedPageIds.length > 0) {
+      await updateIsPageTrashed(db, updateDeletedPageIds);
+    }
+
+    logger.info('Migration has successfully applied');
+
+  },
+
+  async down(db) {
+    logger.info('Rollback migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await db.collection('pagetagrelations').updateMany(
+        {},
+        { $unset: { isPageTrashed: '' } },
+      );
+      logger.info('Migration has been successfully rollbacked');
+    }
+    catch (err) {
+      logger.error(err);
+      logger.info('Migration has failed');
+    }
+
+  },
+};

+ 18 - 0
packages/app/src/models/serializers/in-app-notification-snapshot/page.ts

@@ -0,0 +1,18 @@
+import { IUser } from '~/interfaces/user';
+import { IPage } from '~/interfaces/page';
+
+export interface IPageSnapshot {
+  path: string
+  creator: IUser
+}
+
+export const stringifySnapshot = (page: IPage): string => {
+  return JSON.stringify({
+    path: page.path,
+    creator: page.creator,
+  });
+};
+
+export const parseSnapshot = (snapshot: string): IPageSnapshot => {
+  return JSON.parse(snapshot);
+};

+ 3 - 0
packages/app/src/server/crowi/express-init.js

@@ -4,6 +4,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const path = require('path');
   const express = require('express');
+  const compression = require('compression');
   const helmet = require('helmet');
   const bodyParser = require('body-parser');
   const cookieParser = require('cookie-parser');
@@ -53,6 +54,8 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
     });
 
+  app.use(compression());
+
   app.use(helmet({
     contentSecurityPolicy: false,
     expectCt: false,

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

@@ -23,6 +23,8 @@ import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import SearchService from '../service/search';
 
+import Actiity from '../models/activity';
+
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 
@@ -67,6 +69,9 @@ function Crowi() {
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
   this.slackIntegrationService = null;
+  this.inAppNotificationService = null;
+  this.activityService = null;
+  this.commentService = null;
   this.xss = new Xss();
 
   this.tokens = null;
@@ -124,6 +129,9 @@ Crowi.prototype.init = async function() {
     this.setupExport(),
     this.setupImport(),
     this.setupPageService(),
+    this.setupInAppNotificationService(),
+    this.setupActivityService(),
+    this.setupCommentService(),
     this.setupSyncPageStatusService(),
   ]);
 
@@ -138,6 +146,9 @@ Crowi.prototype.initForTest = async function() {
   await this.setupModels();
   await this.setupConfigManager();
 
+  // setup messaging services
+  await this.setupSocketIoService();
+
   // // customizeService depends on AppService and XssService
   // // passportService depends on appService
   await Promise.all([
@@ -162,6 +173,8 @@ Crowi.prototype.initForTest = async function() {
     // this.setupExport(),
     // this.setupImport(),
     this.setupPageService(),
+    this.setupInAppNotificationService(),
+    this.setupActivityService(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -295,7 +308,15 @@ Crowi.prototype.setupSocketIoService = async function() {
 };
 
 Crowi.prototype.setupModels = async function() {
-  Object.keys(models).forEach((key) => {
+  let allModels = {};
+
+  // include models that dependent on crowi
+  allModels = models;
+
+  // include models that independent from crowi
+  allModels.Activity = Actiity;
+
+  Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
   });
 };
@@ -661,6 +682,27 @@ Crowi.prototype.setupPageService = async function() {
   }
 };
 
+Crowi.prototype.setupInAppNotificationService = async function() {
+  const InAppNotificationService = require('../service/in-app-notification');
+  if (this.inAppNotificationService == null) {
+    this.inAppNotificationService = new InAppNotificationService(this);
+  }
+};
+
+Crowi.prototype.setupActivityService = async function() {
+  const ActivityService = require('../service/activity');
+  if (this.activityService == null) {
+    this.activityService = new ActivityService(this);
+  }
+};
+
+Crowi.prototype.setupCommentService = async function() {
+  const CommentService = require('../service/comment');
+  if (this.commentService == null) {
+    this.commentService = new CommentService(this);
+  }
+};
+
 Crowi.prototype.setupSyncPageStatusService = async function() {
   const SyncPageStatusService = require('../service/system-events/sync-page-status');
   if (this.syncPageStatusService == null) {

+ 20 - 0
packages/app/src/server/events/activity.ts

@@ -0,0 +1,20 @@
+import { EventEmitter } from 'events';
+import loggerFactory from '../../utils/logger';
+
+const logger = loggerFactory('growi:events:activity');
+
+
+class ActivityEvent extends EventEmitter {
+
+  onRemove(action: string, activity: any): void {
+    logger.info('onRemove activity event fired');
+  }
+
+  onCreate(action: string, activity: any): void {
+    logger.info('onCreate activity event fired');
+  }
+
+}
+
+const instance = new ActivityEvent();
+export default instance;

+ 13 - 4
packages/app/src/server/events/comment.ts

@@ -1,5 +1,8 @@
+import loggerFactory from '~/utils/logger';
 
-import util from 'util';
+const logger = loggerFactory('growi:events:comment');
+
+const util = require('util');
 
 const events = require('events');
 
@@ -10,8 +13,14 @@ function CommentEvent(crowi) {
 }
 util.inherits(CommentEvent, events.EventEmitter);
 
-CommentEvent.prototype.onCreate = function(comment) {};
-CommentEvent.prototype.onUpdate = function(comment) {};
-CommentEvent.prototype.onDelete = function(comment) {};
+CommentEvent.prototype.onCreate = function(comment) {
+  logger.info('onCreate comment event fired');
+};
+CommentEvent.prototype.onUpdate = function(comment) {
+  logger.info('onUpdate comment event fired');
+};
+CommentEvent.prototype.onDelete = function(comment) {
+  logger.info('onRemove comment event fired');
+};
 
 module.exports = CommentEvent;

+ 106 - 0
packages/app/src/server/models/activity.ts

@@ -0,0 +1,106 @@
+import {
+  Types, Document, Model, Schema,
+} from 'mongoose';
+
+import { getOrCreateModel, getModelSafely } from '@growi/core';
+import loggerFactory from '../../utils/logger';
+
+
+import ActivityDefine from '../util/activityDefine';
+import activityEvent from '../events/activity';
+
+import Subscription from './subscription';
+
+const logger = loggerFactory('growi:models:activity');
+
+
+export interface ActivityDocument extends Document {
+  _id: Types.ObjectId
+  user: Types.ObjectId | any
+  targetModel: string
+  target: Types.ObjectId
+  action: string
+  event: Types.ObjectId
+  eventModel: string
+
+  getNotificationTargetUsers(): Promise<any[]>
+}
+
+export interface ActivityModel extends Model<ActivityDocument> {
+  getActionUsersFromActivities(activities: ActivityDocument[]): any[]
+}
+// TODO: add revision id
+const activitySchema = new Schema<ActivityDocument, ActivityModel>({
+  user: {
+    type: Schema.Types.ObjectId,
+    ref: 'User',
+    index: true,
+    require: true,
+  },
+  targetModel: {
+    type: String,
+    require: true,
+    enum: ActivityDefine.getSupportTargetModelNames(),
+  },
+  target: {
+    type: Schema.Types.ObjectId,
+    refPath: 'targetModel',
+    require: true,
+  },
+  action: {
+    type: String,
+    require: true,
+    enum: ActivityDefine.getSupportActionNames(),
+  },
+  event: {
+    type: Schema.Types.ObjectId,
+    refPath: 'eventModel',
+  },
+  eventModel: {
+    type: String,
+    enum: ActivityDefine.getSupportEventModelNames(),
+  },
+}, {
+  timestamps: true,
+});
+activitySchema.index({ target: 1, action: 1 });
+activitySchema.index({
+  user: 1, target: 1, action: 1, createdAt: 1,
+}, { unique: true });
+
+
+activitySchema.methods.getNotificationTargetUsers = async function() {
+  const User = getModelSafely('User') || require('~/server/models/user')();
+  const { user: actionUser, target } = this;
+
+  const [subscribeUsers, unsubscribeUsers] = await Promise.all([
+    Subscription.getSubscription((target as any) as Types.ObjectId),
+    Subscription.getUnsubscription((target as any) as Types.ObjectId),
+  ]);
+
+  const unique = array => Object.values(array.reduce((objects, object) => ({ ...objects, [object.toString()]: object }), {}));
+  const filter = (array, pull) => {
+    const ids = pull.map(object => object.toString());
+    return array.filter(object => !ids.includes(object.toString()));
+  };
+  const notificationUsers = filter(unique([...subscribeUsers]), [...unsubscribeUsers, actionUser]);
+  const activeNotificationUsers = await User.find({
+    _id: { $in: notificationUsers },
+    status: User.STATUS_ACTIVE,
+  }).distinct('_id');
+  return activeNotificationUsers;
+};
+
+activitySchema.post('save', async(savedActivity: ActivityDocument) => {
+  let targetUsers: Types.ObjectId[] = [];
+  try {
+    targetUsers = await savedActivity.getNotificationTargetUsers();
+  }
+  catch (err) {
+    logger.error(err);
+  }
+
+  activityEvent.emit('create', targetUsers, savedActivity);
+});
+
+export default getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);

+ 26 - 27
packages/app/src/server/models/comment.js

@@ -1,10 +1,8 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
 module.exports = function(crowi) {
   const debug = require('debug')('growi:models:comment');
   const mongoose = require('mongoose');
   const ObjectId = mongoose.Schema.Types.ObjectId;
+  const commentEvent = crowi.event('comment');
 
   const commentSchema = new mongoose.Schema({
     page: { type: ObjectId, ref: 'Page', index: true },
@@ -83,41 +81,42 @@ module.exports = function(crowi) {
     }));
   };
 
-  commentSchema.statics.removeCommentsByPageId = function(pageId) {
+  commentSchema.statics.updateCommentsByPageId = async function(comment, isMarkdown, commentId) {
     const Comment = this;
 
-    return new Promise(((resolve, reject) => {
-      Comment.remove({ page: pageId }, (err, done) => {
-        if (err) {
-          return reject(err);
-        }
+    const commentData = await Comment.findOneAndUpdate(
+      { _id: commentId },
+      { $set: { comment, isMarkdown } },
+    );
 
-        resolve(done);
-      });
-    }));
+    await commentEvent.emit('update', commentData);
+
+    return commentData;
   };
 
-  commentSchema.methods.removeWithReplies = async function() {
+
+  /**
+   * post remove hook
+   */
+  commentSchema.post('reomove', async(savedComment) => {
+    await commentEvent.emit('remove', savedComment);
+  });
+
+  commentSchema.methods.removeWithReplies = async function(comment) {
     const Comment = crowi.model('Comment');
-    return Comment.remove({
+
+    await Comment.remove({
       $or: (
         [{ replyTo: this._id }, { _id: this._id }]),
     });
+
+    await commentEvent.emit('remove', comment);
+    return;
   };
 
-  /**
-   * post save hook
-   */
-  commentSchema.post('save', (savedComment) => {
-    const Page = crowi.model('Page');
-
-    Page.updateCommentCount(savedComment.page)
-      .then((page) => {
-        debug('CommentCount Updated', page);
-      })
-      .catch(() => {
-      });
-  });
+  commentSchema.statics.findCreatorsByPage = async function(page) {
+    return this.distinct('creator', { page }).exec();
+  };
 
   return mongoose.model('Comment', commentSchema);
 };

+ 1 - 0
packages/app/src/server/models/config.ts

@@ -235,6 +235,7 @@ schema.statics.getLocalconfig = function(crowi) {
     isSearchServiceReachable: crowi.searchService.isReachable,
     isMailerSetup: crowi.mailService.isMailerSetup,
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+    pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
   };
 
   return localConfig;

+ 20 - 0
packages/app/src/server/models/in-app-notification-settings.ts

@@ -0,0 +1,20 @@
+import { Schema, Model, Document } from 'mongoose';
+import { getOrCreateModel } from '@growi/core';
+
+import { IInAppNotificationSettings, subscribeRuleNames } from '~/interfaces/in-app-notification';
+
+export interface InAppNotificationSettingsDocument extends IInAppNotificationSettings, Document {}
+export type InAppNotificationSettingsModel = Model<InAppNotificationSettingsDocument>
+
+const inAppNotificationSettingsSchema = new Schema<InAppNotificationSettingsDocument, InAppNotificationSettingsModel>({
+  userId: { type: Schema.Types.ObjectId },
+  subscribeRules: [
+    {
+      name: { type: String, require: true, enum: subscribeRuleNames },
+      isEnabled: { type: Boolean },
+    },
+  ],
+});
+
+// eslint-disable-next-line max-len
+export default getOrCreateModel<InAppNotificationSettingsDocument, InAppNotificationSettingsModel>('InAppNotificationSettings', inAppNotificationSettingsSchema);

+ 105 - 0
packages/app/src/server/models/in-app-notification.ts

@@ -0,0 +1,105 @@
+import {
+  Types, Document, Schema, Model,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+
+import { getOrCreateModel } from '@growi/core';
+import { ActivityDocument } from './activity';
+import ActivityDefine from '../util/activityDefine';
+
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+
+const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
+
+export interface InAppNotificationDocument extends Document {
+  _id: Types.ObjectId
+  user: Types.ObjectId
+  targetModel: string
+  target: Types.ObjectId
+  action: string
+  activities: ActivityDocument[]
+  status: string
+  createdAt: Date
+  snapshot: string
+}
+
+
+export interface InAppNotificationModel extends Model<InAppNotificationDocument> {
+  findLatestInAppNotificationsByUser(user: Types.ObjectId, skip: number, offset: number)
+  getUnreadCountByUser(user: Types.ObjectId): Promise<number | undefined>
+  open(user, id: Types.ObjectId): Promise<InAppNotificationDocument | null>
+  read(user) /* : Promise<Query<any>> */
+
+  STATUS_UNREAD: string
+  STATUS_UNOPENED: string
+  STATUS_OPENED: string
+}
+
+const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotificationModel>({
+  user: {
+    type: Schema.Types.ObjectId,
+    ref: 'User',
+    index: true,
+    require: true,
+  },
+  targetModel: {
+    type: String,
+    require: true,
+    enum: ActivityDefine.getSupportTargetModelNames(),
+  },
+  target: {
+    type: Schema.Types.ObjectId,
+    refPath: 'targetModel',
+    require: true,
+  },
+  action: {
+    type: String,
+    require: true,
+    enum: ActivityDefine.getSupportActionNames(),
+  },
+  activities: [
+    {
+      type: Schema.Types.ObjectId,
+      ref: 'Activity',
+    },
+  ],
+  status: {
+    type: String,
+    default: STATUS_UNREAD,
+    enum: InAppNotificationStatuses,
+    index: true,
+    require: true,
+  },
+  createdAt: {
+    type: Date,
+    default: new Date(),
+  },
+  snapshot: {
+    type: String,
+    require: true,
+  },
+});
+inAppNotificationSchema.plugin(mongoosePaginate);
+
+const transform = (doc, ret) => {
+  delete ret.activities;
+};
+inAppNotificationSchema.set('toObject', { virtuals: true, transform });
+inAppNotificationSchema.set('toJSON', { virtuals: true, transform });
+inAppNotificationSchema.index({
+  user: 1, target: 1, action: 1, createdAt: 1,
+});
+
+inAppNotificationSchema.statics.STATUS_UNOPENED = function() {
+  return STATUS_UNOPENED;
+};
+inAppNotificationSchema.statics.STATUS_UNREAD = function() {
+  return STATUS_UNREAD;
+};
+inAppNotificationSchema.statics.STATUS_OPENED = function() {
+  return STATUS_OPENED;
+};
+
+const InAppNotification = getOrCreateModel<InAppNotificationDocument, InAppNotificationModel>('InAppNotification', inAppNotificationSchema);
+
+export { InAppNotification };

+ 45 - 25
packages/app/src/server/models/page-tag-relation.js

@@ -24,6 +24,13 @@ const schema = new mongoose.Schema({
     type: ObjectId,
     ref: 'Tag',
     required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
   },
 });
 // define unique compound index
@@ -39,27 +46,34 @@ schema.plugin(uniqueValidator);
 class PageTagRelation {
 
   static async createTagListWithCount(option) {
-    const Tag = mongoose.model('Tag');
     const opt = option || {};
     const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset || 0;
-    const limit = opt.limit || 50;
+    const offset = opt.offset;
+    const limit = opt.limit;
 
-    const existTagIds = await Tag.find().distinct('_id');
     const tags = await this.aggregate()
-      .match({ relatedTag: { $in: existTagIds } })
-      .group({ _id: '$relatedTag', count: { $sum: 1 } })
-      .sort(sortOpt);
-
-    const list = tags.slice(offset, offset + limit);
-    const totalCount = tags.length;
-
-    return { list, totalCount };
+      .match({ isPageTrashed: false })
+      .lookup({
+        from: 'tags',
+        localField: 'relatedTag',
+        foreignField: '_id',
+        as: 'tag',
+      })
+      .unwind('$tag')
+      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+    return { data: tags, totalCount };
   }
 
-  static async findByPageId(pageId) {
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
-    return relations.filter((relation) => { return relation.relatedTag !== null });
+  static async findByPageId(pageId, options = {}) {
+    const isAcceptRelatedTagNull = options.nullable || null;
+    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
   }
 
   static async listTagNamesByPage(pageId) {
@@ -125,17 +139,23 @@ class PageTagRelation {
     const Tag = mongoose.model('Tag');
 
     // get relations for this page
-    const relations = await this.findByPageId(pageId);
-
-    // unlink relations
-    const unlinkTagRelations = relations.filter((relation) => { return !tags.includes(relation.relatedTag.name) });
-    const bulkDeletePromise = this.deleteMany({
-      relatedPage: pageId,
-      relatedTag: { $in: unlinkTagRelations.map((relation) => { return relation.relatedTag._id }) },
+    const relations = await this.findByPageId(pageId, { nullable: true });
+
+    const unlinkTagRelationIds = [];
+    const relatedTagNames = [];
+
+    relations.forEach((relation) => {
+      if (relation.relatedTag == null) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+      else {
+        relatedTagNames.push(relation.relatedTag.name);
+        if (!tags.includes(relation.relatedTag.name)) {
+          unlinkTagRelationIds.push(relation._id);
+        }
+      }
     });
-
-    // filter tags to create
-    const relatedTagNames = relations.map((relation) => { return relation.relatedTag.name });
+    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
     // find or create tags
     const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
     const tagEntities = await Tag.findOrCreateMany(tagsToCreate);

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

@@ -1159,6 +1159,16 @@ module.exports = function(crowi) {
     return pageData.save();
   };
 
+  pageSchema.methods.getNotificationTargetUsers = async function() {
+    const Comment = mongoose.model('Comment');
+    const Revision = mongoose.model('Revision');
+
+    const [commentCreators, revisionAuthors] = await Promise.all([Comment.findCreatorsByPage(this), Revision.findAuthorsByPage(this)]);
+
+    const targetUsers = new Set([this.creator].concat(commentCreators, revisionAuthors));
+    return Array.from(targetUsers);
+  };
+
   pageSchema.statics.getHistories = function() {
     // TODO
 

+ 5 - 0
packages/app/src/server/models/revision.js

@@ -90,5 +90,10 @@ module.exports = function(crowi) {
     }));
   };
 
+  revisionSchema.statics.findAuthorsByPage = async function(page) {
+    const result = await this.distinct('author', { path: page.path }).exec();
+    return result;
+  };
+
   return mongoose.model('Revision', revisionSchema);
 };

+ 91 - 0
packages/app/src/server/models/subscription.ts

@@ -0,0 +1,91 @@
+import {
+  Types, Document, Model, Schema,
+} from 'mongoose';
+
+import { getOrCreateModel } from '@growi/core';
+import ActivityDefine from '../util/activityDefine';
+
+export const STATUS_SUBSCRIBE = 'SUBSCRIBE';
+export const STATUS_UNSUBSCRIBE = 'UNSUBSCRIBE';
+const STATUSES = [STATUS_SUBSCRIBE, STATUS_UNSUBSCRIBE];
+
+export interface ISubscription {
+  user: Types.ObjectId
+  targetModel: string
+  target: Types.ObjectId
+  status: string
+  createdAt: Date
+
+  isSubscribing(): boolean
+  isUnsubscribing(): boolean
+}
+
+export interface SubscriptionDocument extends ISubscription, Document {}
+
+export interface SubscriptionModel extends Model<SubscriptionDocument> {
+  findByUserIdAndTargetId(userId: Types.ObjectId, targetId: Types.ObjectId): any
+  upsertSubscription(user: Types.ObjectId, targetModel: string, target: Types.ObjectId, status: string): any
+  subscribeByPageId(user: Types.ObjectId, pageId: Types.ObjectId, status: string): any
+  getSubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>
+  getUnsubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>
+}
+
+const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
+  user: {
+    type: Schema.Types.ObjectId,
+    ref: 'User',
+    index: true,
+    required: true,
+  },
+  targetModel: {
+    type: String,
+    require: true,
+    enum: ActivityDefine.getSupportTargetModelNames(),
+  },
+  target: {
+    type: Schema.Types.ObjectId,
+    refPath: 'targetModel',
+    require: true,
+  },
+  status: {
+    type: String,
+    require: true,
+    enum: STATUSES,
+  },
+  createdAt: { type: Date, default: new Date() },
+});
+
+subscriptionSchema.methods.isSubscribing = function() {
+  return this.status === STATUS_SUBSCRIBE;
+};
+
+subscriptionSchema.methods.isUnsubscribing = function() {
+  return this.status === STATUS_UNSUBSCRIBE;
+};
+
+subscriptionSchema.statics.findByUserIdAndTargetId = function(userId, targetId) {
+  return this.findOne({ user: userId, target: targetId });
+};
+
+subscriptionSchema.statics.upsertSubscription = function(user, targetModel, target, status) {
+  const query = { user, targetModel, target };
+  const doc = { ...query, status };
+  const options = {
+    upsert: true, new: true, setDefaultsOnInsert: true, runValidators: true,
+  };
+  return this.findOneAndUpdate(query, doc, options);
+};
+
+subscriptionSchema.statics.subscribeByPageId = function(user, pageId, status) {
+  return this.upsertSubscription(user, 'Page', pageId, status);
+};
+
+subscriptionSchema.statics.getSubscription = async function(target) {
+  return this.find({ target, status: STATUS_SUBSCRIBE }).distinct('user');
+};
+
+subscriptionSchema.statics.getUnsubscription = async function(target) {
+  return this.find({ target, status: STATUS_UNSUBSCRIBE }).distinct('user');
+};
+
+export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);

+ 8 - 0
packages/app/src/server/routes/all-in-app-notifications.ts

@@ -0,0 +1,8 @@
+import {
+  Request, Response,
+} from 'express';
+
+export const list = (req: Request, res: Response): void => {
+
+  return res.render('me/all-in-app-notifications');
+};

+ 4 - 0
packages/app/src/server/routes/apiv3/bookmarks.js

@@ -261,6 +261,10 @@ module.exports = (crowi) => {
       }
       if (bool) {
         bookmark = await Bookmark.add(page, req.user);
+
+        const pageEvent = crowi.event('page');
+        // in-app notification
+        pageEvent.emit('bookmark', page, req.user);
       }
       else {
         bookmark = await Bookmark.removeBookmark(page, req.user);

+ 117 - 0
packages/app/src/server/routes/apiv3/in-app-notification.ts

@@ -0,0 +1,117 @@
+import { IInAppNotification } from '../../../interfaces/in-app-notification';
+
+const express = require('express');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
+
+const router = express.Router();
+
+
+module.exports = (crowi) => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const inAppNotificationService = crowi.inAppNotificationService;
+  const User = crowi.model('User');
+
+  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    const user = req.user;
+
+    const limit = parseInt(req.query.limit) || 10;
+
+    let offset = 0;
+    if (req.query.offset) {
+      offset = parseInt(req.query.offset, 10);
+    }
+
+    const queryOptions = {
+      offset,
+      limit,
+    };
+
+    // set in-app-notification status to categorize
+    if (req.query.status != null) {
+      Object.assign(queryOptions, { status: req.query.status });
+    }
+
+    const paginationResult = await inAppNotificationService.getLatestNotificationsByUser(user._id, queryOptions);
+
+
+    const getActionUsersFromActivities = function(activities) {
+      return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
+    };
+
+    const serializedDocs: Array<IInAppNotification> = paginationResult.docs.map((doc) => {
+      if (doc.user != null && doc.user instanceof User) {
+        doc.user = serializeUserSecurely(doc.user);
+      }
+      // To add a new property into mongoose doc, need to change the format of doc to an object
+      const docObj: IInAppNotification = doc.toObject();
+      const actionUsersNew = getActionUsersFromActivities(doc.activities);
+
+      const serializedActionUsers = actionUsersNew.map((actionUser) => {
+        return serializeUserSecurely(actionUser);
+      });
+
+      docObj.actionUsers = serializedActionUsers;
+      return docObj;
+    });
+
+    const serializedPaginationResult = {
+      ...paginationResult,
+      docs: serializedDocs,
+    };
+
+    return res.apiv3(serializedPaginationResult);
+  });
+
+  router.get('/status', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    const userId = req.user._id;
+    try {
+      const count = await inAppNotificationService.getUnreadCountByUser(userId);
+      return res.apiv3({ count });
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  router.post('/read', accessTokenParser, loginRequiredStrictly, csrf, async(req, res) => {
+    const user = req.user;
+
+    try {
+      await inAppNotificationService.read(user);
+      return res.apiv3();
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  router.post('/open', accessTokenParser, loginRequiredStrictly, csrf, async(req, res) => {
+    const user = req.user;
+    const id = req.body.id;
+
+    try {
+      const notification = await inAppNotificationService.open(user, id);
+      const result = { notification };
+      return res.apiv3(result);
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, csrf, async(req, res) => {
+    const user = req.user;
+
+    try {
+      await inAppNotificationService.updateAllNotificationsAsOpened(user);
+      return res.apiv3();
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
+  });
+
+  return router;
+};

+ 3 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -27,6 +27,9 @@ module.exports = (crowi) => {
   router.use('/import', require('./import')(crowi));
   router.use('/search', require('./search')(crowi));
 
+
+  router.use('/in-app-notification', require('./in-app-notification')(crowi));
+
   router.use('/personal-setting', require('./personal-setting')(crowi));
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));

+ 96 - 0
packages/app/src/server/routes/apiv3/page.js

@@ -1,6 +1,7 @@
 import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
+import Subscription, { STATUS_SUBSCRIBE, STATUS_UNSUBSCRIBE } from '~/server/models/subscription';
 
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
@@ -200,6 +201,13 @@ module.exports = (crowi) => {
       query('fromPath').isString(),
       query('toPath').isString(),
     ],
+    subscribe: [
+      body('pageId').isString(),
+      body('status').isBoolean(),
+    ],
+    subscribeStatus: [
+      query('pageId').isString(),
+    ],
   };
 
   /**
@@ -315,6 +323,10 @@ module.exports = (crowi) => {
     res.apiv3({ result });
 
     if (isLiked) {
+      const pageEvent = crowi.event('page');
+      // in-app notification
+      pageEvent.emit('like', page, req.user);
+
       try {
         // global notification
         await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
@@ -573,5 +585,89 @@ module.exports = (crowi) => {
   //   return res.apiv3({ dummy });
   // });
 
+  /**
+   * @swagger
+   *
+   *    /page/subscribe:
+   *      put:
+   *        tags: [Page]
+   *        summary: /page/subscribe
+   *        description: Update subscription status
+   *        operationId: updateSubscriptionStatus
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageId:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update subscription status.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/Page'
+   *          500:
+   *            description: Internal server error.
+   */
+  router.put('/subscribe', accessTokenParser, loginRequiredStrictly, csrf, validator.subscribe, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.body;
+    const userId = req.user._id;
+    const status = req.body.status ? STATUS_SUBSCRIBE : STATUS_UNSUBSCRIBE;
+    try {
+      const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
+      return res.apiv3({ subscription });
+    }
+    catch (err) {
+      logger.error('Failed to update subscribe status', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /page/subscribe:
+   *      get:
+   *        tags: [Page]
+   *        summary: /page/subscribe
+   *        description: Get subscription status
+   *        operationId: getSubscriptionStatus
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageId:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *        responses:
+   *          200:
+   *            description: Succeeded to get subscription status.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/Page'
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/subscribe', loginRequiredStrictly, validator.subscribeStatus, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.query;
+    const userId = req.user._id;
+
+    const page = await Page.findById(pageId);
+    if (!page) throw new Error('Page not found');
+
+    try {
+      const subscription = await Subscription.findByUserIdAndTargetId(userId, pageId);
+      const subscribing = subscription ? subscription.isSubscribing() : null;
+      return res.apiv3({ subscribing });
+    }
+    catch (err) {
+      logger.error('Failed to ge subscribe status', err);
+      return res.apiv3(err, 500);
+    }
+  });
+
   return router;
 };

+ 21 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -1,6 +1,8 @@
 import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const { pathUtils } = require('@growi/core');
@@ -195,7 +197,9 @@ module.exports = (crowi) => {
 
   async function saveTagsAction({ createdPage, pageTags }) {
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
       return PageTagRelation.listTagNamesByPage(createdPage.id);
     }
 
@@ -293,6 +297,8 @@ module.exports = (crowi) => {
       Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
     }
 
+    res.apiv3(result, 201);
+
     try {
       // global notification
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
@@ -316,7 +322,13 @@ module.exports = (crowi) => {
       }
     }
 
-    return res.apiv3(result, 201);
+    // create subscription
+    try {
+      await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
+    }
+    catch (err) {
+      logger.error('Failed to create subscription document', err);
+    }
   });
 
 
@@ -629,6 +641,14 @@ module.exports = (crowi) => {
       logger.error('Create grobal notification failed', err);
     }
 
+    // create subscription (parent page only)
+    try {
+      await crowi.inAppNotificationService.createSubscription(req.user.id, newParentPage._id, subscribeRuleNames.PAGE_CREATE);
+    }
+    catch (err) {
+      logger.error('Failed to create subscription document', err);
+    }
+
     return res.apiv3(result);
   });
 

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

@@ -5,6 +5,7 @@ import loggerFactory from '~/utils/logger';
 import { listLocaleIds } from '~/utils/locale-utils';
 
 import EditorSettings from '../../models/editor-settings';
+import InAppNotificationSettings from '../../models/in-app-notification-settings';
 
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 
@@ -105,7 +106,10 @@ module.exports = (crowi) => {
       body('textlintSettings.textlintRules.*.name').optional().isString(),
       body('textlintSettings.textlintRules.*.options').optional(),
       body('textlintSettings.textlintRules.*.isEnabled').optional().isBoolean(),
-
+    ],
+    inAppNotificationSettings: [
+      body('defaultSubscribeRules.*.name').isString(),
+      body('defaultSubscribeRules.*.isEnabled').optional().isBoolean(),
     ],
   };
 
@@ -550,5 +554,78 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    /personal-setting/in-app-notification-settings:
+   *      put:
+   *        tags: [in-app-notification-settings]
+   *        operationId: putInAppNotificationSettings
+   *        summary: personal-setting/in-app-notification-settings
+   *        description: Put InAppNotificationSettings
+   *        responses:
+   *          200:
+   *            description: params of InAppNotificationSettings
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    currentUser:
+   *                      type: object
+   *                      description: in-app-notification-settings
+   */
+  // eslint-disable-next-line max-len
+  router.put('/in-app-notification-settings', accessTokenParser, loginRequiredStrictly, csrf, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
+    const query = { userId: req.user.id };
+    const subscribeRules = req.body.subscribeRules;
+
+    if (subscribeRules == null) {
+      return res.apiv3Err('no-rules-found');
+    }
+
+    const options = { upsert: true, new: true, runValidators: true };
+    try {
+      const response = await InAppNotificationSettings.findOneAndUpdate(query, { $set: { subscribeRules } }, options);
+      return res.apiv3(response);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('updating-in-app-notification-settings-failed');
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting/in-app-notification-settings:
+   *      get:
+   *        tags: [in-app-notification-settings]
+   *        operationId: getInAppNotificationSettings
+   *        summary: personal-setting/in-app-notification-settings
+   *        description: Get InAppNotificationSettings
+   *        responses:
+   *          200:
+   *            description: params of InAppNotificationSettings
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    currentUser:
+   *                      type: object
+   *                      description: InAppNotificationSettings
+   */
+  router.get('/in-app-notification-settings', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    const query = { userId: req.user.id };
+    try {
+      const response = await InAppNotificationSettings.findOne(query);
+      return res.apiv3(response);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('getting-in-app-notification-settings-failed');
+    }
+  });
+
+
   return router;
 };

+ 2 - 2
packages/app/src/server/routes/comment.js

@@ -379,7 +379,7 @@ module.exports = function(crowi, app) {
         { _id: commentId },
         { $set: { comment: commentStr, isMarkdown, revision } },
       );
-      commentEvent.emit('create', updatedComment);
+      commentEvent.emit('update', updatedComment);
     }
     catch (err) {
       logger.error(err);
@@ -457,7 +457,7 @@ module.exports = function(crowi, app) {
         throw new Error('Current user is not operatable to this comment.');
       }
 
-      await comment.removeWithReplies();
+      await comment.removeWithReplies(comment);
       await Page.updateCommentCount(comment.page);
       commentEvent.emit('delete', comment);
     }

+ 3 - 0
packages/app/src/server/routes/index.js

@@ -4,6 +4,7 @@ import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 
 import * as forgotPassword from './forgot-password';
+import * as allInAppNotifications from './all-in-app-notifications';
 import * as userActivation from './user-activation';
 
 const multer = require('multer');
@@ -138,6 +139,8 @@ module.exports = function(crowi, app) {
 
   app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   // external-accounts
+  // my in-app-notifications
+  app.get('/me/all-in-app-notifications'   , loginRequiredStrictly, allInAppNotifications.list);
   app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
   // my drafts
   app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);

+ 2 - 0
packages/app/src/server/routes/page.js

@@ -851,8 +851,10 @@ module.exports = function(crowi, app) {
 
     let savedTags;
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(pageId, pageTags);
       savedTags = await PageTagRelation.listTagNamesByPage(pageId);
+      tagEvent.emit('update', page, savedTags);
     }
 
     const result = {

+ 16 - 22
packages/app/src/server/routes/tag.js

@@ -136,15 +136,27 @@ module.exports = function(crowi, app) {
    */
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
+    const User = crowi.model('User');
     const PageTagRelation = crowi.model('PageTagRelation');
+    const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;
     const tags = req.body.tags;
+    const userId = req.user._id;
+    const revisionId = req.body.revisionId;
 
     const result = {};
     try {
       // TODO GC-1921 consider permission
       const page = await Page.findById(pageId);
+      const user = await User.findById(userId);
+
+      if (!await Page.isAccessiblePageByViewer(page._id, user)) {
+        return res.json(ApiResponse.error("You don't have permission to update this page."));
+      }
+
+      const previousRevision = await Revision.findById(revisionId);
+      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
@@ -203,32 +215,14 @@ module.exports = function(crowi, app) {
   api.list = async function(req, res) {
     const limit = +req.query.limit || 50;
     const offset = +req.query.offset || 0;
-    const sortOpt = { count: -1 };
+    const sortOpt = { count: -1, _id: -1 };
     const queryOptions = { offset, limit, sortOpt };
-    const result = {};
 
     try {
-      // get tag list contains id and count properties
-      const listData = await PageTagRelation.createTagListWithCount(queryOptions);
-      const ids = listData.list.map((obj) => { return obj._id });
-
-      // get tag documents for add name data to the list
-      const tags = await Tag.find({ _id: { $in: ids } });
-
-      // add name property
-      result.data = listData.list.map((elm) => {
-        const data = {};
-        const tag = tags.find((tag) => { return (tag.id === elm._id.toString()) });
-
-        data._id = elm._id;
-        data.name = tag.name;
-        data.count = elm.count; // the number of related pages
-        return data;
-      });
-
-      result.totalCount = listData.totalCount;
+      // get tag list contains id name and count properties
+      const tagsWithCount = await PageTagRelation.createTagListWithCount(queryOptions);
 
-      return res.json(ApiResponse.success(result));
+      return res.json(ApiResponse.success(tagsWithCount));
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 38 - 0
packages/app/src/server/service/activity.ts

@@ -0,0 +1,38 @@
+import { getModelSafely } from '@growi/core';
+import Crowi from '../crowi';
+
+
+class ActivityService {
+
+  crowi!: Crowi;
+
+  inAppNotificationService!: any;
+
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+    this.inAppNotificationService = crowi.inAppNotificationService;
+  }
+
+
+  /**
+     * @param {object} parameters
+     * @return {Promise}
+     */
+  createByParameters = function(parameters) {
+    const Activity = getModelSafely('Activity') || require('../models/activity')(this.crowi);
+
+    return Activity.create(parameters);
+  };
+
+
+  /**
+   * @param {User} user
+   * @return {Promise}
+   */
+  findByUser = function(user) {
+    return this.find({ user }).sort({ createdAt: -1 }).exec();
+  };
+
+}
+
+module.exports = ActivityService;

+ 107 - 0
packages/app/src/server/service/comment.ts

@@ -0,0 +1,107 @@
+import { Types } from 'mongoose';
+import { getModelSafely } from '@growi/core';
+import loggerFactory from '../../utils/logger';
+import ActivityDefine from '../util/activityDefine';
+import Crowi from '../crowi';
+
+import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+
+const logger = loggerFactory('growi:service:CommentService');
+
+class CommentService {
+
+  crowi!: Crowi;
+
+  activityService!: any;
+
+  inAppNotificationService!: any;
+
+  commentEvent!: any;
+
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+    this.activityService = crowi.activityService;
+    this.inAppNotificationService = crowi.inAppNotificationService;
+
+    this.commentEvent = crowi.event('comment');
+
+    // init
+    this.initCommentEventListeners();
+  }
+
+  initCommentEventListeners(): void {
+    // create
+    this.commentEvent.on('create', async(savedComment) => {
+
+      try {
+        const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
+        await Page.updateCommentCount(savedComment.page);
+
+        const page = await Page.findById(savedComment.page);
+        if (page == null) {
+          logger.error('Page is not found');
+          return;
+        }
+
+        const activity = await this.createActivity(savedComment, ActivityDefine.ACTION_COMMENT_CREATE);
+        await this.createAndSendNotifications(activity, page);
+      }
+      catch (err) {
+        logger.error('Error occurred while handling the comment create event:\n', err);
+      }
+
+    });
+
+    // update
+    this.commentEvent.on('update', async(updatedComment) => {
+      try {
+        this.commentEvent.onUpdate();
+        await this.createActivity(updatedComment, ActivityDefine.ACTION_COMMENT_UPDATE);
+      }
+      catch (err) {
+        logger.error('Error occurred while handling the comment update event:\n', err);
+      }
+    });
+
+    // remove
+    this.commentEvent.on('remove', async(comment) => {
+      this.commentEvent.onRemove();
+
+      try {
+        const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
+        await Page.updateCommentCount(comment.page);
+      }
+      catch (err) {
+        logger.error('Error occurred while updating the comment count:\n', err);
+      }
+    });
+  }
+
+  private createActivity = async function(comment, action) {
+    const parameters = {
+      user: comment.creator,
+      targetModel: ActivityDefine.MODEL_PAGE,
+      target: comment.page,
+      eventModel: ActivityDefine.MODEL_COMMENT,
+      event: comment._id,
+      action,
+    };
+    const activity = await this.activityService.createByParameters(parameters);
+    return activity;
+  };
+
+  private createAndSendNotifications = async function(activity, page) {
+    const snapshot = stringifySnapshot(page);
+
+    // Get user to be notified
+    let targetUsers: Types.ObjectId[] = [];
+    targetUsers = await activity.getNotificationTargetUsers();
+
+    // Create and send notifications
+    await this.inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
+    await this.inAppNotificationService.emitSocketIo(targetUsers);
+  };
+
+}
+
+module.exports = CommentService;

+ 18 - 0
packages/app/src/server/service/config-loader.ts

@@ -391,6 +391,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  OIDC_TIMEOUT_MULTIPLIER: {
+    ns:      'crowi',
+    key:     'security:passport-oidc:timeoutMultiplier',
+    type:    ValueType.NUMBER,
+    default: 1.5,
+  },
+  OIDC_DISCOVERY_RETRIES: {
+    ns:      'crowi',
+    key:     'security:passport-oidc:discoveryRetries',
+    type:    ValueType.NUMBER,
+    default: 3,
+  },
+  OIDC_CLIENT_CLOCK_TOLERANCE: {
+    ns: 'crowi',
+    key: 'security:passport-oidc:oidcClientClockTolerance',
+    type: ValueType.NUMBER,
+    default: 10,
+  },
   S3_REFERENCE_FILE_WITH_RELAY_MODE: {
     ns:      'crowi',
     key:     'aws:referenceFileWithRelayMode',

+ 179 - 0
packages/app/src/server/service/in-app-notification.ts

@@ -0,0 +1,179 @@
+import { Types } from 'mongoose';
+import { subDays } from 'date-fns';
+import { InAppNotificationStatuses, PaginateResult, IInAppNotification } from '~/interfaces/in-app-notification';
+import Crowi from '../crowi';
+import {
+  InAppNotification,
+  InAppNotificationDocument,
+} from '~/server/models/in-app-notification';
+
+import { ActivityDocument } from '~/server/models/activity';
+import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
+import Subscription, { STATUS_SUBSCRIBE } from '~/server/models/subscription';
+
+import { IUser } from '~/interfaces/user';
+
+import { HasObjectId } from '~/interfaces/has-object-id';
+import loggerFactory from '~/utils/logger';
+import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
+
+const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
+
+const logger = loggerFactory('growi:service:inAppNotification');
+
+
+export default class InAppNotificationService {
+
+  crowi!: Crowi;
+
+  socketIoService!: any;
+
+  commentEvent!: any;
+
+
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+    this.socketIoService = crowi.socketIoService;
+
+    this.getUnreadCountByUser = this.getUnreadCountByUser.bind(this);
+  }
+
+
+  emitSocketIo = async(targetUsers) => {
+    if (this.socketIoService.isInitialized) {
+      targetUsers.forEach(async(userId) => {
+
+        // emit to the room for each user
+        await this.socketIoService.getDefaultSocket()
+          .in(getRoomNameWithId(RoomPrefix.USER, userId))
+          .emit('notificationUpdated');
+      });
+    }
+  }
+
+  upsertByActivity = async function(
+      users: Types.ObjectId[], activity: ActivityDocument, snapshot: string, createdAt?: Date | null,
+  ): Promise<void> {
+    const {
+      _id: activityId, targetModel, target, action,
+    } = activity;
+    const now = createdAt || Date.now();
+    const lastWeek = subDays(now, 7);
+    const operations = users.map((user) => {
+      const filter = {
+        user, target, action, createdAt: { $gt: lastWeek }, snapshot,
+      };
+      const parameters = {
+        user,
+        targetModel,
+        target,
+        action,
+        status: STATUS_UNREAD,
+        createdAt: now,
+        snapshot,
+        $addToSet: { activities: activityId },
+      };
+      return {
+        updateOne: {
+          filter,
+          update: parameters,
+          upsert: true,
+        },
+      };
+    });
+
+    await InAppNotification.bulkWrite(operations);
+    logger.info('InAppNotification bulkWrite has run');
+    return;
+  }
+
+  getLatestNotificationsByUser = async(
+      userId: Types.ObjectId,
+      queryOptions: {offset: number, limit: number, status?: InAppNotificationStatuses},
+  ): Promise<PaginateResult<InAppNotificationDocument>> => {
+    const { limit, offset, status } = queryOptions;
+
+    try {
+      const pagenateOptions = { user: userId };
+      if (status != null) {
+        Object.assign(pagenateOptions, { status });
+      }
+      // TODO: import @types/mongoose-paginate-v2 and use PaginateResult as a type after upgrading mongoose v6.0.0
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const paginationResult = await (InAppNotification as any).paginate(
+        pagenateOptions,
+        {
+          sort: { createdAt: -1 },
+          limit,
+          offset,
+          populate: [
+            { path: 'user' },
+            { path: 'target' },
+            { path: 'activities', populate: { path: 'user' } },
+          ],
+        },
+      );
+
+      return paginationResult;
+    }
+    catch (err) {
+      logger.error('Error', err);
+      throw new Error(err);
+    }
+  }
+
+  read = async function(user: Types.ObjectId): Promise<void> {
+    const query = { user, status: STATUS_UNREAD };
+    const parameters = { status: STATUS_UNOPENED };
+    await InAppNotification.updateMany(query, parameters);
+
+    return;
+  };
+
+  open = async function(user: IUser & HasObjectId, id: Types.ObjectId): Promise<void> {
+    const query = { _id: id, user: user._id };
+    const parameters = { status: STATUS_OPENED };
+    const options = { new: true };
+
+    await InAppNotification.findOneAndUpdate(query, parameters, options);
+    return;
+  }
+
+  updateAllNotificationsAsOpened = async function(user: IUser & HasObjectId): Promise<void> {
+    const filter = { user: user._id, status: STATUS_UNOPENED };
+    const options = { status: STATUS_OPENED };
+
+    await InAppNotification.updateMany(filter, options);
+    return;
+  }
+
+  getUnreadCountByUser = async function(user: Types.ObjectId): Promise<number| undefined> {
+    const query = { user, status: STATUS_UNREAD };
+
+    try {
+      const count = await InAppNotification.countDocuments(query);
+
+      return count;
+    }
+    catch (err) {
+      logger.error('Error on getUnreadCountByUser', err);
+      throw err;
+    }
+  };
+
+  createSubscription = async function(userId: Types.ObjectId, pageId: Types.ObjectId, targetRuleName: string): Promise<void> {
+    const query = { userId };
+    const inAppNotificationSettings = await InAppNotificationSettings.findOne(query);
+    if (inAppNotificationSettings != null) {
+      const subscribeRule = inAppNotificationSettings.subscribeRules.find(subscribeRule => subscribeRule.name === targetRuleName);
+      if (subscribeRule != null && subscribeRule.isEnabled) {
+        await Subscription.subscribeByPageId(userId, pageId, STATUS_SUBSCRIBE);
+      }
+    }
+
+    return;
+  };
+
+}
+
+module.exports = InAppNotificationService;

+ 117 - 11
packages/app/src/server/service/page.js

@@ -1,12 +1,15 @@
 import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import ActivityDefine from '../util/activityDefine';
+
+import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
 const streamToPromise = require('stream-to-promise');
 
-const logger = loggerFactory('growi:models:page');
-const debug = require('debug')('growi:models:page');
+const logger = loggerFactory('growi:service:page');
+const debug = require('debug')('growi:service:page');
 const { Writable } = require('stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
 
@@ -20,11 +23,81 @@ class PageService {
   constructor(crowi) {
     this.crowi = crowi;
     this.pageEvent = crowi.event('page');
+    this.tagEvent = crowi.event('tag');
 
     // init
+    this.initPageEvent();
+  }
+
+  initPageEvent() {
+    // create
     this.pageEvent.on('create', this.pageEvent.onCreate);
-    this.pageEvent.on('update', this.pageEvent.onUpdate);
+
+    // createMany
     this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
+
+    // update
+    this.pageEvent.on('update', async(page, user) => {
+
+      this.pageEvent.onUpdate();
+
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_UPDATE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // rename
+    this.pageEvent.on('rename', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_RENAME);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // delete
+    this.pageEvent.on('delete', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // delete completely
+    this.pageEvent.on('deleteCompletely', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE_COMPLETELY);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // likes
+    this.pageEvent.on('like', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_LIKE);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // bookmark
+    this.pageEvent.on('bookmark', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_BOOKMARK);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
   }
 
   async findPageAndMetaDataByViewer({ pageId, path, user }) {
@@ -141,8 +214,7 @@ class PageService {
       await Page.create(path, body, user, { redirectTo: newPagePath });
     }
 
-    this.pageEvent.emit('delete', page, user);
-    this.pageEvent.emit('create', renamedPage, user);
+    this.pageEvent.emit('rename', page, user);
 
     return renamedPage;
   }
@@ -232,7 +304,7 @@ class PageService {
         logger.debug(`Reverting pages has completed: (totalCount=${count})`);
         // update  path
         targetPage.path = newPagePath;
-        pageEvent.emit('syncDescendants', targetPage, user);
+        pageEvent.emit('syncDescendantsUpdate', targetPage, user);
         callback();
       },
     });
@@ -308,6 +380,7 @@ class PageService {
     if (originTags != null) {
       await PageTagRelation.updatePageTags(createdPage.id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+      this.tagEvent.emit('update', createdPage, savedTags);
     }
 
     const result = serializePageSecurely(createdPage);
@@ -428,7 +501,7 @@ class PageService {
         logger.debug(`Adding pages has completed: (totalCount=${count})`);
         // update  path
         page.path = newPagePath;
-        pageEvent.emit('syncDescendants', page, user);
+        pageEvent.emit('syncDescendantsUpdate', page, user);
         callback();
       },
     });
@@ -442,6 +515,7 @@ class PageService {
 
   async deletePage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
 
     const newPath = Page.getDeletedPageName(page.path);
@@ -466,6 +540,7 @@ class PageService {
         path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
       },
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
     const body = `redirect ${newPath}`;
     await Page.create(page.path, body, user, { redirectTo: newPath });
 
@@ -524,6 +599,9 @@ class PageService {
         throw new Error('Failed to revert pages: ', err);
       }
     }
+    finally {
+      this.pageEvent.emit('syncDescendantsDelete', pages, user);
+    }
   }
 
   /**
@@ -570,12 +648,12 @@ class PageService {
 
     await this.deleteCompletelyOperation(ids, paths);
 
-    this.pageEvent.emit('deleteCompletely', pages, user); // update as renamed page
+    this.pageEvent.emit('syncDescendantsDelete', pages, user); // update as renamed page
 
     return;
   }
 
-  async deleteCompletely(page, user, options = {}, isRecursively = false) {
+  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
     const ids = [page._id];
     const paths = [page.path];
 
@@ -587,7 +665,9 @@ class PageService {
       this.deleteCompletelyDescendantsWithStream(page, user, options);
     }
 
-    this.pageEvent.emit('delete', page, user); // update as renamed page
+    if (!preventEmitting) {
+      this.pageEvent.emit('deleteCompletely', page, user);
+    }
 
     return;
   }
@@ -679,6 +759,7 @@ class PageService {
 
   async revertDeletedPage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
 
     const newPath = Page.getRevertDeletedPageName(page.path);
@@ -690,7 +771,9 @@ class PageService {
       if (originPage.redirectTo !== page.path) {
         throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
       }
-      await this.deleteCompletely(originPage, options);
+
+      await this.deleteCompletely(originPage, user, options, false, true);
+      this.pageEvent.emit('revert', page, user);
     }
 
     if (isRecursively) {
@@ -705,6 +788,7 @@ class PageService {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
       },
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
     await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
 
     return updatedPage;
@@ -774,6 +858,28 @@ class PageService {
     }
   }
 
+  createAndSendNotifications = async function(page, user, action) {
+    const { activityService, inAppNotificationService } = this.crowi;
+
+    const snapshot = stringifySnapshot(page);
+
+    // Create activity
+    const parameters = {
+      user: user._id,
+      targetModel: ActivityDefine.MODEL_PAGE,
+      target: page,
+      action,
+    };
+    const activity = await activityService.createByParameters(parameters);
+
+    // Get user to be notified
+    const targetUsers = await activity.getNotificationTargetUsers();
+
+    // Create and send notifications
+    await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
+    await inAppNotificationService.emitSocketIo(targetUsers);
+  };
+
 }
 
 module.exports = PageService;

+ 122 - 61
packages/app/src/server/service/passport.ts

@@ -12,6 +12,8 @@ import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-sa
 import { BasicStrategy } from 'passport-http';
 
 import { IncomingMessage } from 'http';
+import got from 'got';
+import pRetry from 'p-retry';
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
@@ -381,14 +383,14 @@ class PassportService implements S2sMessageHandlable {
     const { configManager } = this.crowi;
 
     // get configurations
-    const isUserBind          = configManager.getConfig('crowi', 'security:passport-ldap:isUserBind');
-    const serverUrl           = configManager.getConfig('crowi', 'security:passport-ldap:serverUrl');
-    const bindDN              = configManager.getConfig('crowi', 'security:passport-ldap:bindDN');
-    const bindCredentials     = configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword');
-    const searchFilter        = configManager.getConfig('crowi', 'security:passport-ldap:searchFilter') || '(uid={{username}})';
-    const groupSearchBase     = configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
-    const groupSearchFilter   = configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter');
-    const groupDnProperty     = configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty') || 'uid';
+    const isUserBind        = configManager.getConfig('crowi', 'security:passport-ldap:isUserBind');
+    const serverUrl         = configManager.getConfig('crowi', 'security:passport-ldap:serverUrl');
+    const bindDN            = configManager.getConfig('crowi', 'security:passport-ldap:bindDN');
+    const bindCredentials   = configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword');
+    const searchFilter      = configManager.getConfig('crowi', 'security:passport-ldap:searchFilter') || '(uid={{username}})';
+    const groupSearchBase   = configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
+    const groupSearchFilter = configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter');
+    const groupDnProperty   = configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty') || 'uid';
     /* eslint-enable no-multi-spaces */
 
     // parse serverUrl
@@ -627,65 +629,74 @@ class PassportService implements S2sMessageHandlable {
     const redirectUri = (configManager.getConfig('crowi', 'app:siteUrl') != null)
       ? urljoin(this.crowi.appService.getSiteUrl(), '/passport/oidc/callback')
       : configManager.getConfig('crowi', 'security:passport-oidc:callbackUrl'); // DEPRECATED: backward compatible with v3.2.3 and below
-    const oidcIssuer = await OIDCIssuer.discover(issuerHost);
-    logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
-    const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
-    if (authorizationEndpoint) {
-      oidcIssuer.metadata.authorization_endpoint = authorizationEndpoint;
-    }
-    const tokenEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint');
-    if (tokenEndpoint) {
-      oidcIssuer.metadata.token_endpoint = tokenEndpoint;
-    }
-    const revocationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint');
-    if (revocationEndpoint) {
-      oidcIssuer.metadata.revocation_endpoint = revocationEndpoint;
-    }
-    const introspectionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint');
-    if (introspectionEndpoint) {
-      oidcIssuer.metadata.introspection_endpoint = introspectionEndpoint;
-    }
-    const userInfoEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint');
-    if (userInfoEndpoint) {
-      oidcIssuer.metadata.userinfo_endpoint = userInfoEndpoint;
-    }
-    const endSessionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint');
-    if (endSessionEndpoint) {
-      oidcIssuer.metadata.end_session_endpoint = endSessionEndpoint;
-    }
-    const registrationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint');
-    if (registrationEndpoint) {
-      oidcIssuer.metadata.registration_endpoint = registrationEndpoint;
-    }
-    const jwksUri = configManager.getConfig('crowi', 'security:passport-oidc:jwksUri');
-    if (jwksUri) {
-      oidcIssuer.metadata.jwks_uri = jwksUri;
-    }
-    logger.debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
+    // Prevent request timeout error on app init
+    const oidcIssuer = await this.getOIDCIssuerInstace(issuerHost);
+    if (oidcIssuer != null) {
+      logger.debug('Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
-    const client = new oidcIssuer.Client({
-      client_id: clientId,
-      client_secret: clientSecret,
-      redirect_uris: [redirectUri],
-      response_types: ['code'],
-    });
-
-    passport.use('oidc', new OidcStrategy({
-      client,
-      params: { scope: 'openid email profile' },
-    },
-    ((tokenset, userinfo, done) => {
-      if (userinfo) {
-        return done(null, userinfo);
+      const authorizationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint');
+      if (authorizationEndpoint) {
+        oidcIssuer.metadata.authorization_endpoint = authorizationEndpoint;
       }
+      const tokenEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint');
+      if (tokenEndpoint) {
+        oidcIssuer.metadata.token_endpoint = tokenEndpoint;
+      }
+      const revocationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint');
+      if (revocationEndpoint) {
+        oidcIssuer.metadata.revocation_endpoint = revocationEndpoint;
+      }
+      const introspectionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint');
+      if (introspectionEndpoint) {
+        oidcIssuer.metadata.introspection_endpoint = introspectionEndpoint;
+      }
+      const userInfoEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint');
+      if (userInfoEndpoint) {
+        oidcIssuer.metadata.userinfo_endpoint = userInfoEndpoint;
+      }
+      const endSessionEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint');
+      if (endSessionEndpoint) {
+        oidcIssuer.metadata.end_session_endpoint = endSessionEndpoint;
+      }
+      const registrationEndpoint = configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint');
+      if (registrationEndpoint) {
+        oidcIssuer.metadata.registration_endpoint = registrationEndpoint;
+      }
+      const jwksUri = configManager.getConfig('crowi', 'security:passport-oidc:jwksUri');
+      if (jwksUri) {
+        oidcIssuer.metadata.jwks_uri = jwksUri;
+      }
+      logger.debug('Configured issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata);
 
-      return done(null, false);
+      const client = new oidcIssuer.Client({
+        client_id: clientId,
+        client_secret: clientSecret,
+        redirect_uris: [redirectUri],
+        response_types: ['code'],
+      });
+      // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
+      // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
+      const OIDC_CLIENT_CLOCK_TOLERANCE = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcClientClockTolerance');
+      client.CLOCK_TOLERANCE = OIDC_CLIENT_CLOCK_TOLERANCE;
+      passport.use('oidc', new OidcStrategy(
+        {
+          client,
+          params: { scope: 'openid email profile' },
+        },
+        (tokenset, userinfo, done) => {
+          if (userinfo) {
+            return done(null, userinfo);
+          }
 
-    })));
+          return done(null, false);
+        },
+      ));
+
+      this.isOidcStrategySetup = true;
+      logger.debug('OidcStrategy: setup is done');
+    }
 
-    this.isOidcStrategySetup = true;
-    logger.debug('OidcStrategy: setup is done');
   }
 
   /**
@@ -699,6 +710,56 @@ class PassportService implements S2sMessageHandlable {
     this.isOidcStrategySetup = false;
   }
 
+  /**
+ *
+ * Check and initialize connection to OIDC issuer host
+ * Prevent request timeout error on app init
+ *
+ * @param issuerHost
+ * @returns boolean
+ */
+  async isOidcHostReachable(issuerHost) {
+    try {
+      const response = await got(issuerHost, { retry: { limit: 3 } });
+      return response.statusCode === 200;
+    }
+    catch (err) {
+      logger.error('OidcStrategy: issuer host unreachable:', err.code);
+    }
+  }
+
+  /**
+   * Get oidcIssuer object
+   * Utilize p-retry package to retry oidcIssuer initialization 3 times
+   *
+   * @param issuerHost
+   * @returns instance of OIDCIssuer
+   */
+  async getOIDCIssuerInstace(issuerHost) {
+    const OIDC_TIMEOUT_MULTIPLIER = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:timeoutMultiplier');
+    const OIDC_DISCOVERY_RETRIES = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:discoveryRetries');
+    const oidcIssuerHostReady = await this.isOidcHostReachable(issuerHost);
+    if (!oidcIssuerHostReady) {
+      logger.error('OidcStrategy: setup failed: OIDC Issur host unreachable');
+      return;
+    }
+    const oidcIssuer = await pRetry(async() => {
+      return OIDCIssuer.discover(issuerHost);
+    }, {
+      onFailedAttempt: (error) => {
+        // get current OIDCIssuer.defaultHttpOptions.timeout
+        const oidcOptionTimeout = OIDCIssuer.defaultHttpOptions.timeout;
+        // Increases OIDCIssuer.defaultHttpOptions.timeout by multiply with 1.5
+        OIDCIssuer.defaultHttpOptions = { timeout: oidcOptionTimeout * OIDC_TIMEOUT_MULTIPLIER };
+        logger.debug(`OidcStrategy: setup attempt ${error.attemptNumber} failed with error: ${error}. Retrying ...`);
+      },
+      retries: OIDC_DISCOVERY_RETRIES,
+    }).catch((error) => {
+      logger.error(`OidcStrategy: setup failed with error: ${error} `);
+    });
+    return oidcIssuer;
+  }
+
   setupSamlStrategy() {
 
     this.resetSamlStrategy();

+ 2 - 2
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -957,9 +957,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     return this.updateOrInsertDescendantsPagesById(parentPage, user);
   }
 
-  async syncPagesDeletedCompletely(pages, user) {
+  async syncDescendantsPagesDeleted(pages, user) {
     for (let i = 0; i < pages.length; i++) {
-      logger.debug('SearchClient.syncPageDeleted', pages[i].path);
+      logger.debug('SearchClient.syncDescendantsPagesDeleted', pages[i].path);
     }
 
     try {

+ 8 - 2
packages/app/src/server/service/search.ts

@@ -114,11 +114,17 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPagesDeletedCompletely.bind(this.fullTextSearchDelegator));
     pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
+    pageEvent.on('revert', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
+    pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
+    pageEvent.on('syncDescendantsDelete', this.fullTextSearchDelegator.syncDescendantsPagesDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('syncDescendants', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('syncDescendantsUpdate', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('addSeenUsers', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('rename', () => {
+      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator);
+    });
 
     const bookmarkEvent = this.crowi.event('bookmark');
     bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));

+ 52 - 0
packages/app/src/server/util/activityDefine.ts

@@ -0,0 +1,52 @@
+const MODEL_PAGE = 'Page';
+const MODEL_COMMENT = 'Comment';
+
+const ACTION_PAGE_LIKE = 'PAGE_LIKE';
+const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
+const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
+const ACTION_PAGE_RENAME = 'PAGE_RENAME';
+const ACTION_PAGE_DELETE = 'PAGE_DELETE';
+const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
+const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
+const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
+
+const getSupportTargetModelNames = () => {
+  return [MODEL_PAGE];
+};
+
+const getSupportEventModelNames = () => {
+  return [MODEL_COMMENT];
+};
+
+const getSupportActionNames = () => {
+  return [
+    ACTION_PAGE_LIKE,
+    ACTION_PAGE_BOOKMARK,
+    ACTION_PAGE_UPDATE,
+    ACTION_PAGE_RENAME,
+    ACTION_PAGE_DELETE,
+    ACTION_PAGE_DELETE_COMPLETELY,
+    ACTION_COMMENT_CREATE,
+    ACTION_COMMENT_UPDATE,
+  ];
+};
+
+const activityDefine = {
+  MODEL_PAGE,
+  MODEL_COMMENT,
+
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_COMMENT_CREATE,
+  ACTION_COMMENT_UPDATE,
+
+  getSupportTargetModelNames,
+  getSupportEventModelNames,
+  getSupportActionNames,
+};
+
+export default activityDefine;

+ 21 - 0
packages/app/src/server/views/me/all-in-app-notifications.html

@@ -0,0 +1,21 @@
+{% extends '../layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('in_app_notification.notification_list')) }}{% endblock %}
+
+{% block layout_main %}
+
+{% block content_header_wrapper %}
+<header class="py-3">
+  <div class="container-fluid">
+    <h1 class="title">{{ t('in_app_notification.notification_list') }}</h1>
+  </div>
+</header>
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+{% endblock %}
+
+<div id="main" class="main">
+  <div id="content-main" class="content-main grw-container-convertible">
+    <div id="all-in-app-notifications"></div>
+  </div>
+</div>
+{% endblock %}

+ 2 - 5
packages/app/src/server/views/tags.html

@@ -5,11 +5,8 @@
 {% block html_base_css %}tags-page{% endblock %}
 
 {% block layout_main %}
-<header class="py-0">
-  <h1 class="title">{{ t('Tags') }}</h1>
-</header>
-
-<div class="container-fluid">
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+<div class="grw-container-convertible">
   <div class="row">
     <div id="main" class="main mt-3 col-md-12 tags-page">
       <div class="" id="tags-page"></div>

+ 1 - 1
packages/app/src/server/views/widget/alert_siteurl_undefined.html

@@ -1,5 +1,5 @@
 {% if !getConfig('crowi', 'app:siteUrl') %}
-<div class="alert alert-danger d-edit-none mb-0 px-4 py-2">
+<div class="alert alert-danger rounded-0 d-edit-none mb-0 px-4 py-2">
   <i class="icon-exclamation"></i>
   {{ t("security_setting.alert_siteUrl_is_not_set", { link: t('App Settings')}) }} &gt;&gt; <a href="/admin/app">{{t('App Settings')}}<i class="icon-login"></i></a>
 </div>

+ 24 - 0
packages/app/src/stores/in-app-notification.ts

@@ -0,0 +1,24 @@
+import useSWR, { SWRResponse } from 'swr';
+import { InAppNotificationStatuses, IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
+import { apiv3Get } from '../client/util/apiv3-client';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxInAppNotifications = <Data, Error>(
+  limit: number,
+  offset?: number,
+  status?: InAppNotificationStatuses,
+): SWRResponse<PaginateResult<IInAppNotification>, Error> => {
+  return useSWR(
+    ['/in-app-notification/list', limit, offset, status],
+    endpoint => apiv3Get(endpoint, { limit, offset, status }).then(response => response.data),
+  );
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxInAppNotificationStatus = <Data, Error>(
+): SWRResponse<number, Error> => {
+  return useSWR(
+    ['/in-app-notification/status'],
+    endpoint => apiv3Get(endpoint).then(response => response.data.count),
+  );
+};

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

@@ -1,11 +1,14 @@
 import useSWR, { SWRResponse } from 'swr';
 
+import { Types } from 'mongoose';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
 
 import { IPage } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 
+import { useIsGuestUser } from './context';
+
 
 export const useSWRxPageByPath = (path: string, initialData?: IPage): SWRResponse<IPage & HasObjectId, Error> => {
   return useSWR(
@@ -43,3 +46,18 @@ export const useSWRxPageList = (
     }),
   );
 };
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxSubscriptionStatus = <Data, Error>(pageId: Types.ObjectId): SWRResponse<{status: boolean | null}, Error> => {
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const key = isGuestUser === false ? ['/page/subscribe', pageId] : null;
+  return useSWR(
+    key,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then((response) => {
+      return {
+        status: response.data.subscribing,
+      };
+    }),
+  );
+};

+ 16 - 4
packages/app/src/stores/ui.tsx

@@ -17,6 +17,8 @@ const logger = loggerFactory('growi:stores:ui');
 
 const isServer = typeof window === 'undefined';
 
+type Nullable<T> = T | null;
+
 
 /** **********************************************************
  *                          Unions
@@ -218,9 +220,19 @@ export const usePageCreateModalOpened = (isOpened?: boolean): SWRResponse<boolea
   return useStaticSWR('isPageCreateModalOpened', isOpened || null, { fallbackData: initialData });
 };
 
+
+export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
+  return useStaticSWR<Nullable<number>, Error>('grant', initialData ?? null);
+};
+
+export const useSelectedGrantGroupId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('grantGroupId', initialData ?? null);
+};
+
+export const useSelectedGrantGroupName = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('grantGroupName', initialData ?? null);
+};
+
 export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
-  return useStaticSWR(
-    'globalSearchTypeahead',
-    initialData ?? null,
-  );
+  return useStaticSWR('globalSearchTypeahead', initialData ?? null);
 };

+ 6 - 0
packages/app/src/styles/_navbar.scss

@@ -101,3 +101,9 @@
     transition: 0.3s ease-in-out;
   }
 }
+
+.grw-notification-badge {
+  position: absolute;
+  top: 6px;
+  right: 3.5px;
+}

+ 8 - 4
packages/app/src/styles/_subnav.scss

@@ -39,7 +39,8 @@
   }
 
   .btn-like,
-  .btn-bookmark {
+  .btn-bookmark,
+  .btn-subscribe {
     height: 40px;
     font-size: 20px;
     border-radius: $border-radius-xl;
@@ -84,7 +85,8 @@
     }
 
     .btn-like,
-    .btn-bookmark {
+    .btn-bookmark,
+    .btn-subscribe {
       @extend .btn-sm;
 
       height: 30px;
@@ -94,8 +96,10 @@
 
     .total-likes,
     .total-bookmarks {
-      height: 12px;
-      font-size: 12px;
+      height: auto;
+    }
+    .total-bookmarks {
+      font-size: 15px;
     }
   }
 }

+ 11 - 0
packages/app/src/styles/atoms/_buttons.scss

@@ -20,6 +20,17 @@
   }
 }
 
+.btn.btn-subscribe {
+  @include button-outline-variant($secondary, $success, rgba(lighten($success, 10%), 0.15), rgba(lighten($success, 10%), 0.5));
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    color: lighten($success, 15%);
+  }
+  &:not(:disabled):not(.disabled):not(:hover) {
+    background-color: transparent;
+  }
+}
+
 .btn-copy,
 .btn-edit {
   opacity: 0.3;

+ 9 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -687,3 +687,12 @@ mark.rbt-highlight-text {
     width: 20px;
   }
 }
+
+/*
+  In App Notification
+*/
+.grw-unopend-notification {
+  width: 7px;
+  height: 7px;
+  background-color: $primary;
+}

+ 8 - 10
packages/app/src/test/integration/service/page.test.js

@@ -351,8 +351,8 @@ describe('PageService', () => {
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename1, testUser2);
-        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+
+        expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename1, testUser2);
 
         expect(resultPage.path).toBe('/renamed1');
         expect(resultPage.updatedAt).toEqual(parentForRename1.updatedAt);
@@ -370,8 +370,8 @@ describe('PageService', () => {
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename2, testUser2);
-        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+
+        expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename2, testUser2);
 
         expect(resultPage.path).toBe('/renamed2');
         expect(resultPage.updatedAt).toEqual(dateToUse);
@@ -389,8 +389,7 @@ describe('PageService', () => {
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename3, testUser2);
-        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename3, testUser2);
 
         expect(resultPage.path).toBe('/renamed3');
         expect(resultPage.updatedAt).toEqual(parentForRename3.updatedAt);
@@ -413,8 +412,7 @@ describe('PageService', () => {
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename4, testUser2);
-        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename4, testUser2);
 
         expect(resultPage.path).toBe('/renamed4');
         expect(resultPage.updatedAt).toEqual(parentForRename4.updatedAt);
@@ -725,7 +723,7 @@ describe('PageService', () => {
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
     });
 
 
@@ -735,7 +733,7 @@ describe('PageService', () => {
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
     });
   });
 

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

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

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "4.5.4",
+  "version": "4.5.5-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.5.4",
+  "version": "4.5.5-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.5.4",
+  "version": "4.5.5-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.5.4",
+  "version": "4.5.5-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

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

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

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

+ 1 - 1
packages/ui/package.json

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

+ 231 - 220
yarn.lock

@@ -769,6 +769,11 @@
   resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.6.0.tgz#3a50b8118254aa2ac26caf9d2aafa72d157e374b"
   integrity sha512-wte6nXXZH62Y/RGysYRlOkKxuJn+4S8xEamMF0fDncxxy0SriCHYwGPyWGF0FWYWmRzbZuEkp7dNebBf9Xfeeg==
 
+"@discoveryjs/json-ext@^0.5.0":
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f"
+  integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA==
+
 "@emotion/is-prop-valid@^0.8.3":
   version "0.8.8"
   resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
@@ -818,10 +823,10 @@
   dependencies:
     stoppable "^1.1.0"
 
-"@google-cloud/common@^3.6.0":
-  version "3.6.0"
-  resolved "https://registry.yarnpkg.com/@google-cloud/common/-/common-3.6.0.tgz#c2f6da5f79279a4a9ac7c71fc02d582beab98e8b"
-  integrity sha512-aHIFTqJZmeTNO9md8XxV+ywuvXF3xBm5WNmgWeeCK+XN5X+kGW0WEX94wGwj+/MdOnrVf4dL2RvSIt9J5yJG6Q==
+"@google-cloud/common@^3.8.1":
+  version "3.8.1"
+  resolved "https://registry.yarnpkg.com/@google-cloud/common/-/common-3.8.1.tgz#1313c55bb66df88f69bf7c828135fae25fbd2036"
+  integrity sha512-FOs3NFU6bDt5mXE7IFpwIeqzLwRZNu9lJYl+bHVNkwmxX/w4VyDZAiGjQHhpV1Ek+muNKlX8HPchxaIxNTuOhw==
   dependencies:
     "@google-cloud/projectify" "^2.0.0"
     "@google-cloud/promisify" "^2.0.0"
@@ -829,8 +834,8 @@
     duplexify "^4.1.1"
     ent "^2.2.0"
     extend "^3.0.2"
-    google-auth-library "^7.0.2"
-    retry-request "^4.1.1"
+    google-auth-library "^7.9.2"
+    retry-request "^4.2.2"
     teeny-request "^7.0.0"
 
 "@google-cloud/paginator@^3.0.0":
@@ -852,26 +857,24 @@
   integrity sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw==
 
 "@google-cloud/storage@^5.8.5":
-  version "5.8.5"
-  resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-5.8.5.tgz#2cf1e2e0ef8ca552abc4450301fef3fea4900ef6"
-  integrity sha512-i0gB9CRwQeOBYP7xuvn14M40LhHCwMjceBjxE4CTvsqL519sVY5yVKxLiAedHWGwUZHJNRa7Q2CmNfkdRwVNPg==
+  version "5.16.1"
+  resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-5.16.1.tgz#6a9a2c160282676d2725d3426e97417753426354"
+  integrity sha512-C2li/2PUfLSGEetebLL70uQRwqm6PS+kBtFEjr5AnAn/Qv0UnD8V+rI9Y4RmwxWFvhlPAgg+ZRqa4bkK4eUxlA==
   dependencies:
-    "@google-cloud/common" "^3.6.0"
+    "@google-cloud/common" "^3.8.1"
     "@google-cloud/paginator" "^3.0.0"
     "@google-cloud/promisify" "^2.0.0"
     arrify "^2.0.0"
     async-retry "^1.3.1"
     compressible "^2.0.12"
-    date-and-time "^1.0.0"
+    date-and-time "^2.0.0"
     duplexify "^4.0.0"
     extend "^3.0.2"
-    gaxios "^4.0.0"
-    gcs-resumable-upload "^3.1.4"
+    gcs-resumable-upload "^3.6.0"
     get-stream "^6.0.0"
     hash-stream-validation "^0.2.2"
-    mime "^2.2.0"
+    mime "^3.0.0"
     mime-types "^2.0.8"
-    onetime "^5.1.0"
     p-limit "^3.0.1"
     pumpify "^2.0.0"
     snakeize "^0.1.0"
@@ -3317,6 +3320,23 @@
     "@webassemblyjs/wast-parser" "1.9.0"
     "@xtuc/long" "4.2.2"
 
+"@webpack-cli/configtest@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.1.0.tgz#8342bef0badfb7dfd3b576f2574ab80c725be043"
+  integrity sha512-ttOkEkoalEHa7RaFYpM0ErK1xc4twg3Am9hfHhL7MVqlHebnkYd2wuI/ZqTDj0cVzZho6PdinY0phFZV3O0Mzg==
+
+"@webpack-cli/info@^1.4.0":
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-1.4.0.tgz#b9179c3227ab09cbbb149aa733475fcf99430223"
+  integrity sha512-F6b+Man0rwE4n0409FyAJHStYA5OIZERxmnUfLVwv0mc0V1wLad3V7jqRlMkgKBeAq07jUvglacNaa6g9lOpuw==
+  dependencies:
+    envinfo "^7.7.3"
+
+"@webpack-cli/serve@^1.6.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.6.0.tgz#2c275aa05c895eccebbfc34cfb223c6e8bd591a2"
+  integrity sha512-ZkVeqEmRpBV2GHvjjUZqEai2PpUbuq8Bqd//vEYsp63J8WyexI8ppCqVS3Zs0QADf6aWuPdU+0XsPI647PVlQA==
+
 "@xmldom/xmldom@^0.7.0", "@xmldom/xmldom@^0.7.5":
   version "0.7.5"
   resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d"
@@ -3987,6 +4007,13 @@ async-retry@^1.3.1:
   dependencies:
     retry "0.12.0"
 
+async-retry@^1.3.3:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280"
+  integrity sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==
+  dependencies:
+    retry "0.13.1"
+
 async@0.9.x, async@^0.9.0:
   version "0.9.2"
   resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
@@ -5123,14 +5150,6 @@ chainsaw@~0.1.0:
   dependencies:
     traverse ">=0.3.0 <0.4"
 
-chalk@2.4.2, chalk@^2.0, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2:
-  version "2.4.2"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
-  dependencies:
-    ansi-styles "^3.2.1"
-    escape-string-regexp "^1.0.5"
-    supports-color "^5.3.0"
-
 chalk@4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72"
@@ -5157,6 +5176,14 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
+chalk@^2.0, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
 chalk@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
@@ -5464,15 +5491,6 @@ cliui@^4.0.0:
     strip-ansi "^4.0.0"
     wrap-ansi "^2.0.0"
 
-cliui@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
-  integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
-  dependencies:
-    string-width "^3.1.0"
-    strip-ansi "^5.2.0"
-    wrap-ansi "^5.1.0"
-
 cliui@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
@@ -5643,6 +5661,11 @@ colorette@^1.2.2:
   resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
   integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
 
+colorette@^2.0.14:
+  version "2.0.16"
+  resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da"
+  integrity sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==
+
 colors@^1.1.2, colors@^1.2.5, colors@^1.3.3:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
@@ -5696,6 +5719,11 @@ commander@^6.2.1:
   resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
   integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
 
+commander@^7.0.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
+  integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
+
 commander@^8.1.0:
   version "8.3.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
@@ -5845,11 +5873,11 @@ configstore@^3.0.0:
     xdg-basedir "^3.0.0"
 
 configstore@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.0.tgz#37de662c7a49b5fe8dbcf8f6f5818d2d81ed852b"
-  integrity sha512-eE/hvMs7qw7DlcB5JPRnthmrITuHMmACUJAp89v6PT6iOqzoLS7HRWhBtuHMlhNHo2AhUSA/3Dh1bKNJHcublQ==
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
+  integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==
   dependencies:
-    dot-prop "^5.1.0"
+    dot-prop "^5.2.0"
     graceful-fs "^4.1.2"
     make-dir "^3.0.0"
     unique-string "^2.0.0"
@@ -6317,7 +6345,16 @@ cross-env@^7.0.0:
   dependencies:
     cross-spawn "^7.0.1"
 
-cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5:
+cross-spawn@^5.0.1:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
+  dependencies:
+    lru-cache "^4.0.1"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
+cross-spawn@^6.0.0, cross-spawn@^6.0.5:
   version "6.0.5"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
   integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
@@ -6328,15 +6365,6 @@ cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.5:
     shebang-command "^1.2.0"
     which "^1.2.9"
 
-cross-spawn@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
-  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
-  dependencies:
-    lru-cache "^4.0.1"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
 cross-spawn@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14"
@@ -6624,10 +6652,10 @@ data-urls@^2.0.0:
     whatwg-mimetype "^2.3.0"
     whatwg-url "^8.0.0"
 
-date-and-time@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-1.0.0.tgz#0062394bdf6f44e961f0db00511cb19cdf3cc0a5"
-  integrity sha512-477D7ypIiqlXBkxhU7YtG9wWZJEQ+RUpujt2quTfgf4+E8g5fNUkB0QIL0bVyP5/TKBg8y55Hfa1R/c4bt3dEw==
+date-and-time@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-2.0.1.tgz#bc8b72704980e8a0979bb186118d30d02059ef04"
+  integrity sha512-O7Xe5dLaqvY/aF/MFWArsAM1J4j7w1CSZlPCX9uHgmb+6SbkPd8Q4YOvfvH/cZGvFlJFfHOZKxQtmMUOoZhc/w==
 
 date-fns@^2.23.0:
   version "2.23.0"
@@ -7393,14 +7421,6 @@ engine.io@~5.2.0:
     engine.io-parser "~4.0.0"
     ws "~7.4.2"
 
-enhanced-resolve@4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f"
-  dependencies:
-    graceful-fs "^4.1.2"
-    memory-fs "^0.4.0"
-    tapable "^1.0.0"
-
 enhanced-resolve@^4.0.0, enhanced-resolve@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz#2f3cfd84dbe3b487f18f2db2ef1e064a571ca5ec"
@@ -7454,7 +7474,7 @@ env-paths@^2.2.0:
   resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
   integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==
 
-envinfo@^7.7.4:
+envinfo@^7.7.3, envinfo@^7.7.4:
   version "7.8.1"
   resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475"
   integrity sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==
@@ -8692,16 +8712,6 @@ find-up@^4.0.0, find-up@^4.1.0:
     locate-path "^5.0.0"
     path-exists "^4.0.0"
 
-findup-sync@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1"
-  integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==
-  dependencies:
-    detect-file "^1.0.0"
-    is-glob "^4.0.0"
-    micromatch "^3.0.4"
-    resolve-dir "^1.0.1"
-
 findup-sync@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-4.0.0.tgz#956c9cdde804052b881b428512905c4a5f2cdef0"
@@ -9030,12 +9040,13 @@ gcp-metadata@^4.2.0:
     gaxios "^4.0.0"
     json-bigint "^1.0.0"
 
-gcs-resumable-upload@^3.1.4:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/gcs-resumable-upload/-/gcs-resumable-upload-3.1.4.tgz#2e591889efb02247af26868de300b398346b17b5"
-  integrity sha512-5dyDfHrrVcIskiw/cPssVD4HRiwoHjhk1Nd6h5W3pQ/qffDvhfy4oNCr1f3ZXFPwTnxkCbibsB+73oOM+NvmJQ==
+gcs-resumable-upload@^3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/gcs-resumable-upload/-/gcs-resumable-upload-3.6.0.tgz#6a2fdf3ba1bc00d956d119427b5fc963d1fcdb79"
+  integrity sha512-IyaNs4tx3Mp2UKn0CltRUiW/ZXYFlBNuK/V+ixs80chzVD+BJq3+8bfiganATFfCoMluAjokF9EswNJdVuOs8A==
   dependencies:
     abort-controller "^3.0.0"
+    async-retry "^1.3.3"
     configstore "^5.0.0"
     extend "^3.0.2"
     gaxios "^4.0.0"
@@ -9323,13 +9334,6 @@ global-dirs@^0.1.0:
   dependencies:
     ini "^1.3.4"
 
-global-modules@2.0.0, global-modules@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
-  integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==
-  dependencies:
-    global-prefix "^3.0.0"
-
 global-modules@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
@@ -9338,6 +9342,13 @@ global-modules@^1.0.0:
     is-windows "^1.0.1"
     resolve-dir "^1.0.0"
 
+global-modules@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
+  integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==
+  dependencies:
+    global-prefix "^3.0.0"
+
 global-prefix@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
@@ -9448,10 +9459,10 @@ gonzales-pe@^4.0.3:
   dependencies:
     minimist "^1.2.5"
 
-google-auth-library@^7.0.0, google-auth-library@^7.0.2:
-  version "7.0.4"
-  resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.0.4.tgz#610cb010de71435dca47dfbe8dc7fbff23055d2c"
-  integrity sha512-o8irYyeijEiecTXeoEe8UKNEzV1X+uhR4b2oNdapDMZixypp0J+eHimGOyx5Joa3UAeokGngdtDLXtq9vDqG2Q==
+google-auth-library@^7.0.0, google-auth-library@^7.9.2:
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.11.0.tgz#b63699c65037310a424128a854ba7e736704cbdb"
+  integrity sha512-3S5jn2quRumvh9F/Ubf7GFrIq71HZ5a6vqosgdIu105kkk0WtSqc2jGCRqtWWOLRS8SX3AHACMOEDxhyWAQIcg==
   dependencies:
     arrify "^2.0.0"
     base64-js "^1.3.0"
@@ -10182,13 +10193,6 @@ import-lazy@^4.0.0:
   resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153"
   integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==
 
-import-local@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d"
-  dependencies:
-    pkg-dir "^3.0.0"
-    resolve-cwd "^2.0.0"
-
 import-local@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/import-local/-/import-local-0.1.1.tgz#b1179572aacdc11c6a91009fb430dbcab5f668a8"
@@ -10350,16 +10354,16 @@ internal-slot@^1.0.3:
     has "^1.0.3"
     side-channel "^1.0.4"
 
-interpret@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
-  integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
-
 interpret@^1.0.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
   integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
 
+interpret@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
+  integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
+
 into-stream@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6"
@@ -10547,6 +10551,13 @@ is-core-module@^2.4.0:
   dependencies:
     has "^1.0.3"
 
+is-core-module@^2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.0.tgz#0321336c3d0925e497fd97f5d95cb114a5ccd548"
+  integrity sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==
+  dependencies:
+    has "^1.0.3"
+
 is-data-descriptor@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -12155,15 +12166,6 @@ loader-runner@^2.4.0:
   resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357"
   integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==
 
-loader-utils@1.2.3, loader-utils@^1.2.3:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7"
-  integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==
-  dependencies:
-    big.js "^5.2.2"
-    emojis-list "^2.0.0"
-    json5 "^1.0.1"
-
 loader-utils@^1.0.2, loader-utils@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd"
@@ -12172,6 +12174,15 @@ loader-utils@^1.0.2, loader-utils@^1.1.0:
     emojis-list "^2.0.0"
     json5 "^0.5.0"
 
+loader-utils@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7"
+  integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==
+  dependencies:
+    big.js "^5.2.2"
+    emojis-list "^2.0.0"
+    json5 "^1.0.1"
+
 loader-utils@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0"
@@ -12839,7 +12850,7 @@ mem@^4.0.0:
     mimic-fn "^1.0.0"
     p-is-promise "^2.0.0"
 
-memory-fs@^0.4.0, memory-fs@^0.4.1:
+memory-fs@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
   dependencies:
@@ -13082,7 +13093,7 @@ micromatch@^2.3.11:
     parse-glob "^3.0.4"
     regex-cache "^0.4.2"
 
-micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4:
+micromatch@^3.1.10, micromatch@^3.1.4:
   version "3.1.10"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
   dependencies:
@@ -13226,10 +13237,10 @@ mime@>=2.4.6:
   resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1"
   integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==
 
-mime@^2.2.0:
-  version "2.4.4"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5"
-  integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==
+mime@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7"
+  integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==
 
 mimic-fn@^1.0.0:
   version "1.1.0"
@@ -14708,7 +14719,7 @@ os-homedir@^1.0.0:
   resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
   integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
 
-os-locale@^3.0.0, os-locale@^3.1.0:
+os-locale@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a"
   dependencies:
@@ -15297,7 +15308,7 @@ path-key@^3.1.0:
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.0.tgz#99a10d870a803bdd5ee6f0470e58dfcd2f9a54d3"
   integrity sha512-8cChqz0RP6SHJkMt48FW0A7+qUOn+OsnOsVtzI59tZ8m+5bCSk7hzwET0pulwOM2YMn9J1efb07KB9l9f30SGg==
 
-path-parse@^1.0.5, path-parse@^1.0.6:
+path-parse@^1.0.5, path-parse@^1.0.6, path-parse@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
@@ -16363,7 +16374,7 @@ randombytes@^2.1.0:
   dependencies:
     safe-buffer "^5.1.0"
 
-randomcolor@>=0.5.4:
+randomcolor@>=0.5.4, randomcolor@^0.5.4:
   version "0.5.4"
   resolved "https://registry.yarnpkg.com/randomcolor/-/randomcolor-0.5.4.tgz#df615b13f25b89ea58c5f8f72647f0a6f07adcc3"
   integrity sha512-nYd4nmTuuwMFzHL6W+UWR5fNERGZeVauho8mrJDUSXdNDbao4rbrUwhuLgKC/j8VCS5+34Ria8CsTDuBjrIrQA==
@@ -16627,6 +16638,15 @@ react-scrolllock@^1.0.9:
     create-react-class "^15.5.2"
     prop-types "^15.5.10"
 
+react-tagcloud@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/react-tagcloud/-/react-tagcloud-2.1.1.tgz#b8883634f76b5681c91a178689070efa0d442657"
+  integrity sha512-cM96jzUOKQqu2qlzwcO91r239MSDbFiAslFNk4Hja3MaZ4Y89goIzbTyXZwonkeJck1zY5wkNhJYeJ8YSdOwXg==
+  dependencies:
+    prop-types "^15.6.2"
+    randomcolor "^0.5.4"
+    shuffle-array "^1.0.1"
+
 react-transition-group@^2.2.0:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.1.tgz#e9fb677b79e6455fd391b03823afe84849df4a10"
@@ -16929,6 +16949,13 @@ rechoir@^0.6.2:
   dependencies:
     resolve "^1.1.6"
 
+rechoir@^0.7.0:
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.7.1.tgz#9478a96a1ca135b5e88fc027f03ee92d6c645686"
+  integrity sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==
+  dependencies:
+    resolve "^1.9.0"
+
 reconnecting-websocket@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
@@ -17339,6 +17366,15 @@ resolve@^1.3.2:
   dependencies:
     path-parse "^1.0.6"
 
+resolve@^1.9.0:
+  version "1.21.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f"
+  integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA==
+  dependencies:
+    is-core-module "^2.8.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
 resolve@^2.0.0-next.3:
   version "2.0.0-next.3"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46"
@@ -17373,18 +17409,24 @@ ret@~0.1.10:
   version "0.1.15"
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
 
-retry-request@^4.1.1:
-  version "4.1.3"
-  resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-4.1.3.tgz#d5f74daf261372cff58d08b0a1979b4d7cab0fde"
-  integrity sha512-QnRZUpuPNgX0+D1xVxul6DbJ9slvo4Rm6iV/dn63e048MvGbUZiKySVt6Tenp04JqmchxjiLltGerOJys7kJYQ==
+retry-request@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-4.2.2.tgz#b7d82210b6d2651ed249ba3497f07ea602f1a903"
+  integrity sha512-xA93uxUD/rogV7BV59agW/JHPGXeREMWiZc9jhcwY4YdZ7QOtC7qbomYg0n4wyk2lJhggjvKvhNX8wln/Aldhg==
   dependencies:
     debug "^4.1.1"
+    extend "^3.0.2"
 
 retry@0.12.0, retry@^0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
   integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=
 
+retry@0.13.1:
+  version "0.13.1"
+  resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658"
+  integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==
+
 reusify@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
@@ -17943,10 +17985,10 @@ shellwords@^0.1.1:
   resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
   integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
 
-shipjs-lib@0.23.3:
-  version "0.23.3"
-  resolved "https://registry.yarnpkg.com/shipjs-lib/-/shipjs-lib-0.23.3.tgz#25cc73544f99480fb66f10428d252b8292d355c7"
-  integrity sha512-ZyKAKI/d6fBw25XTK1zxRcJyPlQECvzNZar/N9iS7CZF6qs9yyUnxeHlB4FuF4rIvdPSdz0ePjQGhkvYnwFeEQ==
+shipjs-lib@0.24.1:
+  version "0.24.1"
+  resolved "https://registry.yarnpkg.com/shipjs-lib/-/shipjs-lib-0.24.1.tgz#bf0951a0c2dd50d21e373a300e3f3c6de868c182"
+  integrity sha512-iQSGz80CJ+aR4fEWSjj32qNd25DwEWB0seZ+ieKFyHQuJCwi3VaRsVd3OSnFE1iotHU03azXuq+JOVqmkFzKsg==
   dependencies:
     deepmerge "^4.2.2"
     dotenv "^8.1.0"
@@ -17954,10 +17996,10 @@ shipjs-lib@0.23.3:
     semver "6.3.0"
     shelljs "0.8.4"
 
-shipjs@^0.23.3:
-  version "0.23.3"
-  resolved "https://registry.yarnpkg.com/shipjs/-/shipjs-0.23.3.tgz#f302852ee8deebd21b497eb6da52fed19df12f3d"
-  integrity sha512-3DZMQ5VVVj2d3bkeF5Tk0U2j/IpZUm/Evk/coRvvRA4cu8bcm9eXh6zcSpJVwMUWloL7SmxK/Y/GbCMb60+qDw==
+shipjs@^0.24.1:
+  version "0.24.1"
+  resolved "https://registry.yarnpkg.com/shipjs/-/shipjs-0.24.1.tgz#c8149594b4bc78abd3a84f171220179cf7f23755"
+  integrity sha512-9VdudGeMjXIDQe3YYIdbbTUc9xqBggBnfgAoJ4Clj+cIq19ACcqDaqGfZN/e4QMzjMyWPGb8FItCpAD2SEQKKQ==
   dependencies:
     "@babel/runtime" "^7.6.3"
     "@octokit/rest" "^17.0.0"
@@ -17981,7 +18023,7 @@ shipjs@^0.23.3:
     prettier "^2.0.0"
     serialize-javascript "^3.0.0"
     shell-quote "^1.7.2"
-    shipjs-lib "0.23.3"
+    shipjs-lib "0.24.1"
     temp-write "4.0.0"
     tempfile "^3.0.0"
 
@@ -18029,6 +18071,11 @@ should@^13.2.1:
     should-type-adaptors "^1.0.1"
     should-util "^1.0.0"
 
+shuffle-array@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/shuffle-array/-/shuffle-array-1.0.1.tgz#c4ff3cfe74d16f93730592301b25e6577b12898b"
+  integrity sha1-xP88/nTRb5NzBZIwGyXmV3sSiYs=
+
 side-channel@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@@ -18517,19 +18564,13 @@ sshpk@^1.7.0:
     tweetnacl "~0.14.0"
 
 ssri@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5"
+  integrity sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==
   dependencies:
     figgy-pudding "^3.5.1"
 
-ssri@^8.0.0:
-  version "8.0.0"
-  resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.0.tgz#79ca74e21f8ceaeddfcb4b90143c458b8d988808"
-  integrity sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA==
-  dependencies:
-    minipass "^3.1.1"
-
-ssri@^8.0.1:
+ssri@^8.0.0, ssri@^8.0.1:
   version "8.0.1"
   resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af"
   integrity sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==
@@ -18731,7 +18772,7 @@ string-width@^1.0.1, string-width@^1.0.2:
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.1"
 
-string-width@^3.0.0, string-width@^3.1.0:
+string-width@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
   integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
@@ -18831,7 +18872,7 @@ stringify-entities@^1.0.1:
     is-alphanumerical "^1.0.0"
     is-hexadecimal "^1.0.0"
 
-strip-ansi@5.2.0, strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
+strip-ansi@5.2.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
   dependencies:
@@ -19112,12 +19153,6 @@ superagent@^1.2.0:
     readable-stream "1.0.27-1"
     reduce-component "1.0.1"
 
-supports-color@6.1.0, supports-color@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
-  dependencies:
-    has-flag "^3.0.0"
-
 supports-color@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
@@ -19149,6 +19184,12 @@ supports-color@^5.5.0:
   dependencies:
     has-flag "^3.0.0"
 
+supports-color@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
+  dependencies:
+    has-flag "^3.0.0"
+
 supports-color@^7.0.0, supports-color@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
@@ -19171,6 +19212,11 @@ supports-hyperlinks@^2.0.0:
     has-flag "^4.0.0"
     supports-color "^7.0.0"
 
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
 svg-tags@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
@@ -20034,9 +20080,9 @@ trim-newlines@^2.0.0:
   integrity sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA=
 
 trim-newlines@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30"
-  integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
+  integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==
 
 trim-off-newlines@^1.0.0:
   version "1.0.1"
@@ -20146,9 +20192,9 @@ tsc-alias@^1.2.9:
     normalize-path "^3.0.0"
 
 tsconfig-paths-webpack-plugin@^3.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-3.5.1.tgz#e4dbf492a20dca9caab60086ddacb703afc2b726"
-  integrity sha512-n5CMlUUj+N5pjBhBACLq4jdr9cPTitySCjIosoQm0zwK99gmrcTGAfY9CwxRFT9+9OleNWXPRUcxsKP4AYExxQ==
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-3.5.2.tgz#01aafff59130c04a8c4ebc96a3045c43c376449a"
+  integrity sha512-EhnfjHbzm5IYI9YPNVIxx1moxMI4bpHD2e0zTXeDNQcwjjRaGepP7IhTHJkyDBG0CAOoxRfe7jCG630Ou+C6Pw==
   dependencies:
     chalk "^4.1.0"
     enhanced-resolve "^5.7.0"
@@ -20353,13 +20399,10 @@ ua-parser-js@1.0.2:
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775"
   integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==
 
-ua-parser-js@^0.7.18:
-  version "0.7.19"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b"
-
-ua-parser-js@^0.7.9:
-  version "0.7.17"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
+ua-parser-js@^0.7.18, ua-parser-js@^0.7.9:
+  version "0.7.31"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6"
+  integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==
 
 uberproto@^1.1.0:
   version "1.2.0"
@@ -20851,11 +20894,6 @@ uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2:
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
-v8-compile-cache@2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe"
-  integrity sha512-CNmdbwQMBjwr9Gsmohvm0pbL954tJrNzf6gWL3K+QMQf00PF7ERGrEiLgjuU3mKreLC2MeGhUsNV9ybTbLgd3w==
-
 v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@@ -21096,29 +21134,31 @@ webpack-bundle-analyzer@^3.9.0:
     opener "^1.5.1"
     ws "^6.0.0"
 
-webpack-cli@^3.3.7:
-  version "3.3.7"
-  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.7.tgz#77c8580dd8e92f69d635e0238eaf9d9c15759a91"
-  integrity sha512-OhTUCttAsr+IZSMVwGROGRHvT+QAs8H6/mHIl4SvhAwYywjiylYjpwybGx7WQ9Hkb45FhjtsymkwiRRbGJ1SZQ==
-  dependencies:
-    chalk "2.4.2"
-    cross-spawn "6.0.5"
-    enhanced-resolve "4.1.0"
-    findup-sync "3.0.0"
-    global-modules "2.0.0"
-    import-local "2.0.0"
-    interpret "1.2.0"
-    loader-utils "1.2.3"
-    supports-color "6.1.0"
-    v8-compile-cache "2.0.3"
-    yargs "13.2.4"
-
-webpack-merge@^4.2.2:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d"
-  integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==
+webpack-cli@^4.9.1:
+  version "4.9.1"
+  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.9.1.tgz#b64be825e2d1b130f285c314caa3b1ba9a4632b3"
+  integrity sha512-JYRFVuyFpzDxMDB+v/nanUdQYcZtqFPGzmlW4s+UkPMFhSpfRNmf1z4AwYcHJVdvEFAM7FFCQdNTpsBYhDLusQ==
+  dependencies:
+    "@discoveryjs/json-ext" "^0.5.0"
+    "@webpack-cli/configtest" "^1.1.0"
+    "@webpack-cli/info" "^1.4.0"
+    "@webpack-cli/serve" "^1.6.0"
+    colorette "^2.0.14"
+    commander "^7.0.0"
+    execa "^5.0.0"
+    fastest-levenshtein "^1.0.12"
+    import-local "^3.0.2"
+    interpret "^2.2.0"
+    rechoir "^0.7.0"
+    webpack-merge "^5.7.3"
+
+webpack-merge@^5.7.3:
+  version "5.8.0"
+  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61"
+  integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==
   dependencies:
-    lodash "^4.17.15"
+    clone-deep "^4.0.1"
+    wildcard "^2.0.0"
 
 webpack-sources@^1.0.0:
   version "1.3.0"
@@ -21142,7 +21182,7 @@ webpack-sources@^1.1.0:
     source-list-map "^2.0.0"
     source-map "~0.6.1"
 
-webpack@^4.39.3:
+webpack@^4.46.0:
   version "4.46.0"
   resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.46.0.tgz#bf9b4404ea20a073605e0a011d188d77cb6ad542"
   integrity sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==
@@ -21270,6 +21310,11 @@ widest-line@^2.0.0:
   dependencies:
     string-width "^2.1.1"
 
+wildcard@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
+  integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
+
 window-size@0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
@@ -21313,15 +21358,6 @@ wrap-ansi@^2.0.0:
     string-width "^1.0.1"
     strip-ansi "^3.0.1"
 
-wrap-ansi@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
-  integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
-  dependencies:
-    ansi-styles "^3.2.0"
-    string-width "^3.0.0"
-    strip-ansi "^5.0.0"
-
 wrap-ansi@^6.2.0:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -21640,14 +21676,6 @@ yargs-parser@^11.1.1:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
 
-yargs-parser@^13.1.0:
-  version "13.1.1"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0"
-  integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==
-  dependencies:
-    camelcase "^5.0.0"
-    decamelize "^1.2.0"
-
 yargs-parser@^18.1.2, yargs-parser@^18.1.3:
   version "18.1.3"
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
@@ -21656,23 +21684,6 @@ yargs-parser@^18.1.2, yargs-parser@^18.1.3:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
 
-yargs@13.2.4:
-  version "13.2.4"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.2.4.tgz#0b562b794016eb9651b98bd37acf364aa5d6dc83"
-  integrity sha512-HG/DWAJa1PAnHT9JAhNa8AbAv3FPaiLzioSjCcmuXXhP8MlpHO5vwls4g4j6n30Z74GVQj8Xa62dWVx1QCGklg==
-  dependencies:
-    cliui "^5.0.0"
-    find-up "^3.0.0"
-    get-caller-file "^2.0.1"
-    os-locale "^3.1.0"
-    require-directory "^2.1.1"
-    require-main-filename "^2.0.0"
-    set-blocking "^2.0.0"
-    string-width "^3.0.0"
-    which-module "^2.0.0"
-    y18n "^4.0.0"
-    yargs-parser "^13.1.0"
-
 yargs@17.1.1:
   version "17.1.1"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.1.1.tgz#c2a8091564bdb196f7c0a67c1d12e5b85b8067ba"