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