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

Merge pull request #5978 from weseek/imprv/omit-personal-container

imprv: ProfileImageSettings
Yuki Takei 3 лет назад
Родитель
Сommit
036602a5ea

+ 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
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:PersonalContainer');
 const logger = loggerFactory('growi:services:PersonalContainer');
 
 
-const DEFAULT_IMAGE = '/images/icons/user.svg';
-
 /**
 /**
  * Service container for personal settings page (PersonalSettings.jsx)
  * Service container for personal settings page (PersonalSettings.jsx)
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
@@ -29,8 +27,6 @@ export default class PersonalContainer extends Container {
       isEmailPublished: false,
       isEmailPublished: false,
       lang: 'en_US',
       lang: 'en_US',
       isGravatarEnabled: false,
       isGravatarEnabled: false,
-      isUploadedPicture: false,
-      uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
       externalAccounts: [],
       apiToken: '',
       apiToken: '',
       slackMemberId: '',
       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
    * 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
    * Associate LDAP account
    */
    */

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

@@ -1,20 +1,20 @@
 import React from 'react';
 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 {
 import {
   Modal,
   Modal,
   ModalHeader,
   ModalHeader,
   ModalBody,
   ModalBody,
   ModalFooter,
   ModalFooter,
 } from 'reactstrap';
 } 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 'react-image-crop/dist/ReactCrop.css';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import loggerFactory from '~/utils/logger';
+
 
 
 const logger = loggerFactory('growi:ImageCropModal');
 const logger = loggerFactory('growi:ImageCropModal');
 
 
@@ -113,12 +113,11 @@ class ImageCropModal extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const ProfileImageFormWrapper = withUnstatedContainers(ImageCropModal, [AppContainer]);
 ImageCropModal.propTypes = {
 ImageCropModal.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   show: PropTypes.bool.isRequired,
   show: PropTypes.bool.isRequired,
   src: PropTypes.string,
   src: PropTypes.string,
   onModalClose: PropTypes.func.isRequired,
   onModalClose: PropTypes.func.isRequired,
   onCropCompleted: 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]);

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

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

@@ -1,6 +1,8 @@
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
+import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 const crypto = require('crypto');
 const crypto = require('crypto');
 
 
 const debug = require('debug')('growi:models:user');
 const debug = require('debug')('growi:models:user');
@@ -227,9 +229,7 @@ module.exports = function(crowi) {
 
 
   userSchema.methods.generateImageUrlCached = async function() {
   userSchema.methods.generateImageUrlCached = async function() {
     if (this.isGravatarEnabled) {
     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) {
     if (this.image != null) {
       return this.image;
       return this.image;

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