Taichi Masuyama 4 лет назад
Родитель
Сommit
7b75b4b248
45 измененных файлов с 441 добавлено и 442 удалено
  1. 29 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 2
      packages/app/docker/README.md
  5. 8 8
      packages/app/package.json
  6. 0 4
      packages/app/src/client/admin.jsx
  7. 1 3
      packages/app/src/client/app.jsx
  8. 6 54
      packages/app/src/client/legacy/crowi.js
  9. 6 18
      packages/app/src/client/services/ContextExtractor.tsx
  10. 0 157
      packages/app/src/client/services/NavigationContainer.js
  11. 0 7
      packages/app/src/client/services/PageContainer.js
  12. 27 0
      packages/app/src/client/util/blink-section-header.ts
  13. 45 0
      packages/app/src/client/util/smooth-scroll.ts
  14. 2 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  15. 6 7
      packages/app/src/components/ContentLinkButtons.jsx
  16. 40 0
      packages/app/src/components/EventListeneres/HashChanged.tsx
  17. 5 5
      packages/app/src/components/Fab.jsx
  18. 10 9
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  19. 1 1
      packages/app/src/components/LikeButtons.tsx
  20. 1 3
      packages/app/src/components/Navbar/GlobalSearch.jsx
  21. 1 10
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  22. 0 1
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  23. 5 0
      packages/app/src/components/Page.jsx
  24. 20 11
      packages/app/src/components/Page/DisplaySwitcher.jsx
  25. 5 5
      packages/app/src/components/Page/RevisionRenderer.jsx
  26. 1 0
      packages/app/src/components/Page/TagLabels.jsx
  27. 15 2
      packages/app/src/components/PageEditor.jsx
  28. 5 0
      packages/app/src/components/PageEditorByHackmd.jsx
  29. 13 2
      packages/app/src/components/SavePageControls.jsx
  30. 0 2
      packages/app/src/components/Sidebar/SidebarNav.tsx
  31. 1 10
      packages/app/src/components/StickyStretchableScroller.jsx
  32. 6 6
      packages/app/src/components/TableOfContents.jsx
  33. 71 41
      packages/app/src/stores/context.tsx
  34. 79 47
      packages/app/src/stores/ui.tsx
  35. 10 9
      packages/app/src/stores/use-static-swr.tsx
  36. 1 1
      packages/codemirror-textlint/package.json
  37. 1 1
      packages/core/package.json
  38. 1 1
      packages/plugin-attachment-refs/package.json
  39. 1 1
      packages/plugin-lsx/package.json
  40. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  41. 1 1
      packages/slack/package.json
  42. 2 2
      packages/slackbot-proxy/package.json
  43. 5 1
      packages/slackbot-proxy/src/services/RelationsService.ts
  44. 1 1
      packages/ui/package.json
  45. 4 4
      yarn.lock

+ 29 - 1
CHANGELOG.md

@@ -1,9 +1,37 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.13...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.0...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v4.5.0](https://github.com/weseek/growi/compare/v4.4.13...v4.5.0) - 2021-12-06
+
+### BREAKING CHANGES
+
+- imprv: APIv3 payload (#4770) @LuqmanHakim-Grune
+
+### 💎 Features
+
+- feat: Slackbot unfurl (#4720) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: APIv3 payload (#4770) @LuqmanHakim-Grune
+- imprv: upgrade passport from v0.4.x to v0.5.x (#4727) @mudana-grune
+- imprv: Show site url in unfurl footer (#4755) @hakumizuki
+- imprv: SWRize context (#4740) @hakumizuki
+- imprv: Upgrade mongoose from 5.x to 6.x (#4659) @mudana-grune
+
+### 🐛 Bug Fixes
+
+- fix(slackbot-proxy): Support new API v3 data scheme (#4800) @yuki-takei
+- fix(Slackbot): Slash commands response when sent from disabled channels (#4754) @stevenfukase
+
+### 🧰 Maintenance
+
+- ci(deps): bump detect-indent from 6.0.0 to 7.0.0 (#4635) @dependabot
+- ci(deps): bump passport-saml from 2.2.0 to 3.2.0 (#4431) @dependabot
+
 ## [v4.4.13](https://github.com/weseek/growi/compare/v4.4.12...v4.4.13) - 2021-11-19
 ## [v4.4.13](https://github.com/weseek/growi/compare/v4.4.12...v4.4.13) - 2021-11-19
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`4.5.13`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.0/docker/Dockerfile)
-* [`4.5.13-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.0/docker/Dockerfile)
+* [`4.5.0`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.0/docker/Dockerfile)
+* [`4.5.0-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.0/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 
 

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "4.5.0-RC.0",
+  "version": "4.5.1-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -58,11 +58,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.5.0-RC.0",
-    "@growi/plugin-attachment-refs": "^4.5.0-RC.0",
-    "@growi/plugin-lsx": "^4.5.0-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.5.0-RC.0",
-    "@growi/slack": "^4.5.0-RC.0",
+    "@growi/codemirror-textlint": "^4.5.1-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.1-RC.0",
+    "@growi/plugin-lsx": "^4.5.1-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.1-RC.0",
+    "@growi/slack": "^4.5.1-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -158,7 +158,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
     "@alienfast/i18next-loader": "^1.0.16",
-    "@growi/ui": "^4.5.0-RC.0",
+    "@growi/ui": "^4.5.1-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
@@ -228,7 +228,7 @@
     "sass-loader": "^10.1.1",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^4.2.0",
     "socket.io-client": "^4.2.0",
-    "sticky-events": "^3.1.3",
+    "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
     "styled-components": "^5.0.1",
     "stylelint": "^14.0.1",
     "stylelint": "^14.0.1",

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

@@ -25,8 +25,6 @@ import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
 import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
 import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -57,7 +55,6 @@ appContainer.initContents();
 const { i18n } = appContainer;
 const { i18n } = appContainer;
 
 
 // create unstated container instance
 // create unstated container instance
-const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);
 const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
 const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
@@ -71,7 +68,6 @@ const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
   appContainer,
   appContainer,
-  navigationContainer,
   adminAppContainer,
   adminAppContainer,
   adminImportContainer,
   adminImportContainer,
   adminSocketIoContainer,
   adminSocketIoContainer,

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

@@ -42,7 +42,6 @@ import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 
 
 import ContextExtractor from '~/client/services/ContextExtractor';
 import ContextExtractor from '~/client/services/ContextExtractor';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
@@ -62,7 +61,6 @@ const { i18n } = appContainer;
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 
 // create unstated container instance
 // create unstated container instance
-const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
@@ -72,7 +70,7 @@ const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
-  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
+  appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
   commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
   commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
 ];
 ];
 
 

+ 6 - 54
packages/app/src/client/legacy/crowi.js

@@ -1,3 +1,5 @@
+const { blinkElem, blinkSectionHeaderAtBoot } = require('../util/blink-section-header');
+
 /* eslint-disable react/jsx-filename-extension */
 /* eslint-disable react/jsx-filename-extension */
 require('jquery.cookie');
 require('jquery.cookie');
 
 
@@ -16,8 +18,6 @@ window.Crowi = Crowi;
  */
  */
 Crowi.setCaretLineData = function(line) {
 Crowi.setCaretLineData = function(line) {
   const { appContainer } = window;
   const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
-  // navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   const pageEditorDom = document.querySelector('#page-editor');
   pageEditorDom.setAttribute('data-caret-line', line);
   pageEditorDom.setAttribute('data-caret-line', line);
 };
 };
@@ -112,48 +112,6 @@ Crowi.initClassesByOS = function() {
   });
   });
 };
 };
 
 
-Crowi.findHashFromUrl = function(url) {
-  let match;
-  /* eslint-disable no-cond-assign */
-  if (match = url.match(/#(.+)$/)) {
-    return `#${match[1]}`;
-  }
-  /* eslint-enable no-cond-assign */
-
-  return '';
-};
-
-Crowi.findSectionHeader = function(hash) {
-  if (hash.length === 0) {
-    return;
-  }
-
-  // omit '#'
-  const id = hash.replace('#', '');
-  // don't use jQuery and document.querySelector
-  //  because hash may containe Base64 encoded strings
-  const elem = document.getElementById(id);
-  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
-    return elem;
-  }
-
-  return null;
-};
-
-Crowi.unblinkSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.remove('blink');
-  }
-};
-
-Crowi.blinkSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.add('blink');
-  }
-};
-
 // window.addEventListener('load', () => {
 // window.addEventListener('load', () => {
 //   const { appContainer } = window;
 //   const { appContainer } = window;
 //   const pageContainer = appContainer.getContainer('PageContainer');
 //   const pageContainer = appContainer.getContainer('PageContainer');
@@ -219,28 +177,22 @@ window.addEventListener('load', () => {
     });
     });
   }
   }
 
 
-  Crowi.blinkSelectedSection(window.location.hash);
+  blinkSectionHeaderAtBoot();
+
   Crowi.modifyScrollTop();
   Crowi.modifyScrollTop();
   Crowi.initClassesByOS();
   Crowi.initClassesByOS();
 });
 });
 
 
 window.addEventListener('hashchange', (e) => {
 window.addEventListener('hashchange', (e) => {
-  Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
-  Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
   Crowi.modifyScrollTop();
-  // const { appContainer } = window;
-  // const navigationContainer = appContainer.getContainer('NavigationContainer');
-
 
 
   // hash on page
   // hash on page
   if (window.location.hash) {
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
     if (window.location.hash === '#edit') {
-      // navigationContainer.setEditorMode('edit');
       Crowi.setCaretLineAndFocusToEditor();
       Crowi.setCaretLineAndFocusToEditor();
     }
     }
-    else if (window.location.hash === '#hackmd') {
-      // navigationContainer.setEditorMode('hackmd');
-    }
+    // else if (window.location.hash === '#hackmd') {
+    // }
   }
   }
 });
 });
 
 

+ 6 - 18
packages/app/src/client/services/ContextExtractor.tsx

@@ -9,24 +9,13 @@ import {
 } from '../../stores/context';
 } from '../../stores/context';
 
 
 import {
 import {
-  EditorMode, useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+  useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 
 const jsonNull = 'null';
 const jsonNull = 'null';
 
 
-const getInitialEditorMode = (): EditorMode => {
-  switch (window.location.hash) {
-    case '#edit':
-      return EditorMode.Editor;
-    case '#hackmd':
-      return EditorMode.HackMD;
-    default:
-      return EditorMode.View;
-  }
-};
-
 const ContextExtractorOnce: FC = () => {
 const ContextExtractorOnce: FC = () => {
 
 
   const mainContent = document.querySelector('#content-main');
   const mainContent = document.querySelector('#content-main');
@@ -73,12 +62,6 @@ const ContextExtractorOnce: FC = () => {
   // App
   // App
   useCurrentUser(currentUser);
   useCurrentUser(currentUser);
 
 
-  // Navigation
-  useEditorMode(getInitialEditorMode());
-  usePreferDrawerModeByUser();
-  usePreferDrawerModeOnEditByUser();
-  useIsDeviceSmallerThanMd();
-
   // Page
   // Page
   useCreatedAt(createdAt);
   useCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
   useDeleteUsername(deleteUsername);
@@ -108,6 +91,11 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useTargetAndAncestors(targetAndAncestors);
 
 
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
   return null;
   return null;
 };
 };
 
 

+ 0 - 157
packages/app/src/client/services/NavigationContainer.js

@@ -1,157 +0,0 @@
-import { Container } from 'unstated';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:services:NavigationContainer');
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-const SCROLL_THRES_SKIP = 200;
-const WIKI_HEADER_LINK = 120;
-
-export default class NavigationContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    const { localStorage } = window;
-
-    this.state = {
-      // editorMode: 'view',
-
-      isScrollTop: true,
-    };
-
-    // this.setEditorMode = this.setEditorMode.bind(this);
-    this.initScrollEvent();
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'NavigationContainer';
-  }
-
-  getPageContainer() {
-    return this.appContainer.getContainer('PageContainer');
-  }
-
-  initScrollEvent() {
-    window.addEventListener('scroll', () => {
-      const currentYOffset = window.pageYOffset;
-
-      // original throttling
-      if (SCROLL_THRES_SKIP < currentYOffset) {
-        return;
-      }
-
-      this.setState({
-        isScrollTop: currentYOffset === 0,
-      });
-    });
-  }
-
-  // setEditorMode(editorMode) {
-  //   const { isNotCreatable } = this.getPageContainer().state;
-
-  //   if (this.appContainer.currentUser == null) {
-  //     logger.warn('Please login or signup to edit the page or use hackmd.');
-  //     return;
-  //   }
-
-  //   if (isNotCreatable) {
-  //     logger.warn('This page could not edit.');
-  //     return;
-  //   }
-
-  //   this.setState({ editorMode });
-  //   if (editorMode === 'view') {
-  //     $('body').removeClass('on-edit');
-  //     $('body').removeClass('builtin-editor');
-  //     $('body').removeClass('hackmd');
-  //     $('body').removeClass('pathname-sidebar');
-  //     window.history.replaceState(null, '', window.location.pathname);
-  //   }
-
-  //   if (editorMode === 'edit') {
-  //     $('body').addClass('on-edit');
-  //     $('body').addClass('builtin-editor');
-  //     $('body').removeClass('hackmd');
-  //     // editing /Sidebar
-  //     if (window.location.pathname === '/Sidebar') {
-  //       $('body').addClass('pathname-sidebar');
-  //     }
-  //     window.location.hash = '#edit';
-  //   }
-
-  //   if (editorMode === 'hackmd') {
-  //     $('body').addClass('on-edit');
-  //     $('body').addClass('hackmd');
-  //     $('body').removeClass('builtin-editor');
-  //     $('body').removeClass('pathname-sidebar');
-  //     window.location.hash = '#hackmd';
-  //   }
-
-  //   this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
-  // }
-
-  /**
-   * Update drawer related state by specified 'newState' object
-   * @param {object} newState A newest state object
-   *
-   * Specify 'newState' like following code:
-   *
-   *   { ...this.state, overwriteParam: overwriteValue }
-   *
-   * because updating state of unstated container will be delayed unless you use await
-   */
-  // updateDrawerMode(newState) {
-  //   const {
-  //     editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-  //   } = newState;
-
-  //   // get preference on view or edit
-  //   const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-  //   const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
-  //   const isDrawerOpened = false; // close Drawer anyway
-
-  //   this.setState({ isDrawerMode, isDrawerOpened });
-  // }
-
-  /**
-   * Function that implements the click event for realizing smooth scroll
-   * @param {array} elements
-   */
-  addSmoothScrollEvent(elements = {}) {
-    elements.forEach(link => link.addEventListener('click', (e) => {
-      e.preventDefault();
-
-      const href = link.getAttribute('href').replace('#', '');
-      window.location.hash = href;
-      const targetDom = document.getElementById(href);
-      this.smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
-    }));
-  }
-
-  smoothScrollIntoView(element = null, offsetTop = 0) {
-    const targetElement = element || window.document.body;
-
-    // get the distance to the target element top
-    const rectTop = targetElement.getBoundingClientRect().top;
-
-    const top = window.pageYOffset + rectTop - offsetTop;
-
-    window.scrollTo({
-      top,
-      behavior: 'smooth',
-    });
-  }
-
-}

+ 0 - 7
packages/app/src/client/services/PageContainer.js

@@ -162,13 +162,6 @@ export default class PageContainer extends Container {
   }
   }
 
 
 
 
-  // get isAbleToOpenPageEditor() {
-  //   const { isNotCreatable, isTrashPage } = this.state;
-  //   const { isGuestUser } = this.appContainer;
-
-  //   return (!isNotCreatable && !isTrashPage && !isGuestUser);
-  // }
-
   /**
   /**
    * whether to display reaction buttons
    * whether to display reaction buttons
    * ex.) like, bookmark
    * ex.) like, bookmark

+ 27 - 0
packages/app/src/client/util/blink-section-header.ts

@@ -0,0 +1,27 @@
+let lastBlinkedElem;
+
+export const blinkElem = (elem: HTMLElement): void => {
+  if (lastBlinkedElem != null) {
+    lastBlinkedElem.classList.remove('blink');
+  }
+
+  elem.classList.add('blink');
+  lastBlinkedElem = elem;
+};
+
+export const blinkSectionHeaderAtBoot = (): HTMLElement | undefined => {
+  const { hash } = window.location;
+
+  if (hash.length === 0) {
+    return;
+  }
+
+  // omit '#'
+  const id = hash.replace('#', '');
+  // don't use jQuery and document.querySelector
+  //  because hash may containe Base64 encoded strings
+  const elem = document.getElementById(id);
+  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
+    blinkElem(elem);
+  }
+};

+ 45 - 0
packages/app/src/client/util/smooth-scroll.ts

@@ -0,0 +1,45 @@
+const WIKI_HEADER_LINK = 120;
+
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+  const targetElement = element || window.document.body;
+
+  // get the distance to the target element top
+  const rectTop = targetElement.getBoundingClientRect().top;
+
+  const top = window.pageYOffset + rectTop - offsetTop;
+
+  window.scrollTo({
+    top,
+    behavior: 'smooth',
+  });
+};
+
+export type SmoothScrollEventCallback = (elem: HTMLElement) => void;
+
+export const addSmoothScrollEvent = (elements: HTMLAnchorElement[], callback?: SmoothScrollEventCallback): void => {
+  elements.forEach((link) => {
+    const href = link.getAttribute('href');
+
+    if (href == null) {
+      return;
+    }
+
+    link.addEventListener('click', (e) => {
+      e.preventDefault();
+
+      // modify location.hash without scroll
+      window.history.pushState({}, '', link.href);
+
+      // smooth scroll
+      const elemId = href.replace('#', '');
+      const targetDom = document.getElementById(elemId);
+      if (targetDom != null) {
+        smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
+
+        if (callback != null) {
+          callback(targetDom);
+        }
+      }
+    });
+  });
+};

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

@@ -95,7 +95,7 @@ const OfficialBotSettings = (props) => {
       <div className="mx-3">
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
           const {
-            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
           } = slackAppIntegration;
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
           return (
@@ -118,6 +118,7 @@ const OfficialBotSettings = (props) => {
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
+                permissionsForSlackEventActions={permissionsForSlackEventActions}
                 onUpdateTokens={onUpdateTokens}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
                 onSubmitForm={onSubmitForm}
               />
               />

+ 6 - 7
packages/app/src/components/ContentLinkButtons.jsx

@@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 
 
@@ -20,7 +20,7 @@ const WIKI_HEADER_LINK = 120;
  */
  */
 const ContentLinkButtons = (props) => {
 const ContentLinkButtons = (props) => {
 
 
-  const { appContainer, navigationContainer, pageContainer } = props;
+  const { appContainer, pageContainer } = props;
   const { pageUser, path } = pageContainer.state;
   const { pageUser, path } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
   const { isSharedUser } = appContainer;
   const { isSharedUser } = appContainer;
@@ -39,7 +39,7 @@ const ContentLinkButtons = (props) => {
         <button
         <button
           type="button"
           type="button"
           className="btn btn-outline-secondary btn-sm btn-block"
           className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => navigationContainer.smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
         >
         >
           <i className="mr-2 icon-fw icon-bubbles"></i>
           <i className="mr-2 icon-fw icon-bubbles"></i>
           <span>Comments</span>
           <span>Comments</span>
@@ -53,7 +53,7 @@ const ContentLinkButtons = (props) => {
       <button
       <button
         type="button"
         type="button"
         className="btn btn-outline-secondary btn-sm px-2"
         className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
       >
       >
         <i className="mr-2 icon-star"></i>
         <i className="mr-2 icon-star"></i>
         <span>Bookmarks</span>
         <span>Bookmarks</span>
@@ -67,7 +67,7 @@ const ContentLinkButtons = (props) => {
       <button
       <button
         type="button"
         type="button"
         className="btn btn-outline-secondary btn-sm px-3"
         className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
       >
       >
         <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
         <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
         <span>Recently Created</span>
         <span>Recently Created</span>
@@ -90,8 +90,7 @@ const ContentLinkButtons = (props) => {
 
 
 ContentLinkButtons.propTypes = {
 ContentLinkButtons.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, NavigationContainer, PageContainer]);
+export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 40 - 0
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -0,0 +1,40 @@
+import { FC, useCallback, useEffect } from 'react';
+
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
+
+const HashChanged: FC<void> = () => {
+  const { data: isEditable } = useIsEditable();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const hashchangeHandler = useCallback(() => {
+    const { hash } = window.location;
+
+    if (hash == null) {
+      return;
+    }
+
+    if (hash === '#edit') {
+      mutateEditorMode(EditorMode.Editor);
+    }
+  }, [mutateEditorMode]);
+
+  // setup effect
+  useEffect(() => {
+    if (!isEditable) {
+      return;
+    }
+
+    window.addEventListener('hashchange', hashchangeHandler);
+
+    // return remove handler
+    return function cleanup() {
+      window.removeEventListener('hashchange', hashchangeHandler);
+    };
+
+  }, [hashchangeHandler, isEditable, mutateEditorMode]);
+
+  return null;
+};
+
+export default HashChanged;

+ 5 - 5
packages/app/src/components/Fab.jsx

@@ -5,8 +5,9 @@ import loggerFactory from '~/utils/logger';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+
 import { useCreateModalStatus } from '~/stores/ui';
 import { useCreateModalStatus } from '~/stores/ui';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import CreatePageIcon from './Icons/CreatePageIcon';
@@ -15,7 +16,7 @@ import ReturnTopIcon from './Icons/ReturnTopIcon';
 const logger = loggerFactory('growi:cli:Fab');
 const logger = loggerFactory('growi:cli:Fab');
 
 
 const Fab = (props) => {
 const Fab = (props) => {
-  const { navigationContainer, appContainer } = props;
+  const { appContainer } = props;
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
 
 
   const { open: openCreateModal } = useCreateModalStatus();
   const { open: openCreateModal } = useCreateModalStatus();
@@ -72,7 +73,7 @@ const Fab = (props) => {
         <button
         <button
           type="button"
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
-          onClick={() => navigationContainer.smoothScrollIntoView()}
+          onClick={() => smoothScrollIntoView()}
         >
         >
           <ReturnTopIcon />
           <ReturnTopIcon />
         </button>
         </button>
@@ -84,7 +85,6 @@ const Fab = (props) => {
 
 
 Fab.propTypes = {
 Fab.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(Fab, [AppContainer, NavigationContainer]);
+export default withUnstatedContainers(Fab, [AppContainer]);

+ 10 - 9
packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,15 +1,19 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
 
 
 const EditPage = (props) => {
 const EditPage = (props) => {
+  const { data: isEditable } = useIsEditable();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { mutate: mutateEditorMode } = useEditorMode();
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {
+    if (!isEditable) {
+      return;
+    }
+
     // ignore when dom that has 'modal in' classes exists
     // ignore when dom that has 'modal in' classes exists
     if (document.getElementsByClassName('modal in').length > 0) {
     if (document.getElementsByClassName('modal in').length > 0) {
       return;
       return;
@@ -19,20 +23,17 @@ const EditPage = (props) => {
 
 
     // remove this
     // remove this
     props.onDeleteRender(this);
     props.onDeleteRender(this);
-  }, [mutateEditorMode, props]);
+  }, [isEditable, mutateEditorMode, props]);
 
 
-  return <></>;
+  return null;
 };
 };
 
 
 EditPage.propTypes = {
 EditPage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 };
 
 
-const EditPageWrapper = withUnstatedContainers(EditPage, [NavigationContainer]);
-
-EditPageWrapper.getHotkeyStrokes = () => {
+EditPage.getHotkeyStrokes = () => {
   return [['e']];
   return [['e']];
 };
 };
 
 
-export default EditPageWrapper;
+export default EditPage;

+ 1 - 1
packages/app/src/components/LikeButtons.tsx

@@ -60,7 +60,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
       <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
       <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
         <PopoverBody className="seen-user-popover">
         <PopoverBody className="seen-user-popover">
           <div className="px-2 text-right user-list-content text-truncate text-muted">
           <div className="px-2 text-right user-list-content text-truncate text-muted">
-            {props.likers.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
+            {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
           </div>
           </div>
         </PopoverBody>
         </PopoverBody>
       </Popover>
       </Popover>

+ 1 - 3
packages/app/src/components/Navbar/GlobalSearch.jsx

@@ -4,7 +4,6 @@ import { withTranslation } from 'react-i18next';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 
 
 import SearchForm from '../SearchForm';
 import SearchForm from '../SearchForm';
 
 
@@ -97,7 +96,6 @@ class GlobalSearch extends React.Component {
 GlobalSearch.propTypes = {
 GlobalSearch.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 
 
   dropup: PropTypes.bool,
   dropup: PropTypes.bool,
 };
 };
@@ -105,6 +103,6 @@ GlobalSearch.propTypes = {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer, NavigationContainer]);
+const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer]);
 
 
 export default withTranslation()(GlobalSearchWrapper);
 export default withTranslation()(GlobalSearchWrapper);

+ 1 - 10
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -1,18 +1,12 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { useCreateModalStatus, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 import { useCreateModalStatus, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';
 
 
 const GrowiNavbarBottom = (props) => {
 const GrowiNavbarBottom = (props) => {
 
 
-  const {
-    navigationContainer,
-  } = props;
-
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { open: openCreateModal } = useCreateModalStatus();
   const { open: openCreateModal } = useCreateModalStatus();
@@ -71,8 +65,5 @@ const GrowiNavbarBottom = (props) => {
   );
   );
 };
 };
 
 
-GrowiNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
 
 
-export default withUnstatedContainers(GrowiNavbarBottom, [NavigationContainer]);
+export default GrowiNavbarBottom;

+ 0 - 1
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -11,7 +11,6 @@ import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings'
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 
 import {
 import {

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

@@ -167,6 +167,11 @@ Page.propTypes = {
 
 
 const PageWrapper = (props) => {
 const PageWrapper = (props) => {
   const { data } = useEditorMode();
   const { data } = useEditorMode();
+
+  if (data == null) {
+    return null;
+  }
+
   return <Page {...props} editorMode={data} />;
   return <Page {...props} editorMode={data} />;
 };
 };
 
 

+ 20 - 11
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -14,6 +14,8 @@ import ContentLinkButtons from '../ContentLinkButtons';
 import PageAccessories from '../PageAccessories';
 import PageAccessories from '../PageAccessories';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+import { useIsEditable } from '~/stores/context';
 
 
 
 
 const DisplaySwitcher = (props) => {
 const DisplaySwitcher = (props) => {
@@ -22,6 +24,7 @@ const DisplaySwitcher = (props) => {
   } = props;
   } = props;
   const { isPageExist, pageUser } = pageContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
 
 
+  const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
 
 
   const isViewMode = editorMode === EditorMode.View;
   const isViewMode = editorMode === EditorMode.View;
@@ -54,18 +57,24 @@ const DisplaySwitcher = (props) => {
 
 
           </div>
           </div>
         </TabPane>
         </TabPane>
-        <TabPane tabId={EditorMode.Editor}>
-          <div id="page-editor">
-            <Editor />
-          </div>
-        </TabPane>
-        <TabPane tabId={EditorMode.HackMD}>
-          <div id="page-editor-with-hackmd">
-            <PageEditorByHackmd />
-          </div>
-        </TabPane>
+        { isEditable && (
+          <TabPane tabId={EditorMode.Editor}>
+            <div id="page-editor">
+              <Editor />
+            </div>
+          </TabPane>
+        ) }
+        { isEditable && (
+          <TabPane tabId={EditorMode.HackMD}>
+            <div id="page-editor-with-hackmd">
+              <PageEditorByHackmd />
+            </div>
+          </TabPane>
+        ) }
       </TabContent>
       </TabContent>
-      {!isViewMode && <EditorNavbarBottom /> }
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
     </>
     </>
   );
   );
 };
 };

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

@@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { blinkElem } from '~/client/util/blink-section-header';
 
 
 import RevisionBody from './RevisionBody';
 import RevisionBody from './RevisionBody';
 
 
@@ -35,7 +36,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
 
   componentDidUpdate(prevProps) {
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords, navigationContainer } = this.props;
+    const { markdown, highlightKeywords } = this.props;
 
 
     // render only when props.markdown is updated
     // render only when props.markdown is updated
     if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
     if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
@@ -46,7 +47,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
 
     const HeaderLink = document.getElementsByClassName('revision-head-link');
     const HeaderLink = document.getElementsByClassName('revision-head-link');
     const HeaderLinkArray = Array.from(HeaderLink);
     const HeaderLinkArray = Array.from(HeaderLink);
-    navigationContainer.addSmoothScrollEvent(HeaderLinkArray);
+    addSmoothScrollEvent(HeaderLinkArray, blinkElem);
 
 
     const { interceptorManager } = this.props.appContainer;
     const { interceptorManager } = this.props.appContainer;
 
 
@@ -119,7 +120,6 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
 
 LegacyRevisionRenderer.propTypes = {
 LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
   highlightKeywords: PropTypes.string,
@@ -129,7 +129,7 @@ LegacyRevisionRenderer.propTypes = {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRenderer, [AppContainer, NavigationContainer]);
+const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRenderer, [AppContainer]);
 
 
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types

+ 1 - 0
packages/app/src/components/Page/TagLabels.jsx

@@ -8,6 +8,7 @@ import AppContainer from '~/client/services/AppContainer';
 
 
 import RenderTagLabels from './RenderTagLabels';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 import TagEditModal from './TagEditModal';
+import { EditorMode } from '~/stores/ui';
 
 
 class TagLabels extends React.Component {
 class TagLabels extends React.Component {
 
 

+ 15 - 2
packages/app/src/components/PageEditor.jsx

@@ -17,6 +17,7 @@ import EditorContainer from '~/client/services/EditorContainer';
 
 
 // TODO: remove this when omitting unstated is completed
 // TODO: remove this when omitting unstated is completed
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
 
 
 const logger = loggerFactory('growi:PageEditor');
 const logger = loggerFactory('growi:PageEditor');
 
 
@@ -309,6 +310,10 @@ class PageEditor extends React.Component {
   }
   }
 
 
   render() {
   render() {
+    if (!this.props.isEditable) {
+      return null;
+    }
+
     const config = this.props.appContainer.getConfig();
     const config = this.props.appContainer.getConfig();
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
@@ -353,8 +358,14 @@ class PageEditor extends React.Component {
 const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
 const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
 
 
 const PageEditorWrapper = (props) => {
 const PageEditorWrapper = (props) => {
-  const { data } = useEditorMode();
-  return <PageEditorHOCWrapper {...props} editorMode={data} />;
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+
+  if (isEditable == null || editorMode == null) {
+    return null;
+  }
+
+  return <PageEditorHOCWrapper {...props} isEditable={isEditable} editorMode={editorMode} />;
 };
 };
 
 
 PageEditor.propTypes = {
 PageEditor.propTypes = {
@@ -362,6 +373,8 @@ PageEditor.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
 
+  isEditable: PropTypes.bool.isRequired,
+
   // TODO: remove this when omitting unstated is completed
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
 };
 };

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

@@ -424,6 +424,11 @@ const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd,
 
 
 const PageEditorByHackmdWrapper = (props) => {
 const PageEditorByHackmdWrapper = (props) => {
   const { data } = useEditorMode();
   const { data } = useEditorMode();
+
+  if (data == null) {
+    return null;
+  }
+
   return <PageEditorByHackmdHOCWrapper {...props} editorMode={data} />;
   return <PageEditorByHackmdHOCWrapper {...props} editorMode={data} />;
 };
 };
 
 

+ 13 - 2
packages/app/src/components/SavePageControls.jsx

@@ -19,6 +19,7 @@ import GrantSelector from './SavePageControls/GrantSelector';
 
 
 // TODO: remove this when omitting unstated is completed
 // TODO: remove this when omitting unstated is completed
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
 
 
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
@@ -114,8 +115,18 @@ class SavePageControls extends React.Component {
 const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
 const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
 
 
 const SavePageControlsWrapper = (props) => {
 const SavePageControlsWrapper = (props) => {
-  const { data } = useEditorMode();
-  return <SavePageControlsHOCWrapper {...props} editorMode={data} />;
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+
+  if (isEditable == null || editorMode == null) {
+    return null;
+  }
+
+  if (!isEditable) {
+    return null;
+  }
+
+  return <SavePageControlsHOCWrapper {...props} editorMode={editorMode} />;
 };
 };
 
 
 SavePageControls.propTypes = {
 SavePageControls.propTypes = {

+ 0 - 2
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -18,13 +18,11 @@ const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
     contents, iconName, onItemSelected,
     contents, iconName, onItemSelected,
   } = props;
   } = props;
 
 
-  // TODO: migrate from NavigationContainer
   const { data: currentContents, mutate } = useCurrentSidebarContents();
   const { data: currentContents, mutate } = useCurrentSidebarContents();
 
 
   const isSelected = contents === currentContents;
   const isSelected = contents === currentContents;
 
 
   const itemSelectedHandler = useCallback(() => {
   const itemSelectedHandler = useCallback(() => {
-    // const { navigationContainer, onItemSelected } = this.props;
     if (onItemSelected != null) {
     if (onItemSelected != null) {
       onItemSelected(contents);
       onItemSelected(contents);
     }
     }

+ 1 - 10
packages/app/src/components/StickyStretchableScroller.jsx

@@ -5,8 +5,6 @@ import { debounce } from 'throttle-debounce';
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 
 
 
 
@@ -103,7 +101,7 @@ const StickyStretchableScroller = (props) => {
 
 
   const stickyChangeHandler = useCallback((event) => {
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
     logger.debug('StickyEvents.CHANGE detected');
-    resetScrollbar();
+    setTimeout(resetScrollbar, 100);
   }, [resetScrollbar]);
   }, [resetScrollbar]);
 
 
   // setup effect by sticky event
   // setup effect by sticky event
@@ -139,13 +137,6 @@ const StickyStretchableScroller = (props) => {
     };
     };
   }, [resetScrollbarDebounced]);
   }, [resetScrollbarDebounced]);
 
 
-  // setup effect by isScrollTop
-  // useEffect(() => {
-  //   if (navigationContainer.state.isScrollTop) {
-  //     resetScrollbar();
-  //   }
-  // }, [navigationContainer.state.isScrollTop, resetScrollbar]);
-
   // setup effect by update props
   // setup effect by update props
   useEffect(() => {
   useEffect(() => {
     resetScrollbarDebounced();
     resetScrollbarDebounced();

+ 6 - 6
packages/app/src/components/TableOfContents.jsx

@@ -5,7 +5,8 @@ import loggerFactory from '~/utils/logger';
 
 
 
 
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { blinkElem } from '~/client/util/blink-section-header';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
@@ -20,7 +21,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
  */
 const TableOfContents = (props) => {
 const TableOfContents = (props) => {
 
 
-  const { t, pageContainer, navigationContainer } = props;
+  const { t, pageContainer } = props;
   const { pageUser } = pageContainer.state;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
   const isUserPage = pageUser != null;
 
 
@@ -50,8 +51,8 @@ const TableOfContents = (props) => {
   useEffect(() => {
   useEffect(() => {
     const tocDom = document.getElementById('revision-toc-content');
     const tocDom = document.getElementById('revision-toc-content');
     const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
     const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
-    navigationContainer.addSmoothScrollEvent(anchorsInToc);
-  }, [tocHtml, navigationContainer]);
+    addSmoothScrollEvent(anchorsInToc, blinkElem);
+  }, [tocHtml]);
 
 
   return (
   return (
     <StickyStretchableScroller
     <StickyStretchableScroller
@@ -85,13 +86,12 @@ const TableOfContents = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer, NavigationContainer]);
+const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer]);
 
 
 TableOfContents.propTypes = {
 TableOfContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
 
 
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(TableOfContentsWrapper);
 export default withTranslation()(TableOfContentsWrapper);

+ 71 - 41
packages/app/src/stores/context.tsx

@@ -1,4 +1,6 @@
-import { SWRResponse } from 'swr';
+import { Key, SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
 import { IUser } from '../interfaces/user';
 import { IUser } from '../interfaces/user';
@@ -10,123 +12,151 @@ import { TargetAndAncestors } from '../interfaces/page-listing-results';
 type Nullable<T> = T | null;
 type Nullable<T> = T | null;
 
 
 export const useCurrentUser = (initialData?: IUser): SWRResponse<Nullable<IUser>, Error> => {
 export const useCurrentUser = (initialData?: IUser): SWRResponse<Nullable<IUser>, Error> => {
-  return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData || null);
+  return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData ?? null);
 };
 };
 
 
 export const useRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionId', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('revisionId', initialData ?? null);
 };
 };
 
 
 export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
 export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('currentPagePath', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('currentPagePath', initialData ?? null);
 };
 };
 
 
-export const useIsSharedUser = (): SWRResponse<boolean, Error> => {
-  const { data: currentUser } = useCurrentUser();
-  const { data: currentPagePath } = useCurrentPagePath();
-
-  const isLoading = currentUser === undefined || currentPagePath === undefined;
-
-  const key = isLoading ? null : 'isSharedUser';
-  const value = !isLoading && currentUser == null && pagePathUtils.isSharedPage(currentPagePath as string);
-
-  return useStaticSWR(key, value);
-};
 
 
 export const usePageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
 export const usePageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageId', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('pageId', initialData ?? null);
 };
 };
 
 
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData ?? null);
 };
 };
 
 
 export const useCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('createdAt', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('createdAt', initialData ?? null);
 };
 };
 
 
 export const useUpdatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useUpdatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('updatedAt', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('updatedAt', initialData ?? null);
 };
 };
 
 
 export const useDeletedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useDeletedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('deletedAt', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('deletedAt', initialData ?? null);
 };
 };
 
 
 export const useIsUserPage = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useIsUserPage = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isUserPage', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isUserPage', initialData ?? null);
 };
 };
 
 
-export const useIsTrashPage = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isTrashPage', initialData || null);
+export const useIsTrashPage = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
+  return useStaticSWR<Nullable<boolean>, Error>('isTrashPage', initialData ?? null);
 };
 };
 
 
 export const useIsDeleted = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useIsDeleted = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isDeleted', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isDeleted', initialData ?? null);
 };
 };
 
 
 export const useIsDeletable = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useIsDeletable = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isDeletable', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isDeletable', initialData ?? null);
 };
 };
 
 
-export const useIsNotCreatable = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isNotCreatable', initialData || null);
+export const useIsNotCreatable = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
+  return useStaticSWR<Nullable<boolean>, Error>('isNotCreatable', initialData ?? null);
 };
 };
 
 
 export const useIsAbleToDeleteCompletely = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useIsAbleToDeleteCompletely = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isAbleToDeleteCompletely', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isAbleToDeleteCompletely', initialData ?? null);
 };
 };
 
 
 export const useIsPageExist = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useIsPageExist = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isPageExist', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isPageExist', initialData ?? null);
 };
 };
 
 
 export const usePageUser = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const usePageUser = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageUser', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('pageUser', initialData ?? null);
 };
 };
 
 
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData ?? null);
 };
 };
 
 
 export const useTemplateTagData = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useTemplateTagData = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData ?? null);
 };
 };
 
 
 export const useShareLinksNumber = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useShareLinksNumber = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData ?? null);
 };
 };
 
 
 export const useShareLinkId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useShareLinkId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('shareLinkId', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('shareLinkId', initialData ?? null);
 };
 };
 
 
 export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData ?? null);
 };
 };
 
 
 export const useLastUpdateUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useLastUpdateUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('lastUpdateUsername', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('lastUpdateUsername', initialData ?? null);
 };
 };
 
 
 export const useDeleteUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useDeleteUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData ?? null);
 };
 };
 
 
 export const usePageIdOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const usePageIdOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageIdOnHackmd', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('pageIdOnHackmd', initialData ?? null);
 };
 };
 
 
 export const useHasDraftOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useHasDraftOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData ?? null);
 };
 };
 
 
 export const useCreator = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useCreator = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('creator', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('creator', initialData ?? null);
 };
 };
 
 
 export const useRevisionAuthor = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useRevisionAuthor = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData ?? null);
+};
+
+
+/** **********************************************************
+ *                     Computed contexts
+ *********************************************************** */
+
+export const useIsGuestUser = (): SWRResponse<boolean, Error> => {
+  const { data: currentUser } = useCurrentUser();
+
+  return useSWRImmutable(
+    ['isGuestUser', currentUser],
+    (key: Key, currentUser: IUser) => currentUser == null,
+  );
+};
+
+export const useIsEditable = (): SWRResponse<boolean, Error> => {
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isNotCreatable } = useIsNotCreatable();
+  const { data: isTrashPage } = useIsTrashPage();
+
+  return useSWRImmutable(
+    ['isEditable', isGuestUser, isTrashPage, isNotCreatable],
+    (key: Key, isGuestUser: boolean, isTrashPage: boolean, isNotCreatable: boolean) => {
+      return (!isNotCreatable && !isTrashPage && !isGuestUser);
+    },
+  );
+};
+
+export const useIsSharedUser = (): SWRResponse<boolean, Error> => {
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  return useSWRImmutable(
+    ['isSharedUser', isGuestUser, currentPagePath],
+    (key: Key, isGuestUser: boolean, currentPagePath: string) => {
+      return isGuestUser && pagePathUtils.isSharedPage(currentPagePath as string);
+    },
+  );
 };
 };
 
 
 export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {
 export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {

+ 79 - 47
packages/app/src/stores/ui.tsx

@@ -12,7 +12,7 @@ import loggerFactory from '~/utils/logger';
 import { sessionStorageMiddleware } from './middlewares/sync-to-storage';
 import { sessionStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
-import { useCurrentPagePath } from './context';
+import { useCurrentPagePath, useIsEditable } from './context';
 
 
 const logger = loggerFactory('growi:stores:ui');
 const logger = loggerFactory('growi:stores:ui');
 
 
@@ -61,54 +61,86 @@ export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
 };
 };
 
 
 
 
-const postChangeEditorModeMiddleware: Middleware = (useSWRNext) => {
-  return (...args) => {
-    // -- TODO: https://redmine.weseek.co.jp/issues/81817
-    const swrNext = useSWRNext(...args);
-    return {
-      ...swrNext,
-      mutate: (data, shouldRevalidate) => {
-        return swrNext.mutate(data, shouldRevalidate)
-          .then((value) => {
-            const newEditorMode = value as unknown as EditorMode;
-            switch (newEditorMode) {
-              case EditorMode.View:
-                $('body').removeClass('on-edit');
-                $('body').removeClass('builtin-editor');
-                $('body').removeClass('hackmd');
-                $('body').removeClass('pathname-sidebar');
-                window.history.replaceState(null, '', window.location.pathname);
-                break;
-              case EditorMode.Editor:
-                $('body').addClass('on-edit');
-                $('body').addClass('builtin-editor');
-                $('body').removeClass('hackmd');
-                // editing /Sidebar
-                if (window.location.pathname === '/Sidebar') {
-                  $('body').addClass('pathname-sidebar');
-                }
-                window.location.hash = '#edit';
-                break;
-              case EditorMode.HackMD:
-                $('body').addClass('on-edit');
-                $('body').addClass('hackmd');
-                $('body').removeClass('builtin-editor');
-                $('body').removeClass('pathname-sidebar');
-                window.location.hash = '#hackmd';
-                break;
-            }
-            return value;
-          });
-      },
-    };
-  };
+const updateBodyClassesForEditorMode = (newEditorMode: EditorMode) => {
+  switch (newEditorMode) {
+    case EditorMode.View:
+      $('body').removeClass('on-edit');
+      $('body').removeClass('builtin-editor');
+      $('body').removeClass('hackmd');
+      $('body').removeClass('pathname-sidebar');
+      window.history.replaceState(null, '', window.location.pathname);
+      break;
+    case EditorMode.Editor:
+      $('body').addClass('on-edit');
+      $('body').addClass('builtin-editor');
+      $('body').removeClass('hackmd');
+      // editing /Sidebar
+      if (window.location.pathname === '/Sidebar') {
+        $('body').addClass('pathname-sidebar');
+      }
+      window.location.hash = '#edit';
+      break;
+    case EditorMode.HackMD:
+      $('body').addClass('on-edit');
+      $('body').addClass('hackmd');
+      $('body').removeClass('builtin-editor');
+      $('body').removeClass('pathname-sidebar');
+      window.location.hash = '#hackmd';
+      break;
+  }
+};
+
+export const useEditorModeByHash = (): SWRResponse<EditorMode, Error> => {
+  return useSWRImmutable(
+    ['initialEditorMode', window.location.hash],
+    (key: Key, hash: string) => {
+      switch (hash) {
+        case '#edit':
+          return EditorMode.Editor;
+        case '#hackmd':
+          return EditorMode.HackMD;
+        default:
+          return EditorMode.View;
+      }
+    },
+  );
 };
 };
 
 
-export const useEditorMode = (editorMode?: EditorMode): SWRResponse<EditorMode, Error> => {
-  const key: Key = 'editorMode';
-  const initialData = EditorMode.View;
+let isEditorModeLoaded = false;
+export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
+  const { data: _isEditable } = useIsEditable();
+  const { data: editorModeByHash } = useEditorModeByHash();
+
+  const isLoading = _isEditable === undefined;
+  const isEditable = !isLoading && _isEditable;
+  const initialData = isEditable ? editorModeByHash : EditorMode.View;
+
+  const swrResponse = useSWRImmutable(
+    isLoading ? null : ['editorMode', isEditable],
+    null,
+    { fallbackData: initialData },
+  );
+
+  // initial updating
+  if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
+    if (isEditable) {
+      updateBodyClassesForEditorMode(swrResponse.data);
+    }
+    isEditorModeLoaded = true;
+  }
 
 
-  return useStaticSWR(key, editorMode || null, { fallbackData: initialData, use: [postChangeEditorModeMiddleware] });
+  return {
+    ...swrResponse,
+
+    // overwrite mutate
+    mutate: (editorMode: EditorMode, shouldRevalidate?: boolean) => {
+      if (!isEditable) {
+        return Promise.resolve(EditorMode.View); // fixed if not editable
+      }
+      updateBodyClassesForEditorMode(editorMode);
+      return swrResponse.mutate(editorMode, shouldRevalidate);
+    },
+  };
 };
 };
 
 
 export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> => {
 export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> => {
@@ -170,7 +202,7 @@ export const useDrawerMode = (): SWRResponse<boolean, Error> => {
   };
   };
 
 
   return useSWRImmutable(
   return useSWRImmutable(
-    condition ? [editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
+    condition ? ['isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
     calcDrawerMode,
     calcDrawerMode,
     {
     {
       fallback: calcDrawerMode,
       fallback: calcDrawerMode,

+ 10 - 9
packages/app/src/stores/use-static-swr.tsx

@@ -1,29 +1,30 @@
+import assert from 'assert';
 import {
 import {
   Key, SWRConfiguration, SWRResponse,
   Key, SWRConfiguration, SWRResponse,
 } from 'swr';
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
-import { Fetcher } from 'swr/dist/types';
 
 
 
 
 export function useStaticSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
 export function useStaticSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
-export function useStaticSWR<Data, Error>(key: Key, data: Data | Fetcher<Data> | null): SWRResponse<Data, Error>;
-export function useStaticSWR<Data, Error>(key: Key, data: Data | Fetcher<Data> | null,
+export function useStaticSWR<Data, Error>(key: Key, data: Data | null): SWRResponse<Data, Error>;
+export function useStaticSWR<Data, Error>(key: Key, data: Data | null,
   configuration: SWRConfiguration<Data, Error> | undefined): SWRResponse<Data, Error>;
   configuration: SWRConfiguration<Data, Error> | undefined): SWRResponse<Data, Error>;
 
 
 export function useStaticSWR<Data, Error>(
 export function useStaticSWR<Data, Error>(
     ...args: readonly [Key]
     ...args: readonly [Key]
-    | readonly [Key, Data | Fetcher<Data> | null]
-    | readonly [Key, Data | Fetcher<Data> | null, SWRConfiguration<Data, Error> | undefined]
+    | readonly [Key, Data | null]
+    | readonly [Key, Data | null, SWRConfiguration<Data, Error> | undefined]
 ): SWRResponse<Data, Error> {
 ): SWRResponse<Data, Error> {
-  const [key, fetcher, configuration] = args;
+  const [key, data, configuration] = args;
+
+  assert.notStrictEqual(configuration?.fetcher, null, 'useStaticSWR does not support \'configuration.fetcher\'');
 
 
   const swrResponse = useSWRImmutable(key, null, configuration);
   const swrResponse = useSWRImmutable(key, null, configuration);
 
 
   // mutate
   // mutate
-  const fetcherFixed = fetcher || configuration?.fetcher;
-  if (fetcherFixed != null) {
+  if (data != null) {
     const { mutate } = swrResponse;
     const { mutate } = swrResponse;
-    mutate(fetcherFixed);
+    mutate(data);
   }
   }
 
 
   return swrResponse;
   return swrResponse;

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

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

+ 1 - 1
packages/core/package.json

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

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

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

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

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

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "4.5.0-RC.0",
+  "version": "4.5.1-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/slack/package.json

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

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "4.5.0-RC.0",
+  "version": "4.5.1-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^4.5.0-RC.0",
+    "@growi/slack": "^4.5.1-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@tsed/common": "^6.43.0",

+ 5 - 1
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -50,7 +50,11 @@ export class RelationsService {
 
 
   private async syncSupportedGrowiCommands(relation:Relation): Promise<Relation> {
   private async syncSupportedGrowiCommands(relation:Relation): Promise<Relation> {
     const res = await this.getSupportedGrowiCommands(relation);
     const res = await this.getSupportedGrowiCommands(relation);
-    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = res.data.data;
+
+    // support both of v4.4.x and v4.5.x
+    // see: https://redmine.weseek.co.jp/issues/82985
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = res.data.data ?? res.data;
+
     if (relation !== null) {
     if (relation !== null) {
       relation.permissionsForBroadcastUseCommands = permissionsForBroadcastUseCommands;
       relation.permissionsForBroadcastUseCommands = permissionsForBroadcastUseCommands;
       relation.permissionsForSingleUseCommands = permissionsForSingleUseCommands;
       relation.permissionsForSingleUseCommands = permissionsForSingleUseCommands;

+ 1 - 1
packages/ui/package.json

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

+ 4 - 4
yarn.lock

@@ -18533,10 +18533,10 @@ statuses@~1.4.0:
   version "1.4.0"
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
 
 
-sticky-events@^3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/sticky-events/-/sticky-events-3.1.3.tgz#7b6b4091988b87b9f4e711c7c6532de07ab156dd"
-  integrity sha512-nTm2bDaYTXFHAyQS59mWDRnnno/D8oj3C4JddOdipq6ZRnLLqjj+PeyCSbHPwMVdfvQoKwmMmAztp+YybDhvtA==
+sticky-events@^3.4.11:
+  version "3.4.11"
+  resolved "https://registry.yarnpkg.com/sticky-events/-/sticky-events-3.4.11.tgz#c44b7866648c5b2818a00fe93f709aa86e9a09d3"
+  integrity sha512-g1ex5lR7EGJv8EXJh4gdBu0m8FMgAVeqFAow3dRR9MwxAIfBNVC2GtlXI1z+oMLE+/Ot2At+gp1aO/tbUGoOnQ==
 
 
 stoppable@^1.1.0:
 stoppable@^1.1.0:
   version "1.1.0"
   version "1.1.0"