diff --git a/assets/images/dragonfly2.svg b/assets/images/dragonfly2.svg deleted file mode 100644 index ce2b4d7a..00000000 --- a/assets/images/dragonfly2.svg +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/images/edit.svg b/assets/images/edit.svg new file mode 100644 index 00000000..b2c1e3e8 --- /dev/null +++ b/assets/images/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/uploadPhoto.svg b/assets/images/uploadPhoto.svg new file mode 100644 index 00000000..27510b9a --- /dev/null +++ b/assets/images/uploadPhoto.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/locales/en.json b/locales/en.json index 71b05905..0ba50036 100644 --- a/locales/en.json +++ b/locales/en.json @@ -109,6 +109,26 @@ "buttonClose": "Close", "buttonDonate": "Donate now" }, + "EditProfile": { + "bodyCancel": "If you click cancel all changes you made will be lost.", + "buttonCancel": "Cancel", + "buttonContinue": "Continue Editing", + "buttonSave": "Save changes", + "charactersExplanation": "Only basic characters and numbers (A-Z, 0-9) are allowed, no symbols or whitespace, 3-24 characters", + "confirmationMessage": "Great! Your profile changes have been saved!", + "errorNameTaken": "Sorry, this name is already taken!", + "errorInvalidName": "Ups! Invalid name, try again.", + "errorImageCapture": "Sorry, there was a problem with image capture.", + "errorSaveChanges": "Sorry, there was a problem with saving changes.", + "heading": "Edit profile", + "optionCapture": "Capture", + "optionTryAgain": "Try Again", + "optionCamera": "Camera", + "optionUpload": "Upload", + "optionFile": "Gallery", + "titleCancel": "Do you want to save changes to your profile?", + "usernameExplanation": "Your username is how your friends can search for you on the Circles App." + }, "Finder": { "bodyFilterDirect": "Directly trusted", "bodyFilterExternal": "External", diff --git a/package-lock.json b/package-lock.json index 4e8c27ae..43b4b1a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "circles-myxogastria", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "2.0.0", + "version": "2.0.1", "license": "AGPL-3.0", "dependencies": { "@circles/core": "^2.10.10", diff --git a/package.json b/package.json index 866ebbe4..6cb15369 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "circles-myxogastria", - "version": "2.0.0", + "version": "2.0.1", "description": "Webapp and mobile client for Circles", "main": "src/index.js", "private": true, @@ -57,7 +57,7 @@ "webpack-dev-server": "^3.11.2" }, "dependencies": { - "@circles/core": "^2.10.10", + "@circles/core": "^2.12.0", "@circles/timecircles": "^1.0.6", "@material-ui/core": "^4.12.4", "@material-ui/lab": "^4.0.0-alpha.61", diff --git a/src/components/Avatar.js b/src/components/Avatar.js index 3387a67c..eb008df8 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -21,6 +21,7 @@ const useStyles = makeStyles(() => ({ avatarContainer: { position: 'relative', margin: '0 auto', + cursor: 'pointer', }, organizationIndicator: { position: 'absolute', @@ -29,11 +30,14 @@ const useStyles = makeStyles(() => ({ }, })); -const Avatar = ({ address, size = 'small', ...props }) => { +const Avatar = ({ address, size = 'small', url, useCache, ...props }) => { const classes = useStyles(); const theme = useTheme(); - const { avatarUrl, username } = useUserdata(address); + let { avatarUrl, username } = useUserdata(address, useCache); + + if (url) avatarUrl = url; + const { isOrganization } = useIsOrganization(address); const sizePixelAvatar = @@ -68,6 +72,8 @@ const Avatar = ({ address, size = 'small', ...props }) => { Avatar.propTypes = { address: PropTypes.string, size: PropTypes.string, + url: PropTypes.string, + useCache: PropTypes.bool, }; export default React.memo(Avatar); diff --git a/src/components/AvatarHeader.js b/src/components/AvatarHeader.js index e6bc516d..c233a1c6 100644 --- a/src/components/AvatarHeader.js +++ b/src/components/AvatarHeader.js @@ -42,9 +42,9 @@ const AvatarHeader = ({ hideImage, username }) => { const displayedUsername = username ? ( `@${username}` ) : safe.currentAccount ? ( - + ) : safe.pendingAddress ? ( - + ) : null; return ( @@ -56,6 +56,7 @@ const AvatarHeader = ({ hideImage, username }) => { address={safe.currentAccount || safe.pendingAddress} className={classes.avatarContainer} size={'smallXl'} + useCache={false} /> )} diff --git a/src/components/Button.js b/src/components/Button.js index 135657c2..6fd4000b 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -53,6 +53,17 @@ const useStyles = makeStyles((theme) => ({ linear-gradient(to right, ${theme.custom.colors.purple}, ${theme.custom.colors.purpleDark}) border-box`, border: '1px solid transparent', }, + buttonWithoutBorder: { + border: 0, + background: theme.custom.gradients.purple, + backgroundClip: 'text', + color: 'transparent', + '-webkit-background-clip': 'text', + '-webkit-text-fill-color': 'transparent', + '&:hover': { + backgroundColor: 'transparent', + }, + }, })); // eslint-disable-next-line react/display-name @@ -68,6 +79,7 @@ const Button = React.forwardRef( isWhite, isWhiteText, isGradientBorder, + isWithoutBorder, to, ...props }, @@ -83,6 +95,7 @@ const Button = React.forwardRef( [classes.buttonWhite]: isWhite, [classes.buttonWhiteText]: isWhiteText, [classes.buttonGradientBorder]: isGradientBorder, + [classes.buttonWithoutBorder]: isWithoutBorder, }); return React.createElement( @@ -113,6 +126,7 @@ Button.propTypes = { isPrimary: PropTypes.bool, isWhite: PropTypes.bool, isWhiteText: PropTypes.bool, + isWithoutBorder: PropTypes.bool, onClick: PropTypes.func, to: PropTypes.string, }; diff --git a/src/components/DialogInfo.js b/src/components/DialogInfo.js index eee5be2f..5b60d654 100644 --- a/src/components/DialogInfo.js +++ b/src/components/DialogInfo.js @@ -26,7 +26,16 @@ const useStyles = makeStyles((theme) => ({ }, })); -const DialogInfo = ({ dialogContent, handleClose, id, isOpen, title }) => { +const DialogInfo = ({ + dialogContent, + handleClose, + id, + isOpen, + title, + fullWidth, + maxWidth, + isBtnClose = true, +}) => { const classes = useStyles(); return ( @@ -34,13 +43,15 @@ const DialogInfo = ({ dialogContent, handleClose, id, isOpen, title }) => { aria-describedby={`dialog-${id}-text`} aria-labelledby={`dialog-${id}-description`} className={classes.dialogContainer} + fullWidth={fullWidth} + maxWidth={maxWidth} open={isOpen} onClose={handleClose} > {title} {dialogContent} - + {isBtnClose && } ); @@ -48,10 +59,13 @@ const DialogInfo = ({ dialogContent, handleClose, id, isOpen, title }) => { DialogInfo.propTypes = { dialogContent: PropTypes.element, + fullWidth: PropTypes.bool, handleClose: PropTypes.func.isRequired, - id: PropTypes.string.isRequired, + id: PropTypes.string, + isBtnClose: PropTypes.bool, isOpen: PropTypes.bool.isRequired, - title: PropTypes.string.isRequired, + maxWidth: PropTypes.string, + title: PropTypes.string, }; export default React.memo(DialogInfo); diff --git a/src/components/ImageCapture.js b/src/components/ImageCapture.js new file mode 100644 index 00000000..4b6c93d8 --- /dev/null +++ b/src/components/ImageCapture.js @@ -0,0 +1,143 @@ +import { Box, CircularProgress } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import clsx from 'clsx'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { createRef, useCallback } from 'react'; + +import Button from '~/components/Button'; +import translate from '~/services/locale'; + +const useStyles = makeStyles((theme) => ({ + videoContainer: { + marginBottom: '25px', + height: '198px', + clipPath: 'circle(99px at center)', + [theme.breakpoints.up('sm')]: { + minHeight: '263px', + clipPath: 'circle(130px at center)', + }, + overflow: 'hidden', + }, + imageCanvas: { + display: 'none', + }, + loadingMask: { + background: theme.custom.colors.cornflowerBlue, + opacity: 0.5, + position: 'relative', + }, + loadingIndicator: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }, + imageCaptureContainer: { + position: 'relative', + + [theme.breakpoints.up('sm')]: { + minHeight: '400px', + }, + }, + captureBtn: { + marginBottom: '25px', + }, + uploadBtn: { + cursor: 'not-allowed', + }, +})); + +const ImageCapture = ({ onCapture, onError, width, userMediaConfig }) => { + const classes = useStyles(); + const [isLoading, setIsLoading] = useState(true); + + const playerRef = createRef(); + const canvasRef = createRef(); + const tracks = useRef(); + useEffect(() => { + navigator.mediaDevices + .getUserMedia(userMediaConfig) + .then((stream) => { + if (playerRef.current) { + playerRef.current.srcObject = stream; + setIsLoading(false); + } + tracks.current = stream.getTracks(); + }) + .catch((error) => { + if (onError) onError(error); + }); + /* eslint-disable react-hooks/exhaustive-deps */ + }, [onError, userMediaConfig]); + /* eslint-enable react-hooks/exhaustive-deps */ + + useEffect(() => { + return () => { + if (tracks.current) { + tracks.current.forEach((track) => track.stop()); + } + }; + }, []); + + const captureImage = useCallback(() => { + const imageWidth = playerRef.current.offsetWidth; + const imageHeight = playerRef.current.offsetHeight; + [canvasRef.current.width, canvasRef.current.height] = [ + imageWidth, + imageHeight, + ]; + const context = canvasRef.current.getContext('2d'); + context.drawImage(playerRef.current, 0, 0, imageWidth, imageHeight); + if (onCapture) { + const pngData = canvasRef.current.toDataURL(); + canvasRef.current.toBlob((blob) => { + onCapture({ + blob, + png: pngData, + file: new File([blob], `${new Date().getTime()}.png`, { + type: 'image/png', + }), + }); + }); + } + }, [onCapture, canvasRef, playerRef]); + + return ( + + + {isLoading && ( + + + + + + )} + + + + + + + ); +}; + +ImageCapture.propTypes = { + onCapture: PropTypes.func, + onError: PropTypes.func, + userMediaConfig: PropTypes.object, + width: PropTypes.string, +}; + +export default ImageCapture; diff --git a/src/components/NavigationFloating.js b/src/components/NavigationFloating.js index 60f3e833..39825dfd 100644 --- a/src/components/NavigationFloating.js +++ b/src/components/NavigationFloating.js @@ -5,7 +5,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { - //EDIT_PROFILE, + EDIT_PROFILE_PATH, MY_PROFILE_PATH, ORGANIZATION_MEMBERS_PATH, } from '~/routes'; @@ -153,13 +153,13 @@ export default function NavigationFloating(props) { )} - {/* - + + - */} + + + + )} + + ); +}; + +UploadFromCamera.propTypes = { + imageCaptureError: PropTypes.func, + isUploading: PropTypes.bool, + uploadImgSrc: PropTypes.func, + uploadPhoto: PropTypes.func, +}; + +export default UploadFromCamera; diff --git a/src/components/UsernameDisplay.js b/src/components/UsernameDisplay.js index dd99d29a..773e9b7e 100644 --- a/src/components/UsernameDisplay.js +++ b/src/components/UsernameDisplay.js @@ -4,12 +4,13 @@ import React from 'react'; import { useUserdata } from '~/hooks/username'; const UsernameDisplay = (props) => { - const { username } = useUserdata(props.address); + const { username } = useUserdata(props.address, props.fromCache); return `@${username}`; }; UsernameDisplay.propTypes = { address: PropTypes.string.isRequired, + fromCache: PropTypes.bool, }; export default React.memo(UsernameDisplay); diff --git a/src/components/VerifiedUsernameInput.js b/src/components/VerifiedUsernameInput.js index 9f3e1fc5..1e00fc37 100644 --- a/src/components/VerifiedUsernameInput.js +++ b/src/components/VerifiedUsernameInput.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { useCallback, useEffect, useState } from 'react'; import Input from '~/components/Input'; +import { useUserdata } from '~/hooks/username'; import core from '~/services/core'; import translate from '~/services/locale'; import debounce from '~/utils/debounce'; @@ -10,6 +11,8 @@ const DEBOUNCE_DELAY = 500; const MAX_USERNAME_LENGTH = 24; const VerifiedUsernameInput = ({ + address, + allowCurrentUser, label, onChange, onStatusChange, @@ -29,6 +32,7 @@ const VerifiedUsernameInput = ({ }, [value, onStatusChange, isError, isLoading]); const [errorMessage, setErrorMessage] = useState(''); + const { username: userUsername } = useUserdata(address); // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedUsernameCheck = useCallback( @@ -66,9 +70,14 @@ const VerifiedUsernameInput = ({ (username) => { setIsError(false); setIsLoading(true); - debouncedUsernameCheck(username); + if (username === userUsername && allowCurrentUser) { + setIsLoading(false); + return; + } else { + debouncedUsernameCheck(username); + } }, - [debouncedUsernameCheck], + [debouncedUsernameCheck, allowCurrentUser, userUsername], ); return ( @@ -87,6 +96,8 @@ const VerifiedUsernameInput = ({ }; VerifiedUsernameInput.propTypes = { + address: PropTypes.string, + allowCurrentUser: PropTypes.bool, label: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, onStatusChange: PropTypes.func.isRequired, diff --git a/src/hooks/username.js b/src/hooks/username.js index 05c202ac..4cdd144a 100644 --- a/src/hooks/username.js +++ b/src/hooks/username.js @@ -49,14 +49,14 @@ export function useIsOrganization(address) { }; } -export function useUserdata(address) { +export function useUserdata(address, useCache = true) { const [data, setData] = useState(defaultUserdata(address)); useEffect(() => { let isUnloaded = false; const request = async () => { - const result = await resolveUsernames([address]); + const result = await resolveUsernames([address], useCache); if (isUnloaded) { return; @@ -76,7 +76,7 @@ export function useUserdata(address) { return () => { isUnloaded = true; }; - }, [address]); + }, [address, useCache]); return data; } diff --git a/src/routes.js b/src/routes.js index 6021b917..c3f6f5c8 100644 --- a/src/routes.js +++ b/src/routes.js @@ -7,6 +7,7 @@ import { ACCOUNT_CREATE } from '~/store/tutorial/actions'; import Activities from '~/views/Activities'; import Dashboard from '~/views/Dashboard'; import DashboardOrganization from '~/views/DashboardOrganization'; +import EditProfile from '~/views/EditProfile'; import Error from '~/views/Error'; import Login from '~/views/Login'; import NotFound from '~/views/NotFound'; @@ -33,6 +34,7 @@ export const MY_PROFILE_PATH = '/profile'; // Main routes export const ACTIVITIES_PATH = '/activities'; export const DASHBOARD_PATH = '/'; +export const EDIT_PROFILE_PATH = '/edit'; export const LOGIN_PATH = '/welcome/login'; export const ONBOARDING_PATH = '/welcome/onboarding'; export const ORGANIZATION_MEMBERS_ADD_PATH = '/sharedwallet/members/add'; @@ -48,7 +50,6 @@ export const SETTINGS_PATH = '/settings'; export const SHARE_PATH = '/share'; export const VALIDATION_PATH = '/validation'; export const WELCOME_PATH = '/welcome'; -export const EDIT_PROFILE = '/editprofile'; const SessionContainer = ({ component: Component, @@ -261,6 +262,7 @@ const Routes = () => { + { + return await requestCore('user', 'update', { + safeAddress, + username, + email, + avatarUrl, + }); + }, + + getEmail: async (safeAddress) => { + return await requestCore('user', 'getEmail', { + safeAddress, + }); + }, }; // Trust module diff --git a/src/services/username.js b/src/services/username.js index 4e1578cb..ddc10772 100644 --- a/src/services/username.js +++ b/src/services/username.js @@ -6,7 +6,7 @@ const requests = {}; const requested = []; const failed = []; -export default async function resolveUsernames(addresses) { +export default async function resolveUsernames(addresses, useCache) { const requestKey = addresses.sort().join(''); addresses.forEach((address) => { @@ -15,7 +15,7 @@ export default async function resolveUsernames(addresses) { // Check if we're currently requesting the same addresses and return it // instead of doing the same request again - if (requestKey in requests) { + if (useCache && requestKey in requests) { return requests[requestKey]; } @@ -24,7 +24,7 @@ export default async function resolveUsernames(addresses) { return key.includes(requestKey); }); - if (supersetRequestKey) { + if (useCache && supersetRequestKey) { return requests[supersetRequestKey]; } @@ -35,7 +35,7 @@ export default async function resolveUsernames(addresses) { // Prepare request addresses.forEach((address) => { - if (address in cache) { + if (useCache && address in cache) { // Use result from cache to not ask server again result[address] = cache[address]; } else if (!failed.includes[address]) { diff --git a/src/styles/icons.js b/src/styles/icons.js index 24bc703f..f6df03a5 100644 --- a/src/styles/icons.js +++ b/src/styles/icons.js @@ -10,7 +10,7 @@ import circles from '%/images/circles.svg'; import closeOutline from '%/images/close-outline.svg'; import close from '%/images/close.svg'; import connections from '%/images/connections.svg'; -import dragonFly from '%/images/dragonfly2.svg'; +import edit from '%/images/edit.svg'; import facebook from '%/images/facebook.svg'; import follow from '%/images/follow.svg'; import friends from '%/images/friends.svg'; @@ -30,6 +30,7 @@ import trustActive from '%/images/trust-active.svg'; import trustMutual from '%/images/trust-mutual.svg'; import trust from '%/images/trust.svg'; import twitter from '%/images/twitter.svg'; +import uploadPhoto from '%/images/uploadPhoto.svg'; import world from '%/images/world.svg'; export const IconActivity = (props) => { @@ -68,14 +69,14 @@ export const IconConnections = (props) => { return ; }; -export const IconDragonFly = (props) => { - return ; -}; - export const IconFacebook = (props) => { return ; }; +export const IconEdit = (props) => { + return ; +}; + export const IconFollow = (props) => { return ; }; @@ -148,6 +149,10 @@ export const IconTwitter = (props) => { return ; }; +export const IconUploadPhoto = (props) => { + return ; +}; + export const IconWorld = (props) => { return ; }; diff --git a/src/styles/theme.js b/src/styles/theme.js index 6a6907db..7f78a3de 100644 --- a/src/styles/theme.js +++ b/src/styles/theme.js @@ -57,6 +57,8 @@ export const colors = { disco: '#99164C', violet: '#5A2F56', tapestry: '#A75183', + greyHover: 'rgba(222, 213, 221, 0.8)', + cornflowerBlue: '#efeaef80', }; const gradients = { diff --git a/src/utils/deviceDetect.js b/src/utils/deviceDetect.js new file mode 100644 index 00000000..8bd44ae3 --- /dev/null +++ b/src/utils/deviceDetect.js @@ -0,0 +1,23 @@ +const checkDevice = (userAgent) => { + const isAndroid = () => Boolean(userAgent.match(/Android/i)); + const isIos = () => Boolean(userAgent.match(/iPhone|iPad|iPod/i)); + const isOpera = () => Boolean(userAgent.match(/Opera Mini/i)); + const isWindows = () => Boolean(userAgent.match(/IEMobile/i)); + const isSSR = () => Boolean(userAgent.match(/SSR/i)); + + const isMobile = () => + Boolean(isAndroid() || isIos() || isOpera() || isWindows()); + const isDesktop = () => Boolean(!isMobile() && !isSSR()); + return { + isMobile, + isDesktop, + isAndroid, + isIos, + isSSR, + }; +}; +export function getDeviceDetect() { + const userAgent = + typeof navigator === 'undefined' ? 'SSR' : navigator.userAgent; + return checkDevice(userAgent); +} diff --git a/src/views/EditProfile.js b/src/views/EditProfile.js new file mode 100644 index 00000000..8d489e00 --- /dev/null +++ b/src/views/EditProfile.js @@ -0,0 +1,379 @@ +import { + Badge, + Box, + Container, + IconButton, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import PropTypes from 'prop-types'; +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Redirect } from 'react-router-dom'; + +import { DASHBOARD_PATH } from '~/routes'; + +import Avatar from '~/components/Avatar'; +import Button from '~/components/Button'; +import CenteredHeading from '~/components/CenteredHeading'; +import DialogInfo from '~/components/DialogInfo'; +import Footer from '~/components/Footer'; +import Header from '~/components/Header'; +import UploadFromCamera from '~/components/UploadFromCamera'; +import VerifiedUsernameInput from '~/components/VerifiedUsernameInput'; +import View from '~/components/View'; +import { useUserdata } from '~/hooks/username'; +import core from '~/services/core'; +import translate from '~/services/locale'; +import notify, { NotificationsTypes } from '~/store/notifications/actions'; +import { IconUploadPhoto } from '~/styles/icons'; + +const BOTTOM_SPACING = '30px'; + +const useStyles = makeStyles((theme) => ({ + uploadButton: { + background: theme.custom.gradients.purple, + color: theme.palette.common.white, + padding: 0, + }, + uploadButtonIcon: { + position: 'relative', + left: 1, + width: '2.2em', + height: '2.2em', + }, + textContainer: { + textAlign: 'center', + '& >p': { + marginBottom: '8px', + }, + }, + dialogContentContainer: { + '& >p': { + marginBottom: BOTTOM_SPACING, + }, + }, + continueButton: { + marginBottom: BOTTOM_SPACING, + }, + actionButtonsContainer: { + marginBottom: BOTTOM_SPACING, + }, + usernameInputContainer: { + marginBottom: BOTTOM_SPACING, + marginTop: '50px', + }, + openCameraInput: { + display: 'none', + }, + saveButton: { + marginBottom: '20px', + }, +})); + +const DialogContentUpload = ({ onFileUpload, handleClose, uploadImgSrc }) => { + const classes = useStyles(); + const dispatch = useDispatch(); + const [isLoading, setIsLoading] = useState(false); + const [isUploadFromCamera, setIsUploadFromCamera] = useState(false); + const fileInputElem = useRef(); + + const galleryBtnHandler = (event) => { + event.preventDefault(); + fileInputElem.current.click(); + setIsUploadFromCamera(false); + }; + const cameraBtnHandler = () => { + setIsUploadFromCamera(true); + }; + + const uploadFile = async (event) => { + const { files } = event.target; + if (files.length === 0) { + return; + } + + uploadPhoto(files); + }; + + async function uploadPhoto(files) { + setIsLoading(true); + + let data; + + if (files.length) { + data = [...files].reduce((acc, file) => { + acc.append('files', file, file.name); + return acc; + }, new FormData()); + } else { + data = new FormData(); + data.append('files', files); + } + + try { + const result = await core.utils.requestAPI({ + path: ['uploads', 'avatar'], + method: 'POST', + data, + }); + + onFileUpload(result.data.url); + handleClose(); + } catch (error) { + dispatch( + notify({ + text: translate('AvatarUploader.errorAvatarUpload'), + type: NotificationsTypes.ERROR, + }), + ); + } + + setIsLoading(false); + } + + const onImageCaptureErrorHandler = () => { + dispatch( + notify({ + text: translate('EditProfile.errorImageCapture'), + type: NotificationsTypes.ERROR, + }), + ); + }; + + return ( + + {!isUploadFromCamera && ( + <> + + + + )} + {!isUploadFromCamera && ( + + + + )} + {isUploadFromCamera && ( + + )} + + ); +}; + +const EditProfile = () => { + const classes = useStyles(); + const [isClose, setIsClose] = useState(false); + const [isDisabled, setIsDisabled] = useState(false); + const [isOpenDialogCloseInfo, setIsOpenDialogCloseInfo] = useState(false); + const [isOpenDialogUploadInfo, setIsOpenDialogUploadInfo] = useState(false); + const [usernameInput, setUsernameInput] = useState(username); + const [profilePicUrl, setProfilePicUrl] = useState(''); + const dispatch = useDispatch(); + + const safe = useSelector((state) => state.safe); + const { username } = useUserdata(safe.currentAccount); + + const onChangeUsernameHandler = (username) => { + setUsernameInput(username); + }; + + const onDisabledChange = (updatedValue) => { + if (username !== usernameInput) { + setIsDisabled(updatedValue); + } else { + setIsDisabled(false); + } + }; + + const saveChangesHandler = async () => { + editUserData(); + }; + + async function editUserData() { + try { + const userEmail = await core.user.getEmail(safe.currentAccount); + const result = await core.user.update( + safe.currentAccount, + usernameInput, + userEmail, + profilePicUrl, + ); + + if (result) { + dispatch( + notify({ + text: translate('EditProfile.confirmationMessage'), + type: NotificationsTypes.SUCCESS, + }), + ); + setIsClose(true); + } + } catch (error) { + dispatch( + notify({ + text: translate('EditProfile.errorSaveChanges'), + type: NotificationsTypes.ERROR, + }), + ); + } + } + + const dialogOpenInfoHandler = () => { + setIsOpenDialogCloseInfo(true); + }; + + const dialogCloseInfoHandler = () => { + setIsClose(true); + }; + + const onFileUploadHandler = (updatedValue) => { + setProfilePicUrl(updatedValue); + }; + + const uploadImgSrcHandler = (updatedValue) => { + setProfilePicUrl(updatedValue); + }; + + useEffect(() => { + setUsernameInput(username); + }, [username]); + + const dialogContentClose = ( + + + {translate('EditProfile.bodyCancel')} + + + + + ); + + if (isClose) { + return ; + } + + return ( + <> +
+ {translate('EditProfile.heading')} +
+ + + setIsOpenDialogCloseInfo(false)} + isBtnClose={false} + isOpen={isOpenDialogCloseInfo} + maxWidth={'xs'} + title={translate('EditProfile.titleCancel')} + /> + setIsOpenDialogUploadInfo(false)} + uploadImgSrc={uploadImgSrcHandler} + onFileUpload={onFileUploadHandler} + /> + } + fullWidth + handleClose={() => setIsOpenDialogUploadInfo(false)} + isOpen={isOpenDialogUploadInfo} + maxWidth={'xs'} + /> + + + + + } + overlap="circular" + onClick={() => setIsOpenDialogUploadInfo(true)} + > + + + + + + + + + {translate('EditProfile.usernameExplanation')} + + + {translate('EditProfile.charactersExplanation')} + + + + +
+ + +
+ + ); +}; + +DialogContentUpload.propTypes = { + handleClose: PropTypes.func, + onFileUpload: PropTypes.func.isRequired, + uploadImgSrc: PropTypes.func, +}; + +export default EditProfile; diff --git a/src/views/Share.js b/src/views/Share.js index 682c7e85..3e3189c7 100644 --- a/src/views/Share.js +++ b/src/views/Share.js @@ -1,6 +1,9 @@ -import { Container } from '@material-ui/core'; +import { Container, IconButton } from '@material-ui/core'; import React, { Fragment } from 'react'; import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { EDIT_PROFILE_PATH } from '~/routes'; import ButtonBack from '~/components/ButtonBack'; import ButtonShare from '~/components/ButtonShare'; @@ -11,6 +14,7 @@ import ShareBox from '~/components/ShareBox'; import View from '~/components/View'; import { useProfileLink } from '~/hooks/url'; import translate from '~/services/locale'; +import { IconEdit } from '~/styles/icons'; const Share = () => { const safe = useSelector((state) => state.safe); @@ -22,6 +26,9 @@ const Share = () => {
{translate('Share.headingShare')} + + +
diff --git a/test/format.test.js b/test/format.test.js index b06e924f..5e3d28e4 100644 --- a/test/format.test.js +++ b/test/format.test.js @@ -6,61 +6,128 @@ describe('Format utils', () => { it('should print a human readable TC currency string which is the same as CRC value when timestamp is Circles launch date', () => { const { toWei } = web3.utils; const format = formatCirclesValue; - const circlesInceptionTimestamp = new Date("2020-10-15T00:00:00.000Z").getTime(); + const circlesInceptionTimestamp = new Date( + '2020-10-15T00:00:00.000Z', + ).getTime(); - expect(format(toWei('1.12345', 'ether'), circlesInceptionTimestamp)).toBe('3.37'); - expect(format(toWei('1.12345', 'ether'), circlesInceptionTimestamp, 3)).toBe('3.370'); + expect(format(toWei('1.12345', 'ether'), circlesInceptionTimestamp)).toBe( + '3.37', + ); + expect( + format(toWei('1.12345', 'ether'), circlesInceptionTimestamp, 3), + ).toBe('3.370'); }); - + it('should return a lower time circles value for a timestamp later than the Circles launch date', () => { const { toWei } = web3.utils; const format = formatCirclesValue; - const laterThanLaunchState = new Date("2021-11-15T00:00:00.000Z").getTime(); - - expect(Number(format(toWei('100', 'ether'), laterThanLaunchState))).toBeLessThan(300.00); + const laterThanLaunchState = new Date( + '2021-11-15T00:00:00.000Z', + ).getTime(); + + expect( + Number(format(toWei('100', 'ether'), laterThanLaunchState)), + ).toBeLessThan(300.0); }); - + it('should round down by cutting of number by default', () => { const { toWei } = web3.utils; const format = formatCirclesValue; - const circlesInceptionTimestamp = new Date("2020-10-15T00:00:00.000Z").getTime(); - - expect(format(toWei('1.32345', 'ether'), circlesInceptionTimestamp, 0)).toBe('3'); - expect(format(toWei('1.32345', 'ether'), circlesInceptionTimestamp, 1)).toBe('3.9'); - expect(format(toWei('1.32345', 'ether'), circlesInceptionTimestamp, 2)).toBe('3.97'); - expect(format(toWei('1.32345', 'ether'), circlesInceptionTimestamp, 4)).toBe('3.9703'); + const circlesInceptionTimestamp = new Date( + '2020-10-15T00:00:00.000Z', + ).getTime(); + + expect( + format(toWei('1.32345', 'ether'), circlesInceptionTimestamp, 0), + ).toBe('3'); + expect( + format(toWei('1.32345', 'ether'), circlesInceptionTimestamp, 1), + ).toBe('3.9'); + expect( + format(toWei('1.32345', 'ether'), circlesInceptionTimestamp, 2), + ).toBe('3.97'); + expect( + format(toWei('1.32345', 'ether'), circlesInceptionTimestamp, 4), + ).toBe('3.9703'); }); - + it('should round correctly when using roundDown false', () => { const { toWei } = web3.utils; const format = formatCirclesValue; - const circlesInceptionTimestamp = new Date("2020-10-15T00:00:00.000Z").getTime(); + const circlesInceptionTimestamp = new Date( + '2020-10-15T00:00:00.000Z', + ).getTime(); const roundDown = false; - + // reference time circles translation with 8 decimals 3.97366800 - expect(format(toWei('1.32456', 'ether'), circlesInceptionTimestamp, 0, roundDown)).toBe('4'); - expect(format(toWei('1.32456', 'ether'), circlesInceptionTimestamp, 1, roundDown)).toBe('4.0'); - expect(format(toWei('1.32456', 'ether'), circlesInceptionTimestamp, 2, roundDown)).toBe('3.97'); - expect(format(toWei('1.32456', 'ether'), circlesInceptionTimestamp, 4, roundDown)).toBe('3.9737'); + expect( + format( + toWei('1.32456', 'ether'), + circlesInceptionTimestamp, + 0, + roundDown, + ), + ).toBe('4'); + expect( + format( + toWei('1.32456', 'ether'), + circlesInceptionTimestamp, + 1, + roundDown, + ), + ).toBe('4.0'); + expect( + format( + toWei('1.32456', 'ether'), + circlesInceptionTimestamp, + 2, + roundDown, + ), + ).toBe('3.97'); + expect( + format( + toWei('1.32456', 'ether'), + circlesInceptionTimestamp, + 4, + roundDown, + ), + ).toBe('3.9737'); }); - + it('should remove decimals for exact numbers by default', () => { const { toWei } = web3.utils; const format = formatCirclesValue; - const circlesInceptionTimestamp = new Date("2020-10-15T00:00:00.000Z").getTime(); + const circlesInceptionTimestamp = new Date( + '2020-10-15T00:00:00.000Z', + ).getTime(); - expect(format(toWei('100', 'ether'), circlesInceptionTimestamp)).toBe('300'); - expect(format(toWei('100.00', 'ether'), circlesInceptionTimestamp)).toBe('300'); + expect(format(toWei('100', 'ether'), circlesInceptionTimestamp)).toBe( + '300', + ); + expect(format(toWei('100.00', 'ether'), circlesInceptionTimestamp)).toBe( + '300', + ); }); - + it('will not remove decimals for exact numbers when roundDown is false', () => { const { toWei } = web3.utils; const format = formatCirclesValue; - const circlesInceptionTimestamp = new Date("2020-10-15T00:00:00.000Z").getTime(); + const circlesInceptionTimestamp = new Date( + '2020-10-15T00:00:00.000Z', + ).getTime(); const roundDown = false; - expect(format(toWei('100', 'ether'), circlesInceptionTimestamp, 2, roundDown)).toBe('300.00'); - expect(format(toWei('100.00', 'ether'), circlesInceptionTimestamp, 2, roundDown)).toBe('300.00'); + expect( + format(toWei('100', 'ether'), circlesInceptionTimestamp, 2, roundDown), + ).toBe('300.00'); + expect( + format( + toWei('100.00', 'ether'), + circlesInceptionTimestamp, + 2, + roundDown, + ), + ).toBe('300.00'); }); }); });