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

Merge branch 'feat/gw7626-oidc-reconnection' of https://github.com/weseek/growi into feat/gw7626-oidc-reconnection

mudana 4 лет назад
Родитель
Сommit
1343df1f1a
62 измененных файлов с 2320 добавлено и 155 удалено
  1. 15 0
      packages/app/resource/locales/en_US/translation.json
  2. 15 0
      packages/app/resource/locales/ja_JP/translation.json
  3. 15 0
      packages/app/resource/locales/zh_CN/translation.json
  4. 2 0
      packages/app/src/client/admin.jsx
  5. 2 0
      packages/app/src/client/app.jsx
  6. 8 1
      packages/app/src/client/services/ContextExtractor.tsx
  7. 4 29
      packages/app/src/client/services/EditorContainer.js
  8. 18 3
      packages/app/src/client/util/editor.ts
  9. 3 1
      packages/app/src/components/FormattedDistanceDate.jsx
  10. 97 0
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  11. 117 0
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  12. 49 0
      packages/app/src/components/InAppNotification/InAppNotificationList.tsx
  13. 142 0
      packages/app/src/components/InAppNotification/InAppNotificationPage.tsx
  14. 53 0
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  15. 111 0
      packages/app/src/components/Me/InAppNotificationSettings.tsx
  16. 7 0
      packages/app/src/components/Me/PersonalSettings.jsx
  17. 6 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  18. 6 2
      packages/app/src/components/Navbar/SubNavButtons.jsx
  19. 17 5
      packages/app/src/components/Page.jsx
  20. 20 5
      packages/app/src/components/PageEditor.jsx
  21. 14 3
      packages/app/src/components/PageEditorByHackmd.jsx
  22. 23 29
      packages/app/src/components/PaginationWrapper.tsx
  23. 34 10
      packages/app/src/components/SavePageControls.jsx
  24. 82 0
      packages/app/src/components/SubscribeButton.tsx
  25. 60 0
      packages/app/src/interfaces/in-app-notification.ts
  26. 18 0
      packages/app/src/models/serializers/in-app-notification-snapshot/page.ts
  27. 43 1
      packages/app/src/server/crowi/index.js
  28. 20 0
      packages/app/src/server/events/activity.ts
  29. 13 4
      packages/app/src/server/events/comment.ts
  30. 106 0
      packages/app/src/server/models/activity.ts
  31. 26 27
      packages/app/src/server/models/comment.js
  32. 1 0
      packages/app/src/server/models/config.ts
  33. 20 0
      packages/app/src/server/models/in-app-notification-settings.ts
  34. 105 0
      packages/app/src/server/models/in-app-notification.ts
  35. 10 0
      packages/app/src/server/models/page.js
  36. 5 0
      packages/app/src/server/models/revision.js
  37. 91 0
      packages/app/src/server/models/subscription.ts
  38. 8 0
      packages/app/src/server/routes/all-in-app-notifications.ts
  39. 4 0
      packages/app/src/server/routes/apiv3/bookmarks.js
  40. 117 0
      packages/app/src/server/routes/apiv3/in-app-notification.ts
  41. 3 0
      packages/app/src/server/routes/apiv3/index.js
  42. 96 0
      packages/app/src/server/routes/apiv3/page.js
  43. 19 1
      packages/app/src/server/routes/apiv3/pages.js
  44. 78 1
      packages/app/src/server/routes/apiv3/personal-setting.js
  45. 2 2
      packages/app/src/server/routes/comment.js
  46. 3 0
      packages/app/src/server/routes/index.js
  47. 38 0
      packages/app/src/server/service/activity.ts
  48. 107 0
      packages/app/src/server/service/comment.ts
  49. 179 0
      packages/app/src/server/service/in-app-notification.ts
  50. 111 11
      packages/app/src/server/service/page.js
  51. 2 2
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  52. 8 2
      packages/app/src/server/service/search.ts
  53. 52 0
      packages/app/src/server/util/activityDefine.ts
  54. 21 0
      packages/app/src/server/views/me/all-in-app-notifications.html
  55. 24 0
      packages/app/src/stores/in-app-notification.ts
  56. 16 0
      packages/app/src/stores/page.tsx
  57. 16 4
      packages/app/src/stores/ui.tsx
  58. 6 0
      packages/app/src/styles/_navbar.scss
  59. 4 2
      packages/app/src/styles/_subnav.scss
  60. 11 0
      packages/app/src/styles/atoms/_buttons.scss
  61. 9 0
      packages/app/src/styles/theme/_apply-colors.scss
  62. 8 10
      packages/app/src/test/integration/service/page.test.js

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

@@ -257,6 +257,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": {

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

@@ -259,6 +259,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": {

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

@@ -238,6 +238,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} />,

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

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

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

@@ -0,0 +1,97 @@
+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">
+        <button type="button" className="nav-link border-0 bg-transparent waves-effect waves-light">
+          <i className="icon-bell mr-2" /> {badge}
+        </button>
+      </DropdownToggle>
+      <DropdownMenu className="px-2" right>
+        <InAppNotificationList inAppNotificationData={inAppNotificationData} />
+        <DropdownItem divider />
+        <a className="dropdown-item d-flex justify-content-center" href="/me/all-in-app-notifications">{ t('in_app_notification.see_all') }</a>
+      </DropdownMenu>
+    </Dropdown>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const InAppNotificationDropdownWrapper = withUnstatedContainers(InAppNotificationDropdown, [SocketIoContainer]);
+
+export default InAppNotificationDropdownWrapper;

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

@@ -0,0 +1,117 @@
+import React from 'react';
+
+import { UserPicture } from '@growi/ui';
+import { IInAppNotification } from '~/interfaces/in-app-notification';
+import { HasObjectId } from '~/interfaces/has-object-id';
+
+// Change the display for each targetmodel
+import PageModelNotification from './PageNotification/PageModelNotification';
+
+interface Props {
+  notification: IInAppNotification & HasObjectId
+}
+
+const InAppNotificationElm = (props: Props): JSX.Element => {
+
+  const { notification } = props;
+
+  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 = '';
+  }
+
+  return (
+    <div className="dropdown-item d-flex flex-row mb-3">
+      <div className="p-2 mr-2 d-flex align-items-center">
+        <span className={`${notification.status === 'UNOPENED' ? 'grw-unopend-notification' : 'ml-2'} rounded-circle mr-3`}></span>
+        {renderActionUserPictures()}
+      </div>
+      {notification.targetModel === 'Page' && (
+        <PageModelNotification
+          notification={notification}
+          actionMsg={actionMsg}
+          actionIcon={actionIcon}
+          actionUsers={actionUsers}
+        />
+      )}
+    </div>
+  );
+};
+
+export default InAppNotificationElm;

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

@@ -0,0 +1,49 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
+import { HasObjectId } from '~/interfaces/has-object-id';
+import InAppNotificationElm from './InAppNotificationElm';
+
+
+type Props = {
+  inAppNotificationData?: PaginateResult<IInAppNotification>;
+};
+
+const InAppNotificationList: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  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;
+
+  const renderInAppNotificationList = () => {
+    const inAppNotificationList = notifications.map((notification: IInAppNotification & HasObjectId) => {
+      return (
+        <div className="d-flex flex-row" key={notification._id}>
+          <InAppNotificationElm notification={notification} />
+        </div>
+      );
+    });
+
+    return inAppNotificationList;
+  };
+
+  return (
+    <>
+      {notifications.length === 0 ? <>{t('in_app_notification.no_notification')}</> : renderInAppNotificationList()}
+    </>
+  );
+};
+
+
+export default InAppNotificationList;

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

@@ -0,0 +1,142 @@
+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>
+      )}
+        <InAppNotificationList inAppNotificationData={notificationData} />
+
+        {notificationData.totalDocs > 0
+          && (
+            <PaginationWrapper
+              activePage={activePage}
+              changePage={setAllNotificationPageNumber}
+              totalItemsCount={notificationData.totalDocs}
+              pagingLimit={notificationData.limit}
+              align="center"
+              size="sm"
+            />
+          )
+        }
+      </>
+    );
+  };
+
+  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} />
+  );
+};
+
+const InAppNotificationPage = withUnstatedContainers(InAppNotificationPageBody, [AppContainer]);
+export default InAppNotificationPage;
+
+InAppNotificationPageBody.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};

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

@@ -0,0 +1,53 @@
+import React, { FC, useCallback } from 'react';
+import { PagePathLabel } from '@growi/ui';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { parseSnapshot } from '../../../models/serializers/in-app-notification-snapshot/page';
+import { IInAppNotification } from '~/interfaces/in-app-notification';
+import { HasObjectId } from '~/interfaces/has-object-id';
+import FormattedDistanceDate from '../../FormattedDistanceDate';
+
+interface Props {
+  notification: IInAppNotification & HasObjectId
+  actionMsg: string
+  actionIcon: string
+  actionUsers: string
+}
+
+const PageModelNotification: FC<Props> = (props: Props) => {
+  const {
+    notification, actionMsg, actionIcon, actionUsers,
+  } = props;
+
+  const snapshot = parseSnapshot(notification.snapshot);
+  const pagePath = { path: snapshot.path };
+
+  const notificationClickHandler = useCallback(() => {
+    // set notification status "OPEND"
+    apiv3Post('/in-app-notification/open', { id: notification._id });
+
+    // jump to target page
+    const targetPagePath = notification.target?.path;
+    if (targetPagePath != null) {
+      window.location.href = targetPagePath;
+    }
+  }, []);
+
+  return (
+    <div className="p-2">
+      <div onClick={notificationClickHandler}>
+        <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>
+    </div>
+  );
+};
+
+export default 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]);
 

+ 6 - 0
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,6 +33,10 @@ 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"

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

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

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

@@ -0,0 +1,82 @@
+import React, { FC } from 'react';
+
+
+import { Types } from 'mongoose';
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+import { withUnstatedContainers } from './UnstatedUtils';
+import { useSWRxSubscribeButton } from '../stores/page';
+
+
+import { toastError } from '~/client/util/apiNotification';
+import AppContainer from '~/client/services/AppContainer';
+import PageContainer from '~/client/services/PageContainer';
+
+type Props = {
+  appContainer: AppContainer,
+  pageId: Types.ObjectId,
+};
+
+const SubscribeButton: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { appContainer, pageId } = props;
+  const { data: subscriptionData, mutate } = useSWRxSubscribeButton(pageId);
+
+  let isSubscribed;
+
+  switch (subscriptionData?.status) {
+    case true:
+      isSubscribed = true;
+      break;
+    case false:
+      isSubscribed = false;
+      break;
+    default:
+      isSubscribed = null;
+  }
+
+  const buttonClass = `${isSubscribed ? 'active' : ''} ${appContainer.isGuestUser ? 'disabled' : ''}`;
+  const iconClass = isSubscribed || isSubscribed == null ? 'fa fa-eye' : 'fa fa-eye-slash';
+
+  const handleClick = async() => {
+    if (appContainer.isGuestUser) {
+      return;
+    }
+
+    try {
+      const res = await appContainer.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>
+
+      {appContainer.isGuestUser && (
+        <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SubscribeButtonWrapper = withUnstatedContainers(SubscribeButton, [AppContainer, PageContainer]);
+export default SubscribeButtonWrapper;

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

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

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

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

+ 19 - 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');
@@ -293,6 +295,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 +320,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 +639,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);

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

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

+ 111 - 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');
 
@@ -22,9 +25,78 @@ class PageService {
     this.pageEvent = crowi.event('page');
 
     // 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 +213,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 +303,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();
       },
     });
@@ -428,7 +499,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();
       },
     });
@@ -524,6 +595,9 @@ class PageService {
         throw new Error('Failed to revert pages: ', err);
       }
     }
+    finally {
+      this.pageEvent.emit('syncDescendantsDelete', pages, user);
+    }
   }
 
   /**
@@ -570,12 +644,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 +661,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;
   }
@@ -690,7 +766,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) {
@@ -774,6 +852,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;

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

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

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

@@ -1,5 +1,6 @@
 import useSWR, { SWRResponse } from 'swr';
 
+import { Types } from 'mongoose';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
 
@@ -43,3 +44,18 @@ export const useSWRxPageList = (
     }),
   );
 };
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxSubscribeButton = <Data, Error>(
+  pageId: Types.ObjectId,
+
+): SWRResponse<{status: boolean | null}, Error> => {
+  return useSWR(
+    ['/page/subscribe', pageId],
+    (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;
+}

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

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