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

Merge branch 'feat/82892-fix-warnings-in-search-page' into feat/82892-82974-warnings-in-search

* feat/82892-fix-warnings-in-search-page: (69 commits)
  Renamed
  Removed unnecessary code
  Throw instead
  Added delete modal
  Added revision attr as minimized data for rendering pagetree
  improved rendering clamp for highlight
  Added appendHighlight
  Removed unnecessary param
  Renamed usePageId -> useCurrentPageId, Removed unnecessary condition
  Imprv/inject user UI settings (#4830)
  Added filter by viewer
  Refactored ItemsTree.tsx
  add padding bottom
  remove condition
  Fixed lint
  Fixed lint
  82894 change max-height
  Added swr for root
  subtract height of search control from 100vh
  Improved types
  ...
Mao 4 лет назад
Родитель
Сommit
9cc4c5c168
79 измененных файлов с 936 добавлено и 848 удалено
  1. 41 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. 26 20
      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. 5 5
      packages/app/src/components/BookmarkButton.jsx
  16. 69 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  17. 6 7
      packages/app/src/components/ContentLinkButtons.jsx
  18. 40 0
      packages/app/src/components/EventListeneres/HashChanged.tsx
  19. 5 5
      packages/app/src/components/Fab.jsx
  20. 10 9
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  21. 1 1
      packages/app/src/components/LikeButtons.tsx
  22. 1 3
      packages/app/src/components/Navbar/GlobalSearch.jsx
  23. 1 10
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  24. 0 1
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  25. 5 0
      packages/app/src/components/Page.jsx
  26. 20 11
      packages/app/src/components/Page/DisplaySwitcher.jsx
  27. 5 5
      packages/app/src/components/Page/RevisionRenderer.jsx
  28. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  29. 15 2
      packages/app/src/components/PageEditor.jsx
  30. 5 2
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  31. 5 0
      packages/app/src/components/PageEditorByHackmd.jsx
  32. 13 2
      packages/app/src/components/SavePageControls.jsx
  33. 26 16
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  34. 9 70
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  35. 12 71
      packages/app/src/components/Sidebar.tsx
  36. 35 4
      packages/app/src/components/Sidebar/PageTree.tsx
  37. 59 20
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  38. 62 38
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  39. 0 2
      packages/app/src/components/Sidebar/SidebarNav.tsx
  40. 9 13
      packages/app/src/components/StickyStretchableScroller.jsx
  41. 6 6
      packages/app/src/components/TableOfContents.jsx
  42. 6 1
      packages/app/src/interfaces/page-listing-results.ts
  43. 2 2
      packages/app/src/interfaces/page.ts
  44. 1 1
      packages/app/src/interfaces/search.ts
  45. 31 0
      packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts
  46. 9 2
      packages/app/src/server/models/obsolete-page.js
  47. 7 3
      packages/app/src/server/models/page.ts
  48. 2 2
      packages/app/src/server/models/user-ui-settings.ts
  49. 14 0
      packages/app/src/server/routes/apiv3/page-listing.ts
  50. 0 18
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  51. 16 15
      packages/app/src/server/routes/index.js
  52. 4 4
      packages/app/src/server/routes/page.js
  53. 1 0
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  54. 2 11
      packages/app/src/server/service/search.ts
  55. 1 10
      packages/app/src/server/service/slack-command-handler/search.js
  56. 5 7
      packages/app/src/server/service/slack-event-handler/link-shared.ts
  57. 6 0
      packages/app/src/server/views/layout/layout.html
  58. 73 43
      packages/app/src/stores/context.tsx
  59. 15 1
      packages/app/src/stores/page-listing.tsx
  60. 6 7
      packages/app/src/stores/page.tsx
  61. 94 116
      packages/app/src/stores/ui.tsx
  62. 10 9
      packages/app/src/stores/use-static-swr.tsx
  63. 5 0
      packages/app/src/styles/_layout.scss
  64. 4 2
      packages/app/src/styles/_search.scss
  65. 1 17
      packages/app/src/styles/_sidebar.scss
  66. 5 0
      packages/app/src/styles/_subnav.scss
  67. 1 1
      packages/codemirror-textlint/package.json
  68. 1 1
      packages/core/package.json
  69. 1 1
      packages/plugin-attachment-refs/package.json
  70. 1 1
      packages/plugin-lsx/package.json
  71. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  72. 1 1
      packages/slack/package.json
  73. 1 0
      packages/slack/src/index.ts
  74. 10 0
      packages/slack/src/utils/generate-last-update-markdown.ts
  75. 2 0
      packages/slack/src/utils/required-scopes.ts
  76. 2 2
      packages/slackbot-proxy/package.json
  77. 5 1
      packages/slackbot-proxy/src/services/RelationsService.ts
  78. 1 1
      packages/ui/package.json
  79. 4 4
      yarn.lock

+ 41 - 1
CHANGELOG.md

@@ -1,9 +1,49 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.13...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.2...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.2](https://github.com/weseek/growi/compare/v4.5.1...v4.5.2) - 2021-12-06
+
+### 🐛 Bug Fixes
+
+- fix: Added scope for unfurl (#4811) @hakumizuki
+
+## [v4.5.1](https://github.com/weseek/growi/compare/v4.5.0...v4.5.1) - 2021-12-06
+
+### 🐛 Bug Fixes
+
+- fix: /admin/slack-integration page dump undefined error (#4806) @yuki-takei
+
+## [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.3-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.3-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.2`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.2/docker/Dockerfile)
+* [`4.5.2-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.2/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.3-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.3-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.3-RC.0",
+    "@growi/plugin-lsx": "^4.5.3-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.3-RC.0",
+    "@growi/slack": "^4.5.3-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.3-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') {
+    // }
   }
   }
 });
 });
 
 

+ 26 - 20
packages/app/src/client/services/ContextExtractor.tsx

@@ -4,29 +4,19 @@ import { pagePathUtils } from '@growi/core';
 import {
 import {
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
-  usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
+  useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
 } from '../../stores/context';
 } from '../../stores/context';
-
 import {
 import {
-  EditorMode, useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+  useIsDeviceSmallerThanMd,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 
 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');
@@ -36,6 +26,11 @@ const ContextExtractorOnce: FC = () => {
    */
    */
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
 
 
+  /*
+   * UserUISettings from DOM
+   */
+  const userUISettings: Partial<IUserUISettings> = JSON.parse(document.getElementById('growi-user-ui-settings')?.textContent || jsonNull);
+
   /*
   /*
    * Page Context from DOM
    * Page Context from DOM
    */
    */
@@ -73,11 +68,12 @@ const ContextExtractorOnce: FC = () => {
   // App
   // App
   useCurrentUser(currentUser);
   useCurrentUser(currentUser);
 
 
-  // Navigation
-  useEditorMode(getInitialEditorMode());
-  usePreferDrawerModeByUser();
-  usePreferDrawerModeOnEditByUser();
-  useIsDeviceSmallerThanMd();
+  // UserUISettings
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
+  usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed);
+  useCurrentSidebarContents(userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
 
   // Page
   // Page
   useCreatedAt(createdAt);
   useCreatedAt(createdAt);
@@ -93,7 +89,7 @@ const ContextExtractorOnce: FC = () => {
   useIsTrashPage(isTrashPage);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useLastUpdateUsername(lastUpdateUsername);
-  usePageId(pageId);
+  useCurrentPageId(pageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   usePageUser(pageUser);
   useCurrentPagePath(path);
   useCurrentPagePath(path);
@@ -108,6 +104,16 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useTargetAndAncestors(targetAndAncestors);
 
 
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
+  // 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}
               />
               />

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

@@ -42,11 +42,11 @@ class LegacyBookmarkButton extends React.Component {
           ${`btn-${this.props.size}`} ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
           ${`btn-${this.props.size}`} ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
         >
           <i className="icon-star mr-3"></i>
           <i className="icon-star mr-3"></i>
-          {sumOfBookmarks && (
-            <span className="total-bookmarks">
-              {sumOfBookmarks}
-            </span>
-          )}
+          <span className="total-bookmarks">
+            {sumOfBookmarks && (
+              sumOfBookmarks
+            )}
+          </span>
         </button>
         </button>
 
 
         {isGuestUser && (
         {isGuestUser && (

+ 69 - 0
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -0,0 +1,69 @@
+import React, { FC } from 'react';
+
+import toastr from 'toastr';
+import { useTranslation } from 'react-i18next';
+
+import { IPageHasId } from '~/interfaces/page';
+
+type PageItemControlProps = {
+  page: Partial<IPageHasId>,
+  onClickDeleteButton?: (pageId: string) => void,
+}
+
+const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
+
+  const { page, onClickDeleteButton } = props;
+  const { t } = useTranslation('');
+
+  const deleteButtonHandler = () => {
+    if (onClickDeleteButton != null && page._id != null) {
+      onClickDeleteButton(page._id);
+    }
+  };
+  return (
+    <>
+      <button
+        type="button"
+        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        data-toggle="dropdown"
+      >
+        <i className="fa fa-ellipsis-v text-muted"></i>
+      </button>
+      <div className="dropdown-menu dropdown-menu-right">
+
+        {/* TODO: if there is the following button in XD add it here
+        <button
+          type="button"
+          className="btn btn-link p-0"
+          value={page.path}
+          onClick={(e) => {
+            window.location.href = e.currentTarget.value;
+          }}
+        >
+          <i className="icon-login" />
+        </button>
+        */}
+
+        {/*
+          TODO: add function to the following buttons like using modal or others
+          ref: https://estoc.weseek.co.jp/redmine/issues/79026
+        */}
+        <button className="dropdown-item text-danger" type="button" onClick={deleteButtonHandler}>
+          <i className="icon-fw icon-fire"></i>{t('Delete')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw icon-star"></i>{t('Add to bookmark')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw icon-docs"></i>{t('Duplicate')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw  icon-action-redo"></i>{t('Move/Rename')}
+        </button>
+      </div>
+    </>
+  );
+
+};
+
+export default PageItemControl;

+ 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 - 1
packages/app/src/components/PageDeleteModal.tsx

@@ -149,7 +149,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        <div className="form-group">
+        <div className="form-group grw-scrollable-modal-body pb-1">
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}

+ 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 - 2
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -264,7 +264,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const linePosition = Math.max(0, line);
     const linePosition = Math.max(0, line);
 
 
     editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
     editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
-    this.setScrollTopByLine(linePosition);
+
+    setTimeout(() => {
+      this.setScrollTopByLine(linePosition);
+    }, 100);
   }
   }
 
 
   /**
   /**
@@ -277,7 +280,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     const editor = this.getCodeMirror();
     const editor = this.getCodeMirror();
     // get top position of the line
     // get top position of the line
-    const top = editor.charCoords({ line, ch: 0 }, 'local').top;
+    const top = editor.charCoords({ line: line - 1, ch: 0 }, 'local').top;
     editor.scrollTo(null, top);
     editor.scrollTo(null, top);
   }
   }
 
 

+ 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 = {

+ 26 - 16
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -26,19 +26,30 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
   return (
   return (
     <div className="content-main">
     <div className="content-main">
       <div className="search-result row" id="search-result">
       <div className="search-result row" id="search-result">
-        <div className="col-lg-6  page-list border boder-gray search-result-list px-0" id="search-result-list">
-
+        <div className="col-lg-6 page-list border boder-gray search-result-list px-0" id="search-result-list">
           <nav><SearchControl></SearchControl></nav>
           <nav><SearchControl></SearchControl></nav>
-          <div className="d-flex align-items-center justify-content-between mt-1 mb-3">
-            <div className="search-result-meta text-nowrap mr-3">
-              <span className="font-weight-light">{t('search_result.result_meta')} </span>
-              <span className="h5">{`"${searchingKeyword}"`}</span>
-              {/* Todo: replace "1-10" to the appropriate value */}
-              <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
-            </div>
-            <div className="input-group search-result-select-group">
-              <div className="input-group-prepend">
-                <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+          <div className="search-result-list-scroll">
+            <div className="d-flex align-items-center justify-content-between mt-1 mb-3">
+              <div className="search-result-meta text-nowrap mr-3">
+                <span className="font-weight-light">{t('search_result.result_meta')} </span>
+                <span className="h5">{`"${searchingKeyword}"`}</span>
+                {/* Todo: replace "1-10" to the appropriate value */}
+                <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
+              </div>
+              <div className="input-group search-result-select-group">
+                <div className="input-group-prepend">
+                  <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+                </div>
+                <select
+                  defaultValue={props.initialPagingLimit}
+                  className="custom-select"
+                  id="inputGroupSelect01"
+                  onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}
+                >
+                  {[20, 50, 100, 200].map((limit) => {
+                    return <option key={limit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
+                  })}
+                </select>
               </div>
               </div>
               <select
               <select
                 defaultValue={props.initialPagingLimit}
                 defaultValue={props.initialPagingLimit}
@@ -51,10 +62,9 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
                 })}
                 })}
               </select>
               </select>
             </div>
             </div>
-          </div>
-
-          <div className="page-list">
-            <ul className="page-list-ul page-list-ul-flat nav nav-pills"><SearchResultList></SearchResultList></ul>
+            <div className="page-list">
+              <ul className="page-list-ul page-list-ul-flat nav nav-pills"><SearchResultList></SearchResultList></ul>
+            </div>
           </div>
           </div>
         </div>
         </div>
         <div className="col-lg-6 d-none d-lg-block search-result-content">
         <div className="col-lg-6 d-none d-lg-block search-result-content">

+ 9 - 70
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -1,75 +1,13 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
 import Clamp from 'react-multiline-clamp';
 import Clamp from 'react-multiline-clamp';
-import toastr from 'toastr';
 
 
-import { useTranslation } from 'react-i18next';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
-import { IPageSearchResultData } from '../../interfaces/search';
-
-import { IPageHasId } from '~/interfaces/page';
-
-type PageItemControlProps = {
-  page: IPageHasId,
-  onClickDeleteButton?: (pageId: string)=>void,
-}
 
 
-const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
-
-  const { page, onClickDeleteButton } = props;
-  const { t } = useTranslation('');
-
-  const deleteButtonHandler = () => {
-    if (onClickDeleteButton != null) {
-      onClickDeleteButton(page._id);
-    }
-  };
-  return (
-    <>
-      <button
-        type="button"
-        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
-        data-toggle="dropdown"
-      >
-        <i className="fa fa-ellipsis-v text-muted"></i>
-      </button>
-      <div className="dropdown-menu dropdown-menu-right">
-
-        {/* TODO: if there is the following button in XD add it here
-        <button
-          type="button"
-          className="btn btn-link p-0"
-          value={page.path}
-          onClick={(e) => {
-            window.location.href = e.currentTarget.value;
-          }}
-        >
-          <i className="icon-login" />
-        </button>
-        */}
-
-        {/*
-          TODO: add function to the following buttons like using modal or others
-          ref: https://estoc.weseek.co.jp/redmine/issues/79026
-        */}
-        <button className="dropdown-item text-danger" type="button" onClick={deleteButtonHandler}>
-          <i className="icon-fw icon-fire"></i>{t('Delete')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw icon-star"></i>{t('Add to bookmark')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw icon-docs"></i>{t('Duplicate')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
-          <i className="icon-fw  icon-action-redo"></i>{t('Move/Rename')}
-        </button>
-      </div>
-    </>
-  );
+import { IPageSearchResultData } from '../../interfaces/search';
+import PageItemControl from '../Common/Dropdown/PageItemControl';
 
 
-};
 
 
 type Props = {
 type Props = {
   page: IPageSearchResultData,
   page: IPageSearchResultData,
@@ -143,12 +81,13 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               </div>
               </div>
             </div>
             </div>
             <div className="my-2">
             <div className="my-2">
-              <Clamp
-                lines={2}
-              >
-                {pageMeta.elasticSearchResult != null
-                && <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>}
-              </Clamp>
+              {
+                pageMeta.elasticSearchResult != null && (
+                  <Clamp lines={2}>
+                    <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
+                  </Clamp>
+                )
+              }
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 12 - 71
packages/app/src/components/Sidebar.tsx

@@ -43,20 +43,9 @@ const GlobalNavigation = () => {
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
 };
 };
 
 
-// dummy skelton contents
-const GlobalNavigationSkelton = () => {
-  return (
-    <div className="grw-sidebar-nav">
-      <div className="grw-sidebar-nav-primary-container">
-      </div>
-      <div className="grw-sidebar-nav-secondary-container">
-      </div>
-    </div>
-  );
-};
-
-
 const SidebarContentsWrapper = () => {
 const SidebarContentsWrapper = () => {
+  const [resetKey, setResetKey] = useState(0);
+
   const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
   const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
 
 
   const calcViewHeight = useCallback(() => {
   const calcViewHeight = useCallback(() => {
@@ -73,10 +62,11 @@ const SidebarContentsWrapper = () => {
         contentsElemSelector="#grw-sidebar-content-container"
         contentsElemSelector="#grw-sidebar-content-container"
         stickyElemSelector=".grw-sidebar"
         stickyElemSelector=".grw-sidebar"
         calcViewHeightFunc={calcViewHeight}
         calcViewHeightFunc={calcViewHeight}
+        resetKey={resetKey}
       />
       />
 
 
       <div id="grw-sidebar-contents-scroll-target">
       <div id="grw-sidebar-contents-scroll-target">
-        <div id="grw-sidebar-content-container">
+        <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
           <SidebarContents />
           <SidebarContents />
         </div>
         </div>
       </div>
       </div>
@@ -86,13 +76,6 @@ const SidebarContentsWrapper = () => {
   );
   );
 };
 };
 
 
-// dummy skelton contents
-const SidebarSkeltonContents = () => {
-  return (
-    <div>Skelton Contents!!!</div>
-  );
-};
-
 
 
 type Props = {
 type Props = {
 }
 }
@@ -104,26 +87,12 @@ const Sidebar: FC<Props> = (props: Props) => {
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
   const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
   const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
 
 
+  const [isTransitionEnabled, setTransitionEnabled] = useState(false);
+
   const [isHover, setHover] = useState(false);
   const [isHover, setHover] = useState(false);
   const [isDragging, setDrag] = useState(false);
   const [isDragging, setDrag] = useState(false);
-  const [isMounted, setMounted] = useState(false);
 
 
   const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
   const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
-  /**
-   * hack and override UIController.storeState
-   *
-   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
-   */
-  // hackUIController() {
-  //   const { navigationUIController } = this.props;
-
-  //   // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
-  //   const orgStoreState = navigationUIController.storeState;
-  //   navigationUIController.storeState = async(state) => {
-  //     await navigationUIController.setState(state);
-  //     orgStoreState(state);
-  //   };
-  // }
 
 
   const toggleDrawerMode = useCallback((bool) => {
   const toggleDrawerMode = useCallback((bool) => {
     const isStateModified = isResizeDisabled !== bool;
     const isStateModified = isResizeDisabled !== bool;
@@ -133,52 +102,24 @@ const Sidebar: FC<Props> = (props: Props) => {
 
 
     // Drawer <-- Dock
     // Drawer <-- Dock
     if (bool) {
     if (bool) {
-      // // cache state
-      // this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
-      // this.sidebarWidthCached = navigationUIController.state.productNavWidth;
-
-      // // clear transition temporary
-      // if (this.sidebarCollapsedCached) {
-      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
-      // }
-
       // disable resize
       // disable resize
       mutateSidebarResizeDisabled(true, false);
       mutateSidebarResizeDisabled(true, false);
     }
     }
     // Drawer --> Dock
     // Drawer --> Dock
     else {
     else {
-      // // clear transition temporary
-      // if (this.sidebarCollapsedCached) {
-      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
-      // }
-
       // enable resize
       // enable resize
       mutateSidebarResizeDisabled(false, false);
       mutateSidebarResizeDisabled(false, false);
-
-      // // restore width
-      // if (this.sidebarWidthCached != null) {
-      //   navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
-      // }
     }
     }
   }, [isResizeDisabled, mutateSidebarResizeDisabled]);
   }, [isResizeDisabled, mutateSidebarResizeDisabled]);
 
 
-  // addCssClassTemporary(className) {
-  //   // clear
-  //   this.sidebarElem.classList.add(className);
-
-  //   // restore after 300ms
-  //   setTimeout(() => {
-  //     this.sidebarElem.classList.remove(className);
-  //   }, 300);
-  // }
-
   const backdropClickedHandler = useCallback(() => {
   const backdropClickedHandler = useCallback(() => {
     mutateDrawerOpened(false, false);
     mutateDrawerOpened(false, false);
   }, [mutateDrawerOpened]);
   }, [mutateDrawerOpened]);
 
 
   useEffect(() => {
   useEffect(() => {
-    // this.hackUIController();
-    setMounted(true);
+    setTimeout(() => {
+      setTransitionEnabled(true);
+    }, 1000);
   }, []);
   }, []);
 
 
   useEffect(() => {
   useEffect(() => {
@@ -285,10 +226,10 @@ const Sidebar: FC<Props> = (props: Props) => {
     <>
     <>
       <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
       <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
         <div className="data-layout-container">
         <div className="data-layout-container">
-          <div className="navigation" onMouseLeave={hoverOutHandler}>
+          <div className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`} onMouseLeave={hoverOutHandler}>
             <div className="grw-navigation-wrap">
             <div className="grw-navigation-wrap">
               <div className="grw-global-navigation">
               <div className="grw-global-navigation">
-                { isMounted ? <GlobalNavigation></GlobalNavigation> : <GlobalNavigationSkelton></GlobalNavigationSkelton> }
+                <GlobalNavigation></GlobalNavigation>
               </div>
               </div>
               <div
               <div
                 ref={resizableContainer}
                 ref={resizableContainer}
@@ -298,7 +239,7 @@ const Sidebar: FC<Props> = (props: Props) => {
               >
               >
                 <div className="grw-contextual-navigation-child">
                 <div className="grw-contextual-navigation-child">
                   <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
                   <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
-                    { isMounted ? <SidebarContentsWrapper></SidebarContentsWrapper> : <SidebarSkeltonContents></SidebarSkeltonContents> }
+                    <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                   </div>
                 </div>
                 </div>
               </div>
               </div>

+ 35 - 4
packages/app/src/components/Sidebar/PageTree.tsx

@@ -1,16 +1,37 @@
-import React, { FC, memo } from 'react';
+import React, { FC, memo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import { useCurrentPagePath, useCurrentPageId, useTargetAndAncestors } from '~/stores/context';
 
 
 import ItemsTree from './PageTree/ItemsTree';
 import ItemsTree from './PageTree/ItemsTree';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
+import { IPageForPageDeleteModal } from '../PageDeleteModal';
 
 
 
 
 const PageTree: FC = memo(() => {
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { data } = useSWRxV5MigrationStatus();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: targetId } = useCurrentPageId();
+  const { data: targetAndAncestorsData } = useTargetAndAncestors();
+
+  const { data: migrationStatus } = useSWRxV5MigrationStatus();
+
+  // for delete modal
+  const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
+  const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
+
+  const onClickDeleteByPage = (page: IPageForPageDeleteModal) => {
+    setDeleteModalOpen(true);
+    setPagesToDelete([page]);
+  };
+
+  const onCloseDelete = () => {
+    setDeleteModalOpen(false);
+  };
+
+  const path = currentPath || '/';
 
 
   return (
   return (
     <>
     <>
@@ -19,12 +40,22 @@ const PageTree: FC = memo(() => {
       </div>
       </div>
 
 
       <div className="grw-sidebar-content-body">
       <div className="grw-sidebar-content-body">
-        <ItemsTree />
+        <ItemsTree
+          targetPath={path}
+          targetId={targetId}
+          targetAndAncestorsData={targetAndAncestorsData}
+          isDeleteModalOpen={isDeleteModalOpen}
+          pagesToDelete={pagesToDelete}
+          isAbleToDeleteCompletely={false} // TODO: pass isAbleToDeleteCompletely
+          isDeleteCompletelyModal={false} // TODO: pass isDeleteCompletelyModal
+          onCloseDelete={onCloseDelete}
+          onClickDeleteByPage={onClickDeleteByPage}
+        />
       </div>
       </div>
 
 
       <div className="grw-sidebar-content-footer">
       <div className="grw-sidebar-content-footer">
         {
         {
-          data?.migratablePagesCount != null && data.migratablePagesCount !== 0 && (
+          migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
             <PrivateLegacyPages />
             <PrivateLegacyPages />
           )
           )
         }
         }

+ 59 - 20
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -5,55 +5,68 @@ import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
+import { IPageHasId } from '~/interfaces/page';
 import { useSWRxPageChildren } from '../../../stores/page-listing';
 import { useSWRxPageChildren } from '../../../stores/page-listing';
-import { usePageId } from '../../../stores/context';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import PageItemControl from '../../Common/Dropdown/PageItemControl';
+import { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 
 
 
 interface ItemProps {
 interface ItemProps {
   itemNode: ItemNode
   itemNode: ItemNode
+  targetId?: string
   isOpen?: boolean
   isOpen?: boolean
+  onClickDeleteByPage?(page: IPageForPageDeleteModal): void
 }
 }
 
 
 // Utility to mark target
 // Utility to mark target
-const markTarget = (children: ItemNode[], targetId: string): void => {
+const markTarget = (children: ItemNode[], targetId?: string): void => {
+  if (targetId == null) {
+    return;
+  }
+
   children.forEach((node) => {
   children.forEach((node) => {
     if (node.page._id === targetId) {
     if (node.page._id === targetId) {
       node.page.isTarget = true;
       node.page.isTarget = true;
     }
     }
     return node;
     return node;
   });
   });
-
-  return;
 };
 };
 
 
 type ItemControlProps = {
 type ItemControlProps = {
+  page: Partial<IPageHasId>
+  onClickDeleteButtonHandler?(): void
   onClickPlusButtonHandler?(): void
   onClickPlusButtonHandler?(): void
 }
 }
 
 
 const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
 const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
-  const onClickHandler = () => {
-    const { onClickPlusButtonHandler: handler } = props;
-    if (handler == null) {
+  const onClickPlusButton = () => {
+    if (props.onClickPlusButtonHandler == null) {
       return;
       return;
     }
     }
 
 
-    handler();
+    props.onClickPlusButtonHandler();
   };
   };
 
 
+  const onClickDeleteButton = () => {
+    if (props.onClickDeleteButtonHandler == null) {
+      return;
+    }
+
+    props.onClickDeleteButtonHandler();
+  };
+
+  if (props.page == null) {
+    return <></>;
+  }
+
   return (
   return (
     <>
     <>
-      <button
-        type="button"
-        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
-        data-toggle="dropdown"
-      >
-        <i className="icon-options-vertical text-muted"></i>
-      </button>
+      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} />
       <button
       <button
         type="button"
         type="button"
         className="btn-link nav-link border-0 rounded grw-btn-page-management py-0"
         className="btn-link nav-link border-0 rounded grw-btn-page-management py-0"
-        onClick={onClickHandler}
+        onClick={onClickPlusButton}
       >
       >
         <i className="icon-plus text-muted"></i>
         <i className="icon-plus text-muted"></i>
       </button>
       </button>
@@ -65,7 +78,7 @@ const ItemCount: FC = () => {
   return (
   return (
     <>
     <>
       <span className="grw-pagetree-count badge badge-pill badge-light">
       <span className="grw-pagetree-count badge badge-pill badge-light">
-        10
+        {/* TODO: consider to show the number of children pages */}
       </span>
       </span>
     </>
     </>
   );
   );
@@ -73,7 +86,9 @@ const ItemCount: FC = () => {
 
 
 const Item: FC<ItemProps> = (props: ItemProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { itemNode, isOpen: _isOpen = false } = props;
+  const {
+    itemNode, targetId, isOpen: _isOpen = false, onClickDeleteByPage,
+  } = props;
 
 
   const { page, children } = itemNode;
   const { page, children } = itemNode;
 
 
@@ -82,7 +97,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
 
 
-  const { data: targetId } = usePageId();
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
   const hasChildren = useCallback((): boolean => {
   const hasChildren = useCallback((): boolean => {
@@ -93,6 +107,26 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     setIsOpen(!isOpen);
     setIsOpen(!isOpen);
   }, [isOpen]);
   }, [isOpen]);
 
 
+  const onClickDeleteButtonHandler = useCallback(() => {
+    if (onClickDeleteByPage == null) {
+      return;
+    }
+
+    const { _id: pageId, revision: revisionId, path } = page;
+
+    if (pageId == null || revisionId == null || path == null) {
+      throw Error('Any of _id, revision, and path must not be null.');
+    }
+
+    const pageToDelete: IPageForPageDeleteModal = {
+      pageId,
+      revisionId: revisionId as string,
+      path,
+    };
+
+    onClickDeleteByPage(pageToDelete);
+  }, [page, onClickDeleteByPage]);
+
   const inputValidator = (title: string | null): AlertInfo | null => {
   const inputValidator = (title: string | null): AlertInfo | null => {
     if (title == null || title === '') {
     if (title == null || title === '') {
       return {
       return {
@@ -158,7 +192,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           <ItemCount />
           <ItemCount />
         </div>
         </div>
         <div className="grw-pagetree-control d-none">
         <div className="grw-pagetree-control d-none">
-          <ItemControl onClickPlusButtonHandler={() => { setNewPageInputShown(true) }} />
+          <ItemControl
+            page={page}
+            onClickDeleteButtonHandler={onClickDeleteButtonHandler}
+            onClickPlusButtonHandler={() => { setNewPageInputShown(true) }}
+          />
         </div>
         </div>
       </div>
       </div>
 
 
@@ -175,6 +213,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             key={node.page._id}
             key={node.page._id}
             itemNode={node}
             itemNode={node}
             isOpen={false}
             isOpen={false}
+            onClickDeleteByPage={onClickDeleteByPage}
           />
           />
         ))
         ))
       }
       }

+ 62 - 38
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,17 +1,17 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
-import { IPage } from '../../../interfaces/page';
+import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren } from '../../../stores/page-listing';
-import { useTargetAndAncestors, useCurrentPagePath } from '../../../stores/context';
-import { HasObjectId } from '../../../interfaces/has-object-id';
-
+import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
+import { TargetAndAncestors } from '~/interfaces/page-listing-results';
+import { toastError } from '~/client/util/apiNotification';
+import PageDeleteModal, { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 
 /*
 /*
  * Utility to generate initial node
  * Utility to generate initial node
  */
  */
-const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[]): ItemNode => {
+const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPageHasId>[]): ItemNode => {
   const nodes = targetAndAncestors.map((page): ItemNode => {
   const nodes = targetAndAncestors.map((page): ItemNode => {
     return new ItemNode(page, []);
     return new ItemNode(page, []);
   });
   });
@@ -25,7 +25,7 @@ const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[])
   return rootNode;
   return rootNode;
 };
 };
 
 
-const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPage & HasObjectId>[]>, rootNode: ItemNode): ItemNode => {
+const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPageHasId>[]>, rootNode: ItemNode): ItemNode => {
   const paths = Object.keys(ancestorsChildren);
   const paths = Object.keys(ancestorsChildren);
 
 
   let currentNode = rootNode;
   let currentNode = rootNode;
@@ -42,53 +42,77 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
   return rootNode;
   return rootNode;
 };
 };
 
 
+type ItemsTreeProps = {
+  targetPath: string
+  targetId?: string
+  targetAndAncestorsData?: TargetAndAncestors
+
+  // for deleteModal
+  isDeleteModalOpen: boolean
+  pagesToDelete: IPageForPageDeleteModal[]
+  isAbleToDeleteCompletely: boolean
+  isDeleteCompletelyModal: boolean
+  onCloseDelete(): void
+  onClickDeleteByPage(page: IPageForPageDeleteModal): void
+}
+
+const renderByInitialNode = (
+    initialNode: ItemNode, DeleteModal: JSX.Element, targetId?: string, onClickDeleteByPage?: (page: IPageForPageDeleteModal) => void,
+): JSX.Element => {
+  return (
+    <div className="grw-pagetree p-3">
+      <Item key={initialNode.page.path} targetId={targetId} itemNode={initialNode} isOpen onClickDeleteByPage={onClickDeleteByPage} />
+      {DeleteModal}
+    </div>
+  );
+};
+
 
 
 /*
 /*
  * ItemsTree
  * ItemsTree
  */
  */
-const ItemsTree: FC = () => {
-  const { data: currentPath } = useCurrentPagePath();
-
-  const { data, error } = useTargetAndAncestors();
-
-  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(currentPath || null);
-
-  if (error != null || error2 != null) {
-    return null;
-  }
+const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
+  const {
+    targetPath, targetId, targetAndAncestorsData, isDeleteModalOpen, pagesToDelete, isAbleToDeleteCompletely, isDeleteCompletelyModal, onCloseDelete,
+    onClickDeleteByPage,
+  } = props;
+
+  const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
+  const { data: rootPageData, error: error2 } = useSWRxRootPage();
+
+  const DeleteModal = (
+    <PageDeleteModal
+      isOpen={isDeleteModalOpen}
+      pages={pagesToDelete}
+      isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+      isDeleteCompletelyModal={isDeleteCompletelyModal}
+      onClose={onCloseDelete}
+    />
+  );
 
 
-  if (data == null) {
+  if (error1 != null || error2 != null) {
+    // TODO: improve message
+    toastError('Error occurred while fetching pages to render PageTree');
     return null;
     return null;
   }
   }
 
 
-  const { targetAndAncestors, rootPage } = data;
-
-  let initialNode: ItemNode;
-
   /*
   /*
-   * Before swr response comes back
+   * Render completely
    */
    */
-  if (ancestorsChildrenData == null) {
-    initialNode = generateInitialNodeBeforeResponse(targetAndAncestors);
+  if (ancestorsChildrenData != null && rootPageData != null) {
+    const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
+    return renderByInitialNode(initialNode, DeleteModal, targetId, onClickDeleteByPage);
   }
   }
 
 
   /*
   /*
-   * When swr request finishes
+   * Before swr response comes back
    */
    */
-  else {
-    const { ancestorsChildren } = ancestorsChildrenData;
-
-    const rootNode = new ItemNode(rootPage);
-
-    initialNode = generateInitialNodeAfterResponse(ancestorsChildren, rootNode);
+  if (targetAndAncestorsData != null) {
+    const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
+    return renderByInitialNode(initialNode, DeleteModal, targetId, onClickDeleteByPage);
   }
   }
 
 
-  const isOpen = true;
-  return (
-    <div className="grw-pagetree p-3">
-      <Item key={(initialNode as ItemNode).page.path} itemNode={(initialNode as ItemNode)} isOpen={isOpen} />
-    </div>
-  );
+  return null;
 };
 };
 
 
 
 

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

+ 9 - 13
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');
 
 
 
 
@@ -50,6 +48,7 @@ const StickyStretchableScroller = (props) => {
   const {
   const {
     children, contentsElemSelector, stickyElemSelector,
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
     calcViewHeightFunc, calcContentsHeightFunc,
+    resetKey,
   } = props;
   } = props;
 
 
   if (scrollTargetSelector == null && children == null) {
   if (scrollTargetSelector == null && children == null) {
@@ -103,7 +102,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,17 +138,12 @@ 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 on init
   useEffect(() => {
   useEffect(() => {
-    resetScrollbarDebounced();
-  }, [resetScrollbarDebounced]);
+    if (resetKey != null) {
+      resetScrollbarDebounced();
+    }
+  }, [resetKey, resetScrollbarDebounced]);
 
 
   return (
   return (
     <>
     <>
@@ -165,6 +159,8 @@ StickyStretchableScroller.propTypes = {
   scrollTargetSelector: PropTypes.string,
   scrollTargetSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
 
 
+  resetKey: PropTypes.any,
+
   calcViewHeightFunc: PropTypes.func,
   calcViewHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
 };
 };

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

+ 6 - 1
packages/app/src/interfaces/page-listing-results.ts

@@ -1,7 +1,12 @@
-import { IPageForItem } from './page';
+import { IPageForItem, IPageHasId } from './page';
 
 
 
 
 type ParentPath = string;
 type ParentPath = string;
+
+export interface RootPageResult {
+  rootPage: IPageHasId
+}
+
 export interface AncestorsChildrenResult {
 export interface AncestorsChildrenResult {
   ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
   ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
 }
 }

+ 2 - 2
packages/app/src/interfaces/page.ts

@@ -31,6 +31,6 @@ export type IPage = {
   deletedAt: Date,
   deletedAt: Date,
 }
 }
 
 
-export type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;
-
 export type IPageHasId = IPage & HasObjectId;
 export type IPageHasId = IPage & HasObjectId;
+
+export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;

+ 1 - 1
packages/app/src/interfaces/search.ts

@@ -9,7 +9,7 @@ export enum CheckboxType {
 export type IPageSearchResultData = {
 export type IPageSearchResultData = {
   pageData: IPageHasId,
   pageData: IPageHasId,
   pageMeta: {
   pageMeta: {
-    bookmarkCount: number,
+    bookmarkCount?: number,
     elasticSearchResult?: {
     elasticSearchResult?: {
       snippet: string,
       snippet: string,
       highlightedPath: string,
       highlightedPath: string,

+ 31 - 0
packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts

@@ -0,0 +1,31 @@
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import loggerFactory from '~/utils/logger';
+
+import UserUISettings from '../models/user-ui-settings';
+
+const logger = loggerFactory('growi:middleware:inject-user-ui-settings-to-localvars');
+
+async function getSettings(userId: string): Promise<Partial<IUserUISettings> | null> {
+  const doc = await UserUISettings.findOne({ user: userId }).exec();
+
+  let partialDoc: Partial<IUserUISettings> | null = null;
+  if (doc != null) {
+    partialDoc = doc.toObject();
+    delete partialDoc.user;
+  }
+
+  return partialDoc;
+}
+
+module.exports = () => {
+  return async(req, res, next) => {
+    try {
+      res.locals.userUISettings = await getSettings(req.user._id);
+    }
+    catch (err: unknown) {
+      logger.error(err);
+    }
+
+    next();
+  };
+};

+ 9 - 2
packages/app/src/server/models/obsolete-page.js

@@ -234,7 +234,14 @@ export class PageQueryBuilder {
 
 
   addConditionAsMigrated() {
   addConditionAsMigrated() {
     this.query = this.query
     this.query = this.query
-      .and({ parent: { $ne: null } });
+      .and(
+        {
+          $or: [
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      );
 
 
     return this;
     return this;
   }
   }
@@ -249,7 +256,7 @@ export class PageQueryBuilder {
   }
   }
 
 
   addConditionToMinimizeDataForRendering() {
   addConditionToMinimizeDataForRendering() {
-    this.query = this.query.select('_id path isEmpty grant');
+    this.query = this.query.select('_id path isEmpty grant revision');
 
 
     return this;
     return this;
   }
   }

+ 7 - 3
packages/app/src/server/models/page.ts

@@ -41,7 +41,7 @@ export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
@@ -227,7 +227,7 @@ const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroup
     relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
   }
   }
 
 
-  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
+  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, false);
 };
 };
 
 
 /*
 /*
@@ -252,7 +252,7 @@ schema.statics.findByPathAndViewer = async function(
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  * The result will include the target as well
  * The result will include the target as well
  */
  */
-schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string): Promise<TargetAndAncestorsResult> {
+schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string, user, userGroups): Promise<TargetAndAncestorsResult> {
   let path;
   let path;
   if (!hasSlash(pathOrId)) {
   if (!hasSlash(pathOrId)) {
     const _id = pathOrId;
     const _id = pathOrId;
@@ -270,7 +270,10 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
 
 
   // Do not populate
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find());
   const queryBuilder = new PageQueryBuilder(this.find());
+  await addViewerCondition(queryBuilder, user, userGroups);
+
   const _targetAndAncestors: PageDocument[] = await queryBuilder
   const _targetAndAncestors: PageDocument[] = await queryBuilder
+    .addConditionAsMigrated()
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToMinimizeDataForRendering()
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortAncestorPages()
     .addConditionToSortAncestorPages()
@@ -314,6 +317,7 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }));
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }));
   await addViewerCondition(queryBuilder, user, userGroups);
   await addViewerCondition(queryBuilder, user, userGroups);
   const _pages = await queryBuilder
   const _pages = await queryBuilder
+    .addConditionAsMigrated()
     .addConditionToMinimizeDataForRendering()
     .addConditionToMinimizeDataForRendering()
     .query
     .query
     .lean()
     .lean()

+ 2 - 2
packages/app/src/server/models/user-ui-settings.ts

@@ -12,7 +12,7 @@ export interface UserUISettingsDocument extends IUserUISettings, Document {}
 export type UserUISettingsModel = Model<UserUISettingsDocument>
 export type UserUISettingsModel = Model<UserUISettingsDocument>
 
 
 const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
 const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
-  user: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  user: { type: Schema.Types.ObjectId, ref: 'User', unique: true },
   isSidebarCollapsed: { type: Boolean, default: false },
   isSidebarCollapsed: { type: Boolean, default: false },
   currentSidebarContents: {
   currentSidebarContents: {
     type: String,
     type: String,
@@ -21,7 +21,7 @@ const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
   },
   },
   currentProductNavWidth: { type: Number },
   currentProductNavWidth: { type: Number },
   preferDrawerModeByUser: { type: Boolean, default: false },
   preferDrawerModeByUser: { type: Boolean, default: false },
-  preferDrawerModeOnEditByUser: { type: Boolean, default: false },
+  preferDrawerModeOnEditByUser: { type: Boolean, default: true },
 });
 });
 
 
 
 

+ 14 - 0
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -41,6 +41,20 @@ export default (crowi: Crowi): Router => {
   const router = express.Router();
   const router = express.Router();
 
 
 
 
+  router.get('/root', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const Page: PageModel = crowi.model('Page');
+
+    let rootPage;
+    try {
+      rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3('rootPage not found'));
+    }
+
+    return res.apiv3({ rootPage });
+  });
+
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
   router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
     const { path } = req.query;

+ 0 - 18
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -25,24 +25,6 @@ module.exports = (crowi) => {
     body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
     body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
   ];
   ];
 
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  router.get('/', loginRequiredStrictly, async(req: any, res: any) => {
-    const { user } = req;
-
-    try {
-      const updatedSettings = await UserUISettings.findOneAndUpdate(
-        { user: user._id },
-        { user: user._id },
-        { upsert: true, new: true },
-      );
-      return res.apiv3(updatedSettings);
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
-
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   router.put('/', loginRequiredStrictly, csrf, validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
   router.put('/', loginRequiredStrictly, csrf, validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
     const { user } = req;
     const { user } = req;

+ 16 - 15
packages/app/src/server/routes/index.js

@@ -28,6 +28,7 @@ module.exports = function(crowi, app) {
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
+  const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
 
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const form = require('../form');
   const form = require('../form');
@@ -52,7 +53,7 @@ module.exports = function(crowi, app) {
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
 
 
-  app.get('/'                         , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
+  app.get('/'                         , applicationInstalled, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
 
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
@@ -132,21 +133,21 @@ module.exports = function(crowi, app) {
   app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
   app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
 
-  app.get('/admin/*'                       , loginRequiredStrictly ,adminRequired, admin.notFound.index);
+  app.get('/admin/*'                            , loginRequiredStrictly ,adminRequired, admin.notFound.index);
 
 
-  app.get('/me'                       , loginRequiredStrictly , me.index);
+  app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   // external-accounts
   // external-accounts
-  app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);
+  app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
   // my drafts
   // my drafts
-  app.get('/me/drafts'                , loginRequiredStrictly, me.drafts.list);
+  app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);
 
 
   app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
-  app.get('/attachment/:pageId/:fileName', loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
-  app.get('/download/:id([0-9a-z]{24})'    , loginRequired, attachment.api.download);
+  app.get('/attachment/:pageId/:fileName'       , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
+  app.get('/download/:id([0-9a-z]{24})'         , loginRequired, attachment.api.download);
 
 
-  app.get('/_search'                 , loginRequired , search.searchPage);
-  app.get('/_api/search'             , accessTokenParser , loginRequired, search.api.search);
+  app.get('/_search'                            , loginRequired, injectUserUISettings, search.searchPage);
+  app.get('/_api/search'                        , accessTokenParser , loginRequired , search.api.search);
 
 
   app.get('/_api/check_username'           , user.api.checkUsername);
   app.get('/_api/check_username'           , user.api.checkUsername);
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
@@ -177,9 +178,9 @@ module.exports = function(crowi, app) {
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
 
-  app.get('/trash$'                   , loginRequired , page.trashPageShowWrapper);
-  app.get('/trash/$'                  , loginRequired , page.trashPageListShowWrapper);
-  app.get('/trash/*/$'                , loginRequired , page.deletedPageListShowWrapper);
+  app.get('/trash$'                   , loginRequired, injectUserUISettings, page.trashPageShowWrapper);
+  app.get('/trash/$'                  , loginRequired, injectUserUISettings, page.trashPageListShowWrapper);
+  app.get('/trash/*/$'                , loginRequired, injectUserUISettings, page.deletedPageListShowWrapper);
 
 
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
@@ -197,9 +198,9 @@ module.exports = function(crowi, app) {
 
 
   app.get('/share/:linkId', page.showSharedPage);
   app.get('/share/:linkId', page.showSharedPage);
 
 
-  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.showPage);
+  app.get('/:id([0-9a-z]{24})'       , loginRequired , injectUserUISettings, page.showPage);
 
 
-  app.get('/*/$'                   , loginRequired , page.redirectorWithEndOfSlash);
-  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.redirector);
+  app.get('/*/$'                   , loginRequired , injectUserUISettings, page.redirectorWithEndOfSlash);
+  app.get('/*'                     , loginRequired , autoReconnectToSearch, injectUserUISettings, page.redirector);
 
 
 };
 };

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

@@ -264,8 +264,8 @@ module.exports = function(crowi, app) {
     renderVars.pages = result.pages;
     renderVars.pages = result.pages;
   }
   }
 
 
-  async function addRenderVarsForPageTree(renderVars, path) {
-    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path);
+  async function addRenderVarsForPageTree(renderVars, path, user) {
+    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path, user);
 
 
     if (targetAndAncestors.length === 0 && !isTopPage(path)) {
     if (targetAndAncestors.length === 0 && !isTopPage(path)) {
       throw new Error('Ancestors must have at least one page.');
       throw new Error('Ancestors must have at least one page.');
@@ -385,7 +385,7 @@ module.exports = function(crowi, app) {
 
 
     await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
     await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
 
 
-    await addRenderVarsForPageTree(renderVars, portalPath);
+    await addRenderVarsForPageTree(renderVars, portalPath, req.user);
 
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
     return res.render(view, renderVars);
@@ -447,7 +447,7 @@ module.exports = function(crowi, app) {
       await addRenderVarsForUserPage(renderVars, page);
       await addRenderVarsForUserPage(renderVars, page);
     }
     }
 
 
-    await addRenderVarsForPageTree(renderVars, path);
+    await addRenderVarsForPageTree(renderVars, path, req.user);
 
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
     return res.render(view, renderVars);

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

@@ -897,6 +897,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     this.appendResultSize(query, from, size);
     this.appendResultSize(query, from, size);
 
 
     await this.appendFunctionScore(query, queryString);
     await this.appendFunctionScore(query, queryString);
+    this.appendHighlight(query);
 
 
     return this.searchKeyword(query);
     return this.searchKeyword(query);
   }
   }

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

@@ -13,7 +13,7 @@ import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { PageModel } from '../models/page';
 import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
-import { IPageHasId } from '~/interfaces/page';
+import { IPageSearchResultData } from '~/interfaces/search';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 const logger = loggerFactory('growi:service:search');
@@ -35,16 +35,7 @@ const normalizeQueryString = (_queryString: string): string => {
 };
 };
 
 
 export type FormattedSearchResult = {
 export type FormattedSearchResult = {
-  data: {
-    pageData: IPageHasId
-    pageMeta: {
-      bookmarkCount?: number
-      elasticsearchResult?: {
-        snippet: string
-        highlightedPath: string
-      }
-    }
-  }[]
+  data: IPageSearchResultData[]
 
 
   totalCount: number
   totalCount: number
 
 

+ 1 - 10
packages/app/src/server/service/slack-command-handler/search.js

@@ -3,7 +3,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 
 const {
 const {
-  markdownSectionBlock, divider,
+  markdownSectionBlock, divider, generateLastUpdateMrkdwn,
 } = require('@growi/slack');
 } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 const { formatDistanceStrict } = require('date-fns');
 
 
@@ -36,15 +36,6 @@ module.exports = (crowi) => {
     return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
     return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
   }
   }
 
 
-  function generateLastUpdateMrkdwn(updatedAt, baseDate) {
-    if (updatedAt != null) {
-      // cast to date
-      const date = new Date(updatedAt);
-      return formatDistanceStrict(date, baseDate);
-    }
-    return '';
-  }
-
   async function retrieveSearchResults(growiCommandArgs, offset = 0) {
   async function retrieveSearchResults(growiCommandArgs, offset = 0) {
     const keywords = getKeywords(growiCommandArgs);
     const keywords = getKeywords(growiCommandArgs);
 
 

+ 5 - 7
packages/app/src/server/service/slack-event-handler/link-shared.ts

@@ -1,9 +1,8 @@
 import urljoin from 'url-join';
 import urljoin from 'url-join';
-import { format } from 'date-fns';
 import {
 import {
   MessageAttachment, LinkUnfurls, WebClient,
   MessageAttachment, LinkUnfurls, WebClient,
 } from '@slack/web-api';
 } from '@slack/web-api';
-import { GrowiBotEvent } from '@growi/slack';
+import { GrowiBotEvent, generateLastUpdateMrkdwn } from '@growi/slack';
 import { SlackEventHandler } from './base-event-handler';
 import { SlackEventHandler } from './base-event-handler';
 import {
 import {
   DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
   DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
@@ -84,18 +83,17 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
 
 
   // builder method for unfurl parameter
   // builder method for unfurl parameter
   generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
   generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
-    const { pageBody: text, updatedAt, commentCount } = body;
+    const { pageBody: text, updatedAt } = body;
 
 
+    const appTitle = this.crowi.appService.getAppTitle();
     const siteUrl = this.crowi.appService.getSiteUrl();
     const siteUrl = this.crowi.appService.getSiteUrl();
 
 
-    const updatedAtFormatted = format(updatedAt, 'yyyy-MM-dd HH:mm');
-    const footer = `URL: ${siteUrl}  Updated at: ${updatedAtFormatted}`;
-
     const attachment: MessageAttachment = {
     const attachment: MessageAttachment = {
       title: body.path,
       title: body.path,
       title_link: toUrl, // permalink
       title_link: toUrl, // permalink
       text,
       text,
-      footer,
+      footer: `<${decodeURI(siteUrl)}|*${appTitle}*>`
+      + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``,
     };
     };
 
 
     const unfurls: LinkUnfurls = {
     const unfurls: LinkUnfurls = {

+ 6 - 0
packages/app/src/server/views/layout/layout.html

@@ -120,6 +120,12 @@
   {{ user|json|safe|preventXss }}
   {{ user|json|safe|preventXss }}
   </script>
   </script>
 {% endif %}
 {% endif %}
+{% if userUISettings != null %}
+  <script type="application/json" id="growi-user-ui-settings">
+  {{ userUISettings|json|safe }}
+  </script>
+{% endif %}
+
 
 
 {% block custom_script %}
 {% block custom_script %}
 <script>
 <script>

+ 73 - 43
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> => {
-  return useStaticSWR<Nullable<any>, Error>('currentPagePath', initialData || null);
+export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, 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> => {
-  return useStaticSWR<Nullable<any>, Error>('pageId', initialData || null);
+export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('currentPageId', 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> => {

+ 15 - 1
packages/app/src/stores/page-listing.tsx

@@ -1,9 +1,23 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
 
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import { apiv3Get } from '../client/util/apiv3-client';
-import { AncestorsChildrenResult, ChildrenResult, V5MigrationStatus } from '../interfaces/page-listing-results';
+import {
+  AncestorsChildrenResult, ChildrenResult, V5MigrationStatus, RootPageResult,
+} from '../interfaces/page-listing-results';
 
 
 
 
+export const useSWRxRootPage = (): SWRResponse<RootPageResult, Error> => {
+  return useSWR(
+    '/page-listing/root',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        rootPage: response.data.rootPage,
+      };
+    }),
+    { revalidateOnFocus: false },
+  );
+};
+
 export const useSWRxPageAncestorsChildren = (
 export const useSWRxPageAncestorsChildren = (
     path: string | null,
     path: string | null,
 ): SWRResponse<AncestorsChildrenResult, Error> => {
 ): SWRResponse<AncestorsChildrenResult, Error> => {

+ 6 - 7
packages/app/src/stores/page.tsx

@@ -1,16 +1,15 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { HasObjectId } from '~/interfaces/has-object-id';
 
 
-import { IPage } from '~/interfaces/page';
+import { IPageHasId } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
 import { apiGet } from '../client/util/apiv1-client';
 
 
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
 import { IPageInfo } from '../interfaces/page-info';
 import { IPageInfo } from '../interfaces/page-info';
 
 
-export const useSWRxPageByPath = (path: string, initialData?: IPage): SWRResponse<IPage & HasObjectId, Error> => {
+export const useSWRxPageByPath = (path: string, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
   return useSWR(
   return useSWR(
     ['/page', path],
     ['/page', path],
     (endpoint, path) => apiv3Get(endpoint, { path }).then(result => result.data.page),
     (endpoint, path) => apiv3Get(endpoint, { path }).then(result => result.data.page),
@@ -22,10 +21,10 @@ export const useSWRxPageByPath = (path: string, initialData?: IPage): SWRRespons
 
 
 
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxRecentlyUpdated = (): SWRResponse<(IPage & HasObjectId)[], Error> => {
+export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> => {
   return useSWR(
   return useSWR(
     '/pages/recent',
     '/pages/recent',
-    endpoint => apiv3Get<{ pages:(IPage & HasObjectId)[] }>(endpoint).then(response => response.data?.pages),
+    endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
   );
   );
 };
 };
 
 
@@ -33,11 +32,11 @@ export const useSWRxRecentlyUpdated = (): SWRResponse<(IPage & HasObjectId)[], E
 export const useSWRxPageList = (
 export const useSWRxPageList = (
     path: string,
     path: string,
     pageNumber?: number,
     pageNumber?: number,
-): SWRResponse<IPagingResult<IPage>, Error> => {
+): SWRResponse<IPagingResult<IPageHasId>, Error> => {
   const page = pageNumber || 1;
   const page = pageNumber || 1;
   return useSWR(
   return useSWR(
     `/pages/list?path=${path}&page=${page}`,
     `/pages/list?path=${path}&page=${page}`,
-    endpoint => apiv3Get<{pages: IPage[], totalCount: number, limit: number}>(endpoint).then((response) => {
+    endpoint => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
       return {
       return {
         items: response.data.pages,
         items: response.data.pages,
         totalCount: response.data.totalCount,
         totalCount: response.data.totalCount,

+ 94 - 116
packages/app/src/stores/ui.tsx

@@ -1,18 +1,15 @@
 import useSWR, {
 import useSWR, {
-  useSWRConfig, SWRResponse, Key, Fetcher, Middleware,
+  useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-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 { useCurrentPagePath } from './context';
+import { useCurrentPagePath, useIsEditable } from './context';
 
 
 const logger = loggerFactory('growi:stores:ui');
 const logger = loggerFactory('growi:stores:ui');
 
 
@@ -36,16 +33,6 @@ export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  *                      for switching UI
  *                      for switching UI
  *********************************************************** */
  *********************************************************** */
 
 
-export const useSWRxUserUISettings = (): SWRResponse<IUserUISettings, Error> => {
-  const key = isServer ? null : 'userUISettings';
-
-  return useSWRImmutable(
-    key,
-    () => apiv3Get<IUserUISettings>('/user-ui-settings').then(response => response.data),
-  );
-};
-
-
 export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
 export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   const key = isServer ? null : 'isMobile';
   const key = isServer ? null : 'isMobile';
 
 
@@ -61,54 +48,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;
 
 
-  return useStaticSWR(key, editorMode || null, { fallbackData: initialData, use: [postChangeEditorModeMiddleware] });
+  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 {
+    ...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> => {
@@ -135,20 +154,24 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> =>
   return useStaticSWR(key);
   return useStaticSWR(key);
 };
 };
 
 
-export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key: Key = data === undefined ? null : 'preferDrawerModeByUser';
-  const initialData = data?.preferDrawerModeByUser;
+export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('preferDrawerModeByUser', initialData ?? null, { fallbackData: false });
+};
+
+export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('preferDrawerModeOnEditByUser', initialData ?? null, { fallbackData: true });
+};
 
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
+export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('isSidebarCollapsed', initialData ?? null, { fallbackData: false });
 };
 };
 
 
-export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key: Key = data === undefined ? null : 'preferDrawerModeOnEditByUser';
-  const initialData = data?.preferDrawerModeOnEditByUser;
+export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
+  return useStaticSWR('sidebarContents', initialData ?? null, { fallbackData: SidebarContentsType.RECENT });
+};
 
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
+export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
+  return useStaticSWR('productNavWidth', initialData ?? null, { fallbackData: 320 });
 };
 };
 
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
@@ -170,7 +193,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,
@@ -183,51 +206,6 @@ export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error>
   return useStaticSWR('isDrawerOpened', isOpened || null, { fallbackData: initialData });
   return useStaticSWR('isDrawerOpened', isOpened || null, { fallbackData: initialData });
 };
 };
 
 
-export const useSidebarCollapsed = (): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'isSidebarCollapsed';
-  const initialData = data?.isSidebarCollapsed || false;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
-export const useCurrentSidebarContents = (): SWRResponse<SidebarContentsType, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'sidebarContents';
-  const initialData = data?.currentSidebarContents || SidebarContentsType.RECENT;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
-export const useCurrentProductNavWidth = (): SWRResponse<number, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'productNavWidth';
-  const initialData = data?.currentProductNavWidth || 320;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
 export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
 export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
   const initialData = false;
   const initialData = false;
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });

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

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

@@ -31,6 +31,11 @@ body.growi-layout-fluid .grw-container-convertible {
   border-bottom: 1px solid transparent;
   border-bottom: 1px solid transparent;
 }
 }
 
 
+.grw-scrollable-modal-body {
+  max-height: calc(100vh - 330px);
+  overflow-y: scroll;
+}
+
 // padding settings for GrowiNavbarBottom
 // padding settings for GrowiNavbarBottom
 .page-wrapper {
 .page-wrapper {
   padding-bottom: $grw-navbar-bottom-height;
   padding-bottom: $grw-navbar-bottom-height;

+ 4 - 2
packages/app/src/styles/_search.scss

@@ -172,9 +172,11 @@
   .search-result-list {
   .search-result-list {
     position: sticky;
     position: sticky;
     top: 0px;
     top: 0px;
-    height: 100vh;
-    overflow-y: scroll;
 
 
+    .search-result-list-scroll {
+      height: calc(100vh - 125px); // subtract the height of SearchControl component
+      overflow-y: scroll;
+    }
     .nav.nav-pills {
     .nav.nav-pills {
       > .page-list-li {
       > .page-list-li {
         > a {
         > a {

+ 1 - 17
packages/app/src/styles/_sidebar.scss

@@ -267,7 +267,7 @@
     top: 0;
     top: 0;
     width: 0;
     width: 0;
   }
   }
-  div.navigation {
+  div.navigation.transition-enabled {
     max-width: 80vw;
     max-width: 80vw;
 
 
     // apply transition
     // apply transition
@@ -329,22 +329,6 @@
   }
   }
 }
 }
 
 
-// supress transition
-.grw-sidebar {
-  &.grw-sidebar-supress-transitions-to-drawer {
-    div.navigation {
-      transition: none !important;
-    }
-  }
-
-  &.grw-sidebar-supress-transitions-to-dock {
-    div.content,
-    div.contextual-navigation {
-      transition: none !important;
-    }
-  }
-}
-
 .grw-sidebar-backdrop.modal-backdrop {
 .grw-sidebar-backdrop.modal-backdrop {
   z-index: $zindex-fixed + 1;
   z-index: $zindex-fixed + 1;
 }
 }

+ 5 - 0
packages/app/src/styles/_subnav.scss

@@ -45,6 +45,11 @@
     border-radius: $border-radius-xl;
     border-radius: $border-radius-xl;
   }
   }
 
 
+  .btn-bookmark {
+    display: flex;
+    align-items: center;
+  }
+
   .total-likes,
   .total-likes,
   .total-bookmarks {
   .total-bookmarks {
     font-size: 17px;
     font-size: 17px;

+ 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.3-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.3-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.3-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.3-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.3-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.3-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "typings": "dist/index.d.ts",

+ 1 - 0
packages/slack/src/index.ts

@@ -44,6 +44,7 @@ export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './utils/block-kit-builder';
 export * from './utils/block-kit-builder';
 export * from './utils/check-communicable';
 export * from './utils/check-communicable';
+export * from './utils/generate-last-update-markdown';
 export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/publish-initial-home-view';
 export * from './utils/publish-initial-home-view';

+ 10 - 0
packages/slack/src/utils/generate-last-update-markdown.ts

@@ -0,0 +1,10 @@
+import { formatDistanceStrict } from 'date-fns';
+
+export function generateLastUpdateMrkdwn(updatedAt: string | Date | number, baseDate: Date): string {
+  if (updatedAt != null) {
+    // cast to date
+    const date = new Date(updatedAt);
+    return formatDistanceStrict(date, baseDate);
+  }
+  return '';
+}

+ 2 - 0
packages/slack/src/utils/required-scopes.ts

@@ -8,4 +8,6 @@ export const requiredScopes: string[] = [
   'groups:history',
   'groups:history',
   'im:history',
   'im:history',
   'mpim:history',
   'mpim:history',
+  'links:read',
+  'links:write',
 ];
 ];

+ 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.3-slackbot-proxy.1",
   "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.3-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.3-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"