// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useState, useEffect } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import classnames from 'classnames'; import QR from 'qrcode-generator'; import { changeDpiBlob } from 'changedpi'; import { SignalService as Proto } from '../protobuf'; import type { SaveAttachmentActionCreatorType } from '../state/ducks/conversations'; import { UsernameLinkState } from '../state/ducks/usernameEnums'; import { ToastType } from '../types/Toast'; import type { ShowToastAction } from '../state/ducks/toast'; import type { LocalizerType } from '../types/Util'; import { IMAGE_PNG } from '../types/MIME'; import { strictAssert } from '../util/assert'; import { drop } from '../util/drop'; import { splitText } from '../util/splitText'; import { loadImage } from '../util/loadImage'; import { Button, ButtonVariant } from './Button'; import { Modal } from './Modal'; import { ConfirmationDialog } from './ConfirmationDialog'; import { Spinner } from './Spinner'; export type PropsType = Readonly<{ i18n: LocalizerType; link?: string; username: string; colorId?: number; usernameLinkCorrupted: boolean; usernameLinkState: UsernameLinkState; setUsernameLinkColor: (colorId: number) => void; resetUsernameLink: () => void; saveAttachment: SaveAttachmentActionCreatorType; showToast: ShowToastAction; onBack: () => void; }>; export type ColorMapEntryType = Readonly<{ fg: string; bg: string; tint: string; }>; const ColorEnum = Proto.AccountRecord.UsernameLink.Color; const DEFAULT_PRESET: ColorMapEntryType = { fg: '#2449c0', bg: '#506ecd', tint: '#ecf0fb', }; export const COLOR_MAP: ReadonlyMap = new Map([ [ColorEnum.BLUE, DEFAULT_PRESET], [ColorEnum.WHITE, { fg: '#000000', bg: '#ffffff', tint: '#f5f5f5' }], [ColorEnum.GREY, { fg: '#464852', bg: '#6a6c75', tint: '#f0f0f1' }], [ColorEnum.OLIVE, { fg: '#73694f', bg: '#aa9c7c', tint: '#f6f5f2' }], [ColorEnum.GREEN, { fg: '#55733f', bg: '#7c9b69', tint: '#f1f5f0' }], [ColorEnum.ORANGE, { fg: '#d96b2d', bg: '#ee691a', tint: '#fef1ea' }], [ColorEnum.PINK, { fg: '#bb617b', bg: '#f77099', tint: '#fef1f5' }], [ColorEnum.PURPLE, { fg: '#7651c5', bg: '#a183d4', tint: '#f5f3fb' }], ]); const LOGO_PATH = 'M16.904 32.723V35a17.034 17.034 0 0 1-5.594-1.334l.595-2.22a14.763 14' + '.763 0 0 0 5 1.277ZM9.119 33.064l.667-2.49-5.707 1.338 1.18-5.034-2.3' + '82.209-1.22 5.204A1.7 1.7 0 0 0 3.7 34.334l5.419-1.27ZM3.28 19.159c.1' + '5 1.91.671 3.77 1.53 5.477l-2.41.21a17.037 17.037 0 0 1-1.397-5.688H3' + '.28ZM3.277 16.885H1c.146-2.223.727-4.4 1.712-6.403l1.972 1.139a14.765' + ' 14.765 0 0 0-1.407 5.264ZM5.821 9.652 3.85 8.513a17.035 17.035 0 0 1' + ' 4.69-4.68l1.138 1.972a14.763 14.763 0 0 0-3.856 3.847ZM11.648 4.672l' + '-1.139-1.973c2-.978 4.172-1.556 6.395-1.699v2.277a14.762 14.762 0 0 0' + '-5.256 1.395ZM19.177 3.283c1.816.145 3.593.625 5.24 1.42l1.137-1.973a' + '17.034 17.034 0 0 0-6.377-1.725v2.278ZM29.795 9.118c.14.186.276.376.4' + '07.568l1.971-1.139a17.035 17.035 0 0 0-4.654-4.675l-1.138 1.973a14.76' + '3 14.763 0 0 1 3.414 3.273ZM32.52 15.322c.096.518.163 1.04.203 1.563H' + '35a17.048 17.048 0 0 0-1.694-6.367l-1.973 1.14c.552 1.16.952 2.391 1.' + '187 3.664ZM32.188 22.09a14.759 14.759 0 0 1-.871 2.287l1.972 1.139a17' + '.032 17.032 0 0 0 1.708-6.357H32.72a14.768 14.768 0 0 1-.532 2.93ZM28' + '.867 27.995a14.757 14.757 0 0 1-2.504 2.173l1.139 1.973a17.028 17.028' + ' 0 0 0 4.65-4.657l-1.972-1.139c-.396.58-.835 1.13-1.313 1.65ZM23.259 ' + '31.797c-1.314.5-2.69.809-4.082.92v2.278a17.033 17.033 0 0 0 6.358-1.7' + '16l-1.139-1.972c-.371.179-.75.342-1.137.49Z M11.66 7.265a12.463 12.46' + '3 0 0 1 11.9-.423 12.466 12.466 0 0 1 6.42 14.612 12.47 12.47 0 0 1-1' + '3.21 8.954 12.462 12.462 0 0 1-5.411-1.857L6.246 29.75l1.199-5.115a12' + '.47 12.47 0 0 1 4.216-17.37Z'; const CLASS = 'UsernameLinkModalBody'; const AUTODETECT_TYPE_NUMBER = 0; const ERROR_CORRECTION_LEVEL = 'H'; const CENTER_CUTAWAY_PERCENTAGE = 30 / 184; const CENTER_LOGO_PERCENTAGE = 38 / 184; const QR_NATIVE_SIZE = 36; export const PRINT_WIDTH = 424; export const PRINT_HEIGHT = 576; const PRINT_PIXEL_RATIO = 3; const PRINT_QR_SIZE = 184; const PRINT_DPI = 300; const BASE_PILL_WIDTH = 296; const BASE_PILL_HEIGHT = 324; const USERNAME_TOP = 352; const USERNAME_MAX_WIDTH = 222; const USERNAME_LINE_HEIGHT = 26; const USERNAME_FONT = `600 20px/${USERNAME_LINE_HEIGHT}px Inter`; const USERNAME_LETTER_SPACING = -0.34; const HINT_BASE_TOP = 447; const HINT_MAX_WIDTH = 296; const HINT_LINE_HEIGHT = 17; const HINT_FONT = `400 14px/${HINT_LINE_HEIGHT}px Inter`; const HINT_LETTER_SPACING = 0; type BlotchesPropsType = Readonly<{ size: number; link: string; color: string; }>; function QRCode({ size, link, color }: BlotchesPropsType): JSX.Element { const qr = QR(AUTODETECT_TYPE_NUMBER, ERROR_CORRECTION_LEVEL); qr.addData(link); qr.make(); const moduleCount = qr.getModuleCount(); const center = moduleCount / 2; const radius = CENTER_CUTAWAY_PERCENTAGE * moduleCount; function hasPixel(x: number, y: number): boolean { if (x < 0 || y < 0 || x >= moduleCount || y >= moduleCount) { return false; } const distanceFromCenter = Math.sqrt( (x - center + 0.5) ** 2 + (y - center + 0.5) ** 2 ); // Center and 1 dot away should remain clear for the logo placement. if (Math.ceil(distanceFromCenter) <= radius + 3) { return false; } return qr.isDark(x, y); } const path = []; for (let y = 0; y < moduleCount; y += 1) { for (let x = 0; x < moduleCount; x += 1) { if (!hasPixel(x, y)) { continue; } const onTop = hasPixel(x, y - 1); const onBottom = hasPixel(x, y + 1); const onLeft = hasPixel(x - 1, y); const onRight = hasPixel(x + 1, y); const roundTL = !onLeft && !onTop; const roundTR = !onTop && !onRight; const roundBR = !onRight && !onBottom; const roundBL = !onBottom && !onLeft; path.push( `M${2 * x} ${2 * y + 1}`, roundTL ? 'a1 1 0 0 1 1 -1' : 'v-1h1', roundTR ? 'a1 1 0 0 1 1 1' : 'h1v1', roundBR ? 'a1 1 0 0 1 -1 1' : 'v1h-1', roundBL ? 'a1 1 0 0 1 -1 -1' : 'h-1v-1', 'z' ); } } const QR_SCALE = size / 2 / moduleCount; const CENTER_X = size / 2; const CENTER_Y = size / 2; const LOGO_SIZE = CENTER_LOGO_PERCENTAGE * size; const LOGO_X = CENTER_X - LOGO_SIZE / 2; const LOGO_Y = CENTER_Y - LOGO_SIZE / 2; const LOGO_SCALE = LOGO_SIZE / QR_NATIVE_SIZE; return ( <> ); } type ExportedImagePropsType = Readonly<{ link: string; colorId: number; usernameLines: number; }>; function ExportedImage({ link, colorId, usernameLines, }: ExportedImagePropsType): JSX.Element { const { fg, bg, tint } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET; const isWhiteBackground = colorId === ColorEnum.WHITE; const extraHeight = (usernameLines - 1) * USERNAME_LINE_HEIGHT; const pillHeight = BASE_PILL_HEIGHT + extraHeight; return ( {/* QR + Username pill */} {/* QR code with a frame */} {isWhiteBackground ? ( ) : ( )} ); } type CreateCanvasAndContextOptionsType = Readonly<{ width: number; height: number; }>; function createCanvasAndContext({ width, height, }: CreateCanvasAndContextOptionsType): [ OffscreenCanvas, OffscreenCanvasRenderingContext2D, ] { const canvas = new OffscreenCanvas( PRINT_PIXEL_RATIO * width, PRINT_PIXEL_RATIO * height ); const context = canvas.getContext('2d'); strictAssert(context, 'Failed to get 2d context'); // Retina support context.scale(PRINT_PIXEL_RATIO, PRINT_PIXEL_RATIO); // Common font config context.textAlign = 'center'; context.textBaseline = 'top'; ( context as unknown as { textRendering: string; } ).textRendering = 'optimizeLegibility'; context.imageSmoothingEnabled = false; return [canvas, context]; } type CreateTextMeasurerOptionsType = Readonly<{ font: string; letterSpacing: number; maxWidth: number; direction: 'ltr' | 'rtl'; }>; function createTextMeasurer({ font, letterSpacing, maxWidth, direction, }: CreateTextMeasurerOptionsType): (text: string) => boolean { const [, context] = createCanvasAndContext({ width: 1, height: 1 }); context.font = font; context.direction = direction; // Experimental Chrome APIs ( context as unknown as { letterSpacing: number; } ).letterSpacing = letterSpacing; return value => context.measureText(value).width > maxWidth; } type GenerateImageURLOptionsType = Readonly<{ link: string; username: string; hint: string; hintDirection: 'ltr' | 'rtl'; colorId: number; }>; // Exported for testing export async function _generateImageBlob({ link, username, hint, hintDirection, colorId, }: GenerateImageURLOptionsType): Promise { const usernameLines = splitText(username, { granularity: 'grapheme', shouldBreak: createTextMeasurer({ maxWidth: USERNAME_MAX_WIDTH, font: USERNAME_FONT, letterSpacing: USERNAME_LETTER_SPACING, direction: 'ltr', }), }); const hintLines = splitText(hint, { granularity: 'word', shouldBreak: createTextMeasurer({ maxWidth: HINT_MAX_WIDTH, font: HINT_FONT, letterSpacing: HINT_LETTER_SPACING, direction: hintDirection, }), }); const [canvas, context] = createCanvasAndContext({ width: PRINT_WIDTH, height: PRINT_HEIGHT, }); const svg = renderToStaticMarkup( ); const svgURL = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; const img = await loadImage(svgURL); context.drawImage(img, 0, 0, PRINT_WIDTH, PRINT_HEIGHT); const isWhiteBackground = colorId === ColorEnum.WHITE; context.save(); context.font = USERNAME_FONT; context.direction = 'ltr'; // Experimental Chrome APIs ( context as unknown as { letterSpacing: number; } ).letterSpacing = USERNAME_LETTER_SPACING; context.fillStyle = isWhiteBackground ? '#000' : '#fff'; const centerX = PRINT_WIDTH / 2; for (const [i, line] of usernameLines.entries()) { context.fillText(line, centerX, USERNAME_TOP + i * USERNAME_LINE_HEIGHT); } context.restore(); context.save(); context.font = HINT_FONT; context.direction = hintDirection; // Experimental Chrome APIs ( context as unknown as { letterSpacing: number; } ).letterSpacing = HINT_LETTER_SPACING; context.fillStyle = 'rgba(60, 60, 69, 0.70)'; const hintTop = HINT_BASE_TOP + (usernameLines.length - 1) * USERNAME_LINE_HEIGHT; for (const [i, line] of hintLines.entries()) { context.fillText(line, centerX, hintTop + i * HINT_LINE_HEIGHT); } context.restore(); const blob = await canvas.convertToBlob({ type: 'image/png' }); return changeDpiBlob(blob, PRINT_DPI); } type UsernameLinkColorRadioPropsType = Readonly<{ i18n: LocalizerType; index: number; colorId: number; fgColor: string; bgColor: string; isSelected: boolean; onSelect: (colorId: number) => void; }>; function UsernameLinkColorRadio({ i18n, index, colorId, fgColor, bgColor, isSelected, onSelect, }: UsernameLinkColorRadioPropsType): JSX.Element { const className = `${CLASS}__colors__radio`; const onClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); onSelect(colorId); }, [colorId, onSelect] ); const onRef = useCallback( (elem: HTMLButtonElement | null): void => { if (elem) { // Note that these cannot be set through html attributes elem.style.setProperty('--bg-color', bgColor); elem.style.setProperty('--fg-color', fgColor); } }, [fgColor, bgColor] ); const isWhiteBackground = colorId === ColorEnum.WHITE; return ( ); } type UsernameLinkColorsPropsType = Readonly<{ i18n: LocalizerType; value: number; onChange: (colorId: number) => void; onSave: () => void; onCancel: () => void; }>; function UsernameLinkColors({ i18n, value, onChange, onSave, onCancel, }: UsernameLinkColorsPropsType): JSX.Element { const className = `${CLASS}__colors`; const normalizedValue = value === ColorEnum.UNKNOWN ? ColorEnum.BLUE : value; return (
{[...COLOR_MAP.entries()].map(([colorId, { fg, bg }], index) => { return ( ); })}
); } enum ResetModalVisibility { NotMounted = 'NotMounted', Closed = 'Closed', Open = 'Open', } export function UsernameLinkModalBody({ i18n, link, username, usernameLinkCorrupted, usernameLinkState, colorId: initialColorId = ColorEnum.UNKNOWN, setUsernameLinkColor, resetUsernameLink, saveAttachment, showToast, onBack, }: PropsType): JSX.Element { const [pngData, setPngData] = useState(); const [showColors, setShowColors] = useState(false); const [confirmReset, setConfirmReset] = useState(false); const [resetModalVisibility, setResetModalVisibility] = useState( ResetModalVisibility.NotMounted ); const [showError, setShowError] = useState(false); const [colorId, setColorId] = useState(initialColorId); const { fg: fgColor, bg: bgColor } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET; const isWhiteBackground = colorId === ColorEnum.WHITE; const onCardRef = useCallback( (elem: HTMLDivElement | null): void => { if (elem) { // Note that these cannot be set through html attributes elem.style.setProperty('--bg-color', bgColor); elem.style.setProperty('--fg-color', fgColor); elem.style.setProperty( '--text-color', isWhiteBackground ? '#000' : '#fff' ); } }, [bgColor, fgColor, isWhiteBackground] ); useEffect(() => { let isAborted = false; async function run() { if (!link) { return; } const blob = await _generateImageBlob({ link, username, colorId, hint: i18n('icu:UsernameLinkModalBody__hint'), hintDirection: i18n.getLocaleDirection(), }); const arrayBuffer = await blob.arrayBuffer(); if (isAborted) { return; } setPngData(new Uint8Array(arrayBuffer)); } drop(run()); return () => { isAborted = true; }; }, [i18n, link, username, colorId, bgColor, fgColor]); const onSave = useCallback( (e: React.MouseEvent) => { e.preventDefault(); if (!pngData) { return; } saveAttachment({ data: pngData, fileName: 'signal-username-qr-code.png', contentType: IMAGE_PNG, size: pngData.length, }); }, [saveAttachment, pngData] ); const onStartColorChange = useCallback((e: React.MouseEvent) => { e.preventDefault(); setShowColors(true); }, []); const onCopyLink = useCallback( (e: React.MouseEvent) => { e.preventDefault(); if (link) { drop(window.navigator.clipboard.writeText(link)); showToast({ toastType: ToastType.CopiedUsernameLink }); } }, [link, showToast] ); const onCopyUsername = useCallback( (e: React.MouseEvent) => { e.preventDefault(); drop(window.navigator.clipboard.writeText(username)); showToast({ toastType: ToastType.CopiedUsername }); }, [username, showToast] ); // Color change sub modal const onUsernameLinkColorChange = useCallback((newColor: number) => { setColorId(newColor); }, []); const onUsernameLinkColorSave = useCallback(() => { setUsernameLinkColor(colorId); setShowColors(false); }, [setUsernameLinkColor, colorId]); const onUsernameLinkColorCancel = useCallback(() => { setShowColors(false); setColorId(initialColorId); }, [initialColorId]); // Reset sub modal const onClickReset = useCallback(() => { setConfirmReset(true); }, []); const onCancelReset = useCallback(() => { setConfirmReset(false); }, []); const onConfirmReset = useCallback(() => { setShowError(false); setConfirmReset(false); resetUsernameLink(); }, [resetUsernameLink]); const onCloseError = useCallback(() => { if (showError) { onBack(); } }, [showError, onBack]); useEffect(() => { if (!usernameLinkCorrupted) { return; } resetUsernameLink(); }, [usernameLinkCorrupted, resetUsernameLink]); useEffect(() => { if (usernameLinkState !== UsernameLinkState.Error) { return; } setShowError(true); }, [usernameLinkState]); const onResetModalClose = useCallback(() => { setResetModalVisibility(ResetModalVisibility.Closed); }, []); const isReady = usernameLinkState === UsernameLinkState.Ready; const isResettingLink = usernameLinkCorrupted || !isReady; useEffect(() => { setResetModalVisibility(x => { // Initial mount shouldn't show the modal if (x === ResetModalVisibility.NotMounted || isResettingLink) { return ResetModalVisibility.Closed; } return ResetModalVisibility.Open; }); }, [isResettingLink]); const info = ( <>
{i18n('icu:UsernameLinkModalBody__help')}
); let linkImage: JSX.Element | undefined; if (isReady && link) { linkImage = ( ); } else if (usernameLinkState === UsernameLinkState.Error) { linkImage = ; } else { linkImage = ( ); } return (
{linkImage}
{showColors ? (
{username}
) : ( )}
{confirmReset && ( {i18n('icu:UsernameLinkModalBody__reset__confirm')} )} {showError && ( {i18n('icu:UsernameLinkModalBody__error__text')} )} {resetModalVisibility === ResetModalVisibility.Open && ( {i18n('icu:UsernameLinkModalBody__recovered__text')} )} {showColors ? ( ) : ( info )}
); }