Browse Source

fix conflict

ryoji-s 3 years ago
parent
commit
81b6bdbc7f
61 changed files with 1042 additions and 1301 deletions
  1. 1 1
      packages/app/resource/locales/ja_JP/translation.json
  2. 2 122
      packages/app/src/client/legacy/crowi.js
  3. 0 4
      packages/app/src/client/services/AppContainer.js
  4. 0 4
      packages/app/src/client/services/CommentContainer.js
  5. 1 3
      packages/app/src/client/services/ContextExtractor.tsx
  6. 0 14
      packages/app/src/client/services/EditorContainer.js
  7. 9 11
      packages/app/src/client/services/PageContainer.js
  8. 0 77
      packages/app/src/client/services/PersonalContainer.js
  9. 14 16
      packages/app/src/client/util/GrowiRenderer.js
  10. 4 4
      packages/app/src/client/util/editor.ts
  11. 0 4
      packages/app/src/client/util/markdown-it/footernote.js
  12. 1 2
      packages/app/src/client/util/markdown-it/header-line-number.js
  13. 1 5
      packages/app/src/client/util/markdown-it/header-with-edit-link.js
  14. 1 3
      packages/app/src/client/util/markdown-it/header.js
  15. 0 4
      packages/app/src/client/util/markdown-it/table-with-handsontable-button.js
  16. 0 4
      packages/app/src/client/util/markdown-it/table.js
  17. 11 5
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  18. 4 12
      packages/app/src/components/Fab.jsx
  19. 9 10
      packages/app/src/components/Me/ImageCropModal.jsx
  20. 0 197
      packages/app/src/components/Me/ProfileImageSettings.jsx
  21. 188 0
      packages/app/src/components/Me/ProfileImageSettings.tsx
  22. 9 10
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  23. 5 4
      packages/app/src/components/Page.jsx
  24. 10 10
      packages/app/src/components/Page/DisplaySwitcher.tsx
  25. 3 8
      packages/app/src/components/Page/RevisionBody.jsx
  26. 5 7
      packages/app/src/components/Page/TrashPageAlert.jsx
  27. 23 15
      packages/app/src/components/PageAttachment.jsx
  28. 19 12
      packages/app/src/components/PageComment/Comment.jsx
  29. 31 10
      packages/app/src/components/PageComment/CommentEditor.jsx
  30. 4 1
      packages/app/src/components/PageCreateModal.jsx
  31. 0 440
      packages/app/src/components/PageEditor.jsx
  32. 435 0
      packages/app/src/components/PageEditor.tsx
  33. 42 49
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  34. 49 30
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  35. 13 5
      packages/app/src/components/PageEditor/Preview.tsx
  36. 6 5
      packages/app/src/components/PageEditorByHackmd.jsx
  37. 3 1
      packages/app/src/components/PageList/PageList.tsx
  38. 32 54
      packages/app/src/components/PageStatusAlert.jsx
  39. 7 14
      packages/app/src/components/SavePageControls.jsx
  40. 1 0
      packages/app/src/components/SlackNotification.tsx
  41. 11 0
      packages/app/src/interfaces/attachment.ts
  42. 3 0
      packages/app/src/interfaces/user-trigger-notification.ts
  43. 4 0
      packages/app/src/interfaces/user.ts
  44. 0 1
      packages/app/src/server/models/page.ts
  45. 3 3
      packages/app/src/server/models/user.js
  46. 4 4
      packages/app/src/server/routes/apiv3/page.js
  47. 14 6
      packages/app/src/server/service/page.ts
  48. 0 11
      packages/app/src/server/service/user-notification/index.ts
  49. 0 34
      packages/app/src/server/util/middlewares.js
  50. 0 1
      packages/app/src/server/views/layout-growi/identical-path-page.html
  51. 0 2
      packages/app/src/server/views/widget/page_content.html
  52. 7 10
      packages/app/src/stores/context.tsx
  53. 27 6
      packages/app/src/stores/editor.tsx
  54. 14 5
      packages/app/src/stores/page.tsx
  55. 2 2
      packages/app/src/stores/ui.tsx
  56. 0 1
      packages/app/src/styles/_layout.scss
  57. 0 19
      packages/app/src/styles/_page_list.scss
  58. 1 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  59. 0 3
      packages/app/src/styles/theme/_apply-colors.scss
  60. 8 0
      packages/app/src/utils/gravatar.ts
  61. 1 14
      packages/app/test/integration/models/update-post.test.js

+ 1 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -146,7 +146,7 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
-  "tag_list": "タグリスト",
+  "tag_list": "タグ一覧",
   "popular_tags": "人気のタグ",
   "Check All tags": "全てのタグを見る",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",

+ 2 - 122
packages/app/src/client/legacy/crowi.js

@@ -12,38 +12,8 @@ if (!window) {
 }
 window.Crowi = Crowi;
 
-/**
- * set 'data-caret-line' attribute that will be processed when 'shown.bs.tab' event fired
- * @param {number} line
- */
-Crowi.setCaretLineData = function(line) {
-  const { appContainer } = window;
-  const pageEditorDom = document.querySelector('#page-editor');
-  pageEditorDom.setAttribute('data-caret-line', line);
-};
-
-/**
- * invoked when;
- *
- * 1. 'shown.bs.tab' event fired
- */
-Crowi.setCaretLineAndFocusToEditor = function() {
-  // get 'data-caret-line' attributes
-  const pageEditorDom = document.querySelector('#page-editor');
-
-  if (pageEditorDom == null) {
-    return;
-  }
-
-  const { appContainer } = window;
-  const editorContainer = appContainer.getContainer('EditorContainer');
-  const line = pageEditorDom.getAttribute('data-caret-line') || 0;
-  editorContainer.setCaretLine(+line);
-  // reset data-caret-line attribute
-  pageEditorDom.removeAttribute('data-caret-line');
-
-  // focus
-  editorContainer.focusToEditor();
+Crowi.setCaretLine = function(line) {
+  window.globalEmitter.emit('setCaretLine', line);
 };
 
 // original: middleware.swigFilter
@@ -55,39 +25,6 @@ Crowi.userPicture = function(user) {
   return user.image || '/images/icons/user.svg';
 };
 
-Crowi.modifyScrollTop = function() {
-  const offset = 10;
-
-  const hash = window.location.hash;
-  if (hash === '') {
-    return;
-  }
-
-  const pageHeader = document.querySelector('#page-header');
-  if (!pageHeader) {
-    return;
-  }
-  const pageHeaderRect = pageHeader.getBoundingClientRect();
-
-  const sectionHeader = Crowi.findSectionHeader(hash);
-  if (sectionHeader === null) {
-    return;
-  }
-
-  let timeout = 0;
-  if (window.scrollY === 0) {
-    timeout = 200;
-  }
-  setTimeout(() => {
-    const sectionHeaderRect = sectionHeader.getBoundingClientRect();
-    if (sectionHeaderRect.top >= pageHeaderRect.bottom) {
-      return;
-    }
-
-    window.scrollTo(0, (window.scrollY - pageHeaderRect.height - offset));
-  }, timeout);
-};
-
 Crowi.initClassesByOS = function() {
   // add classes to cmd-key by OS
   const platform = navigator.platform.toLowerCase();
@@ -112,63 +49,6 @@ Crowi.initClassesByOS = function() {
   });
 };
 
-window.addEventListener('load', () => {
-  const crowi = window.crowi;
-  if (crowi && crowi.users && crowi.users.length !== 0) {
-    const totalUsers = crowi.users.length;
-    const $listLiker = $('.page-list-liker');
-    $listLiker.each((i, liker) => {
-      const count = $(liker).data('count') || 0;
-      if (count / totalUsers > 0.05) {
-        $(liker).addClass('popular-page-high');
-        // 5%
-      }
-      else if (count / totalUsers > 0.02) {
-        $(liker).addClass('popular-page-mid');
-        // 2%
-      }
-      else if (count / totalUsers > 0.005) {
-        $(liker).addClass('popular-page-low');
-        // 0.5%
-      }
-    });
-    const $listSeer = $('.page-list-seer');
-    $listSeer.each((i, seer) => {
-      const count = $(seer).data('count') || 0;
-      if (count / totalUsers > 0.10) {
-        // 10%
-        $(seer).addClass('popular-page-high');
-      }
-      else if (count / totalUsers > 0.05) {
-        // 5%
-        $(seer).addClass('popular-page-mid');
-      }
-      else if (count / totalUsers > 0.02) {
-        // 2%
-        $(seer).addClass('popular-page-low');
-      }
-    });
-  }
-
-  blinkSectionHeaderAtBoot();
-
-  Crowi.modifyScrollTop();
-  Crowi.initClassesByOS();
-});
-
-window.addEventListener('hashchange', (e) => {
-  Crowi.modifyScrollTop();
-
-  // hash on page
-  if (window.location.hash) {
-    if (window.location.hash === '#edit') {
-      Crowi.setCaretLineAndFocusToEditor();
-    }
-    // else if (window.location.hash === '#hackmd') {
-    // }
-  }
-});
-
 // adjust min-height of page for print temporarily
 window.onbeforeprint = function() {
   $('#page-wrapper').css('min-height', '0px');

+ 0 - 4
packages/app/src/client/services/AppContainer.js

@@ -143,10 +143,6 @@ export default class AppContainer extends Container {
       throw new Error('The specified instance must not be null');
     }
 
-    if (this.componentInstances[id] != null) {
-      throw new Error('The specified instance couldn\'t register because the same id has already been registered');
-    }
-
     this.componentInstances[id] = instance;
   }
 

+ 0 - 4
packages/app/src/client/services/CommentContainer.js

@@ -30,10 +30,6 @@ export default class CommentContainer extends Container {
 
     this.state = {
       comments: [],
-
-      // settings shared among all of CommentEditor
-      isSlackEnabled: false,
-      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
     };
 
     this.retrieveComments = this.retrieveComments.bind(this);

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

@@ -16,7 +16,7 @@ import {
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
+  useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useDefaultIndentSize, useIsIndentSizeForced,
 } from '../../stores/context';
@@ -85,7 +85,6 @@ const ContextExtractorOnce: FC = () => {
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
-  const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
   const isSearchPage = document.getElementById('search-page') != null;
 
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
@@ -158,7 +157,6 @@ const ContextExtractorOnce: FC = () => {
   useIsDeviceSmallerThanMd();
 
   // Editor
-  useSlackChannels(slackChannels);
   useSelectedGrant(grant);
   useSelectedGrantGroupId(grantGroupId);
   useSelectedGrantGroupName(grantGroupName);

+ 0 - 14
packages/app/src/client/services/EditorContainer.js

@@ -59,20 +59,6 @@ export default class EditorContainer extends Container {
     }
   }
 
-  setCaretLine(line) {
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-    if (pageEditor != null) {
-      pageEditor.setCaretLine(line);
-    }
-  }
-
-  focusToEditor() {
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-    if (pageEditor != null) {
-      pageEditor.focusToEditor();
-    }
-  }
-
   getCurrentOptionsToSave() {
     const opt = {
       pageTags: this.state.tags,

+ 9 - 11
packages/app/src/client/services/PageContainer.js

@@ -217,13 +217,11 @@ export default class PageContainer extends Container {
     }
     this.setState(newState);
 
-    // PageEditor component
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-    if (pageEditor != null) {
-      if (editorMode !== EditorMode.Editor) {
-        pageEditor.updateEditorValue(newState.markdown);
-      }
+    // Update PageEditor component
+    if (editorMode !== EditorMode.Editor) {
+      window.globalEmitter.emit('updateEditorValue', newState.markdown);
     }
+
     // PageEditorByHackmd component
     const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
     if (pageEditorByHackmd != null) {
@@ -271,7 +269,7 @@ export default class PageContainer extends Container {
     let { revisionId } = this.state;
     const options = Object.assign({}, optionsToSave);
 
-    if (editorMode === 'hackmd') {
+    if (editorMode === EditorMode.HackMD) {
       // set option to sync
       options.isSyncRevisionToHackmd = true;
       revisionId = this.state.revisionIdHackmdSynced;
@@ -306,7 +304,7 @@ export default class PageContainer extends Container {
     const options = Object.assign({}, optionsToSave);
 
     let markdown;
-    if (editorMode === 'hackmd') {
+    if (editorMode === EditorMode.HackMD) {
       const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
       markdown = await pageEditorByHackmd.getMarkdown();
       // set option to sync
@@ -451,7 +449,6 @@ export default class PageContainer extends Container {
 
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
-    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     const options = editorContainer.getCurrentOptionsToSave();
     const optionsToSave = Object.assign({}, options);
 
@@ -460,8 +457,9 @@ export default class PageContainer extends Container {
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
-    if (pageEditor != null) {
-      pageEditor.updateEditorValue(markdown);
+    // Update PageEditor component
+    if (editorMode !== EditorMode.Editor) {
+      window.globalEmitter.emit('updateEditorValue', markdown);
     }
 
     editorContainer.setState({ tags: res.tags });

+ 0 - 77
packages/app/src/client/services/PersonalContainer.js

@@ -8,8 +8,6 @@ import { apiv3Get, apiv3Put } from '../util/apiv3-client';
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:PersonalContainer');
 
-const DEFAULT_IMAGE = '/images/icons/user.svg';
-
 /**
  * Service container for personal settings page (PersonalSettings.jsx)
  * @extends {Container} unstated Container
@@ -29,8 +27,6 @@ export default class PersonalContainer extends Container {
       isEmailPublished: false,
       lang: 'en_US',
       isGravatarEnabled: false,
-      isUploadedPicture: false,
-      uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
       apiToken: '',
       slackMemberId: '',
@@ -69,25 +65,6 @@ export default class PersonalContainer extends Container {
     }
   }
 
-  /**
-   * define a function for uploaded picture
-   */
-  getUploadedPictureSrc(user) {
-    if (user == null) {
-      return DEFAULT_IMAGE;
-    }
-    if (user.image) {
-      this.setState({ isUploadedPicture: true });
-      return user.image;
-    }
-    if (user.imageAttachment != null) {
-      this.setState({ isUploadedPicture: true });
-      return user.imageAttachment.filePathProxied;
-    }
-
-    return DEFAULT_IMAGE;
-  }
-
   /**
    * retrieve external accounts that linked me
    */
@@ -178,60 +155,6 @@ export default class PersonalContainer extends Container {
     }
   }
 
-  /**
-   * Update profile image
-   * @memberOf PersonalContainer
-   */
-  async updateProfileImage() {
-    try {
-      const response = await apiv3Put('/personal-setting/image-type', {
-        isGravatarEnabled: this.state.isGravatarEnabled,
-      });
-      const { userData } = response.data;
-      this.setState({
-        isGravatarEnabled: userData.isGravatarEnabled,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to update profile image');
-    }
-  }
-
-  /**
-   * Upload image
-   */
-  async uploadAttachment(file) {
-    try {
-      const formData = new FormData();
-      formData.append('file', file);
-      formData.append('_csrf', this.appContainer.csrfToken);
-      const response = await apiPost('/attachments.uploadProfileImage', formData);
-      this.setState({ isUploadedPicture: true, uploadedPictureSrc: response.attachment.filePathProxied });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to upload profile image');
-    }
-  }
-
-  /**
-   * Delete image
-   */
-  async deleteProfileImage() {
-    try {
-      await apiPost('/attachments.removeProfileImage', { _csrf: this.appContainer.csrfToken });
-      this.setState({ isUploadedPicture: false, uploadedPictureSrc: DEFAULT_IMAGE });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to delete profile image');
-    }
-  }
-
   /**
    * Associate LDAP account
    */

+ 14 - 16
packages/app/src/client/util/GrowiRenderer.js

@@ -40,9 +40,9 @@ export default class GrowiRenderer {
     }
     else {
       this.preProcessors = [
-        new EasyGrid(appContainer),
-        new Linker(appContainer),
-        new CsvToTable(appContainer),
+        new EasyGrid(),
+        new Linker(),
+        new CsvToTable(),
         new XssFilter(appContainer),
       ];
       this.postProcessors = [
@@ -70,10 +70,10 @@ export default class GrowiRenderer {
     this.markdownItConfigurers = [
       new LinkerByRelativePathConfigurer(appContainer),
       new TaskListsConfigurer(appContainer),
-      new HeaderConfigurer(appContainer),
-      new EmojiConfigurer(appContainer),
+      new HeaderConfigurer(),
+      new EmojiConfigurer(),
       new MathJaxConfigurer(appContainer),
-      new DrawioViewerConfigurer(appContainer),
+      new DrawioViewerConfigurer(),
       new PlantUMLConfigurer(appContainer),
       new BlockdiagConfigurer(appContainer),
     ];
@@ -81,29 +81,27 @@ export default class GrowiRenderer {
     // add configurers according to mode
     switch (mode) {
       case 'page': {
-        const pageContainer = appContainer.getContainer('PageContainer');
-
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(appContainer),
+          new FooternoteConfigurer(),
           new TocAndAnchorConfigurer(),
-          new HeaderLineNumberConfigurer(appContainer),
-          new HeaderWithEditLinkConfigurer(appContainer),
-          new TableWithHandsontableButtonConfigurer(appContainer),
+          new HeaderLineNumberConfigurer(),
+          new HeaderWithEditLinkConfigurer(),
+          new TableWithHandsontableButtonConfigurer(),
         ]);
         break;
       }
       case 'editor':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(appContainer),
-          new HeaderLineNumberConfigurer(appContainer),
-          new TableConfigurer(appContainer),
+          new FooternoteConfigurer(),
+          new HeaderLineNumberConfigurer(),
+          new TableConfigurer(),
         ]);
         break;
       // case 'comment':
       //   break;
       default:
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new TableConfigurer(appContainer),
+          new TableConfigurer(),
         ]);
         break;
     }

+ 4 - 4
packages/app/src/client/util/editor.ts

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

+ 0 - 4
packages/app/src/client/util/markdown-it/footernote.js

@@ -1,9 +1,5 @@
 export default class FooternoteConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     md.use(require('markdown-it-footnote'));
   }

+ 1 - 2
packages/app/src/client/util/markdown-it/header-line-number.js

@@ -1,7 +1,6 @@
 export default class HeaderLineNumberConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
+  constructor() {
     this.firstLine = 0;
   }
 

+ 1 - 5
packages/app/src/client/util/markdown-it/header-with-edit-link.js

@@ -1,13 +1,9 @@
 export default class HeaderWithEditLinkConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     md.renderer.rules.heading_close = (tokens, idx) => {
       return `<span class="revision-head-edit-button">
-                <a href="#edit" onClick="Crowi.setCaretLineData(parseInt(this.parentNode.parentNode.dataset.line, 10))">
+                <a href="#edit" onClick="Crowi.setCaretLine(parseInt(this.parentNode.parentNode.dataset.line, 10))">
                   <i class="icon-note"></i>
                 </a>
               </span></${tokens[idx].tag}>`;

+ 1 - 3
packages/app/src/client/util/markdown-it/header.js

@@ -1,8 +1,6 @@
 export default class HeaderConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-
+  constructor() {
     this.injectRevisionHeadClass = this.injectRevisionHeadClass.bind(this);
   }
 

+ 0 - 4
packages/app/src/client/util/markdown-it/table-with-handsontable-button.js

@@ -1,9 +1,5 @@
 export default class TableWithHandsontableButtonConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     md.renderer.rules.table_open = (tokens, idx) => {
       const beginLine = tokens[idx].map[0] + 1;

+ 0 - 4
packages/app/src/client/util/markdown-it/table.js

@@ -1,9 +1,5 @@
 export default class TableConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     md.renderer.rules.table_open = (tokens, idx) => {
       return '<table class="table table-bordered">';

+ 11 - 5
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -1,15 +1,17 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
+import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+import { withTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
-import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
+
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 class UserGroupUserFormByInput extends React.Component {
 
   constructor(props) {
@@ -44,12 +46,16 @@ class UserGroupUserFormByInput extends React.Component {
 
     try {
       await adminUserGroupDetailContainer.addUserByUsername(userName);
+      await adminUserGroupDetailContainer.init();
+      await adminUserGroupDetailContainer.closeUserGroupUserModal();
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       this.setState({ inputUser: '' });
     }
     catch (err) {
       toastError(new Error(`Unable to add "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`));
     }
+
+
   }
 
   validateForm() {

+ 4 - 12
packages/app/src/components/Fab.jsx

@@ -1,24 +1,20 @@
 import React, { useState, useCallback, useEffect } from 'react';
 
-import PropTypes from 'prop-types';
 import StickyEvents from 'sticky-events';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:cli:Fab');
 
-const Fab = (props) => {
-  const { appContainer } = props;
-  const { currentUser } = appContainer;
+const Fab = () => {
+  const { data: currentUser } = useCurrentUser();
 
   const { open: openCreateModal } = usePageCreateModal();
   const { data: currentPath = '' } = useCurrentPagePath();
@@ -85,8 +81,4 @@ const Fab = (props) => {
 
 };
 
-Fab.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withUnstatedContainers(Fab, [AppContainer]);
+export default Fab;

+ 9 - 10
packages/app/src/components/Me/ImageCropModal.jsx

@@ -1,20 +1,20 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import canvasToBlob from 'async-canvas-to-blob';
 
+import canvasToBlob from 'async-canvas-to-blob';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import ReactCrop from 'react-image-crop';
 import {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
 } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import ReactCrop from 'react-image-crop';
-import loggerFactory from '~/utils/logger';
-import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
 import 'react-image-crop/dist/ReactCrop.css';
 import { toastError } from '~/client/util/apiNotification';
+import loggerFactory from '~/utils/logger';
+
 
 const logger = loggerFactory('growi:ImageCropModal');
 
@@ -113,12 +113,11 @@ class ImageCropModal extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const ProfileImageFormWrapper = withUnstatedContainers(ImageCropModal, [AppContainer]);
 ImageCropModal.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   show: PropTypes.bool.isRequired,
   src: PropTypes.string,
   onModalClose: PropTypes.func.isRequired,
   onCropCompleted: PropTypes.func.isRequired,
 };
-export default withTranslation()(ProfileImageFormWrapper);
+
+export default withTranslation()(ImageCropModal);

+ 0 - 197
packages/app/src/components/Me/ProfileImageSettings.jsx

@@ -1,197 +0,0 @@
-import React from 'react';
-
-import md5 from 'md5';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-import ImageCropModal from './ImageCropModal';
-
-class ProfileImageSettings extends React.Component {
-
-  constructor(appContainer) {
-    super();
-
-    this.state = {
-      show: false,
-      src: null,
-    };
-
-    this.imageRef = null;
-    this.onSelectFile = this.onSelectFile.bind(this);
-    this.onClickDeleteBtn = this.onClickDeleteBtn.bind(this);
-    this.hideModal = this.hideModal.bind(this);
-    this.cancelModal = this.cancelModal.bind(this);
-    this.onCropCompleted = this.onCropCompleted.bind(this);
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, personalContainer } = this.props;
-
-    try {
-      await personalContainer.updateProfileImage();
-      toastSuccess(t('toaster.update_successed', { target: t('Set Profile Image') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  generateGravatarSrc() {
-    const email = this.props.personalContainer.state.email || '';
-    const hash = md5(email.trim().toLowerCase());
-    return `https://gravatar.com/avatar/${hash}`;
-  }
-
-  onSelectFile(e) {
-    if (e.target.files && e.target.files.length > 0) {
-      const reader = new FileReader();
-      reader.addEventListener('load', () => this.setState({ src: reader.result }));
-      reader.readAsDataURL(e.target.files[0]);
-      this.setState({ show: true });
-    }
-  }
-
-  /**
-   * @param {object} croppedImage cropped profile image for upload
-   */
-  async onCropCompleted(croppedImage) {
-    const { t, personalContainer } = this.props;
-    try {
-      await personalContainer.uploadAttachment(croppedImage);
-      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.hideModal();
-  }
-
-  async onClickDeleteBtn() {
-    const { t, personalContainer } = this.props;
-    try {
-      await personalContainer.deleteProfileImage();
-      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  showModal() {
-    this.setState({ show: true });
-  }
-
-  hideModal() {
-    this.setState({ show: false });
-  }
-
-  cancelModal() {
-    this.hideModal();
-  }
-
-  render() {
-    const { t, personalContainer } = this.props;
-    const { uploadedPictureSrc, isGravatarEnabled, isUploadedPicture } = personalContainer.state;
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-md-6 col-12 mb-3 mb-md-0">
-            <h4>
-              <div className="custom-control custom-radio radio-primary">
-                <input
-                  type="radio"
-                  id="radioGravatar"
-                  className="custom-control-input"
-                  form="formImageType"
-                  name="imagetypeForm[isGravatarEnabled]"
-                  checked={isGravatarEnabled}
-                  onChange={() => { personalContainer.changeIsGravatarEnabled(true) }}
-                />
-                <label className="custom-control-label" htmlFor="radioGravatar">
-                  <img src="https://gravatar.com/avatar/00000000000000000000000000000000?s=24" data-hide-in-vrt /> Gravatar
-                </label>
-                <a href="https://gravatar.com/">
-                  <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
-                </a>
-              </div>
-            </h4>
-            <img src={this.generateGravatarSrc()} width="64" data-hide-in-vrt />
-          </div>
-
-          <div className="col-md-6 col-12">
-            <h4>
-              <div className="custom-control custom-radio radio-primary">
-                <input
-                  type="radio"
-                  id="radioUploadPicture"
-                  className="custom-control-input"
-                  form="formImageType"
-                  name="imagetypeForm[isGravatarEnabled]"
-                  checked={!isGravatarEnabled}
-                  onChange={() => { personalContainer.changeIsGravatarEnabled(false) }}
-                />
-                <label className="custom-control-label" htmlFor="radioUploadPicture">
-                  { t('Upload Image') }
-                </label>
-              </div>
-            </h4>
-            <div className="row mb-3">
-              <label className="col-sm-4 col-12 col-form-label text-left">
-                { t('Current Image') }
-              </label>
-              <div className="col-sm-8 col-12">
-                {uploadedPictureSrc && (<p><img src={uploadedPictureSrc} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>)}
-                {isUploadedPicture && <button type="button" className="btn btn-danger" onClick={this.onClickDeleteBtn}>{ t('Delete Image') }</button>}
-              </div>
-            </div>
-            <div className="row">
-              <label className="col-sm-4 col-12 col-form-label text-left">
-                {t('Upload new image')}
-              </label>
-              <div className="col-sm-8 col-12">
-                <input type="file" onChange={this.onSelectFile} name="profileImage" accept="image/*" />
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <ImageCropModal
-          show={this.state.show}
-          src={this.state.src}
-          onModalClose={this.cancelModal}
-          onCropCompleted={this.onCropCompleted}
-        />
-
-        <div className="row my-3">
-          <div className="offset-4 col-5">
-            <button type="button" className="btn btn-primary" onClick={this.onClickSubmit} disabled={personalContainer.state.retrieveError != null}>
-              {t('Update')}
-            </button>
-          </div>
-        </div>
-
-      </React.Fragment>
-    );
-  }
-
-}
-
-
-const ProfileImageSettingsWrapper = withUnstatedContainers(ProfileImageSettings, [AppContainer, PersonalContainer]);
-
-ProfileImageSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-};
-
-export default withTranslation()(ProfileImageSettingsWrapper);

+ 188 - 0
packages/app/src/components/Me/ProfileImageSettings.tsx

@@ -0,0 +1,188 @@
+import React, { useCallback, useState } from 'react';
+
+
+import { useTranslation } from 'react-i18next';
+
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useCurrentUser } from '~/stores/context';
+import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import ImageCropModal from './ImageCropModal';
+
+const DEFAULT_IMAGE = '/images/icons/user.svg';
+
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+const ProfileImageSettings = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { appContainer } = props;
+
+  const { data: currentUser } = useCurrentUser();
+
+  const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
+  const [uploadedPictureSrc, setUploadedPictureSrc] = useState(() => {
+    if (typeof currentUser?.imageAttachment === 'string') {
+      return currentUser?.image;
+    }
+    return currentUser?.imageAttachment?.filePathProxied ?? currentUser?.image;
+  });
+
+  const [showImageCropModal, setShowImageCropModal] = useState(false);
+  const [imageCropSrc, setImageCropSrc] = useState<string|ArrayBuffer|null>(null);
+
+  const selectFileHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files == null || e.target.files.length === 0) {
+      return;
+    }
+
+    const reader = new FileReader();
+    reader.addEventListener('load', () => setImageCropSrc(reader.result));
+    reader.readAsDataURL(e.target.files[0]);
+
+    setShowImageCropModal(true);
+  }, []);
+
+  const cropCompletedHandler = useCallback(async(croppedImage) => {
+    try {
+      const formData = new FormData();
+      formData.append('file', croppedImage);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      formData.append('_csrf', appContainer.csrfToken!);
+      const response = await apiPost('/attachments.uploadProfileImage', formData);
+
+      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      setUploadedPictureSrc((response as any).attachment.filePathProxied);
+
+      // close modal
+      setShowImageCropModal(false);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [appContainer.csrfToken, t]);
+
+  const deleteImageHandler = useCallback(async() => {
+    try {
+      await apiPost('/attachments.removeProfileImage');
+
+      setUploadedPictureSrc(undefined);
+      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t]);
+
+  const submit = useCallback(async() => {
+    try {
+      const response = await apiv3Put('/personal-setting/image-type', { isGravatarEnabled });
+
+      const { userData } = response.data;
+      setGravatarEnabled(userData.isGravatarEnabled);
+
+      toastSuccess(t('toaster.update_successed', { target: t('Set Profile Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isGravatarEnabled, t]);
+
+  if (currentUser == null) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="row">
+        <div className="col-md-6 col-12 mb-3 mb-md-0">
+          <h4>
+            <div className="custom-control custom-radio radio-primary">
+              <input
+                type="radio"
+                id="radioGravatar"
+                className="custom-control-input"
+                form="formImageType"
+                name="imagetypeForm[isGravatarEnabled]"
+                checked={isGravatarEnabled}
+                onChange={() => setGravatarEnabled(true)}
+              />
+              <label className="custom-control-label" htmlFor="radioGravatar">
+                <img src={GRAVATAR_DEFAULT} data-hide-in-vrt /> Gravatar
+              </label>
+              <a href="https://gravatar.com/">
+                <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
+              </a>
+            </div>
+          </h4>
+          <img src={generateGravatarSrc(currentUser.email)} width="64" data-hide-in-vrt />
+        </div>
+
+        <div className="col-md-6 col-12">
+          <h4>
+            <div className="custom-control custom-radio radio-primary">
+              <input
+                type="radio"
+                id="radioUploadPicture"
+                className="custom-control-input"
+                form="formImageType"
+                name="imagetypeForm[isGravatarEnabled]"
+                checked={!isGravatarEnabled}
+                onChange={() => setGravatarEnabled(false)}
+              />
+              <label className="custom-control-label" htmlFor="radioUploadPicture">
+                { t('Upload Image') }
+              </label>
+            </div>
+          </h4>
+          <div className="row mb-3">
+            <label className="col-sm-4 col-12 col-form-label text-left">
+              { t('Current Image') }
+            </label>
+            <div className="col-sm-8 col-12">
+              <p><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
+              {uploadedPictureSrc && <button type="button" className="btn btn-danger" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
+            </div>
+          </div>
+          <div className="row">
+            <label className="col-sm-4 col-12 col-form-label text-left">
+              {t('Upload new image')}
+            </label>
+            <div className="col-sm-8 col-12">
+              <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <ImageCropModal
+        show={showImageCropModal}
+        src={imageCropSrc}
+        onModalClose={() => setShowImageCropModal(false)}
+        onCropCompleted={cropCompletedHandler}
+      />
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button type="button" className="btn btn-primary" onClick={submit}>
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+
+    </>
+  );
+
+};
+
+export default withUnstatedContainers(ProfileImageSettings, [AppContainer]);

+ 9 - 10
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -5,8 +5,9 @@ import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
-import { IUser } from '~/interfaces/user';
-import { useIsSearchPage, useCurrentPagePath } from '~/stores/context';
+import {
+  useIsSearchPage, useCurrentPagePath, useIsGuestUser,
+} from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
@@ -19,16 +20,15 @@ import GlobalSearch from './GlobalSearch';
 import PersonalDropdown from './PersonalDropdown';
 
 
-type NavbarRightProps = {
-  currentUser: IUser,
-}
-const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
+const NavbarRight = memo((): JSX.Element => {
   const { t } = useTranslation();
+
   const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isGuestUser } = useIsGuestUser();
+
   const { open: openCreateModal } = usePageCreateModal();
 
-  const { currentUser } = props;
-  const isAuthenticated = currentUser != null;
+  const isAuthenticated = isGuestUser === false;
 
   const authenticatedNavItem = useMemo(() => {
     return (
@@ -110,7 +110,6 @@ const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
 const GrowiNavbar = (props) => {
 
   const { appContainer } = props;
-  const { currentUser } = appContainer;
   const { crowi, isSearchServiceConfigured } = appContainer.config;
 
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
@@ -132,7 +131,7 @@ const GrowiNavbar = (props) => {
 
       {/* Navbar Right  */}
       <ul className="navbar-nav ml-auto">
-        <NavbarRight currentUser={currentUser}></NavbarRight>
+        <NavbarRight></NavbarRight>
         <Confidential confidential={crowi.confidential}></Confidential>
       </ul>
 

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

@@ -9,9 +9,9 @@ import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useCurrentPagePath, useIsGuestUser, useSlackChannels,
+  useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
-import { useIsSlackEnabled } from '~/stores/editor';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -187,8 +187,8 @@ const PageWrapper = (props) => {
   const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannels } = useSlackChannels();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
@@ -227,6 +227,7 @@ const PageWrapper = (props) => {
     return null;
   }
 
+
   return (
     <Page
       {...props}
@@ -236,7 +237,7 @@ const PageWrapper = (props) => {
       isGuestUser={isGuestUser}
       isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
-      slackChannels={slackChannels}
+      slackChannels={slackChannelsData.toString()}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}

+ 10 - 10
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -7,10 +7,10 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
-import { useSWRxPageByPath } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
@@ -18,7 +18,7 @@ import ContentLinkButtons from '../ContentLinkButtons';
 import HashChanged from '../EventListeneres/HashChanged';
 import PageListIcon from '../Icons/PageListIcon';
 import Page from '../Page';
-import Editor from '../PageEditor';
+import PageEditor from '../PageEditor';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
@@ -33,18 +33,18 @@ const { isTopPage } = pagePathUtils;
 const DisplaySwitcher = (): JSX.Element => {
   const { t } = useTranslation();
 
-
   // get element for smoothScroll
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
 
 
   const { data: currentPageId } = useCurrentPageId();
-  const { data: currentPath } = useCurrentPagePath();
+  const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
   const { data: isUserPage } = useIsUserPage();
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
-  const { data: currentPage } = useSWRxPageByPath(currentPath);
+  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
   const { data: editorMode } = useEditorMode();
 
@@ -52,7 +52,7 @@ const DisplaySwitcher = (): JSX.Element => {
 
   const isPageExist = currentPageId != null;
   const isViewMode = editorMode === EditorMode.View;
-  const isTopPagePath = isTopPage(currentPath ?? '');
+  const isTopPagePath = isTopPage(currentPagePath ?? '');
 
   return (
     <>
@@ -66,11 +66,11 @@ const DisplaySwitcher = (): JSX.Element => {
 
                   {/* Page list */}
                   <div className="grw-page-accessories-control">
-                    { currentPath != null && !isSharedUser && (
+                    { currentPagePath != null && !isSharedUser && (
                       <button
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
-                        onClick={() => openDescendantPageListModal(currentPath)}
+                        onClick={() => openDescendantPageListModal(currentPagePath)}
                       >
                         <div className="grw-page-accessories-control-icon">
                           <PageListIcon />
@@ -117,7 +117,7 @@ const DisplaySwitcher = (): JSX.Element => {
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
             <div data-testid="page-editor" id="page-editor">
-              <Editor />
+              <PageEditor />
             </div>
           </TabPane>
         ) }

+ 3 - 8
packages/app/src/components/Page/RevisionBody.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { debounce } from 'throttle-debounce';
 
 export default class RevisionBody extends React.PureComponent {
@@ -58,12 +58,7 @@ export default class RevisionBody extends React.PureComponent {
     const additionalClassName = this.props.additionalClassName || '';
     return (
       <div
-        ref={(elm) => {
-          this.element = elm;
-          if (this.props.inputRef != null) {
-            this.props.inputRef(elm);
-          }
-        }}
+        ref={this.props.inputRef}
         id="wiki"
         className={`wiki ${additionalClassName}`}
         // eslint-disable-next-line react/no-danger
@@ -76,7 +71,7 @@ export default class RevisionBody extends React.PureComponent {
 
 RevisionBody.propTypes = {
   html: PropTypes.string,
-  inputRef: PropTypes.func, // for getting div element
+  inputRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   renderMathJaxInRealtime: PropTypes.bool,

+ 5 - 7
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,18 +1,17 @@
 import React, { useState } from 'react';
-import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
 
 import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 
+import PageContainer from '~/client/services/PageContainer';
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
 import EmptyTrashModal from '../EmptyTrashModal';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
   if (typeof pathOrPathsToDelete !== 'string') {
@@ -145,12 +144,11 @@ const TrashPageAlert = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const TrashPageAlertWrapper = withUnstatedContainers(TrashPageAlert, [AppContainer, PageContainer]);
+const TrashPageAlertWrapper = withUnstatedContainers(TrashPageAlert, [PageContainer]);
 
 
 TrashPageAlert.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 

+ 23 - 15
packages/app/src/components/PageAttachment.jsx

@@ -2,12 +2,12 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import { useIsGuestUser } from '~/stores/context';
 
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
@@ -110,13 +110,10 @@ class PageAttachment extends React.Component {
       });
   }
 
-  isUserLoggedIn() {
-    return this.props.appContainer.currentUser != null;
-  }
-
 
   render() {
-    const { t } = this.props;
+    const { t, isGuestUser } = this.props;
+
     if (this.state.attachments.length === 0) {
       return (
         <div data-testid="page-attachment">
@@ -126,7 +123,7 @@ class PageAttachment extends React.Component {
     }
 
     let deleteAttachmentModal = '';
-    if (this.isUserLoggedIn()) {
+    if (!isGuestUser) {
       const attachmentToDelete = this.state.attachmentToDelete;
       const deleteModalClose = () => {
         this.setState({ attachmentToDelete: null, deleteError: '' });
@@ -158,7 +155,7 @@ class PageAttachment extends React.Component {
           attachments={this.state.attachments}
           inUse={this.state.inUse}
           onAttachmentDeleteClicked={this.onAttachmentDeleteClicked}
-          isUserLoggedIn={this.isUserLoggedIn()}
+          isUserLoggedIn={!isGuestUser}
         />
 
         {deleteAttachmentModal}
@@ -176,16 +173,27 @@ class PageAttachment extends React.Component {
 
 }
 
+PageAttachment.propTypes = {
+  t: PropTypes.func.isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  isGuestUser: PropTypes.bool.isRequired,
+};
+
 /**
  * Wrapper component for using unstated
  */
-const PageAttachmentWrapper = withUnstatedContainers(PageAttachment, [AppContainer, PageContainer]);
+const PageAttachmentUnstatedWrapper = withUnstatedContainers(PageAttachment, [PageContainer]);
 
+const PageAttachmentWrapper = (props) => {
+  const { t } = useTranslation();
+  const { data: isGuestUser } = useIsGuestUser();
 
-PageAttachment.propTypes = {
-  t: PropTypes.func.isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  if (isGuestUser == null) {
+    return <></>;
+  }
+
+  return <PageAttachmentUnstatedWrapper {...props} t={t} isGuestUser={isGuestUser} />;
 };
 
-export default withTranslation()(PageAttachmentWrapper);
+export default PageAttachmentWrapper;

+ 19 - 12
packages/app/src/components/PageComment/Comment.jsx

@@ -1,23 +1,24 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import { useTranslation } from 'react-i18next';
+import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
-
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { useCurrentUser } from '~/stores/context';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
+import HistoryIcon from '../Icons/HistoryIcon';
 import RevisionBody from '../Page/RevisionBody';
+import { withUnstatedContainers } from '../UnstatedUtils';
 import Username from '../User/Username';
-import CommentEditor from './CommentEditor';
+
 import CommentControl from './CommentControl';
-import HistoryIcon from '../Icons/HistoryIcon';
+import CommentEditor from './CommentEditor';
+
 
 /**
  *
@@ -74,11 +75,13 @@ class Comment extends React.PureComponent {
   }
 
   isCurrentUserEqualsToAuthor() {
-    const { creator } = this.props.comment;
-    if (creator == null) {
+    const { comment, currentUser } = this.props;
+    const { creator } = comment;
+
+    if (creator == null || currentUser == null) {
       return false;
     }
-    return creator.username === this.props.appContainer.currentUsername;
+    return creator.username === currentUser.username;
   }
 
   isCurrentRevision() {
@@ -235,12 +238,16 @@ Comment.propTypes = {
   isReadOnly: PropTypes.bool.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  currentUser: PropTypes.object,
   onComment: PropTypes.func,
 };
 
 const CommentWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <Comment t={t} {...props} />;
+
+  const { data: currentUser } = useCurrentUser();
+
+  return <Comment t={t} currentUser={currentUser} {...props} />;
 };
 
 /**

+ 31 - 10
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -13,7 +13,8 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
-import { useCurrentUser } from '~/stores/context';
+import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
@@ -64,6 +65,7 @@ class CommentEditor extends React.Component {
       isUploadableFile,
       errorMessage: undefined,
       isSlackConfigured: config.isSlackConfigured,
+      slackChannels: this.props.slackChannels,
     };
 
     this.updateState = this.updateState.bind(this);
@@ -77,8 +79,8 @@ class CommentEditor extends React.Component {
 
     this.renderHtml = this.renderHtml.bind(this);
     this.handleSelect = this.handleSelect.bind(this);
-    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
     this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
+    this.fetchSlackChannels = this.fetchSlackChannels.bind(this);
   }
 
   updateState(value) {
@@ -97,12 +99,18 @@ class CommentEditor extends React.Component {
     this.renderHtml(this.state.comment);
   }
 
-  onSlackEnabledFlagChange(isSlackEnabled) {
-    this.props.commentContainer.setState({ isSlackEnabled });
+  fetchSlackChannels(slackChannels) {
+    this.setState({ slackChannels });
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.slackChannels !== prevProps.slackChannels) {
+      this.fetchSlackChannels(this.props.slackChannels);
+    }
   }
 
   onSlackChannelsChange(slackChannels) {
-    this.props.commentContainer.setState({ slackChannels });
+    this.setState({ slackChannels });
   }
 
   initializeEditor() {
@@ -165,8 +173,8 @@ class CommentEditor extends React.Component {
           this.state.comment,
           this.state.isMarkdown,
           replyTo,
-          commentContainer.state.isSlackEnabled,
-          commentContainer.state.slackChannels,
+          this.props.isSlackEnabled,
+          this.state.slackChannels,
         );
       }
       this.initializeEditor();
@@ -358,9 +366,9 @@ class CommentEditor extends React.Component {
               && (
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
-                    isSlackEnabled={commentContainer.state.isSlackEnabled}
-                    slackChannels={commentContainer.state.slackChannels}
-                    onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                    isSlackEnabled={this.props.isSlackEnabled}
+                    slackChannels={this.state.slackChannels}
+                    onEnabledFlagChange={this.props.onSlackEnabledFlagChange}
                     onChannelChange={this.onSlackChannelsChange}
                     id="idForComment"
                   />
@@ -416,6 +424,8 @@ CommentEditor.propTypes = {
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
 
+  slackChannels: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   currentUser: PropTypes.instanceOf(Object),
   isMobile: PropTypes.bool,
@@ -426,15 +436,26 @@ CommentEditor.propTypes = {
   commentCreator: PropTypes.string,
   onCancelButtonClicked: PropTypes.func,
   onCommentButtonClicked: PropTypes.func,
+  onSlackEnabledFlagChange: PropTypes.func,
 };
 
 const CommentEditorWrapper = (props) => {
   const { data: isMobile } = useIsMobile();
   const { data: currentUser } = useCurrentUser();
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+
+  const onSlackEnabledFlagChange = (isSlackEnabled) => {
+    mutateIsSlackEnabled(isSlackEnabled, false);
+  };
 
   return (
     <CommentEditorHOCWrapper
       {...props}
+      onSlackEnabledFlagChange={onSlackEnabledFlagChange}
+      slackChannels={slackChannelsData.toString()}
+      isSlackEnabled={isSlackEnabled}
       currentUser={currentUser}
       isMobile={isMobile}
     />

+ 4 - 1
packages/app/src/components/PageCreateModal.jsx

@@ -12,6 +12,7 @@ import { debounce } from 'throttle-debounce';
 
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
@@ -25,13 +26,15 @@ const {
 const PageCreateModal = (props) => {
   const { t, appContainer } = props;
 
+  const { data: currentUser } = useCurrentUser();
+
   const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
   const { isOpened, path } = pageCreateModalData;
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const pathname = path || '';
-  const userPageRootPath = userPageRoot(appContainer.currentUser);
+  const userPageRootPath = userPageRoot(currentUser);
   const isCreatable = isCreatablePage(pathname) || isUsersHomePage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');

+ 0 - 440
packages/app/src/components/PageEditor.jsx

@@ -1,440 +0,0 @@
-import React from 'react';
-
-import { envUtils } from '@growi/core';
-import detectIndent from 'detect-indent';
-import PropTypes from 'prop-types';
-import { throttle, debounce } from 'throttle-debounce';
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { apiGet, apiPost } from '~/client/util/apiv1-client';
-import { getOptionsToSave } from '~/client/util/editor';
-import { useIsEditable, useIsIndentSizeForced, useSlackChannels } from '~/stores/context';
-import { useCurrentIndentSize, useIsSlackEnabled, useIsTextlintEnabled } from '~/stores/editor';
-import {
-  useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
-} from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-
-import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
-import Editor from './PageEditor/Editor';
-import Preview from './PageEditor/Preview';
-import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-
-// TODO: remove this when omitting unstated is completed
-
-const logger = loggerFactory('growi:PageEditor');
-
-class PageEditor extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.previewElement = React.createRef();
-
-    const config = this.props.appContainer.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-    const isMathJaxEnabled = !!config.env.MATHJAX;
-
-    this.state = {
-      markdown: this.props.pageContainer.state.markdown,
-      isUploadable,
-      isUploadableFile,
-      isMathJaxEnabled,
-    };
-
-    this.setCaretLine = this.setCaretLine.bind(this);
-    this.focusToEditor = this.focusToEditor.bind(this);
-    this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
-    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
-    this.onUpload = this.onUpload.bind(this);
-    this.onEditorScroll = this.onEditorScroll.bind(this);
-    this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
-    this.onPreviewScroll = this.onPreviewScroll.bind(this);
-    this.saveDraft = this.saveDraft.bind(this);
-    this.clearDraft = this.clearDraft.bind(this);
-
-    // for scrolling
-    this.lastScrolledDateWithCursor = null;
-    this.isOriginOfScrollSyncEditor = false;
-    this.isOriginOfScrollSyncEditor = false;
-
-    // create throttled function
-    this.scrollPreviewByEditorLineWithThrottle = throttle(20, this.scrollPreviewByEditorLine);
-    this.scrollPreviewByCursorMovingWithThrottle = throttle(20, this.scrollPreviewByCursorMoving);
-    this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
-    this.setMarkdownStateWithDebounce = debounce(50, throttle(100, (value) => {
-      this.setState({ markdown: value });
-    }));
-    this.saveDraftWithDebounce = debounce(800, this.saveDraft);
-
-    // Detect indent size from contents (only when users are allowed to change it)
-    // TODO: https://youtrack.weseek.co.jp/issue/GW-5368
-    if (!props.isIndentSizeForced && this.state.markdown) {
-      const detectedIndent = detectIndent(this.state.markdown);
-      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
-        props.mutateCurrentIndentSize(detectedIndent.amount);
-      }
-    }
-  }
-
-  componentWillMount() {
-    this.props.appContainer.registerComponentInstance('PageEditor', this);
-  }
-
-  getMarkdown() {
-    return this.state.markdown;
-  }
-
-  updateEditorValue(markdown) {
-    this.editor.setValue(markdown);
-  }
-
-  focusToEditor() {
-    this.editor.forceToFocus();
-  }
-
-  /**
-   * set caret position of editor
-   * @param {number} line
-   */
-  setCaretLine(line) {
-    this.editor.setCaretLine(line);
-    scrollSyncHelper.scrollPreview(this.previewElement, line);
-  }
-
-  /**
-   * the change event handler for `markdown` state
-   * @param {string} value
-   */
-  onMarkdownChanged(value) {
-    const { pageContainer } = this.props;
-    this.setMarkdownStateWithDebounce(value);
-    // only when the first time to edit
-    if (!pageContainer.state.revisionId) {
-      this.saveDraftWithDebounce();
-    }
-  }
-
-  // Displays an alert if there is a difference with pageContainer's markdown
-  componentDidUpdate(prevProps, prevState) {
-    const { pageContainer, editorContainer } = this.props;
-
-    if (this.state.markdown !== prevState.markdown) {
-      if (pageContainer.state.markdown !== this.state.markdown) {
-        editorContainer.enableUnsavedWarning();
-      }
-    }
-  }
-
-  /**
-   * save and update state of containers
-   */
-  async onSaveWithShortcut() {
-    const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer, pageContainer,
-    } = this.props;
-
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
-
-    try {
-      // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
-
-      // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(this.state.markdown, this.props.editorMode, optionsToSave);
-      logger.debug('success to save');
-
-      pageContainer.showSuccessToastr();
-
-      // update state of EditorContainer
-      editorContainer.setState({ tags });
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
-    }
-  }
-
-  /**
-   * the upload event handler
-   * @param {any} file
-   */
-  async onUpload(file) {
-    const {
-      appContainer, pageContainer, mutateGrant,
-    } = this.props;
-
-    try {
-      let res = await apiGet('/attachments.limit', {
-        fileSize: file.size,
-      });
-
-      if (!res.isUploadable) {
-        throw new Error(res.errorMessage);
-      }
-
-      const formData = new FormData();
-      const { pageId, path } = pageContainer.state;
-      formData.append('_csrf', appContainer.csrfToken);
-      formData.append('file', file);
-      formData.append('path', path);
-      if (pageId != null) {
-        formData.append('page_id', pageContainer.state.pageId);
-      }
-
-      res = await apiPost('/attachments.add', formData);
-      const attachment = res.attachment;
-      const fileName = attachment.originalName;
-
-      let insertText = `[${fileName}](${attachment.filePathProxied})`;
-      // when image
-      if (attachment.fileFormat.startsWith('image/')) {
-        // modify to "![fileName](url)" syntax
-        insertText = `!${insertText}`;
-      }
-      this.editor.insertText(insertText);
-
-      // when if created newly
-      if (res.pageCreated) {
-        logger.info('Page is created', res.page._id);
-        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, this.props.editorMode);
-        mutateGrant(res.page.grant);
-      }
-    }
-    catch (e) {
-      logger.error('failed to upload', e);
-      pageContainer.showErrorToastr(e);
-    }
-    finally {
-      this.editor.terminateUploadingState();
-    }
-  }
-
-  /**
-   * the scroll event handler from codemirror
-   * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position,
-   *                    the size of the scrollable area, and the size of the visible area (minus scrollbars).
-   *                    And data.line is also available that is added by Editor component
-   * @see https://codemirror.net/doc/manual.html#events
-   */
-  onEditorScroll(data) {
-    // prevent scrolling
-    //   if the elapsed time from last scroll with cursor is shorter than 40ms
-    const now = new Date();
-    if (now - this.lastScrolledDateWithCursor < 40) {
-      return;
-    }
-
-    this.scrollPreviewByEditorLineWithThrottle(data.line);
-  }
-
-  /**
-   * the scroll event handler from codemirror
-   * @param {number} line
-   * @see https://codemirror.net/doc/manual.html#events
-   */
-  onEditorScrollCursorIntoView(line) {
-    // record date
-    this.lastScrolledDateWithCursor = new Date();
-    this.scrollPreviewByCursorMovingWithThrottle(line);
-  }
-
-  /**
-   * scroll Preview element by scroll event
-   * @param {number} line
-   */
-  scrollPreviewByEditorLine(line) {
-    if (this.previewElement == null) {
-      return;
-    }
-
-    // prevent circular invocation
-    if (this.isOriginOfScrollSyncPreview) {
-      this.isOriginOfScrollSyncPreview = false; // turn off the flag
-      return;
-    }
-
-    // turn on the flag
-    this.isOriginOfScrollSyncEditor = true;
-    scrollSyncHelper.scrollPreview(this.previewElement, line);
-  }
-
-  /**
-   * scroll Preview element by cursor moving
-   * @param {number} line
-   */
-  scrollPreviewByCursorMoving(line) {
-    if (this.previewElement == null) {
-      return;
-    }
-
-    // prevent circular invocation
-    if (this.isOriginOfScrollSyncPreview) {
-      this.isOriginOfScrollSyncPreview = false; // turn off the flag
-      return;
-    }
-
-    // turn on the flag
-    this.isOriginOfScrollSyncEditor = true;
-    scrollSyncHelper.scrollPreviewToRevealOverflowing(this.previewElement, line);
-  }
-
-  /**
-   * the scroll event handler from Preview component
-   * @param {number} offset
-   */
-  onPreviewScroll(offset) {
-    this.scrollEditorByPreviewScrollWithThrottle(offset);
-  }
-
-  /**
-   * scroll Editor component by scroll event of Preview component
-   * @param {number} offset
-   */
-  scrollEditorByPreviewScroll(offset) {
-    if (this.previewElement == null) {
-      return;
-    }
-
-    // prevent circular invocation
-    if (this.isOriginOfScrollSyncEditor) {
-      this.isOriginOfScrollSyncEditor = false; // turn off the flag
-      return;
-    }
-
-    // turn on the flag
-    this.isOriginOfScrollSyncPreview = true;
-    scrollSyncHelper.scrollEditor(this.editor, this.previewElement, offset);
-  }
-
-  saveDraft() {
-    const { pageContainer, editorContainer } = this.props;
-    editorContainer.saveDraft(pageContainer.state.path, this.state.markdown);
-  }
-
-  clearDraft() {
-    this.props.editorContainer.clearDraft(this.props.pageContainer.state.path);
-  }
-
-  render() {
-    if (!this.props.isEditable) {
-      return null;
-    }
-
-    const config = this.props.appContainer.getConfig();
-    const noCdn = envUtils.toBoolean(config.env.NO_CDN);
-
-    return (
-      <div className="d-flex flex-wrap">
-        <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
-          <Editor
-            ref={(c) => { this.editor = c }}
-            value={this.state.markdown}
-            noCdn={noCdn}
-            isMobile={this.props.isMobile}
-            isUploadable={this.state.isUploadable}
-            isUploadableFile={this.state.isUploadableFile}
-            isTextlintEnabled={this.props.isTextlintEnabled}
-            indentSize={this.props.indentSize}
-            onScroll={this.onEditorScroll}
-            onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
-            onChange={this.onMarkdownChanged}
-            onUpload={this.onUpload}
-            onSave={this.onSaveWithShortcut}
-          />
-        </div>
-        <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
-          <Preview
-            markdown={this.state.markdown}
-            // eslint-disable-next-line no-return-assign
-            inputRef={(el) => { return this.previewElement = el }}
-            isMathJaxEnabled={this.state.isMathJaxEnabled}
-            renderMathJaxOnInit={false}
-            onScroll={this.onPreviewScroll}
-          />
-        </div>
-        <ConflictDiffModal
-          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
-          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
-          appContainer={this.props.appContainer}
-          pageContainer={this.props.pageContainer}
-          markdownOnEdit={this.state.markdown}
-        />
-      </div>
-    );
-  }
-
-}
-
-PageEditor.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  isEditable: PropTypes.bool.isRequired,
-
-  // TODO: remove this when omitting unstated is completed
-  editorMode: PropTypes.string.isRequired,
-  isMobile: PropTypes.bool,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  grant: PropTypes.number.isRequired,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
-  mutateGrant: PropTypes.func,
-  isTextlintEnabled: PropTypes.bool,
-  isIndentSizeForced: PropTypes.bool,
-  indentSize: PropTypes.number,
-  mutateCurrentIndentSize: PropTypes.func,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
-
-const PageEditorWrapper = (props) => {
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-  const { data: isMobile } = useIsMobile();
-  const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannels } = useSlackChannels();
-  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
-  const { data: grantGroupId } = useSelectedGrantGroupId();
-  const { data: grantGroupName } = useSelectedGrantGroupName();
-  const { data: isTextlintEnabled } = useIsTextlintEnabled();
-  const { data: isIndentSizeForced } = useIsIndentSizeForced();
-  const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-
-  if (isEditable == null || editorMode == null) {
-    return null;
-  }
-
-  return (
-    <PageEditorHOCWrapper
-      {...props}
-      isEditable={isEditable}
-      editorMode={editorMode}
-      isMobile={isMobile}
-      isSlackEnabled={isSlackEnabled}
-      slackChannels={slackChannels}
-      grant={grant}
-      grantGroupId={grantGroupId}
-      grantGroupName={grantGroupName}
-      mutateGrant={mutateGrant}
-      isTextlintEnabled={isTextlintEnabled}
-      isIndentSizeForced={isIndentSizeForced}
-      indentSize={indentSize}
-      mutateCurrentIndentSize={mutateCurrentIndentSize}
-
-    />
-  );
-};
-
-export default PageEditorWrapper;

+ 435 - 0
packages/app/src/components/PageEditor.tsx

@@ -0,0 +1,435 @@
+import React, {
+  useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
+
+import EventEmitter from 'events';
+
+import { envUtils } from '@growi/core';
+import detectIndent from 'detect-indent';
+import { throttle, debounce } from 'throttle-debounce';
+
+import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { getOptionsToSave } from '~/client/util/editor';
+import { useIsEditable, useIsIndentSizeForced, useCurrentPagePath } from '~/stores/context';
+import {
+  useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled,
+} from '~/stores/editor';
+import {
+  EditorMode,
+  useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+
+import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+import Editor from './PageEditor/Editor';
+import Preview from './PageEditor/Preview';
+import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+
+// TODO: remove this when omitting unstated is completed
+
+const logger = loggerFactory('growi:PageEditor');
+
+
+declare let window: {
+  globalEmitter: EventEmitter,
+};
+
+type EditorRef = {
+  setValue: (markdown: string) => void,
+  setCaretLine: (line: number) => void,
+  insertText: (text: string) => void,
+  forceToFocus: () => void,
+  terminateUploadingState: () => void,
+}
+
+type Props = {
+  appContainer: AppContainer,
+  pageContainer: PageContainer,
+  editorContainer: EditorContainer,
+
+  isEditable: boolean,
+
+  editorMode: string,
+  isSlackEnabled: boolean,
+  slackChannels: string,
+  isMobile?: boolean,
+
+  grant: number,
+  grantGroupId?: string,
+  grantGroupName?: string,
+  mutateGrant: (grant: number) => void,
+
+  isTextlintEnabled?: boolean,
+  isIndentSizeForced?: boolean,
+  indentSize?: number,
+  mutateCurrentIndentSize: (indent: number) => void,
+};
+
+// for scrolling
+let lastScrolledDateWithCursor: Date | null = null;
+let isOriginOfScrollSyncEditor = false;
+let isOriginOfScrollSyncPreview = false;
+
+const PageEditor = (props: Props): JSX.Element => {
+  const {
+    appContainer, pageContainer, editorContainer,
+  } = props;
+
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+  const { data: isMobile } = useIsMobile();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
+  const { data: grantGroupId } = useSelectedGrantGroupId();
+  const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: isTextlintEnabled } = useIsTextlintEnabled();
+  const { data: isIndentSizeForced } = useIsIndentSizeForced();
+  const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
+
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
+
+
+  const editorRef = useRef<EditorRef>(null);
+  const previewRef = useRef<HTMLDivElement>(null);
+
+  const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, value => setMarkdown(value))), []);
+  const saveDraftWithDebounce = useMemo(() => debounce(800, () => {
+    editorContainer.saveDraft(pageContainer.state.path, markdown);
+  }), [editorContainer, markdown, pageContainer.state.path]);
+
+  const markdownChangedHandler = useCallback((value: string): void => {
+    setMarkdownWithDebounce(value);
+    // only when the first time to edit
+    if (!pageContainer.state.revisionId) {
+      saveDraftWithDebounce();
+    }
+  }, [pageContainer.state.revisionId, saveDraftWithDebounce, setMarkdownWithDebounce]);
+
+
+  const saveWithShortcut = useCallback(async() => {
+    if (grant == null) {
+      return;
+    }
+
+    const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
+
+    const optionsToSave = getOptionsToSave(isSlackEnabled ?? false, slackChannels, grant, grantGroupId, grantGroupName, editorContainer);
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      // update state of EditorContainer
+      editorContainer.setState({ tags });
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+  }, [editorContainer, editorMode, grant, grantGroupId, grantGroupName, isSlackEnabled, slackChannelsData, markdown, pageContainer]);
+
+
+  /**
+   * the upload event handler
+   * @param {any} file
+   */
+  const uploadHandler = useCallback(async(file) => {
+    if (editorRef.current == null) {
+      return;
+    }
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      let res: any = await apiGet('/attachments.limit', {
+        fileSize: file.size,
+      });
+
+      if (!res.isUploadable) {
+        throw new Error(res.errorMessage);
+      }
+
+      const formData = new FormData();
+      const { pageId, path } = pageContainer.state;
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      formData.append('_csrf', appContainer.csrfToken!);
+      formData.append('file', file);
+      if (path != null) {
+        formData.append('path', path);
+      }
+      if (pageId != null) {
+        formData.append('page_id', pageId);
+      }
+
+      res = await apiPost('/attachments.add', formData);
+      const attachment = res.attachment;
+      const fileName = attachment.originalName;
+
+      let insertText = `[${fileName}](${attachment.filePathProxied})`;
+      // when image
+      if (attachment.fileFormat.startsWith('image/')) {
+        // modify to "![fileName](url)" syntax
+        insertText = `!${insertText}`;
+      }
+      editorRef.current.insertText(insertText);
+
+      // when if created newly
+      if (res.pageCreated) {
+        logger.info('Page is created', res.page._id);
+        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+        mutateGrant(res.page.grant);
+      }
+    }
+    catch (e) {
+      logger.error('failed to upload', e);
+      pageContainer.showErrorToastr(e);
+    }
+    finally {
+      editorRef.current.terminateUploadingState();
+    }
+  }, [appContainer.csrfToken, editorMode, mutateGrant, pageContainer]);
+
+
+  const scrollPreviewByEditorLine = useCallback((line: number) => {
+    if (previewRef.current == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (isOriginOfScrollSyncPreview) {
+      isOriginOfScrollSyncPreview = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    isOriginOfScrollSyncEditor = true;
+    scrollSyncHelper.scrollPreview(previewRef.current, line);
+  }, []);
+  const scrollPreviewByEditorLineWithThrottle = useMemo(() => throttle(20, scrollPreviewByEditorLine), [scrollPreviewByEditorLine]);
+
+  /**
+   * the scroll event handler from codemirror
+   * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position,
+   *                    the size of the scrollable area, and the size of the visible area (minus scrollbars).
+   *                    And data.line is also available that is added by Editor component
+   * @see https://codemirror.net/doc/manual.html#events
+   */
+  const editorScrolledHandler = useCallback(({ line }: { line: number }) => {
+    // prevent scrolling
+    //   if the elapsed time from last scroll with cursor is shorter than 40ms
+    const now = new Date();
+    if (lastScrolledDateWithCursor != null && now.getTime() - lastScrolledDateWithCursor.getTime() < 40) {
+      return;
+    }
+
+    scrollPreviewByEditorLineWithThrottle(line);
+  }, [scrollPreviewByEditorLineWithThrottle]);
+
+  /**
+   * scroll Preview element by cursor moving
+   * @param {number} line
+   */
+  const scrollPreviewByCursorMoving = useCallback((line: number) => {
+    if (previewRef.current == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (isOriginOfScrollSyncPreview) {
+      isOriginOfScrollSyncPreview = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    isOriginOfScrollSyncEditor = true;
+    scrollSyncHelper.scrollPreviewToRevealOverflowing(previewRef.current, line);
+  }, []);
+  const scrollPreviewByCursorMovingWithThrottle = useMemo(() => throttle(20, scrollPreviewByCursorMoving), [scrollPreviewByCursorMoving]);
+
+  /**
+   * the scroll event handler from codemirror
+   * @param {number} line
+   * @see https://codemirror.net/doc/manual.html#events
+   */
+  const editorScrollCursorIntoViewHandler = useCallback((line: number) => {
+    // record date
+    lastScrolledDateWithCursor = new Date();
+    scrollPreviewByCursorMovingWithThrottle(line);
+  }, [scrollPreviewByCursorMovingWithThrottle]);
+
+  /**
+   * scroll Editor component by scroll event of Preview component
+   * @param {number} offset
+   */
+  const scrollEditorByPreviewScroll = useCallback((offset: number) => {
+    if (editorRef.current == null || previewRef.current == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (isOriginOfScrollSyncEditor) {
+      isOriginOfScrollSyncEditor = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    isOriginOfScrollSyncPreview = true;
+
+    scrollSyncHelper.scrollEditor(editorRef.current, previewRef.current, offset);
+  }, []);
+  const scrollEditorByPreviewScrollWithThrottle = useMemo(() => throttle(20, scrollEditorByPreviewScroll), [scrollEditorByPreviewScroll]);
+
+
+  // register dummy instance to get markdown
+  useEffect(() => {
+    const pageEditorInstance = {
+      getMarkdown: () => {
+        return markdown;
+      },
+    };
+    appContainer.registerComponentInstance('PageEditor', pageEditorInstance);
+  }, [appContainer, markdown]);
+
+  // initial caret line
+  useEffect(() => {
+    if (editorRef.current != null) {
+      editorRef.current.setCaretLine(0);
+    }
+  }, []);
+
+  // set handler to set caret line
+  useEffect(() => {
+    const handler = (line) => {
+      if (editorRef.current != null) {
+        editorRef.current.setCaretLine(line);
+      }
+      if (previewRef.current != null) {
+        scrollSyncHelper.scrollPreview(previewRef.current, line);
+      }
+    };
+    window.globalEmitter.on('setCaretLine', handler);
+
+    return function cleanup() {
+      window.globalEmitter.removeListener('setCaretLine', handler);
+    };
+  }, []);
+
+  // set handler to focus
+  useEffect(() => {
+    if (editorRef.current != null && editorMode === EditorMode.Editor) {
+      editorRef.current.forceToFocus();
+    }
+  }, [editorMode]);
+
+  // set handler to update editor value
+  useEffect(() => {
+    const handler = (markdown) => {
+      if (editorRef.current != null) {
+        editorRef.current.setValue(markdown);
+      }
+    };
+    window.globalEmitter.on('updateEditorValue', handler);
+
+    return function cleanup() {
+      window.globalEmitter.removeListener('updateEditorValue', handler);
+    };
+  }, []);
+
+  // Displays an alert if there is a difference with pageContainer's markdown
+  useEffect(() => {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    if (pageContainer.state.markdown! !== markdown) {
+      editorContainer.enableUnsavedWarning();
+    }
+  }, [editorContainer, markdown, pageContainer.state.markdown]);
+
+  // Detect indent size from contents (only when users are allowed to change it)
+  useEffect(() => {
+    const currentPageMarkdown = pageContainer.state.markdown;
+    if (!isIndentSizeForced && currentPageMarkdown != null) {
+      const detectedIndent = detectIndent(currentPageMarkdown);
+      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
+        mutateCurrentIndentSize(detectedIndent.amount);
+      }
+    }
+  }, [isIndentSizeForced, mutateCurrentIndentSize, pageContainer.state.markdown]);
+
+
+  if (!isEditable) {
+    return <></>;
+  }
+
+  const config = props.appContainer.getConfig();
+  const isUploadable = config.upload.image || config.upload.file;
+  const isUploadableFile = config.upload.file;
+  const isMathJaxEnabled = !!config.env.MATHJAX;
+
+  const noCdn = envUtils.toBoolean(config.env.NO_CDN);
+
+  // TODO: omit no-explicit-any -- 2022.06.02 Yuki Takei
+  // It is impossible to avoid the error
+  //  "Property '...' does not exist on type 'IntrinsicAttributes & RefAttributes<any>'"
+  //  because Editor is a class component and must be wrapped with React.forwardRef
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const EditorAny = Editor as any;
+
+  return (
+    <div className="d-flex flex-wrap">
+      <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
+        <EditorAny
+          ref={editorRef}
+          value={markdown}
+          noCdn={noCdn}
+          isMobile={isMobile}
+          isUploadable={isUploadable}
+          isUploadableFile={isUploadableFile}
+          isTextlintEnabled={isTextlintEnabled}
+          indentSize={indentSize}
+          onScroll={editorScrolledHandler}
+          onScrollCursorIntoView={editorScrollCursorIntoViewHandler}
+          onChange={markdownChangedHandler}
+          onUpload={uploadHandler}
+          onSave={() => saveWithShortcut()}
+        />
+      </div>
+      <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
+        <Preview
+          markdown={markdown}
+          // eslint-disable-next-line no-return-assign
+          inputRef={previewRef}
+          isMathJaxEnabled={isMathJaxEnabled}
+          renderMathJaxOnInit={false}
+          onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
+        />
+      </div>
+      <ConflictDiffModal
+        isOpen={pageContainer.state.isConflictDiffModalOpen}
+        onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
+        pageContainer={pageContainer}
+        markdownOnEdit={markdown}
+      />
+    </div>
+  );
+};
+
+/**
+   * Wrapper component for using unstated
+   */
+const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+
+export default PageEditorWrapper;

+ 42 - 49
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -1,22 +1,22 @@
 import React, {
-  useState, useEffect, FC, useRef,
+  useState, useEffect, useRef, useMemo, useCallback,
 } from 'react';
-import PropTypes from 'prop-types';
+
 import { UserPicture } from '@growi/ui';
+import CodeMirror from 'codemirror/lib/codemirror';
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-import { format } from 'date-fns';
-import CodeMirror from 'codemirror/lib/codemirror';
-
-import PageContainer from '../../client/services/PageContainer';
-import AppContainer from '../../client/services/AppContainer';
-import ExpandOrContractButton from '../ExpandOrContractButton';
 
+import { IUser } from '~/interfaces/user';
+import { useCurrentUser } from '~/stores/context';
 import { useEditorMode } from '~/stores/ui';
 
+import PageContainer from '../../client/services/PageContainer';
 import { IRevisionOnConflict } from '../../interfaces/revision';
+import ExpandOrContractButton from '../ExpandOrContractButton';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 require('codemirror/lib/codemirror.css');
@@ -27,10 +27,9 @@ const DMP = require('diff_match_patch');
 Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
 
 type ConflictDiffModalProps = {
-  isOpen: boolean | null;
+  isOpen?: boolean;
   onClose?: (() => void);
   pageContainer: PageContainer;
-  appContainer: AppContainer;
   markdownOnEdit: string;
 };
 
@@ -38,26 +37,26 @@ type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'>
   createdAt: string
 }
 
-export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
+const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IUser }): JSX.Element => {
+  const { currentUser, pageContainer, onClose } = props;
+
+  const { data: editorMode } = useEditorMode();
+
   const { t } = useTranslation('');
   const [resolvedRevision, setResolvedRevision] = useState<string>('');
   const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
   const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
   const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
 
-  const { data: editorMode } = useEditorMode();
-
   const uncontrolledRef = useRef<CodeMirror>(null);
 
-  const { pageContainer, appContainer } = props;
-
   const currentTime: Date = new Date();
 
   const request: IRevisionOnConflictWithStringDate = {
     revisionId: '',
     revisionBody: props.markdownOnEdit,
     createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
-    user: appContainer.currentUser,
+    user: currentUser,
   };
   const origin: IRevisionOnConflictWithStringDate = {
     revisionId: pageContainer.state.revisionId || '',
@@ -89,13 +88,13 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
     }
   }, [codeMirrorRef, origin.revisionBody, request.revisionBody, latest.revisionBody]);
 
-  const onClose = () => {
-    if (props.onClose != null) {
-      props.onClose();
+  const close = useCallback(() => {
+    if (onClose != null) {
+      onClose();
     }
-  };
+  }, [onClose]);
 
-  const onResolveConflict = async() : Promise<void> => {
+  const onResolveConflict = useCallback(async() => {
     // disable button after clicked
     setIsRevisionSelected(false);
 
@@ -103,40 +102,34 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
 
     try {
       await pageContainer.resolveConflict(codeMirrorVal, editorMode);
-      onClose();
+      close();
       pageContainer.showSuccessToastr();
     }
     catch (error) {
       pageContainer.showErrorToastr(error);
     }
 
-  };
-
-  const onExpandModal = () => {
-    setIsModalExpanded(true);
-  };
+  }, [editorMode, close, pageContainer]);
 
-  const onContractModal = () => {
-    setIsModalExpanded(false);
-  };
-
-  const resizeAndCloseButtons = (
+  const resizeAndCloseButtons = useMemo(() => (
     <div className="d-flex flex-nowrap">
       <ExpandOrContractButton
         isWindowExpanded={isModalExpanded}
-        expandWindow={onExpandModal}
-        contractWindow={onContractModal}
+        expandWindow={() => setIsModalExpanded(true)}
+        contractWindow={() => setIsModalExpanded(false)}
       />
-      <button type="button" className="close text-white" onClick={onClose} aria-label="Close">
+      <button type="button" className="close text-white" onClick={close} aria-label="Close">
         <span aria-hidden="true">&times;</span>
       </button>
     </div>
-  );
+  ), [isModalExpanded, close]);
+
+  const isOpen = props.isOpen ?? false;
 
   return (
     <Modal
-      isOpen={props.isOpen || false}
-      toggle={onClose}
+      isOpen={isOpen}
+      toggle={close}
       backdrop="static"
       className={`${isModalExpanded ? ' grw-modal-expanded' : ''}`}
       size="xl"
@@ -145,7 +138,7 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
         <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
       </ModalHeader>
       <ModalBody className="mx-4 my-1">
-        { props.isOpen
+        { isOpen
         && (
           <div className="row">
             <div className="col-12 text-center mt-2 mb-4">
@@ -269,14 +262,14 @@ export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
   );
 };
 
-ConflictDiffModal.propTypes = {
-  isOpen: PropTypes.bool,
-  onClose: PropTypes.func,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  markdownOnEdit: PropTypes.string.isRequired,
-};
 
-ConflictDiffModal.defaultProps = {
-  isOpen: false,
+export const ConflictDiffModal = (props: ConflictDiffModalProps): JSX.Element => {
+  const { isOpen } = props;
+  const { data: currentUser } = useCurrentUser();
+
+  if (!isOpen || currentUser == null) {
+    return <></>;
+  }
+
+  return <ConflictDiffModalCore {...props} currentUser={currentUser} />;
 };

+ 49 - 30
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,23 +1,24 @@
-import React, { useCallback, useState } from 'react';
-import PropTypes from 'prop-types';
+import React, { useCallback, useState, useEffect } from 'react';
 
+import PropTypes from 'prop-types';
 import { Collapse, Button } from 'reactstrap';
 
-import EditorContainer from '~/client/services/EditorContainer';
+
 import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+import { useCurrentPagePath } from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
 } from '~/stores/ui';
 
-import { SlackNotification } from '../SlackNotification';
+import SavePageControls from '../SavePageControls';
 import SlackLogo from '../SlackLogo';
+import { SlackNotification } from '../SlackNotification';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import SavePageControls from '../SavePageControls';
 
 import OptionsSelector from './OptionsSelector';
-import { useIsSlackEnabled } from '~/stores/editor';
-import { useSlackChannels } from '~/stores/context';
 
 const EditorNavbarBottom = (props) => {
 
@@ -30,17 +31,28 @@ const EditorNavbarBottom = (props) => {
 
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannels, mutate: mutateSlackChannels } = useSlackChannels();
   const additionalClasses = ['grw-editor-navbar-bottom'];
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
 
-  const isSlackEnabledToggleHandler = useCallback(
-    (bool: boolean) => mutateIsSlackEnabled(bool), [mutateIsSlackEnabled],
-  );
+  const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
+
+  useEffect(() => {
+    if (slackChannelsData != null) {
+      setSlackChannelsStr(slackChannelsData.toString());
+      mutateIsSlackEnabled(false);
+    }
+  }, [mutateIsSlackEnabled, slackChannelsData]);
+
+  const isSlackEnabledToggleHandler = (bool: boolean) => {
+    mutateIsSlackEnabled(bool, false);
+  };
+
+  const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
+    setSlackChannelsStr(slackChannels);
+  }, []);
 
-  const slackChannelsChangedHandler = useCallback(
-    (slackChannels: string) => mutateSlackChannels(slackChannels), [mutateSlackChannels],
-  );
 
   const renderDrawerButton = () => (
     <button
@@ -73,13 +85,17 @@ const EditorNavbarBottom = (props) => {
       {isSlackConfigured && (
         <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd === true}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
-            <SlackNotification
-              isSlackEnabled={isSlackEnabled ?? false}
-              slackChannels={slackChannels}
-              onEnabledFlagChange={isSlackEnabledToggleHandler}
-              onChannelChange={slackChannelsChangedHandler}
-              id="idForEditorNavbarBottomForMobile"
-            />
+            {isSlackEnabled != null
+            && (
+              <SlackNotification
+                isSlackEnabled={isSlackEnabled}
+                slackChannels={slackChannelsStr}
+                onEnabledFlagChange={isSlackEnabledToggleHandler}
+                onChannelChange={slackChannelsChangedHandler}
+                id="idForEditorNavbarBottomForMobile"
+              />
+            )
+            }
           </nav>
         </Collapse>
       )
@@ -104,16 +120,19 @@ const EditorNavbarBottom = (props) => {
             </Button>
           ) : (
             <div className="mr-2">
-              <SlackNotification
-                isSlackEnabled={isSlackEnabled ?? false}
-                slackChannels={slackChannels}
-                onEnabledFlagChange={isSlackEnabledToggleHandler}
-                onChannelChange={slackChannelsChangedHandler}
-                id="idForEditorNavbarBottom"
-              />
+              {isSlackEnabled != null
+              && (
+                <SlackNotification
+                  isSlackEnabled={isSlackEnabled}
+                  slackChannels={slackChannelsStr}
+                  onEnabledFlagChange={isSlackEnabledToggleHandler}
+                  onChannelChange={slackChannelsChangedHandler}
+                  id="idForEditorNavbarBottom"
+                />
+              )}
             </div>
           ))}
-          <SavePageControls />
+          <SavePageControls slackChannels={slackChannelsStr} isSlackEnabled={isSlackEnabled || false} />
           { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
         </form>
       </div>

+ 13 - 5
packages/app/src/components/PageEditor/Preview.tsx

@@ -1,5 +1,5 @@
 import React, {
-  UIEventHandler, useCallback, useEffect, useMemo, useState,
+  useCallback, useEffect, useMemo, useState, SyntheticEvent,
 } from 'react';
 
 
@@ -18,7 +18,7 @@ type Props = {
   inputRef?: React.RefObject<HTMLDivElement>,
   isMathJaxEnabled?: boolean,
   renderMathJaxOnInit?: boolean,
-  onScroll?: UIEventHandler<HTMLDivElement>,
+  onScroll?: (scrollTop: number) => void,
 }
 
 
@@ -28,7 +28,6 @@ const Preview = (props: Props): JSX.Element => {
     appContainer,
     markdown, pagePath,
     inputRef,
-    onScroll,
   } = props;
 
   const [html, setHtml] = useState('');
@@ -89,7 +88,11 @@ const Preview = (props: Props): JSX.Element => {
     <div
       className="page-editor-preview-body"
       ref={inputRef}
-      onScroll={onScroll}
+      onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
+        if (props.onScroll != null) {
+          props.onScroll(event.currentTarget.scrollTop);
+        }
+      }}
     >
       <RevisionBody
         {...props}
@@ -106,4 +109,9 @@ const Preview = (props: Props): JSX.Element => {
  */
 const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
 
-export default PreviewWrapper;
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const PreviewWrapper2 = (props): JSX.Element => {
+  return <PreviewWrapper {...props} />;
+};
+
+export default PreviewWrapper2;

+ 6 - 5
packages/app/src/components/PageEditorByHackmd.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
 
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
@@ -9,8 +9,8 @@ import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
-import { useSlackChannels } from '~/stores/context';
-import { useIsSlackEnabled } from '~/stores/editor';
+import { useCurrentPagePath } from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -431,8 +431,9 @@ const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd,
 
 const PageEditorByHackmdWrapper = (props) => {
   const { data: editorMode } = useEditorMode();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: slackChannels } = useSlackChannels();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
@@ -446,7 +447,7 @@ const PageEditorByHackmdWrapper = (props) => {
       {...props}
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
-      slackChannels={slackChannels}
+      slackChannels={slackChannelsData.toString()}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}

+ 3 - 1
packages/app/src/components/PageList/PageList.tsx

@@ -1,8 +1,10 @@
 import React from 'react';
+
 import { useTranslation } from 'react-i18next';
 
 import { IPageWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 import { PageListItemL } from './PageListItemL';
@@ -53,7 +55,7 @@ const PageList = (props: Props): JSX.Element => {
 
   return (
     <div className="page-list">
-      <ul className="page-list-ul list-group-flush">
+      <ul className="page-list-ul list-group list-group-flush">
         {pageList}
       </ul>
     </div>

+ 32 - 54
packages/app/src/components/PageStatusAlert.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
@@ -26,7 +26,6 @@ class PageStatusAlert extends React.Component {
     };
 
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
-    this.getContentsForRevisionOutdated = this.getContentsForRevisionOutdated.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
     this.onClickResolveConflict = this.onClickResolveConflict.bind(this);
@@ -57,45 +56,6 @@ class PageStatusAlert extends React.Component {
     ];
   }
 
-  getContentsForRevisionOutdated() {
-    const { t, appContainer, pageContainer } = this.props;
-    const pageEditor = appContainer.getComponentInstance('PageEditor');
-
-    let markdownOnEdit = '';
-    let isConflictOnEdit = false;
-
-    if (pageEditor != null) {
-      markdownOnEdit = pageEditor.getMarkdown();
-      isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
-    }
-
-    return [
-      ['bg-warning', 'd-hackmd-none'],
-      <>
-        <i className="icon-fw icon-pencil"></i>
-        {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
-      </>,
-      <>
-        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
-          <i className="icon-fw icon-reload mr-1"></i>
-          {t('Load latest')}
-        </button>
-        {isConflictOnEdit
-          && (
-            <button
-              type="button"
-              onClick={this.onClickResolveConflict}
-              className="btn btn-outline-white"
-            >
-              <i className="fa fa-fw fa-file-text-o mr-1"></i>
-              {t('modal_resolve_conflict.resolve_conflict')}
-            </button>
-          )
-        }
-      </>,
-    ];
-  }
-
   getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
     return [
@@ -112,20 +72,42 @@ class PageStatusAlert extends React.Component {
   }
 
   getContentsForUpdatedAlert() {
-    const { t } = this.props;
-    const label1 = t('edited this page');
-    const label2 = t('Load latest');
+    const { t, appContainer, pageContainer } = this.props;
+    const pageEditor = appContainer.getComponentInstance('PageEditor');
+
+    let isConflictOnEdit = false;
+
+    if (pageEditor != null) {
+      const markdownOnEdit = pageEditor.getMarkdown();
+      isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
+    }
+
+    const label1 = isConflictOnEdit
+      ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
+      : `${pageContainer.state.lastUpdateUsername} ${t('edited this page')}`;
 
     return [
       ['bg-warning'],
       <>
         <i className="icon-fw icon-bulb"></i>
-        {this.props.pageContainer.state.lastUpdateUsername} {label1}
+        {label1}
+      </>,
+      <>
+        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
+          <i className="icon-fw icon-reload mr-1"></i>
+          {t('Load latest')}
+        </button>
+        { isConflictOnEdit && (
+          <button
+            type="button"
+            onClick={this.onClickResolveConflict}
+            className="btn btn-outline-white"
+          >
+            <i className="fa fa-fw fa-file-text-o mr-1"></i>
+            {t('modal_resolve_conflict.resolve_conflict')}
+          </button>
+        )}
       </>,
-      <a href="#" className="btn btn-outline-white" onClick={this.refreshPage}>
-        <i className="icon-fw icon-reload mr-1"></i>
-        {label2}
-      </a>,
     ];
   }
 
@@ -139,12 +121,8 @@ class PageStatusAlert extends React.Component {
 
     let getContentsFunc = null;
 
-    // when conflicting on save
-    if (isRevisionOutdated) {
-      getContentsFunc = this.getContentsForRevisionOutdated;
-    }
     // when remote revision is newer than both
-    else if (isHackmdDocumentOutdated && isRevisionOutdated) {
+    if (isHackmdDocumentOutdated && isRevisionOutdated) {
       getContentsFunc = this.getContentsForUpdatedAlert;
     }
     // when someone editing with HackMD

+ 7 - 14
packages/app/src/components/SavePageControls.jsx

@@ -1,30 +1,27 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-
 import {
   UncontrolledButtonDropdown, Button,
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import loggerFactory from '~/utils/logger';
 
-import PageContainer from '~/client/services/PageContainer';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import GrantSelector from './SavePageControls/GrantSelector';
-
+import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
+import { useIsEditable } from '~/stores/context';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
-import { useIsEditable, useSlackChannels } from '~/stores/context';
-import { useIsSlackEnabled } from '~/stores/editor';
+import loggerFactory from '~/utils/logger';
+
+import GrantSelector from './SavePageControls/GrantSelector';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:SavePageControls');
 
@@ -142,8 +139,6 @@ const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [App
 const SavePageControlsWrapper = (props) => {
   const { data: isEditable } = useIsEditable();
   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();
@@ -161,8 +156,6 @@ const SavePageControlsWrapper = (props) => {
     <SavePageControlsHOCWrapper
       {...props}
       editorMode={editorMode}
-      isSlackEnabled={isSlackEnabled}
-      slackChannels={slackChannels}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}

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

@@ -1,5 +1,6 @@
 /* eslint-disable react/prop-types */
 import React, { FC } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { PopoverBody, PopoverHeader, UncontrolledPopover } from 'reactstrap';
 

+ 11 - 0
packages/app/src/interfaces/attachment.ts

@@ -0,0 +1,11 @@
+import { Ref } from './common';
+import { IPage } from './page';
+import { IUser } from './user';
+
+export type IAttachment = {
+  page?: Ref<IPage>,
+  creator?: Ref<IUser>,
+
+  // virtual property
+  filePathProxied: string,
+};

+ 3 - 0
packages/app/src/interfaces/user-trigger-notification.ts

@@ -0,0 +1,3 @@
+
+type SlackChannel = string;
+export type SlackChannels = {[updatePost: string]: SlackChannel[]}

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

@@ -1,3 +1,4 @@
+import { IAttachment } from './attachment';
 import { Ref } from './common';
 import { HasObjectId } from './has-object-id';
 
@@ -6,7 +7,10 @@ export type IUser = {
   username: string;
   email: string;
   password: string;
+  image?: string, // for backward conpatibility
+  imageAttachment?: Ref<IAttachment>,
   imageUrlCached: string;
+  isGravatarEnabled: boolean,
   admin: boolean;
 }
 

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

@@ -99,7 +99,6 @@ const schema = new Schema<PageDocument, PageModel>({
   liker: [{ type: ObjectId, ref: 'User' }],
   seenUsers: [{ type: ObjectId, ref: 'User' }],
   commentCount: { type: Number, default: 0 },
-  slackChannels: { type: String },
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified

+ 3 - 3
packages/app/src/server/models/user.js

@@ -1,6 +1,8 @@
 /* eslint-disable no-use-before-define */
+import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
+
 const crypto = require('crypto');
 
 const debug = require('debug')('growi:models:user');
@@ -227,9 +229,7 @@ module.exports = function(crowi) {
 
   userSchema.methods.generateImageUrlCached = async function() {
     if (this.isGravatarEnabled) {
-      const email = this.email || '';
-      const hash = md5(email.trim().toLowerCase());
-      return `https://gravatar.com/avatar/${hash}`;
+      return generateGravatarSrc(this.email);
     }
     if (this.image != null) {
       return this.image;

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

@@ -245,8 +245,8 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    */
-  router.get('/', accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
-    const { pageId, path } = req.query;
+  router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
+    const { pageId, path, user } = req.query;
 
     if (pageId == null && path == null) {
       return res.apiv3Err(new ErrorV3('Parameter path or pageId is required.', 'invalid-request'));
@@ -255,10 +255,10 @@ module.exports = (crowi) => {
     let page;
     try {
       if (pageId != null) { // prioritized
-        page = await Page.findByIdAndViewer(pageId, req.user);
+        page = await Page.findByIdAndViewer(pageId, user);
       }
       else {
-        page = await Page.findByPathAndViewer(path, req.user);
+        page = await Page.findByPathAndViewer(path, user);
       }
     }
     catch (err) {

+ 14 - 6
packages/app/src/server/service/page.ts

@@ -2297,7 +2297,7 @@ class PageService {
         grantedUsers: notEmptyParent.grantedUsers,
       };
 
-      systematicallyCreatedPage = await this.createBySystem(
+      systematicallyCreatedPage = await this.forceCreateBySystem(
         path,
         '',
         options,
@@ -3371,7 +3371,7 @@ class PageService {
     return savedPage;
   }
 
-  private async canProcessCreateBySystem(
+  private async canProcessForceCreateBySystem(
       path: string,
       grantData: {
         grant: number,
@@ -3382,7 +3382,15 @@ class PageService {
     return this.canProcessCreate(path, grantData, false);
   }
 
-  async createBySystem(path: string, body: string, options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] }): Promise<PageDocument> {
+  /**
+   * @private
+   * This method receives the same arguments as the PageService.create method does except for the added type '{ grantedUsers?: ObjectIdLike[] }'.
+   * This additional value is used to determine the grantedUser of the page to be created by system.
+   * This method must not run isGrantNormalized method to validate grant. **If necessary, run it before use this method.**
+   * -- Reason 1: This is because it is not expected to use this method when the grant validation is required.
+   * -- Reason 2: This is because it is not expected to use this method when the program cannot determine the operator.
+   */
+  private async forceCreateBySystem(path: string, body: string, options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] }): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
@@ -3412,9 +3420,9 @@ class PageService {
     if (isGrantOwner && grantedUsers?.length !== 1) {
       throw Error('grantedUser must exist when grant is GRANT_OWNER');
     }
-    const canProcessCreateBySystem = await this.canProcessCreateBySystem(path, grantData);
-    if (!canProcessCreateBySystem) {
-      throw Error('Cannnot process createBySystem');
+    const canProcessForceCreateBySystem = await this.canProcessForceCreateBySystem(path, grantData);
+    if (!canProcessForceCreateBySystem) {
+      throw Error('Cannnot process forceCreateBySystem');
     }
 
     // Prepare a page document

+ 0 - 11
packages/app/src/server/service/user-notification/index.ts

@@ -49,7 +49,6 @@ export class UserNotificationService {
 
     // "dev,slacktest" => [dev,slacktest]
     const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);
-    await this.putDefaultChannelIfEmpty(page.path, slackChannels);
 
     const appTitle = appService.getAppTitle();
     const siteUrl = appService.getSiteUrl();
@@ -69,14 +68,4 @@ export class UserNotificationService {
     return Promise.allSettled(promises);
   }
 
-  private async putDefaultChannelIfEmpty(pagePath:string, slackChannels: (string|null)[]): Promise<void> {
-    const updatePosts = await UpdatePost.findSettingsByPath(pagePath);
-    slackChannels.push(...(updatePosts).map(up => up.channel));
-
-    // insert null if empty to notify once
-    if (slackChannels.length === 0) {
-      slackChannels.push(null);
-    }
-  }
-
 }

+ 0 - 34
packages/app/src/server/util/middlewares.js

@@ -37,25 +37,6 @@ module.exports = (crowi) => {
   };
 
   middlewares.swigFilters = function(swig) {
-    // define a function for Gravatar
-    const generateGravatarSrc = function(user) {
-      const email = user.email || '';
-      const hash = md5(email.trim().toLowerCase());
-      return `https://gravatar.com/avatar/${hash}`;
-    };
-
-    // define a function for uploaded picture
-    const getUploadedPictureSrc = function(user) {
-      if (user.image) {
-        return user.image;
-      }
-      if (user.imageAttachment != null) {
-        return user.imageAttachment.filePathProxied;
-      }
-
-      return '/images/icons/user.svg';
-    };
-
 
     return function(req, res, next) {
       swig.setFilter('path2name', (string) => {
@@ -126,21 +107,6 @@ module.exports = (crowi) => {
           .replace(/\s(https?.+(jpe?g|png|gif))\s/, '\n\n\n![]($1)\n\n\n');
       });
 
-      swig.setFilter('gravatar', generateGravatarSrc);
-      swig.setFilter('uploadedpicture', getUploadedPictureSrc);
-
-      swig.setFilter('picture', (user) => {
-        if (!user) {
-          return '/images/icons/user.svg';
-        }
-
-        if (user.isGravatarEnabled === true) {
-          return generateGravatarSrc(user);
-        }
-
-        return getUploadedPictureSrc(user);
-      });
-
       swig.setFilter('encodeHTML', (string) => {
         return entities.encodeHTML(string);
       });

+ 0 - 1
packages/app/src/server/views/layout-growi/identical-path-page.html

@@ -10,7 +10,6 @@
     <div id="content-main" class="content-main d-flex"
       data-path="{{ encodeURI(path) }}"
       data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-      data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
       data-page-is-not-creatable="true"
       data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
       data-identical-path="true"

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

@@ -14,7 +14,6 @@
   data-page-grant-group-name="{{ grantedGroupName }}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
-  data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-created-at="{{ page.createdAt|datetz('Y/m/d H:i:s') }}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
   data-page-last-update-username="{% if page && page.lastUpdateUser %}{{ page.lastUpdateUser.name }}{% endif %}"
@@ -31,7 +30,6 @@
 <div id="content-main" class="content-main d-flex"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-slack-channels="{% if page %}{{ page.slackChannels }}{% endif %}"
   data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   >

+ 7 - 10
packages/app/src/stores/context.tsx

@@ -88,8 +88,8 @@ export const useShareLinksNumber = (initialData?: Nullable<any>): SWRResponse<Nu
   return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData);
 };
 
-export const useShareLinkId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('shareLinkId', initialData);
+export const useShareLinkId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('shareLinkId', initialData);
 };
 
 export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
@@ -120,10 +120,6 @@ export const useRevisionAuthor = (initialData?: Nullable<any>): SWRResponse<Null
   return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData);
 };
 
-export const useSlackChannels = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('slackChannels', initialData);
-};
-
 export const useIsSearchPage = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('isSearchPage', initialData);
 };
@@ -197,12 +193,13 @@ export const useIsEditable = (): SWRResponse<boolean, Error> => {
 
 export const useIsSharedUser = (): SWRResponse<boolean, Error> => {
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: currentPagePath } = useCurrentPagePath();
+
+  const pathname = window.location.pathname;
 
   return useSWRImmutable(
-    ['isSharedUser', isGuestUser, currentPagePath],
-    (key: Key, isGuestUser: boolean, currentPagePath: string) => {
-      return isGuestUser && pagePathUtils.isSharedPage(currentPagePath as string);
+    ['isSharedUser', isGuestUser, pathname],
+    (key: Key, isGuestUser: boolean, pathname: string) => {
+      return isGuestUser && pagePathUtils.isSharedPage(pathname);
     },
   );
 };

+ 27 - 6
packages/app/src/stores/editor.tsx

@@ -1,17 +1,18 @@
-import { SWRResponse } from 'swr';
+import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
+import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { Nullable } from '~/interfaces/common';
 import { IEditorSettings } from '~/interfaces/editor-settings';
+import { SlackChannels } from '~/interfaces/user-trigger-notification';
 
-import { useCurrentUser, useDefaultIndentSize, useIsGuestUser } from './context';
+import {
+  useCurrentUser, useDefaultIndentSize, useIsGuestUser,
+} from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 
-export const useIsSlackEnabled = (isEnabled?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isSlackEnabled', isEnabled, { fallbackData: false });
-};
-
 
 type EditorSettingsOperation = {
   update: (updateData: Partial<IEditorSettings>) => void,
@@ -69,3 +70,23 @@ export const useCurrentIndentSize = (): SWRResponse<number, Error> => {
     { fallbackData: defaultIndentSize },
   );
 };
+
+/*
+* Slack Notification
+*/
+export const useSWRxSlackChannels = (currentPagePath: Nullable<string>): SWRResponse<string[], Error> => {
+  const shouldFetch: boolean = currentPagePath != null;
+  return useSWR(
+    shouldFetch ? ['/pages.updatePost', currentPagePath] : null,
+    (endpoint, path) => apiGet(endpoint, { path }).then((response: SlackChannels) => response.updatePost),
+    { fallbackData: [''] },
+  );
+};
+
+export const useIsSlackEnabled = (): SWRResponse<boolean, Error> => {
+  return useStaticSWR(
+    'isSlackEnabled',
+    undefined,
+    { fallbackData: false },
+  );
+};

+ 14 - 5
packages/app/src/stores/page.tsx

@@ -13,20 +13,29 @@ import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
 
-import { useCurrentPagePath } from './context';
+import { useCurrentPageId, useCurrentPagePath } from './context';
 import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
 
 
-export const useSWRxPageByPath = (path: string | null | undefined, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
+export const useSWRxPage = (pageId?: string, shareLinkId?: string): SWRResponse<IPageHasId, Error> => {
+  return useSWR(
+    pageId != null ? ['/page', pageId, shareLinkId] : null,
+    (endpoint, pageId, shareLinkId) => apiv3Get(endpoint, { pageId, shareLinkId }).then(result => result.data.page),
+  );
+};
+
+export const useSWRxPageByPath = (path?: string): SWRResponse<IPageHasId, Error> => {
   return useSWR(
     path != null ? ['/page', path] : null,
     (endpoint, path) => apiv3Get(endpoint, { path }).then(result => result.data.page),
-    {
-      fallbackData: initialData,
-    },
   );
 };
 
+export const useSWRxCurrentPage = (shareLinkId?: string): SWRResponse<IPageHasId, Error> => {
+  const { data: currentPageId } = useCurrentPageId();
+
+  return useSWRxPage(currentPageId ?? undefined, shareLinkId);
+};
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> => {

+ 2 - 2
packages/app/src/stores/ui.tsx

@@ -287,8 +287,8 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
 };
 
 
-export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
-  return useStaticSWR<Nullable<number>, Error>('grant', initialData);
+export const useSelectedGrant = (initialData?: number): SWRResponse<number, Error> => {
+  return useStaticSWR<number, Error>('grant', initialData);
 };
 
 export const useSelectedGrantGroupId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {

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

@@ -68,7 +68,6 @@ body.growi-layout-fluid .grw-container-convertible {
   position: sticky;
   // growisubnavigation + grw-navbar-boder + some spacing
   top: calc(100px + 4px + 20px);
-  margin-top: 5px;
 }
 
 .grw-fab {

+ 0 - 19
packages/app/src/styles/_page_list.scss

@@ -97,25 +97,6 @@ body .page-list {
   }
 }
 
-.list-tag-name {
-  font-size: 17px;
-}
-
-.popular-page-high {
-  font-size: 1.1em;
-  font-weight: bold;
-  color: darken($red, 5%);
-}
-
-.popular-page-mid {
-  font-weight: bold;
-  color: #e47800;
-}
-
-.popular-page-low {
-  color: #ab7c7c;
-}
-
 .card-timeline {
   border: 1px solid $gray-300;
   > .card-header {

+ 1 - 2
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -226,9 +226,8 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   }
   // List group
   .list-group-item {
-    // TODO: fix color light theme
     .page-list-snippet {
-      color: $gray-600;
+      color: lighten($body-color, 10%);
     }
   }
 }

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

@@ -79,7 +79,6 @@ pre:not(.hljs):not(.CodeMirror-line) {
 
 // Dropdown
 .grw-personal-dropdown {
-
   .grw-sidebar-mode-icon svg {
     fill: $secondary;
   }
@@ -480,13 +479,11 @@ ul.pagination {
   .list-group {
     .list-group-item {
       a {
-        color: $color-global;
         svg {
           fill: $color-global;
         }
 
         @include hover() {
-          color: $color-global;
           svg {
             fill: $color-global;
           }

+ 8 - 0
packages/app/src/utils/gravatar.ts

@@ -0,0 +1,8 @@
+import md5 from 'md5';
+
+export const GRAVATAR_DEFAULT = 'https://gravatar.com/avatar/00000000000000000000000000000000?s=24';
+
+export const generateGravatarSrc = (email?: string): string => {
+  const hash = md5((email ?? '').trim().toLowerCase());
+  return `https://gravatar.com/avatar/${hash}`;
+};

+ 1 - 14
packages/app/test/integration/models/update-post.test.js

@@ -1,19 +1,6 @@
-const mongoose = require('mongoose');
-
-const { getInstance } = require('../setup-crowi');
+import UpdatePost from '../../../src/server/models/update-post';
 
 describe('UpdatePost', () => {
-  // eslint-disable-next-line no-unused-vars
-  let crowi;
-  let UpdatePost;
-
-  beforeAll(async() => {
-    crowi = await getInstance();
-  });
-
-  beforeEach(async() => {
-    UpdatePost = mongoose.model('UpdatePost');
-  });
 
   describe('.createPrefixesByPathPattern', () => {
     describe('with a path', () => {