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

Merge branch 'master' into fix/96693-chinese-modal-text

ryoji-s 3 лет назад
Родитель
Сommit
28f2b2be99
33 измененных файлов с 334 добавлено и 444 удалено
  1. 0 77
      packages/app/src/client/services/PersonalContainer.js
  2. 14 16
      packages/app/src/client/util/GrowiRenderer.js
  3. 0 4
      packages/app/src/client/util/markdown-it/footernote.js
  4. 1 2
      packages/app/src/client/util/markdown-it/header-line-number.js
  5. 0 4
      packages/app/src/client/util/markdown-it/header-with-edit-link.js
  6. 1 3
      packages/app/src/client/util/markdown-it/header.js
  7. 0 4
      packages/app/src/client/util/markdown-it/table-with-handsontable-button.js
  8. 0 4
      packages/app/src/client/util/markdown-it/table.js
  9. 4 12
      packages/app/src/components/Fab.jsx
  10. 9 10
      packages/app/src/components/Me/ImageCropModal.jsx
  11. 0 197
      packages/app/src/components/Me/ProfileImageSettings.jsx
  12. 188 0
      packages/app/src/components/Me/ProfileImageSettings.tsx
  13. 9 10
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  14. 23 15
      packages/app/src/components/PageAttachment.jsx
  15. 19 12
      packages/app/src/components/PageComment/Comment.jsx
  16. 4 1
      packages/app/src/components/PageCreateModal.jsx
  17. 11 0
      packages/app/src/interfaces/attachment.ts
  18. 4 0
      packages/app/src/interfaces/user.ts
  19. 4 4
      packages/app/src/server/models/attachment.js
  20. 3 2
      packages/app/src/server/models/bookmark.js
  21. 2 1
      packages/app/src/server/models/external-account.js
  22. 2 4
      packages/app/src/server/models/in-app-notification.ts
  23. 2 2
      packages/app/src/server/models/page.ts
  24. 2 2
      packages/app/src/server/models/revision.js
  25. 3 2
      packages/app/src/server/models/share-link.js
  26. 2 1
      packages/app/src/server/models/subscription.ts
  27. 3 3
      packages/app/src/server/models/update-post.ts
  28. 2 1
      packages/app/src/server/models/user-group-relation.js
  29. 4 3
      packages/app/src/server/models/user-group.ts
  30. 6 8
      packages/app/src/server/models/user-registration-order.ts
  31. 4 6
      packages/app/src/server/models/user.js
  32. 0 34
      packages/app/src/server/util/middlewares.js
  33. 8 0
      packages/app/src/utils/gravatar.ts

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

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

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

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

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

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

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

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

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

@@ -1,9 +1,5 @@
 export default class HeaderWithEditLinkConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     md.renderer.rules.heading_close = (tokens, idx) => {
       return `<span class="revision-head-edit-button">

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 4 - 4
packages/app/src/server/models/attachment.js

@@ -6,10 +6,10 @@ import loggerFactory from '~/utils/logger';
 // eslint-disable-next-line no-unused-vars
 const path = require('path');
 
+const { addSeconds } = require('date-fns');
 const mongoose = require('mongoose');
-const uniqueValidator = require('mongoose-unique-validator');
 const mongoosePaginate = require('mongoose-paginate-v2');
-const { addSeconds } = require('date-fns');
+const uniqueValidator = require('mongoose-unique-validator');
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:models:attachment');
@@ -32,9 +32,10 @@ module.exports = function(crowi) {
     originalName: { type: String },
     fileFormat: { type: String, required: true },
     fileSize: { type: Number, default: 0 },
-    createdAt: { type: Date, default: Date.now },
     temporaryUrlCached: { type: String },
     temporaryUrlExpiredAt: { type: Date },
+  }, {
+    timestamps: { createdAt: true, updatedAt: false },
   });
   attachmentSchema.plugin(uniqueValidator);
   attachmentSchema.plugin(mongoosePaginate);
@@ -67,7 +68,6 @@ module.exports = function(crowi) {
     attachment.fileName = fileName;
     attachment.fileFormat = fileFormat;
     attachment.fileSize = fileSize;
-    attachment.createdAt = Date.now();
 
     return attachment;
   };

+ 3 - 2
packages/app/src/server/models/bookmark.js

@@ -16,7 +16,8 @@ module.exports = function(crowi) {
   bookmarkSchema = new mongoose.Schema({
     page: { type: ObjectId, ref: 'Page', index: true },
     user: { type: ObjectId, ref: 'User', index: true },
-    createdAt: { type: Date, default: Date.now },
+  }, {
+    timestamps: { createdAt: true, updatedAt: false },
   });
   bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
   bookmarkSchema.plugin(mongoosePaginate);
@@ -61,7 +62,7 @@ module.exports = function(crowi) {
   bookmarkSchema.statics.add = async function(page, user) {
     const Bookmark = this;
 
-    const newBookmark = new Bookmark({ page, user, createdAt: Date.now() });
+    const newBookmark = new Bookmark({ page, user });
 
     try {
       const bookmark = await newBookmark.save();

+ 2 - 1
packages/app/src/server/models/external-account.js

@@ -15,7 +15,8 @@ const schema = new mongoose.Schema({
   providerType: { type: String, required: true },
   accountId: { type: String, required: true },
   user: { type: ObjectId, ref: 'User', required: true },
-  createdAt: { type: Date, default: Date.now, required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
 });
 // compound index
 schema.index({ providerType: 1, accountId: 1 }, { unique: true });

+ 2 - 4
packages/app/src/server/models/in-app-notification.ts

@@ -71,14 +71,12 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     index: true,
     require: true,
   },
-  createdAt: {
-    type: Date,
-    default: new Date(),
-  },
   snapshot: {
     type: String,
     require: true,
   },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
 });
 inAppNotificationSchema.plugin(mongoosePaginate);
 

+ 2 - 2
packages/app/src/server/models/page.ts

@@ -102,11 +102,11 @@ const schema = new Schema<PageDocument, PageModel>({
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
-  createdAt: { type: Date, default: new Date() },
-  updatedAt: { type: Date, default: new Date() },
+  updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },
 }, {
+  timestamps: { createdAt: true, updatedAt: false },
   toJSON: { getters: true },
   toObject: { getters: true },
 });

+ 2 - 2
packages/app/src/server/models/revision.js

@@ -28,8 +28,9 @@ module.exports = function(crowi) {
     },
     format: { type: String, default: 'markdown' },
     author: { type: ObjectId, ref: 'User' },
-    createdAt: { type: Date, default: Date.now },
     hasDiffToPrev: { type: Boolean },
+  }, {
+    timestamps: { createdAt: true, updatedAt: false },
   });
   revisionSchema.plugin(mongoosePaginate);
 
@@ -55,7 +56,6 @@ module.exports = function(crowi) {
     newRevision.body = body;
     newRevision.format = format;
     newRevision.author = user._id;
-    newRevision.createdAt = Date.now();
     if (pageData.revision != null) {
       newRevision.hasDiffToPrev = body !== previousBody;
     }

+ 3 - 2
packages/app/src/server/models/share-link.js

@@ -2,8 +2,8 @@
 /* eslint-disable no-return-await */
 
 const mongoose = require('mongoose');
-const uniqueValidator = require('mongoose-unique-validator');
 const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -19,7 +19,8 @@ const schema = new mongoose.Schema({
   },
   expiredAt: { type: Date },
   description: { type: String },
-  createdAt: { type: Date, default: Date.now, required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
 });
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);

+ 2 - 1
packages/app/src/server/models/subscription.ts

@@ -50,7 +50,8 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
     require: true,
     enum: AllSubscriptionStatusType,
   },
-  createdAt: { type: Date, default: new Date() },
+}, {
+  timestamps: true,
 });
 
 subscriptionSchema.methods.isSubscribing = function() {

+ 3 - 3
packages/app/src/server/models/update-post.ts

@@ -1,9 +1,9 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
+import { getOrCreateModel } from '@growi/core';
 import {
   Types, Schema, Model, Document,
 } from 'mongoose';
-import { getOrCreateModel } from '@growi/core';
 
 export interface IUpdatePost {
   pathPattern: string
@@ -36,7 +36,8 @@ const updatePostSchema = new Schema<UpdatePostDocument, UpdatePostModel>({
   channel: { type: String, required: true },
   provider: { type: String, required: true },
   creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
-  createdAt: { type: Date, default: new Date(Date.now()) },
+}, {
+  timestamps: true,
 });
 
 updatePostSchema.statics.normalizeChannelName = function(channel) {
@@ -115,7 +116,6 @@ updatePostSchema.statics.createUpdatePost = async function(pathPattern, channel,
     channel: this.normalizeChannelName(channel),
     provider,
     creator,
-    createdAt: Date.now(),
   });
 };
 

+ 2 - 1
packages/app/src/server/models/user-group-relation.js

@@ -12,7 +12,8 @@ const ObjectId = mongoose.Schema.Types.ObjectId;
 const schema = new mongoose.Schema({
   relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
   relatedUser: { type: ObjectId, ref: 'User', required: true },
-  createdAt: { type: Date, default: Date.now, required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
 });
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);

+ 4 - 3
packages/app/src/server/models/user-group.ts

@@ -1,8 +1,8 @@
+import { getOrCreateModel } from '@growi/core';
 import mongoose, {
-  Types, Schema, Model, Document,
+  Schema, Model, Document,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
-import { getOrCreateModel } from '@growi/core';
 
 import { IUserGroup } from '~/interfaces/user';
 
@@ -22,9 +22,10 @@ const ObjectId = mongoose.Schema.Types.ObjectId;
 
 const schema = new Schema<UserGroupDocument, UserGroupModel>({
   name: { type: String, required: true, unique: true },
-  createdAt: { type: Date, default: new Date() },
   parent: { type: ObjectId, ref: 'UserGroup', index: true },
   description: { type: String, default: '' },
+}, {
+  timestamps: true,
 });
 schema.plugin(mongoosePaginate);
 

+ 6 - 8
packages/app/src/server/models/user-registration-order.ts

@@ -1,11 +1,12 @@
+import crypto from 'crypto';
+
+import { getOrCreateModel } from '@growi/core';
+import { addHours } from 'date-fns';
 import {
   Schema, Model, Document,
 } from 'mongoose';
-
-import { addHours } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
-import crypto from 'crypto';
-import { getOrCreateModel } from '@growi/core';
+
 
 export interface IUserRegistrationOrder {
   token: string,
@@ -35,10 +36,7 @@ const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderMo
   isRevoked: { type: Boolean, default: false, required: true },
   expiredAt: { type: Date, default: expiredAt, required: true },
 }, {
-  timestamps: {
-    createdAt: true,
-    updatedAt: false,
-  },
+  timestamps: true,
 });
 schema.plugin(uniqueValidator);
 

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

@@ -1,6 +1,8 @@
 /* eslint-disable no-use-before-define */
+import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
+
 const crypto = require('crypto');
 
 const debug = require('debug')('growi:models:user');
@@ -65,11 +67,11 @@ module.exports = function(crowi) {
     status: {
       type: Number, required: true, default: STATUS_ACTIVE, index: true,
     },
-    createdAt: { type: Date, default: Date.now },
     lastLoginAt: { type: Date },
     admin: { type: Boolean, default: 0, index: true },
     isInvitationEmailSended: { type: Boolean, default: false },
   }, {
+    timestamps: true,
     toObject: {
       transform: (doc, ret, opt) => {
         return omitInsecureAttributes(ret);
@@ -227,9 +229,7 @@ module.exports = function(crowi) {
 
   userSchema.methods.generateImageUrlCached = async function() {
     if (this.isGravatarEnabled) {
-      const email = this.email || '';
-      const hash = md5(email.trim().toLowerCase());
-      return `https://gravatar.com/avatar/${hash}`;
+      return generateGravatarSrc(this.email);
     }
     if (this.image != null) {
       return this.image;
@@ -542,7 +542,6 @@ module.exports = function(crowi) {
     newUser.username = tmpUsername;
     newUser.email = email;
     newUser.setPassword(password);
-    newUser.createdAt = Date.now();
     newUser.status = STATUS_INVITED;
 
     const globalLang = configManager.getConfig('crowi', 'app:globalLang');
@@ -632,7 +631,6 @@ module.exports = function(crowi) {
     if (lang != null) {
       newUser.lang = lang;
     }
-    newUser.createdAt = Date.now();
     newUser.status = status || decideUserStatusOnRegistration();
 
     newUser.save((err, userData) => {

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

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

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

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