Browse Source

Merge branch 'master' into feat/integrate-branch-resume-page-operations

yohei0125 3 years ago
parent
commit
f70f860713

+ 15 - 0
packages/app/resource/Contributor.js

@@ -12,6 +12,11 @@ const contributors = [
           { position: 'Soncho 2nd', name: 'yusuketk' },
           { position: 'Soncho 2nd', name: 'yusuketk' },
           { position: 'Paladin', name: 'itizawa' },
           { position: 'Paladin', name: 'itizawa' },
           { position: 'Valkyrie', name: 'kaoritokashiki' },
           { position: 'Valkyrie', name: 'kaoritokashiki' },
+          { position: 'Slime', name: 'TatsuyaIse' },
+          { position: 'Knight', name: 'Yohei-Shiina' },
+          { position: 'Titan', name: 'ryoh15' },
+          { position: 'Haberion', name: 'hakumizuki' },
+          { position: 'Undefind', name: 'miya' },
         ],
         ],
       },
       },
       {
       {
@@ -37,6 +42,7 @@ const contributors = [
           { name: 'ryuichi-e' },
           { name: 'ryuichi-e' },
           { name: 'N1koge' },
           { name: 'N1koge' },
           { name: 'Ertai87' },
           { name: 'Ertai87' },
+          { name: 'takayuki-t' },
           { name: 'zahmis' },
           { name: 'zahmis' },
           { name: 'takeru0001' },
           { name: 'takeru0001' },
           { name: 'Shu Katabe' },
           { name: 'Shu Katabe' },
@@ -46,6 +52,15 @@ const contributors = [
           { name: 'stevenfukase' },
           { name: 'stevenfukase' },
           { name: 'miya' },
           { name: 'miya' },
           { name: 'kaho819' },
           { name: 'kaho819' },
+          { name: 'yuto-oweseek' },
+          { name: 'maow89126' },
+          { name: 'kntowd' },
+          { name: 'yukendev' },
+          { name: 'asami-n' },
+          { name: 'ryohi15' },
+          { name: 'yoshiro-s' },
+          { name: 'kuimac' },
+          { name: 'akira-sugiyama' },
         ],
         ],
       },
       },
     ],
     ],

+ 2 - 1
packages/app/resource/locales/en_US/translation.json

@@ -390,7 +390,8 @@
     }
     }
   },
   },
   "page_comment": {
   "page_comment": {
-    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
+    "no_user_found": "No user found"
   },
   },
   "page_api_error": {
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "notfound_or_forbidden": "Original page is not found or forbidden.",

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

@@ -390,7 +390,8 @@
     }
     }
   },
   },
   "page_comment": {
   "page_comment": {
-    "display_the_page_when_posting_this_comment": "投稿時のページを表示する"
+    "display_the_page_when_posting_this_comment": "投稿時のページを表示する",
+    "no_user_found": "ユーザー名が見つかりません"
   },
   },
   "page_api_error": {
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",

+ 2 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -369,7 +369,8 @@
 		}
 		}
   },
   },
   "page_comment": {
   "page_comment": {
-    "display_the_page_when_posting_this_comment": "Display the page when posting this comment"
+    "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
+    "no_user_found": "未找到用户名"
   },
   },
 	"page_api_error": {
 	"page_api_error": {
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"notfound_or_forbidden": "未找到或禁止原始页。",

+ 12 - 10
packages/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -1,7 +1,7 @@
-import { Container } from 'unstated';
-
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
+import { Container } from 'unstated';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 
@@ -26,7 +26,7 @@ export default class AdminGoogleSecurityContainer extends Container {
       // set dummy value tile for using suspense
       // set dummy value tile for using suspense
       googleClientId: this.dummyGoogleClientId,
       googleClientId: this.dummyGoogleClientId,
       googleClientSecret: '',
       googleClientSecret: '',
-      isSameUsernameTreatedAsIdenticalUser: false,
+      isSameEmailTreatedAsIdenticalUser: false,
     };
     };
 
 
 
 
@@ -42,7 +42,7 @@ export default class AdminGoogleSecurityContainer extends Container {
       this.setState({
       this.setState({
         googleClientId: googleOAuth.googleClientId,
         googleClientId: googleOAuth.googleClientId,
         googleClientSecret: googleOAuth.googleClientSecret,
         googleClientSecret: googleOAuth.googleClientSecret,
-        isSameUsernameTreatedAsIdenticalUser: googleOAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser: googleOAuth.isSameEmailTreatedAsIdenticalUser,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -74,20 +74,22 @@ export default class AdminGoogleSecurityContainer extends Container {
   }
   }
 
 
   /**
   /**
-   * Switch isSameUsernameTreatedAsIdenticalUser
+   * Switch isSameEmailTreatedAsIdenticalUser
    */
    */
-  switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  switchIsSameEmailTreatedAsIdenticalUser() {
+    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
   }
   }
 
 
+
   /**
   /**
    * Update googleSetting
    * Update googleSetting
    */
    */
   async updateGoogleSetting() {
   async updateGoogleSetting() {
-    const { googleClientId, googleClientSecret, isSameUsernameTreatedAsIdenticalUser } = this.state;
+    const { googleClientId, googleClientSecret, isSameEmailTreatedAsIdenticalUser } = this.state;
+    console.log('updateGoogleSetting', isSameEmailTreatedAsIdenticalUser);
 
 
     let requestParams = {
     let requestParams = {
-      googleClientId, googleClientSecret, isSameUsernameTreatedAsIdenticalUser,
+      googleClientId, googleClientSecret, isSameEmailTreatedAsIdenticalUser,
     };
     };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);
@@ -97,7 +99,7 @@ export default class AdminGoogleSecurityContainer extends Container {
     this.setState({
     this.setState({
       googleClientId: securitySettingParams.googleClientId,
       googleClientId: securitySettingParams.googleClientId,
       googleClientSecret: securitySettingParams.googleClientSecret,
       googleClientSecret: securitySettingParams.googleClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
     });
     });
     return response;
     return response;
   }
   }

+ 7 - 5
packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx

@@ -1,14 +1,16 @@
 /* eslint-disable react/no-danger */
 /* eslint-disable react/no-danger */
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 class GoogleSecurityManagementContents extends React.Component {
 class GoogleSecurityManagementContents extends React.Component {
 
 
@@ -135,8 +137,8 @@ class GoogleSecurityManagementContents extends React.Component {
                     id="bindByUserNameGoogle"
                     id="bindByUserNameGoogle"
                     className="custom-control-input"
                     className="custom-control-input"
                     type="checkbox"
                     type="checkbox"
-                    checked={adminGoogleSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
-                    onChange={() => { adminGoogleSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                    checked={adminGoogleSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
+                    onChange={() => { adminGoogleSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
                   />
                   />
                   <label
                   <label
                     className="custom-control-label"
                     className="custom-control-label"

+ 174 - 0
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -0,0 +1,174 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { useUserUISettings } from '~/client/services/user-ui-settings';
+import {
+  isUserPreferenceExists,
+  isDarkMode as isDarkModeByUtil,
+  applyColorScheme,
+  removeUserPreference,
+  updateUserPreference,
+  updateUserPreferenceWithOsSettings,
+} from '~/client/util/color-scheme';
+import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
+
+import MoonIcon from '../Icons/MoonIcon';
+import SidebarDockIcon from '../Icons/SidebarDockIcon';
+import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
+import SunIcon from '../Icons/SunIcon';
+
+type AppearanceModeDropdownProps = {
+  isAuthenticated: boolean,
+}
+export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: AppearanceModeDropdownProps) => {
+
+  const { t } = useTranslation();
+
+  const { isAuthenticated } = props;
+
+  const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
+  const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
+
+  const { data: isPreferDrawerMode, update: updatePreferDrawerMode } = usePreferDrawerModeByUser();
+  const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
+  const { scheduleToPut } = useUserUISettings();
+
+  const preferDrawerModeSwitchModifiedHandler = useCallback((preferDrawerMode: boolean, isEditMode: boolean) => {
+    if (isEditMode) {
+      mutatePreferDrawerModeOnEdit(preferDrawerMode);
+      scheduleToPut({ preferDrawerModeOnEditByUser: preferDrawerMode });
+    }
+    else {
+      updatePreferDrawerMode(preferDrawerMode);
+    }
+  }, [updatePreferDrawerMode, mutatePreferDrawerModeOnEdit, scheduleToPut]);
+
+  const followOsCheckboxModifiedHandler = useCallback((useOsSettings: boolean) => {
+    if (useOsSettings) {
+      removeUserPreference();
+    }
+    else {
+      updateUserPreferenceWithOsSettings();
+    }
+    applyColorScheme();
+
+    // update states
+    setOsSettings(useOsSettings);
+    setIsDarkMode(isDarkModeByUtil());
+  }, []);
+
+  const userPreferenceSwitchModifiedHandler = useCallback((isDarkMode: boolean) => {
+    updateUserPreference(isDarkMode);
+    applyColorScheme();
+
+    // update state
+    setIsDarkMode(isDarkModeByUtil());
+  }, []);
+
+  /* eslint-disable react/prop-types */
+  const IconWithTooltip = ({
+    id, label, children, additionalClasses,
+  }) => (
+    <>
+      <div id={id} className={`px-2 grw-icon-container ${additionalClasses != null ? additionalClasses : ''}`}>{children}</div>
+      <UncontrolledTooltip placement="bottom" fade={false} target={id}>{label}</UncontrolledTooltip>
+    </>
+  );
+
+  const renderSidebarModeSwitch = useCallback((isEditMode: boolean) => {
+    return (
+      <>
+        <h6 className="dropdown-header">{t(isEditMode ? 'personal_dropdown.sidebar_mode_editor' : 'personal_dropdown.sidebar_mode')}</h6>
+        <form className="px-4">
+          <div className="form-row justify-content-center">
+            <div className="form-group col-auto mb-0 d-flex align-items-center">
+              <IconWithTooltip id={isEditMode ? 'iwt-sidebar-editor-drawer' : 'iwt-sidebar-drawer'} label="Drawer" additionalClasses="grw-sidebar-mode-icon">
+                <SidebarDrawerIcon />
+              </IconWithTooltip>
+              <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
+                <input
+                  id={isEditMode ? 'swSidebarModeOnEditor' : 'swSidebarMode'}
+                  className="custom-control-input"
+                  type="checkbox"
+                  checked={isEditMode ? !isPreferDrawerModeOnEdit : !isPreferDrawerMode}
+                  onChange={e => preferDrawerModeSwitchModifiedHandler(!e.target.checked, isEditMode)}
+                />
+                <label className="custom-control-label" htmlFor={isEditMode ? 'swSidebarModeOnEditor' : 'swSidebarMode'}></label>
+              </div>
+              <IconWithTooltip id={isEditMode ? 'iwt-sidebar-editor-dock' : 'iwt-sidebar-dock'} label="Dock" additionalClasses="grw-sidebar-mode-icon">
+                <SidebarDockIcon />
+              </IconWithTooltip>
+            </div>
+          </div>
+        </form>
+      </>
+    );
+  }, [isPreferDrawerMode, isPreferDrawerModeOnEdit, preferDrawerModeSwitchModifiedHandler, t]);
+
+  return (
+    <>
+      {/* setting button */}
+      <button className="bg-transparent border-0 nav-link" type="button" data-toggle="dropdown" aria-haspopup="true">
+        <i className="icon-settings"></i>
+      </button>
+
+      {/* dropdown */}
+      <div className="dropdown-menu dropdown-menu-right">
+
+        {/* sidebar mode */}
+        {renderSidebarModeSwitch(false)}
+
+        <div className="dropdown-divider"></div>
+
+        {/* side bar mode on editor */}
+        {isAuthenticated && renderSidebarModeSwitch(true)}
+
+        <div className="dropdown-divider"></div>
+
+        {/* color mode */}
+        <h6 className="dropdown-header">{t('personal_dropdown.color_mode')}</h6>
+        <form className="px-4">
+          <div className="form-row justify-content-center">
+            <div className="form-group col-auto d-flex align-items-center">
+              <IconWithTooltip id="iwt-light" label="Light" additionalClasses={useOsSettings ? 'grw-color-mode-icon-muted' : 'grw-color-mode-icon'}>
+                <SunIcon />
+              </IconWithTooltip>
+              <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
+                <input
+                  id="swUserPreference"
+                  className="custom-control-input"
+                  type="checkbox"
+                  checked={isDarkMode}
+                  disabled={useOsSettings}
+                  onChange={e => userPreferenceSwitchModifiedHandler(e.target.checked)}
+                />
+                <label className="custom-control-label" htmlFor="swUserPreference"></label>
+              </div>
+              <IconWithTooltip id="iwt-dark" label="Dark" additionalClasses={useOsSettings ? 'grw-color-mode-icon-muted' : 'grw-color-mode-icon'}>
+                <MoonIcon />
+              </IconWithTooltip>
+            </div>
+          </div>
+          <div className="form-row">
+            <div className="form-group col-auto">
+              <div className="custom-control custom-checkbox">
+                <input
+                  id="cbFollowOs"
+                  className="custom-control-input"
+                  type="checkbox"
+                  checked={useOsSettings}
+                  onChange={e => followOsCheckboxModifiedHandler(e.target.checked)}
+                />
+                <label className="custom-control-label text-nowrap" htmlFor="cbFollowOs">{t('personal_dropdown.use_os_settings')}</label>
+              </div>
+            </div>
+          </div>
+        </form>
+      </div>
+
+    </>
+  );
+
+};

+ 46 - 25
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -1,4 +1,4 @@
-import React, { FC, memo } from 'react';
+import React, { FC, memo, useMemo } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -14,6 +14,7 @@ import GrowiLogo from '../Icons/GrowiLogo';
 import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
 import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
+import { AppearanceModeDropdown } from './AppearanceModeDropdown';
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';
 import PersonalDropdown from './PersonalDropdown';
 import PersonalDropdown from './PersonalDropdown';
 
 
@@ -27,33 +28,53 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
   const { open: openCreateModal } = usePageCreateModal();
   const { open: openCreateModal } = usePageCreateModal();
 
 
   const { currentUser } = props;
   const { currentUser } = props;
-
-  // render login button
-  if (currentUser == null) {
-    return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
-  }
+  const isAuthenticated = currentUser != null;
+
+  const authenticatedNavItem = useMemo(() => {
+    return (
+      <>
+        <li className="nav-item">
+          <InAppNotificationDropdown />
+        </li>
+
+        <li className="nav-item d-none d-md-block">
+          <button
+            className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
+            type="button"
+            data-testid="newPageBtn"
+            onClick={() => openCreateModal(currentPagePath || '')}
+          >
+            <i className="icon-pencil mr-2"></i>
+            <span className="d-none d-lg-block">{ t('New') }</span>
+          </button>
+        </li>
+
+        <li className="grw-personal-dropdown nav-item dropdown">
+          <AppearanceModeDropdown isAuthenticated={isAuthenticated} />
+        </li>
+
+        <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret" data-testid="grw-personal-dropdown">
+          <PersonalDropdown />
+        </li>
+      </>
+    );
+  }, [t, currentPagePath, openCreateModal, isAuthenticated]);
+
+  const notAuthenticatedNavItem = useMemo(() => {
+    return (
+      <>
+        <li className="grw-personal-dropdown nav-item dropdown">
+          <AppearanceModeDropdown isAuthenticated={isAuthenticated} />
+        </li>
+
+        <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
+      </>
+    );
+  }, []);
 
 
   return (
   return (
     <>
     <>
-      <li className="nav-item">
-        <InAppNotificationDropdown />
-      </li>
-
-      <li className="nav-item d-none d-md-block">
-        <button
-          className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
-          type="button"
-          data-testid="newPageBtn"
-          onClick={() => openCreateModal(currentPagePath || '')}
-        >
-          <i className="icon-pencil mr-2"></i>
-          <span className="d-none d-lg-block">{ t('New') }</span>
-        </button>
-      </li>
-
-      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret" data-testid="grw-personal-dropdown">
-        <PersonalDropdown />
-      </li>
+      {isAuthenticated ? authenticatedNavItem : notAuthenticatedNavItem}
     </>
     </>
   );
   );
 });
 });

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

@@ -1,29 +1,11 @@
-import React, { useState, useCallback } from 'react';
+import React from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
 
 
-import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import {
-  isUserPreferenceExists,
-  isDarkMode as isDarkModeByUtil,
-  applyColorScheme,
-  removeUserPreference,
-  updateUserPreference,
-  updateUserPreferenceWithOsSettings,
-} from '~/client/util/color-scheme';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
-import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
-
-
-import MoonIcon from '../Icons/MoonIcon';
-import SidebarDockIcon from '../Icons/SidebarDockIcon';
-import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
-import SunIcon from '../Icons/SunIcon';
-
 
 
 const PersonalDropdown = () => {
 const PersonalDropdown = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -31,13 +13,6 @@ const PersonalDropdown = () => {
 
 
   const user = currentUser || {};
   const user = currentUser || {};
 
 
-  const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
-  const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
-
-  const { data: isPreferDrawerMode, mutate: mutatePreferDrawerMode } = usePreferDrawerModeByUser();
-  const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
-  const { scheduleToPut } = useUserUISettings();
-
   const logoutHandler = async() => {
   const logoutHandler = async() => {
     try {
     try {
       await apiv3Post('/logout');
       await apiv3Post('/logout');
@@ -48,50 +23,6 @@ const PersonalDropdown = () => {
     }
     }
   };
   };
 
 
-  const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
-    mutatePreferDrawerMode(bool);
-    scheduleToPut({ preferDrawerModeByUser: bool });
-  }, [mutatePreferDrawerMode, scheduleToPut]);
-
-  const preferDrawerModeOnEditSwitchModifiedHandler = useCallback((bool) => {
-    mutatePreferDrawerModeOnEdit(bool);
-    scheduleToPut({ preferDrawerModeOnEditByUser: bool });
-  }, [mutatePreferDrawerModeOnEdit, scheduleToPut]);
-
-  const followOsCheckboxModifiedHandler = (bool) => {
-    if (bool) {
-      removeUserPreference();
-    }
-    else {
-      updateUserPreferenceWithOsSettings();
-    }
-    applyColorScheme();
-
-    // update states
-    setOsSettings(bool);
-    setIsDarkMode(isDarkModeByUtil());
-  };
-
-  const userPreferenceSwitchModifiedHandler = (bool) => {
-    updateUserPreference(bool);
-    applyColorScheme();
-
-    // update state
-    setIsDarkMode(isDarkModeByUtil());
-  };
-
-
-  /* eslint-disable react/prop-types */
-  const IconWithTooltip = ({
-    id, label, children, additionalClasses,
-  }) => (
-    <>
-      <div id={id} className={`px-2 grw-icon-container ${additionalClasses != null ? additionalClasses : ''}`}>{children}</div>
-      <UncontrolledTooltip placement="bottom" fade={false} target={id}>{label}</UncontrolledTooltip>
-    </>
-  );
-  /* eslint-enable react/prop-types */
-
   return (
   return (
     <>
     <>
       {/* Button */}
       {/* Button */}
@@ -128,100 +59,6 @@ const PersonalDropdown = () => {
 
 
         <div className="dropdown-divider"></div>
         <div className="dropdown-divider"></div>
 
 
-        {/* Sidebar Mode */}
-        <h6 className="dropdown-header">{t('personal_dropdown.sidebar_mode')}</h6>
-        <form className="px-4">
-          <div className="form-row justify-content-center">
-            <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <IconWithTooltip id="iwt-sidebar-drawer" label="Drawer">
-                <SidebarDrawerIcon />
-              </IconWithTooltip>
-              <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
-                <input
-                  id="swSidebarMode"
-                  className="custom-control-input"
-                  type="checkbox"
-                  checked={!isPreferDrawerMode}
-                  onChange={e => preferDrawerModeSwitchModifiedHandler(!e.target.checked)}
-                />
-                <label className="custom-control-label" htmlFor="swSidebarMode"></label>
-              </div>
-              <IconWithTooltip id="iwt-sidebar-dock" label="Dock">
-                <SidebarDockIcon />
-              </IconWithTooltip>
-            </div>
-          </div>
-        </form>
-
-        {/* Sidebar Mode on Editor */}
-        <h6 className="dropdown-header">{t('personal_dropdown.sidebar_mode_editor')}</h6>
-        <form className="px-4">
-          <div className="form-row justify-content-center">
-            <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <IconWithTooltip id="iwt-sidebar-editor-drawer" label="Drawer">
-                <SidebarDrawerIcon />
-              </IconWithTooltip>
-              <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
-                <input
-                  id="swSidebarModeOnEditor"
-                  className="custom-control-input"
-                  type="checkbox"
-                  checked={!isPreferDrawerModeOnEdit}
-                  onChange={e => preferDrawerModeOnEditSwitchModifiedHandler(!e.target.checked)}
-                />
-                <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
-              </div>
-              <IconWithTooltip id="iwt-sidebar-editor-dock" label="Dock">
-                <SidebarDockIcon />
-              </IconWithTooltip>
-            </div>
-          </div>
-        </form>
-
-        <div className="dropdown-divider"></div>
-
-        {/* Color Mode */}
-        <h6 className="dropdown-header">{t('personal_dropdown.color_mode')}</h6>
-        <form className="px-4">
-          <div className="form-row">
-            <div className="form-group col-auto">
-              <div className="custom-control custom-checkbox">
-                <input
-                  id="cbFollowOs"
-                  className="custom-control-input"
-                  type="checkbox"
-                  checked={useOsSettings}
-                  onChange={e => followOsCheckboxModifiedHandler(e.target.checked)}
-                />
-                <label className="custom-control-label text-nowrap" htmlFor="cbFollowOs">{t('personal_dropdown.use_os_settings')}</label>
-              </div>
-            </div>
-          </div>
-          <div className="form-row justify-content-center">
-            <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <IconWithTooltip id="iwt-light" label="Light" additionalClasses={useOsSettings ? 'grw-icon-container-muted' : ''}>
-                <SunIcon />
-              </IconWithTooltip>
-              <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
-                <input
-                  id="swUserPreference"
-                  className="custom-control-input"
-                  type="checkbox"
-                  checked={isDarkMode}
-                  disabled={useOsSettings}
-                  onChange={e => userPreferenceSwitchModifiedHandler(e.target.checked)}
-                />
-                <label className="custom-control-label" htmlFor="swUserPreference"></label>
-              </div>
-              <IconWithTooltip id="iwt-dark" label="Dark" additionalClasses={useOsSettings ? 'grw-icon-container-muted' : ''}>
-                <MoonIcon />
-              </IconWithTooltip>
-            </div>
-          </div>
-        </form>
-
-        <div className="dropdown-divider"></div>
-
         <button type="button" className="dropdown-item" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{ t('Sign out') }</button>
         <button type="button" className="dropdown-item" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{ t('Sign out') }</button>
       </div>
       </div>
 
 

+ 7 - 7
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -1,27 +1,26 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
+import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
 import {
 import {
   Button,
   Button,
   TabContent, TabPane,
   TabContent, TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
-
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 
 
-import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 import CommentContainer from '~/client/services/CommentContainer';
 import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import NotAvailableForGuest from '../NotAvailableForGuest';
 import Editor from '../PageEditor/Editor';
 import Editor from '../PageEditor/Editor';
 import { SlackNotification } from '../SlackNotification';
 import { SlackNotification } from '../SlackNotification';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import CommentPreview from './CommentPreview';
 import CommentPreview from './CommentPreview';
-import NotAvailableForGuest from '../NotAvailableForGuest';
-import { CustomNavTab } from '../CustomNavigation/CustomNav';
 
 
 
 
 const navTabMapping = {
 const navTabMapping = {
@@ -312,6 +311,7 @@ class CommentEditor extends React.Component {
                 onChange={this.updateState}
                 onChange={this.updateState}
                 onUpload={this.uploadHandler}
                 onUpload={this.uploadHandler}
                 onCtrlEnter={this.ctrlEnterHandler}
                 onCtrlEnter={this.ctrlEnterHandler}
+                isComment
               />
               />
               {/*
               {/*
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.

+ 12 - 1
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -15,6 +15,7 @@ import loggerFactory from '~/utils/logger';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 
 import AbstractEditor from './AbstractEditor';
 import AbstractEditor from './AbstractEditor';
+import CommentMentionHelper from './CommentMentionHelper';
 import DrawioModal from './DrawioModal';
 import DrawioModal from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPicker from './EmojiPicker';
@@ -31,7 +32,6 @@ import pasteHelper from './PasteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import SimpleCheatsheet from './SimpleCheatsheet';
 import SimpleCheatsheet from './SimpleCheatsheet';
 
 
-
 // Textlint
 // Textlint
 window.JSHINT = JSHINT;
 window.JSHINT = JSHINT;
 window.kuromojin = { dicPath: '/static/dict' };
 window.kuromojin = { dicPath: '/static/dict' };
@@ -190,7 +190,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     // fold drawio section
     // fold drawio section
     this.foldDrawioSection();
     this.foldDrawioSection();
+
+    // initialize commentMentionHelper if comment editor is opened
+    if (this.props.isComment) {
+      this.commentMentionHelper = new CommentMentionHelper(this.getCodeMirror());
+    }
     this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
     this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
+
   }
   }
 
 
   componentWillReceiveProps(nextProps) {
   componentWillReceiveProps(nextProps) {
@@ -567,6 +573,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     this.updateCheatsheetStates(null, value);
     this.updateCheatsheetStates(null, value);
 
 
+    // Show username hint on comment editor
+    if (this.props.isComment) {
+      this.commentMentionHelper.showUsernameHint();
+    }
+
   }
   }
 
 
   keyUpHandler(editor, event) {
   keyUpHandler(editor, event) {

+ 62 - 0
packages/app/src/components/PageEditor/CommentMentionHelper.ts

@@ -0,0 +1,62 @@
+import i18n from 'i18next';
+import { debounce } from 'throttle-debounce';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+export default class CommentMentionHelper {
+
+  editor;
+
+  pattern: RegExp;
+
+
+  constructor(editor) {
+    this.editor = editor;
+  }
+
+  getUsernamHint = () => {
+    // Get word that contains `@` character at the begining
+    const currentPos = this.editor.getCursor();
+    const wordStart = this.editor.findWordAt(currentPos).anchor.ch - 1;
+    const wordEnd = this.editor.findWordAt(currentPos).head.ch;
+
+    const searchFrom = { line: currentPos.line, ch: wordStart };
+    const searchTo = { line: currentPos.line, ch: wordEnd };
+
+    const searchMention = this.editor.getRange(searchFrom, searchTo);
+    const isMentioning = searchMention.charAt(0) === '@';
+
+    // Return nothing if not mentioning
+    if (!isMentioning) {
+      return;
+    }
+
+    // Get username after `@` character and search username
+    const mention = searchMention.substr(1);
+    this.editor.showHint({
+      completeSingle: false,
+      hint: async() => {
+        if (mention.length > 0) {
+          const users = await this.getUsersList(mention);
+          return {
+            list: users.length > 0 ? users : [{ text: '', displayText: i18n.t('page_comment.no_user_found') }],
+            from: searchFrom,
+            to: searchTo,
+          };
+        }
+      },
+    });
+  }
+
+  getUsersList = async(username) => {
+    const limit = 20;
+    const { data } = await apiv3Get('/users/list', { username, limit });
+    return data.users.map(user => ({
+      text: `@${user.username} `,
+      displayText: user.username,
+    }));
+  }
+
+showUsernameHint= debounce(800, () => this.getUsernamHint());
+
+}

+ 5 - 8
packages/app/src/components/PageEditor/EmojiPicker.tsx

@@ -23,14 +23,11 @@ const EmojiPicker: FC<Props> = (props: Props) => {
   // Set search emoji input and trigger search
   // Set search emoji input and trigger search
   const searchEmoji = () => {
   const searchEmoji = () => {
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
     const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
-    if (emojiSearchText !== null) {
-
-      const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
-      valueSetter?.call(input, emojiSearchText);
-      const event = new Event('input', { bubbles: true });
-      input.dispatchEvent(event);
-      input.focus();
-    }
+    const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
+    valueSetter?.call(input, emojiSearchText);
+    const event = new Event('input', { bubbles: true });
+    input.dispatchEvent(event);
+    input.focus();
   };
   };
 
 
   const selectEmoji = (emoji) => {
   const selectEmoji = (emoji) => {

+ 5 - 6
packages/app/src/components/PageEditor/OptionsSelector.jsx

@@ -1,16 +1,17 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-
 import {
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 import { DownloadDictModal } from './DownloadDictModal';
 import { DownloadDictModal } from './DownloadDictModal';
 
 
 
 
@@ -146,9 +147,7 @@ class OptionsSelector extends React.Component {
     const { editorContainer } = this.props;
     const { editorContainer } = this.props;
     const newVal = !editorContainer.state.isTextlintEnabled;
     const newVal = !editorContainer.state.isTextlintEnabled;
     editorContainer.setState({ isTextlintEnabled: newVal });
     editorContainer.setState({ isTextlintEnabled: newVal });
-    if (this.state.isSkipAskingAgainChecked) {
-      this.updateIsTextlintEnabledToDB(newVal);
-    }
+    this.updateIsTextlintEnabledToDB(newVal);
   }
   }
 
 
   switchTextlintEnabledHandler() {
   switchTextlintEnabledHandler() {

+ 18 - 9
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -1,18 +1,19 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
+
+import { pagePathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { withTranslation } from 'react-i18next';
 import {
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { pagePathUtils } from '@growi/core';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 
 
 import RevisionDiff from '../PageHistory/RevisionDiff';
 import RevisionDiff from '../PageHistory/RevisionDiff';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 
 const { encodeSpaces } = pagePathUtils;
 const { encodeSpaces } = pagePathUtils;
 
 
@@ -32,16 +33,17 @@ const RevisionComparer = (props) => {
 
 
   const { t, revisionComparerContainer } = props;
   const { t, revisionComparerContainer } = props;
 
 
+  const { path, pageId } = revisionComparerContainer.pageContainer.state;
+
   function toggleDropdown() {
   function toggleDropdown() {
     setDropdownOpen(!dropdownOpen);
     setDropdownOpen(!dropdownOpen);
   }
   }
 
 
-  const pagePathUrl = () => {
+  const generateURL = (pathName) => {
     const { origin } = window.location;
     const { origin } = window.location;
-    const { path } = revisionComparerContainer.pageContainer.state;
     const { sourceRevision, targetRevision } = revisionComparerContainer.state;
     const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
 
-    const url = new URL(path, origin);
+    const url = new URL(pathName, origin);
 
 
     if (sourceRevision != null && targetRevision != null) {
     if (sourceRevision != null && targetRevision != null) {
       const urlParams = `${sourceRevision._id}...${targetRevision._id}`;
       const urlParams = `${sourceRevision._id}...${targetRevision._id}`;
@@ -49,6 +51,7 @@ const RevisionComparer = (props) => {
     }
     }
 
 
     return encodeSpaces(decodeURI(url));
     return encodeSpaces(decodeURI(url));
+
   };
   };
 
 
   const { sourceRevision, targetRevision } = revisionComparerContainer.state;
   const { sourceRevision, targetRevision } = revisionComparerContainer.state;
@@ -76,9 +79,15 @@ const RevisionComparer = (props) => {
           </DropdownToggle>
           </DropdownToggle>
           <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
           <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
             {/* Page path URL */}
             {/* Page path URL */}
-            <CopyToClipboard text={pagePathUrl()}>
+            <CopyToClipboard text={generateURL(path)}>
+              <DropdownItem className="px-3">
+                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(path)} />
+              </DropdownItem>
+            </CopyToClipboard>
+            {/* Permanent Link URL */}
+            <CopyToClipboard text={generateURL(pageId)}>
               <DropdownItem className="px-3">
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl()} />
+                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(pageId)} />
               </DropdownItem>
               </DropdownItem>
             </CopyToClipboard>
             </CopyToClipboard>
             <DropdownItem divider className="my-0"></DropdownItem>
             <DropdownItem divider className="my-0"></DropdownItem>

+ 6 - 2
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -57,7 +57,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
       }
       }
 
 
       {
       {
-        !isLoading && markdown != null ? (
+        (!isLoading && markdown != null) && (
           <div className="p-3">
           <div className="p-3">
             <RevisionRenderer
             <RevisionRenderer
               growiRenderer={renderer}
               growiRenderer={renderer}
@@ -66,7 +66,11 @@ const CustomSidebar: FC<Props> = (props: Props) => {
               additionalClassName="grw-custom-sidebar-content"
               additionalClassName="grw-custom-sidebar-content"
             />
             />
           </div>
           </div>
-        ) : (
+        )
+      }
+
+      {
+        (!isLoading && markdown === undefined) && (
           <SidebarNotFound />
           <SidebarNotFound />
         )
         )
       }
       }

+ 4 - 0
packages/app/src/server/models/user.js

@@ -715,6 +715,10 @@ module.exports = function(crowi) {
     return users;
     return users;
   };
   };
 
 
+  userSchema.statics.findUserByUsernameRegex = async function(username, limit) {
+    return this.find({ username: { $regex: username, $options: 'i' } }).limit(limit);
+  };
+
   class UserUpperLimitException {
   class UserUpperLimitException {
 
 
     constructor() {
     constructor() {

+ 7 - 5
packages/app/src/server/routes/apiv3/security-setting.js

@@ -1,9 +1,9 @@
+import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
+import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
-import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 
 
@@ -12,6 +12,7 @@ const express = require('express');
 const router = express.Router();
 const router = express.Router();
 
 
 const { body } = require('express-validator');
 const { body } = require('express-validator');
+
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 const validator = {
 const validator = {
@@ -461,7 +462,7 @@ module.exports = (crowi) => {
       googleOAuth: {
       googleOAuth: {
         googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
         googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
       },
       },
       githubOAuth: {
       githubOAuth: {
         githubClientId: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientId'),
         githubClientId: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientId'),
@@ -1086,16 +1087,17 @@ module.exports = (crowi) => {
     const requestParams = {
     const requestParams = {
       'security:passport-google:clientId': req.body.googleClientId,
       'security:passport-google:clientId': req.body.googleClientId,
       'security:passport-google:clientSecret': req.body.googleClientSecret,
       'security:passport-google:clientSecret': req.body.googleClientSecret,
-      'security:passport-google:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
+      'security:passport-google:isSameEmailTreatedAsIdenticalUser': req.body.isSameEmailTreatedAsIdenticalUser,
     };
     };
 
 
+
     try {
     try {
       await updateAndReloadStrategySettings('google', requestParams);
       await updateAndReloadStrategySettings('google', requestParams);
 
 
       const securitySettingParams = {
       const securitySettingParams = {
         googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
         googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
       };
       };
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }

+ 11 - 5
packages/app/src/server/routes/apiv3/users.js

@@ -12,9 +12,9 @@ const path = require('path');
 
 
 const { body, query } = require('express-validator');
 const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
 const { isEmail } = require('validator');
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
-const { serializePageSecurely } = require('../../models/serializers/page-serializer');
 
 
+const { serializePageSecurely } = require('../../models/serializers/page-serializer');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 const PAGE_ITEMS = 50;
 const PAGE_ITEMS = 50;
@@ -899,13 +899,19 @@ module.exports = (crowi) => {
    */
    */
   router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
     const userIds = req.query.userIds || null;
     const userIds = req.query.userIds || null;
+    const username = req.query.username || null;
+    const limit = req.query.limit || 20;
 
 
     let userFetcher;
     let userFetcher;
-    if (!userIds || userIds.split(',').length <= 0) {
-      userFetcher = User.findAllUsers();
+    if (userIds !== null && userIds.split(',').length > 0) {
+      userFetcher = User.findUsersByIds(userIds.split(','));
+    }
+    // Get username list by matching pattern from username mention
+    else if (username !== null) {
+      userFetcher = User.findUserByUsernameRegex(username, limit);
     }
     }
     else {
     else {
-      userFetcher = User.findUsersByIds(userIds.split(','));
+      userFetcher = User.findAllUsers();
     }
     }
 
 
     const data = {};
     const data = {};

+ 11 - 10
packages/app/src/server/service/passport.ts

@@ -1,22 +1,23 @@
-import urljoin from 'url-join';
-import luceneQueryParser from 'lucene-query-parser';
+import { IncomingMessage } from 'http';
 
 
+import axiosRetry from 'axios-retry';
+import luceneQueryParser from 'lucene-query-parser';
+import { Strategy as OidcStrategy, Issuer as OIDCIssuer, custom } from 'openid-client';
+import pRetry from 'p-retry';
 import passport from 'passport';
 import passport from 'passport';
+import { Strategy as GitHubStrategy } from 'passport-github';
+import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
+import { BasicStrategy } from 'passport-http';
 import LdapStrategy from 'passport-ldapauth';
 import LdapStrategy from 'passport-ldapauth';
 import { Strategy as LocalStrategy } from 'passport-local';
 import { Strategy as LocalStrategy } from 'passport-local';
-import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
-import { Strategy as GitHubStrategy } from 'passport-github';
-import { Strategy as TwitterStrategy } from 'passport-twitter';
-import { Strategy as OidcStrategy, Issuer as OIDCIssuer, custom } from 'openid-client';
 import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-saml';
 import { Profile, Strategy as SamlStrategy, VerifiedCallback } from 'passport-saml';
-import { BasicStrategy } from 'passport-http';
+import { Strategy as TwitterStrategy } from 'passport-twitter';
+import urljoin from 'url-join';
 
 
-import { IncomingMessage } from 'http';
-import axiosRetry from 'axios-retry';
-import pRetry from 'p-retry';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
+
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:PassportService');
 const logger = loggerFactory('growi:service:PassportService');

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

@@ -8,17 +8,18 @@ import {
 } from 'swr';
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
-
 import { IFocusable } from '~/client/interfaces/focusable';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { Nullable } from '~/interfaces/common';
 import { Nullable } from '~/interfaces/common';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { UpdateDescCountData } from '~/interfaces/websocket';
 import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
-  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage,
+  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
   useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
   useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
 } from './context';
 } from './context';
+import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
 const { isSharedPage } = pagePathUtils;
 const { isSharedPage } = pagePathUtils;
@@ -211,8 +212,27 @@ export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
   return useStaticSWR(key);
   return useStaticSWR(key);
 };
 };
 
 
-export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('preferDrawerModeByUser', initialData, { fallbackData: false });
+type PreferDrawerModeByUserUtils = {
+  update: (preferDrawerMode: boolean) => void
+}
+
+export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> & PreferDrawerModeByUserUtils => {
+  const { data: isGuestUser } = useIsGuestUser();
+  const { scheduleToPut } = useUserUISettings();
+
+  const swrResponse: SWRResponse<boolean, Error> = useStaticSWR('preferDrawerModeByUser', initialData, { use: isGuestUser ? [localStorageMiddleware] : [] });
+
+  return {
+    ...swrResponse,
+    data: swrResponse.data,
+    update: (preferDrawerMode: boolean) => {
+      swrResponse.mutate(preferDrawerMode);
+
+      if (!isGuestUser) {
+        scheduleToPut({ preferDrawerModeByUser: preferDrawerMode });
+      }
+    },
+  };
 };
 };
 
 
 export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {

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

@@ -79,10 +79,14 @@ pre:not(.hljs):not(.CodeMirror-line) {
 
 
 // Dropdown
 // Dropdown
 .grw-personal-dropdown {
 .grw-personal-dropdown {
-  .grw-icon-container svg {
+
+  .grw-sidebar-mode-icon svg {
+    fill: $secondary;
+  }
+  .grw-color-mode-icon svg {
     fill: $color-global;
     fill: $color-global;
   }
   }
-  .grw-icon-container-muted svg {
+  .grw-color-mode-icon-muted svg {
     fill: $secondary;
     fill: $secondary;
   }
   }
 }
 }