From b459bf9570bd051e34e0bce0d8f2f598f3396283 Mon Sep 17 00:00:00 2001 From: William Grant <willmgrant@gmail.com> Date: Wed, 12 Jun 2024 10:09:37 +1000 Subject: [PATCH] fix: generate colors for the QR code in a hook that also converts svgs to data urls we no longer need to update the svgs manually --- images/session/qr/brand.svg | 1 - images/session/qr/shield.svg | 1 - ts/components/SessionQRCode.tsx | 118 +++++++----------- .../dialog/edit-profile/components.tsx | 26 ++-- .../section/CategoryRecoveryPassword.tsx | 30 ++--- ts/hooks/useIconToImageURL.tsx | 116 +++++++++++++++++ 6 files changed, 188 insertions(+), 104 deletions(-) delete mode 100644 images/session/qr/brand.svg delete mode 100644 images/session/qr/shield.svg create mode 100644 ts/hooks/useIconToImageURL.tsx diff --git a/images/session/qr/brand.svg b/images/session/qr/brand.svg deleted file mode 100644 index f54264a60..000000000 --- a/images/session/qr/brand.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 33 40" fill="none"><path fill="black" stroke="black" stroke-width=".3" d="m27.347 20.56-6.521-4.026h4.42c1.849 0 3.622-.818 4.929-2.275 1.307-1.457 2.041-3.432 2.041-5.492s-.734-4.036-2.041-5.492C28.867 1.818 27.095 1 25.246 1H9.813c-2.315.003-4.534 1.03-6.171 2.853-1.637 1.824-2.558 4.297-2.56 6.877 0 1.88.453 3.726 1.31 5.345.857 1.62 2.087 2.954 3.563 3.865l6.522 4.027H8.051a6.346 6.346 0 0 0-2.692.565 6.956 6.956 0 0 0-2.289 1.677 7.869 7.869 0 0 0-1.532 2.53A8.542 8.542 0 0 0 1 31.734c0 1.028.183 2.045.538 2.993a7.87 7.87 0 0 0 1.532 2.531 6.957 6.957 0 0 0 2.29 1.677c.854.385 1.77.577 2.691.566h15.433c2.315-.003 4.535-1.03 6.172-2.853 1.637-1.825 2.558-4.298 2.56-6.877 0-1.88-.452-3.725-1.308-5.345-.856-1.62-2.086-2.954-3.56-3.865Zm-20.35-2.725c-1.113-.688-2.044-1.687-2.703-2.9a8.822 8.822 0 0 1-1.05-4.014C3.156 6.79 6.27 3.406 9.979 3.406h15.128c2.587 0 4.816 2.207 4.944 5.09a5.927 5.927 0 0 1-.295 2.133 5.493 5.493 0 0 1-1.025 1.83c-.45.526-.99.945-1.59 1.232a4.395 4.395 0 0 1-1.895.435h-8.712a.918.918 0 0 0-.68.318c-.18.202-.28.475-.28.76v7.923l-8.577-5.292Zm16.321 19.258H8.191c-2.587 0-4.816-2.207-4.944-5.09a5.928 5.928 0 0 1 .295-2.131 5.491 5.491 0 0 1 1.025-1.83c.45-.527.99-.946 1.588-1.233a4.395 4.395 0 0 1 1.896-.436h8.713a.88.88 0 0 0 .37-.082.966.966 0 0 0 .313-.233c.09-.1.16-.219.209-.35.048-.13.073-.27.073-.411v-7.925l8.571 5.293c1.114.687 2.046 1.688 2.705 2.902a8.82 8.82 0 0 1 1.05 4.017c.087 4.126-3.027 7.509-6.737 7.509Z"/></svg> \ No newline at end of file diff --git a/images/session/qr/shield.svg b/images/session/qr/shield.svg deleted file mode 100644 index 7272b6baa..000000000 --- a/images/session/qr/shield.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 46 56" fill="none"><path fill="black" fill-rule="evenodd" d="M21.013.459A4.578 4.578 0 0 1 22.989 0l.01.02c.655 0 1.302.146 1.948.449.788.38 1.596.742 2.404 1.103l.93.42 4.381 2.012c1.425.65 2.85 1.303 4.276 1.955l.003.002c2.062.943 4.126 1.888 6.192 2.829 1.948.888 3.012 2.695 2.85 4.795-.011.114-.008.268-.004.443.003.095.005.196.005.3.01.243.01.497.01.79a378.737 378.737 0 0 0 0 8.048v.049l-.01.048V24.206c-.01.942-.02 1.894-.048 2.817-.047 2.452-.503 4.776-.902 6.524l-.029.117-.038.118c-.342 1.191-.731 2.46-1.235 3.77-.675 1.718-1.558 3.486-2.708 5.41l-.048.078-.057.078c-1.358 2.051-2.622 3.653-3.971 5.05a29.438 29.438 0 0 1-7.44 5.596c-.043.024-.088.05-.133.078l-.133.078-.143.078-.143.068c-1.33.606-2.917 1.28-4.655 1.758l-.038.02h-.039a5.066 5.066 0 0 1-2.527-.01 26.956 26.956 0 0 1-4.912-1.885l-.029-.03h-.029c-4.665-2.402-8.542-5.957-11.525-10.557l-.133-.156-.143-.244c-1.036-1.748-1.815-3.272-2.451-4.786-.522-1.253-.906-2.478-1.279-3.666l-.023-.074-.143-.45-.066-.224-.048-.224-.085-.435-.086-.435-.021-.107c-.193-.963-.392-1.953-.51-2.998C0 27.848.002 26.218.003 24.656V14.24l.002-.382c.002-.237.004-.459-.002-.664-.076-1.894.893-3.496 2.651-4.326l5.611-2.564 5.611-2.564 3.392-1.552.143-.069.237-.117.076-.039.44-.201c.267-.123.536-.246.805-.365.199-.091.397-.181.596-.27v-.001c.485-.22.968-.438 1.447-.667Zm4.854 25.24 4.432 2.492a6.468 6.468 0 0 1 2.42 2.393 6.592 6.592 0 0 1 .89 3.307 6.073 6.073 0 0 1-1.74 4.256 5.898 5.898 0 0 1-4.195 1.766h-10.49a4.676 4.676 0 0 1-1.829-.35 4.731 4.731 0 0 1-1.555-1.038c-.446-.447-.8-.98-1.042-1.566a4.869 4.869 0 0 1 0-3.705 4.811 4.811 0 0 1 1.042-1.566c.445-.447.974-.8 1.555-1.038a4.676 4.676 0 0 1 1.83-.35h3.008l-4.433-2.492a6.47 6.47 0 0 1-2.422-2.392 6.593 6.593 0 0 1-.89-3.308 6.074 6.074 0 0 1 1.74-4.256 5.898 5.898 0 0 1 4.194-1.766h10.49c1.256 0 2.46.507 3.349 1.408a4.843 4.843 0 0 1 1.388 3.4c0 1.274-.5 2.497-1.388 3.398a4.703 4.703 0 0 1-3.35 1.408h-3.004Zm-11.236-.989a4.99 4.99 0 0 0 1.837 1.795l5.83 3.275v-4.903a.67.67 0 0 1 .19-.47.65.65 0 0 1 .462-.197h5.921c.443 0 .882-.092 1.289-.27a3.27 3.27 0 0 0 1.08-.762 3.368 3.368 0 0 0 .897-2.452c-.087-1.785-1.602-3.15-3.36-3.15H18.494c-2.52 0-4.637 2.093-4.578 4.65.02.877.267 1.734.715 2.484Zm2.649 13.714H27.56c2.522 0 4.638-2.094 4.578-4.648a5.083 5.083 0 0 0-.713-2.486 4.988 4.988 0 0 0-1.838-1.796l-5.826-3.275v4.904a.673.673 0 0 1-.192.471.655.655 0 0 1-.463.195h-5.922c-.443 0-.881.093-1.288.27a3.273 3.273 0 0 0-1.08.763 3.364 3.364 0 0 0-.897 2.452c.087 1.784 1.602 3.15 3.36 3.15Z" clip-rule="evenodd"/></svg> \ No newline at end of file diff --git a/ts/components/SessionQRCode.tsx b/ts/components/SessionQRCode.tsx index bcaaeb1b8..3fcd56f87 100644 --- a/ts/components/SessionQRCode.tsx +++ b/ts/components/SessionQRCode.tsx @@ -1,22 +1,20 @@ -import { MouseEvent, useRef } from 'react'; +import { MouseEvent, useEffect, useRef, useState } from 'react'; import { QRCode } from 'react-qrcode-logo'; import { CSSProperties } from 'styled-components'; -import { ThemeStateType } from '../themes/constants/colors'; -import { THEME_GLOBALS } from '../themes/globals'; -import { AnimatedFlex } from './basic/Flex'; +import { COLORS } from '../themes/constants/colors'; +import { saveQRCode } from '../util/saveQRCode'; +import { Flex } from './basic/Flex'; export type SessionQRCodeProps = { id: string; value: string; size: number; + hasLogo: boolean; backgroundColor?: string; foregroundColor?: string; logoImage?: string; - logoWidth?: number; - logoHeight?: number; - logoIsSVG?: boolean; - theme?: ThemeStateType; - ignoreTheme?: boolean; + logoSize?: number; + loading: boolean; ariaLabel?: string; dataTestId?: string; style?: CSSProperties; @@ -27,68 +25,35 @@ export function SessionQRCode(props: SessionQRCodeProps) { id, value, size, - backgroundColor = 'white', - foregroundColor = 'black', + backgroundColor = COLORS.WHITE, + foregroundColor = COLORS.BLACK, + hasLogo, logoImage, - logoWidth, - logoHeight, - logoIsSVG, - theme, - ignoreTheme, + logoSize, ariaLabel, dataTestId, + loading, style, } = props; + const [logo, setLogo] = useState(logoImage); + const [bgColor, setBgColor] = useState(backgroundColor); + const [fgColor, setFgColor] = useState(foregroundColor); const qrRef = useRef<QRCode>(null); - - // const [svgDataURL, setSvgDataURL] = useState(''); - // const [currentTheme, setCurrentTheme] = useState(theme); - // const [loading, setLoading] = useState(false); - - // const loadLogoImage = useCallback(async () => { - // if (logoImage && logoIsSVG) { - // setLoading(true); - // try { - // const response = await fetch(logoImage); - // let svgString = await response.text(); - - // if (!ignoreTheme && theme && !isEmpty(theme)) { - // svgString = svgString.replaceAll( - // 'black', - // getThemeValue( - // checkDarkTheme(theme) ? '--background-primary-color' : '--text-primary-color' - // ) - // ); - // } - - // setSvgDataURL(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`); - // } catch (error) { - // window.log.error('Error fetching the QR Code logo which is an svg:', error); - // } - // setLoading(false); - // } - // }, [ignoreTheme, logoImage, logoIsSVG, theme]); - - // useMount(() => { - // void loadLogoImage(); - // }); - - // useEffect(() => { - // if (theme && theme !== currentTheme) { - // setCurrentTheme(theme); - // void loadLogoImage(); - // } - // }, [currentTheme, loadLogoImage, theme]); - const qrCanvasSize = 1000; - const canvasLogoWidth = - logoWidth && logoHeight ? (qrCanvasSize * 0.25 * logoWidth) / logoHeight : undefined; - const canvasLogoHeight = logoHeight ? (qrCanvasSize * 0.25 * logoHeight) / logoHeight : undefined; + const canvasLogoSize = logoSize ? (qrCanvasSize * 0.25 * logoSize) / logoSize : 250; + + useEffect(() => { + // Don't pass the component props to the QR component directly instead update it's props in the next render cycle to prevent janky renders + if (!loading && hasLogo && logo !== logoImage) { + setBgColor(backgroundColor); + setFgColor(foregroundColor); + setLogo(logoImage); + } + }, [backgroundColor, foregroundColor, hasLogo, loading, logo, logoImage]); - // We use an AnimatedFlex because we fade in the QR code to hide the logo flickering on first render return ( - <AnimatedFlex + <Flex container={true} justifyContent="center" alignItems="center" @@ -97,12 +62,22 @@ export function SessionQRCode(props: SessionQRCodeProps) { id={id} aria-label={ariaLabel || 'QR code'} title={window.i18n('clickToTrustContact')} - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ duration: THEME_GLOBALS['--default-duration-seconds'] }} + // onClick={(event: MouseEvent<HTMLDivElement>) => { + // event.preventDefault(); + // const fileName = `${id}-${new Date().toISOString()}.png`; + // try { + // qrRef.current?.download('png', fileName); + // } catch (e) { + // window.log.error(`Error downloading QR code: ${fileName}\n${e}`); + // } + // }} onClick={(event: MouseEvent<HTMLDivElement>) => { event.preventDefault(); - qrRef.current?.download('jpg', id); + void saveQRCode(id, { + ...props, + id: `temp-${props.id}`, + style: { display: 'none' }, + }); }} data-testId={dataTestId || 'session-qr-code'} style={style} @@ -113,14 +88,13 @@ export function SessionQRCode(props: SessionQRCodeProps) { value={value} ecLevel={'Q'} size={qrCanvasSize} - bgColor={backgroundColor} - fgColor={foregroundColor} + bgColor={bgColor} + fgColor={fgColor} quietZone={40} - logoImage={logoImage} - logoWidth={canvasLogoWidth} - logoHeight={canvasLogoHeight} + logoImage={logo} + logoWidth={canvasLogoSize} + logoHeight={canvasLogoSize} removeQrCodeBehindLogo={true} - logoOnLoad={e => window.log.debug(`WIP: [SessionQRCode] logo loaded`, e)} style={{ borderRadius: '10px', cursor: 'pointer', @@ -129,6 +103,6 @@ export function SessionQRCode(props: SessionQRCodeProps) { height: size, }} /> - </AnimatedFlex> + </Flex> ); } diff --git a/ts/components/dialog/edit-profile/components.tsx b/ts/components/dialog/edit-profile/components.tsx index 9df6de5ed..80e7904e1 100644 --- a/ts/components/dialog/edit-profile/components.tsx +++ b/ts/components/dialog/edit-profile/components.tsx @@ -1,6 +1,5 @@ import styled from 'styled-components'; -import { useIsDarkTheme, useTheme } from '../../../state/selectors/theme'; -import { getThemeValue } from '../../../themes/globals'; +import { useIconToImageURL } from '../../../hooks/useIconToImageURL'; import { SessionQRCode } from '../../SessionQRCode'; import { Avatar, AvatarSize } from '../../avatar/Avatar'; import { Flex } from '../../basic/Flex'; @@ -8,25 +7,22 @@ import { SpacerSM } from '../../basic/Text'; import { SessionIconButton } from '../../icon'; export const QRView = ({ sessionID }: { sessionID: string }) => { - const theme = useTheme(); - const isDarkTheme = useIsDarkTheme(); + const { dataURL, iconSize, iconColor, backgroundColor, loading } = useIconToImageURL({ + iconType: 'brand', + iconSize: 40, + }); return ( <SessionQRCode id={'session-account-id'} value={sessionID} size={190} - backgroundColor={getThemeValue( - isDarkTheme ? '--text-primary-color' : '--background-primary-color' - )} - foregroundColor={getThemeValue( - isDarkTheme ? '--background-primary-color' : '--text-primary-color' - )} - logoImage={'./images/session/qr/brand.svg'} - logoWidth={40} - logoHeight={40} - logoIsSVG={true} - theme={theme} + hasLogo={true} + backgroundColor={backgroundColor} + foregroundColor={iconColor} + logoImage={dataURL} + logoSize={iconSize} + loading={loading} ariaLabel={'Account ID QR code'} dataTestId={'your-qr-code'} style={{ marginTop: '-1px' }} diff --git a/ts/components/settings/section/CategoryRecoveryPassword.tsx b/ts/components/settings/section/CategoryRecoveryPassword.tsx index f1736ed0e..8038d2c16 100644 --- a/ts/components/settings/section/CategoryRecoveryPassword.tsx +++ b/ts/components/settings/section/CategoryRecoveryPassword.tsx @@ -3,13 +3,14 @@ import { useState } from 'react'; import { useDispatch } from 'react-redux'; import useMount from 'react-use/lib/useMount'; import styled from 'styled-components'; +import { useIconToImageURL } from '../../../hooks/useIconToImageURL'; import { usePasswordModal } from '../../../hooks/usePasswordModal'; import { mnDecode } from '../../../session/crypto/mnemonic'; import { updateHideRecoveryPasswordModel } from '../../../state/ducks/modalDialog'; import { showSettingsSection } from '../../../state/ducks/section'; import { useHideRecoveryPasswordEnabled } from '../../../state/selectors/settings'; -import { useIsDarkTheme, useTheme } from '../../../state/selectors/theme'; -import { THEME_GLOBALS, getThemeValue } from '../../../themes/globals'; +import { useIsDarkTheme } from '../../../state/selectors/theme'; +import { THEME_GLOBALS } from '../../../themes/globals'; import { getCurrentRecoveryPhrase } from '../../../util/storage'; import { SessionQRCode } from '../../SessionQRCode'; import { AnimatedFlex } from '../../basic/Flex'; @@ -53,6 +54,12 @@ export const SettingsCategoryRecoveryPassword = () => { const hideRecoveryPassword = useHideRecoveryPasswordEnabled(); + const isDarkTheme = useIsDarkTheme(); + const { dataURL, iconSize, iconColor, backgroundColor, loading } = useIconToImageURL({ + iconType: 'shield', + iconSize: 56, + }); + const dispatch = useDispatch(); const { hasPassword, passwordValid } = usePasswordModal({ @@ -61,8 +68,6 @@ export const SettingsCategoryRecoveryPassword = () => { dispatch(showSettingsSection('privacy')); }, }); - const theme = useTheme(); - const isDarkTheme = useIsDarkTheme(); const fetchRecoverPhrase = () => { const newRecoveryPhrase = getCurrentRecoveryPhrase(); @@ -103,17 +108,12 @@ export const SettingsCategoryRecoveryPassword = () => { id={'session-recovery-password'} value={hexEncodedSeed} size={260} - backgroundColor={getThemeValue( - isDarkTheme ? '--text-primary-color' : '--background-primary-color' - )} - foregroundColor={getThemeValue( - isDarkTheme ? '--background-primary-color' : '--text-primary-color' - )} - logoImage={'./images/session/qr/shield.svg'} - logoWidth={56} - logoHeight={56} - logoIsSVG={true} - theme={theme} + hasLogo={true} + backgroundColor={backgroundColor} + foregroundColor={iconColor} + logoImage={dataURL} + logoSize={iconSize} + loading={loading} ariaLabel={'Recovery Password QR Code'} dataTestId={'session-recovery-password'} /> diff --git a/ts/hooks/useIconToImageURL.tsx b/ts/hooks/useIconToImageURL.tsx new file mode 100644 index 000000000..b72acf62d --- /dev/null +++ b/ts/hooks/useIconToImageURL.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import useMount from 'react-use/lib/useMount'; +import { SessionIcon, SessionIconProps, SessionIconType } from '../components/icon'; +import { sleepFor } from '../session/utils/Promise'; +import { useIsDarkTheme } from '../state/selectors/theme'; +import { COLORS } from '../themes/constants/colors'; +import { getThemeValue } from '../themes/globals'; +import { ThemeColorVariables } from '../themes/variableColors'; + +const convertIconToImageURL = async ( + props: Pick<SessionIconProps, 'iconType' | 'iconSize' | 'iconColor' | 'backgroundColor'> +) => { + const { iconType, iconSize, iconColor = COLORS.BLACK, backgroundColor = COLORS.WHITE } = props; + + const root = document.querySelector('#root'); + const divElement = document.createElement('div'); + divElement.id = 'icon-to-image-url'; + root?.appendChild(divElement); + + const reactRoot = createRoot(divElement!); + reactRoot.render( + <SessionIcon + iconType={iconType} + iconSize={iconSize} + iconColor={iconColor} + backgroundColor={backgroundColor} + /> + ); + // wait for it to render + await sleepFor(100); + const svg = root?.querySelector(`#icon-to-image-url svg`); + svg?.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + const svgString = svg?.outerHTML; + reactRoot?.unmount(); + root?.removeChild(divElement); + + if (svgString) { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`; + } + + return null; +}; + +export const useIconToImageURL = ({ + iconType, + iconSize, + theming = true, +}: { + iconType: SessionIconType; + iconSize: number; + theming?: boolean; +}) => { + const isDarkTheme = useIsDarkTheme(); + const [dataURL, setDataURL] = useState(''); + const [loading, setLoading] = useState(false); + const [mounted, setMounted] = useState(false); + const [inDarkTheme, setInDarkTheme] = useState(false); + const [backgroundColor, setBackgroundColor] = useState(''); + const [iconColor, setIconColor] = useState(''); + + const chooseColor = useCallback( + (darkColor: keyof ThemeColorVariables, lightColor: keyof ThemeColorVariables) => { + return theming ? getThemeValue(isDarkTheme ? darkColor : lightColor) : ''; + }, + [isDarkTheme, theming] + ); + + const loadURL = useCallback(async () => { + setLoading(true); + setDataURL(''); + try { + const bgColor = chooseColor('--text-primary-color', '--background-primary-color'); + const fgColor = chooseColor('--background-primary-color', '--text-primary-color'); + + setBackgroundColor(bgColor); + setIconColor(fgColor); + + const newURL = await convertIconToImageURL({ + iconType, + iconSize, + iconColor: fgColor, + backgroundColor: bgColor, + }); + + if (!newURL) { + throw new Error('[useIconToImageURL] Failed to convert icon to URL'); + } + setDataURL(newURL); + setInDarkTheme(!!theming && isDarkTheme); + + if (!mounted) { + setMounted(true); + } + setLoading(false); + } catch (error) { + window.log.error('[useIconToImageURL] Error fetching icon data url', error); + } + }, [chooseColor, iconSize, iconType, isDarkTheme, mounted, theming]); + + useMount(() => { + void loadURL(); + }); + + useEffect(() => { + if (mounted && theming && isDarkTheme !== inDarkTheme) { + void loadURL(); + } + }, [inDarkTheme, isDarkTheme, loadURL, mounted, theming]); + + const returnProps = { dataURL, iconSize, iconColor, backgroundColor, loading }; + + window.log.debug(`WIP: [useIconToImageURL] returnProps: ${JSON.stringify(returnProps)}`); + + return returnProps; +};