Username Link QR Code

This commit is contained in:
Fedor Indutny 2023-07-20 05:14:08 +02:00 committed by GitHub
parent 68dfc46185
commit e1d2dbd8ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 2187 additions and 120 deletions

View file

@ -73,6 +73,7 @@ export function EditUsernameModalBody({
const [hasEverChanged, setHasEverChanged] = useState(false);
const [nickname, setNickname] = useState(currentNickname);
const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false);
const [isConfirmingSave, setIsConfirmingSave] = useState(false);
useEffect(() => {
if (state === UsernameReservationState.Closed) {
@ -144,6 +145,18 @@ export function EditUsernameModalBody({
}, []);
const onSave = useCallback(() => {
if (!currentUsername) {
confirmUsername();
} else {
setIsConfirmingSave(true);
}
}, [confirmUsername, currentUsername]);
const onCancelSave = useCallback(() => {
setIsConfirmingSave(false);
}, []);
const onConfirmUsername = useCallback(() => {
confirmUsername();
}, [confirmUsername]);
@ -285,6 +298,26 @@ export function EditUsernameModalBody({
})}
</ConfirmationDialog>
)}
{isConfirmingSave && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.confirmChange"
cancelText={i18n('icu:cancel')}
actions={[
{
action: onConfirmUsername,
style: 'negative',
text: i18n(
'icu:EditUsernameModalBody__change-confirmation__continue'
),
},
]}
i18n={i18n}
onClose={onCancelSave}
>
{i18n('icu:EditUsernameModalBody__change-confirmation')}
</ConfirmationDialog>
)}
</>
);
}

View file

@ -12,6 +12,7 @@ import { ProfileEditor } from './ProfileEditor';
import { EditUsernameModalBody } from './EditUsernameModalBody';
import {
UsernameEditState,
UsernameLinkState,
UsernameReservationState,
} from '../state/ducks/usernameEnums';
import { UUID } from '../types/UUID';
@ -49,6 +50,12 @@ export default {
i18n: {
defaultValue: i18n,
},
usernameLink: {
defaultValue: 'https://signal.me/#eu/testtest',
},
usernameLinkFgColor: {
defaultValue: '',
},
isUsernameFlagEnabled: {
control: { type: 'checkbox' },
defaultValue: false,
@ -62,16 +69,25 @@ export default {
Deleting: UsernameEditState.Deleting,
},
},
usernameLinkState: {
control: { type: 'select' },
defaultValue: UsernameLinkState.Ready,
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
},
onEditStateChanged: { action: true },
onProfileChanged: { action: true },
onSetSkinTone: { action: true },
saveAttachment: { action: true },
setUsernameLinkColor: { action: true },
showToast: { action: true },
recentEmojis: {
defaultValue: [],
},
replaceAvatar: { action: true },
resetUsernameLink: { action: true },
saveAvatarToDisk: { action: true },
markCompletedUsernameOnboarding: { action: true },
markCompletedUsernameLinkOnboarding: { action: true },
openUsernameReservationModal: { action: true },
setUsernameEditState: { action: true },
deleteUsername: { action: true },

View file

@ -25,8 +25,12 @@ import { Intl } from './Intl';
import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal';
import { PanelRow } from './conversation/conversation-details/PanelRow';
import type { ProfileDataType } from '../state/ducks/conversations';
import type {
ProfileDataType,
SaveAttachmentActionCreatorType,
} from '../state/ducks/conversations';
import { UsernameEditState } from '../state/ducks/usernameEnums';
import type { UsernameLinkState } from '../state/ducks/usernameEnums';
import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast';
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
@ -34,14 +38,15 @@ import { assertDev } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu';
import { UsernameLinkModalBody } from './UsernameLinkModalBody';
import { UsernameOnboardingModalBody } from './UsernameOnboardingModalBody';
import {
ConversationDetailsIcon,
IconType,
} from './conversation/conversation-details/ConversationDetailsIcon';
import { isWhitespace, trim } from '../util/whitespaceStringUtil';
import { generateUsernameLink } from '../util/sgnlHref';
import { UserText } from './UserText';
import { Tooltip, TooltipPlacement } from './Tooltip';
export enum EditState {
None = 'None',
@ -50,6 +55,7 @@ export enum EditState {
Bio = 'Bio',
Username = 'Username',
UsernameOnboarding = 'UsernameOnboarding',
UsernameLink = 'UsernameLink',
}
type PropsExternalType = {
@ -70,20 +76,28 @@ export type PropsDataType = {
familyName?: string;
firstName: string;
hasCompletedUsernameOnboarding: boolean;
hasCompletedUsernameLinkOnboarding: boolean;
i18n: LocalizerType;
isUsernameFlagEnabled: boolean;
userAvatarData: ReadonlyArray<AvatarDataType>;
username?: string;
usernameEditState: UsernameEditState;
markCompletedUsernameOnboarding: () => void;
usernameLinkState: UsernameLinkState;
usernameLinkColor?: number;
usernameLink?: string;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type PropsActionType = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
markCompletedUsernameOnboarding: () => void;
markCompletedUsernameLinkOnboarding: () => void;
onSetSkinTone: (tone: number) => unknown;
replaceAvatar: ReplaceAvatarActionType;
saveAttachment: SaveAttachmentActionCreatorType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
setUsernameEditState: (editState: UsernameEditState) => void;
setUsernameLinkColor: (color: number) => void;
resetUsernameLink: () => void;
deleteUsername: () => void;
showToast: ShowToastAction;
openUsernameReservationModal: () => void;
@ -131,9 +145,11 @@ export function ProfileEditor({
familyName,
firstName,
hasCompletedUsernameOnboarding,
hasCompletedUsernameLinkOnboarding,
i18n,
isUsernameFlagEnabled,
markCompletedUsernameOnboarding,
markCompletedUsernameLinkOnboarding,
onEditStateChanged,
onProfileChanged,
onSetSkinTone,
@ -142,13 +158,19 @@ export function ProfileEditor({
recentEmojis,
renderEditUsernameModalBody,
replaceAvatar,
resetUsernameLink,
saveAttachment,
saveAvatarToDisk,
setUsernameEditState,
setUsernameLinkColor,
showToast,
skinTone,
userAvatarData,
username,
usernameEditState,
usernameLinkState,
usernameLinkColor,
usernameLink,
}: PropsType): JSX.Element {
const focusInputRef = useRef<HTMLInputElement | null>(null);
const [editState, setEditState] = useState<EditState>(EditState.None);
@ -499,8 +521,22 @@ export function ProfileEditor({
}}
/>
);
} else if (editState === EditState.UsernameLink) {
content = (
<UsernameLinkModalBody
i18n={i18n}
link={usernameLink}
username={username ?? ''}
colorId={usernameLinkColor}
usernameLinkState={usernameLinkState}
setUsernameLinkColor={setUsernameLinkColor}
resetUsernameLink={resetUsernameLink}
saveAttachment={saveAttachment}
showToast={showToast}
/>
);
} else if (editState === EditState.None) {
let maybeUsernameRow: JSX.Element | undefined;
let maybeUsernameRows: JSX.Element | undefined;
if (isUsernameFlagEnabled) {
let actions: JSX.Element | undefined;
@ -528,21 +564,6 @@ export function ProfileEditor({
showToast({ toastType: ToastType.CopiedUsername });
},
},
{
group: 'copy',
icon: 'ProfileEditor__username-menu__copy-link-icon',
label: i18n('icu:ProfileEditor--username--copy-link'),
onClick: () => {
assertDev(
username !== undefined,
'Should not be visible without username'
);
void window.navigator.clipboard.writeText(
generateUsernameLink(username)
);
showToast({ toastType: ToastType.CopiedUsernameLink });
},
},
{
// Different group to display a divider above it
group: 'delete',
@ -568,24 +589,74 @@ export function ProfileEditor({
}
}
maybeUsernameRow = (
<PanelRow
className="ProfileEditor__row"
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
}
label={username || i18n('icu:ProfileEditor--username')}
info={username && generateUsernameLink(username, { short: true })}
onClick={() => {
openUsernameReservationModal();
if (username || hasCompletedUsernameOnboarding) {
setEditState(EditState.Username);
} else {
setEditState(EditState.UsernameOnboarding);
let maybeUsernameLinkRow: JSX.Element | undefined;
if (username) {
maybeUsernameLinkRow = (
<PanelRow
className="ProfileEditor__row"
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username-link" />
}
}}
actions={actions}
/>
label={i18n('icu:ProfileEditor__username-link')}
onClick={() => {
setEditState(EditState.UsernameLink);
}}
/>
);
if (!hasCompletedUsernameLinkOnboarding) {
const tooltip = (
<div className="ProfileEditor__username-link__tooltip__container">
<div className="ProfileEditor__username-link__tooltip__icon" />
<div className="ProfileEditor__username-link__tooltip__content">
<h3>
{i18n('icu:ProfileEditor__username-link__tooltip__title')}
</h3>
<p>{i18n('icu:ProfileEditor__username-link__tooltip__body')}</p>
</div>
<button
type="button"
className="ProfileEditor__username-link__tooltip__close"
onClick={markCompletedUsernameLinkOnboarding}
aria-label={i18n('icu:close')}
/>
</div>
);
maybeUsernameLinkRow = (
<Tooltip
className="ProfileEditor__username-link__tooltip"
direction={TooltipPlacement.Bottom}
sticky
content={tooltip}
>
{maybeUsernameLinkRow}
</Tooltip>
);
}
}
maybeUsernameRows = (
<>
<PanelRow
className="ProfileEditor__row"
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
}
label={username || i18n('icu:ProfileEditor--username')}
onClick={() => {
openUsernameReservationModal();
if (username || hasCompletedUsernameOnboarding) {
setEditState(EditState.Username);
} else {
setEditState(EditState.UsernameOnboarding);
}
}}
actions={actions}
/>
{maybeUsernameLinkRow}
</>
);
}
@ -618,7 +689,7 @@ export function ProfileEditor({
setEditState(EditState.ProfileName);
}}
/>
{maybeUsernameRow}
{maybeUsernameRows}
<PanelRow
className="ProfileEditor__row"
icon={
@ -680,7 +751,9 @@ export function ProfileEditor({
},
]}
>
{i18n('icu:ProfileEditor--username--confirm-delete-body')}
{i18n('icu:ProfileEditor--username--confirm-delete-body-2', {
username,
})}
</ConfirmationDialog>
)}

View file

@ -39,6 +39,7 @@ export function ProfileEditorModal({
[EditState.ProfileName]: i18n('icu:ProfileEditorModal--name'),
[EditState.UsernameOnboarding]: undefined,
[EditState.Username]: i18n('icu:ProfileEditorModal--username'),
[EditState.UsernameLink]: undefined,
};
const [modalTitle, setModalTitle] = useState(

View file

@ -0,0 +1,104 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import type { Meta, Story } from '@storybook/react';
import enMessages from '../../_locales/en/messages.json';
import { UsernameLinkState } from '../state/ducks/usernameEnums';
import { setupI18n } from '../util/setupI18n';
import { SignalService as Proto } from '../protobuf';
import type { PropsType } from './UsernameLinkModalBody';
import { UsernameLinkModalBody } from './UsernameLinkModalBody';
import { Modal } from './Modal';
const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
const i18n = setupI18n('en', enMessages);
export default {
component: UsernameLinkModalBody,
title: 'Components/UsernameLinkModalBody',
argTypes: {
i18n: {
defaultValue: i18n,
},
link: {
control: { type: 'text' },
defaultValue:
'https://signal.me#eu/n-AJkmmykrFB7j6UODGndSycxcMdp_v6ppRp9rFu5Ad39q_9Ngi_k9-TARWfT43t',
},
username: {
control: { type: 'text' },
defaultValue: 'alice.12',
},
usernameLinkState: {
control: { type: 'select' },
defaultValue: UsernameLinkState.Ready,
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
},
colorId: {
control: { type: 'select' },
defaultValue: ColorEnum.BLUE,
mapping: {
blue: ColorEnum.BLUE,
white: ColorEnum.WHITE,
grey: ColorEnum.GREY,
olive: ColorEnum.OLIVE,
green: ColorEnum.GREEN,
orange: ColorEnum.ORANGE,
pink: ColorEnum.PINK,
purple: ColorEnum.PURPLE,
},
},
showToast: { action: true },
resetUsernameLink: { action: true },
setUsernameLinkColor: { action: true },
},
} as Meta;
type ArgsType = PropsType;
// eslint-disable-next-line react/function-component-definition
const Template: Story<ArgsType> = args => {
const [attachment, setAttachment] = useState<string | undefined>();
const saveAttachment = useCallback(({ data }: { data?: Uint8Array }) => {
if (!data) {
setAttachment(undefined);
return;
}
const blob = new Blob([data], {
type: 'image/png',
});
setAttachment(oldURL => {
if (oldURL) {
URL.revokeObjectURL(oldURL);
}
return URL.createObjectURL(blob);
});
}, []);
return (
<>
<Modal modalName="story" i18n={i18n} hasXButton>
<UsernameLinkModalBody {...args} saveAttachment={saveAttachment} />
</Modal>
{attachment && <img src={attachment} alt="printable qr code" />}
</>
);
};
export const Normal = Template.bind({});
Normal.args = {};
Normal.story = {
name: 'normal',
};
export const NoLink = Template.bind({});
NoLink.args = { link: '' };
NoLink.story = {
name: 'normal',
};

View file

@ -0,0 +1,739 @@
// 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 { 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;
usernameLinkState: UsernameLinkState;
setUsernameLinkColor: (colorId: number) => void;
resetUsernameLink: () => void;
saveAttachment: SaveAttachmentActionCreatorType;
showToast: ShowToastAction;
}>;
export type ColorMapEntryType = Readonly<{
fg: string;
bg: string;
}>;
const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
const DEFAULT_PRESET: ColorMapEntryType = { fg: '#2449c0', bg: '#506ecd' };
export const COLOR_MAP: ReadonlyMap<number, ColorMapEntryType> = new Map([
[ColorEnum.BLUE, DEFAULT_PRESET],
[ColorEnum.WHITE, { fg: '#000000', bg: '#ffffff' }],
[ColorEnum.GREY, { fg: '#464852', bg: '#6a6c74' }],
[ColorEnum.OLIVE, { fg: '#73694f', bg: '#a89d7f' }],
[ColorEnum.GREEN, { fg: '#55733f', bg: '#829a6e' }],
[ColorEnum.ORANGE, { fg: '#d96b2d', bg: '#de7134' }],
[ColorEnum.PINK, { fg: '#bb617b', bg: '#e67899' }],
[ColorEnum.PURPLE, { fg: '#7651c5', bg: '#9c84cf' }],
]);
const CLASS = 'UsernameLinkModalBody';
const AUTODETECT_TYPE_NUMBER = 0;
const ERROR_CORRECTION_LEVEL = 'H';
const CENTER_CUTAWAY_PERCENTAGE = 32 / 184;
const PRINT_WIDTH = 296;
const DEFAULT_PRINT_HEIGHT = 324;
const PRINT_SHADOW_BLUR = 4;
const PRINT_CARD_RADIUS = 24;
const PRINT_MAX_USERNAME_WIDTH = 222;
const PRINT_USERNAME_LINE_HEIGHT = 25;
const PRINT_USERNAME_Y = 269;
const PRINT_QR_SIZE = 184;
const PRINT_QR_Y = 48;
const PRINT_QR_PADDING = 16;
const PRINT_QR_PADDING_RADIUS = 12;
const PRINT_DPI = 224;
const PRINT_LOGO_SIZE = 36;
type BlotchesPropsType = Readonly<{
className?: string;
link: string;
color: string;
}>;
function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element {
const qr = QR(AUTODETECT_TYPE_NUMBER, ERROR_CORRECTION_LEVEL);
qr.addData(link);
qr.make();
const size = qr.getModuleCount();
const center = size / 2;
const radius = CENTER_CUTAWAY_PERCENTAGE * size;
function hasPixel(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= size || y >= size) {
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 + 2) {
return false;
}
return qr.isDark(x, y);
}
const path = [];
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; 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'
);
}
}
return (
<svg
className={className}
viewBox={`0 0 ${2 * size} ${2 * size}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx={size}
cy={size}
r={radius * 2}
stroke={color}
strokeWidth={2}
/>
<path d={path.join('')} fill={color} />
</svg>
);
}
type CreateCanvasAndContextOptionsType = Readonly<{
width: number;
height: number;
devicePixelRatio?: number;
}>;
function createCanvasAndContext({
width,
height,
devicePixelRatio = window.devicePixelRatio,
}: CreateCanvasAndContextOptionsType): [
OffscreenCanvas,
OffscreenCanvasRenderingContext2D
] {
const canvas = new OffscreenCanvas(
devicePixelRatio * width,
devicePixelRatio * height
);
const context = canvas.getContext('2d');
strictAssert(context, 'Failed to get 2d context');
// Retina support
context.scale(devicePixelRatio, devicePixelRatio);
// Font config
context.font = `600 20px/${PRINT_USERNAME_LINE_HEIGHT}px Inter`;
context.textAlign = 'center';
context.textBaseline = 'top';
// Experimental Chrome APIs
(
context as unknown as {
letterSpacing: number;
}
).letterSpacing = -0.34;
(
context as unknown as {
textRendering: string;
}
).textRendering = 'optimizeLegibility';
context.imageSmoothingEnabled = false;
return [canvas, context];
}
type GetLogoCanvasOptionsType = Readonly<{
fgColor: string;
imageUrl?: string;
devicePixelRatio?: number;
}>;
async function getLogoCanvas({
fgColor,
imageUrl = 'images/signal-qr-logo.svg',
devicePixelRatio,
}: GetLogoCanvasOptionsType): Promise<OffscreenCanvas> {
const img = new Image();
await new Promise((resolve, reject) => {
img.addEventListener('load', resolve);
img.addEventListener('error', () =>
reject(new Error('Failed to load image'))
);
img.src = imageUrl;
});
const [canvas, context] = createCanvasAndContext({
width: PRINT_LOGO_SIZE,
height: PRINT_LOGO_SIZE,
devicePixelRatio,
});
context.fillStyle = fgColor;
context.fillRect(0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE);
context.globalCompositeOperation = 'destination-in';
context.drawImage(img, 0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE);
return canvas;
}
function splitUsername(username: string): Array<string> {
const result = new Array<string>();
const [, context] = createCanvasAndContext({ width: 1, height: 1 });
// Compute number of lines and height of username
for (let i = 0, last = 0; i < username.length; i += 1) {
const part = username.slice(last, i);
if (context.measureText(part).width > PRINT_MAX_USERNAME_WIDTH) {
result.push(username.slice(last, i - 1));
last = i - 1;
} else if (i === username.length - 1) {
result.push(username.slice(last));
}
}
return result;
}
type GenerateImageURLOptionsType = Readonly<{
link: string;
username: string;
colorId: number;
bgColor: string;
fgColor: string;
// For testing
logoUrl?: string;
devicePixelRatio?: number;
}>;
// Exported for testing
export async function _generateImageBlob({
link,
username,
colorId,
bgColor,
fgColor,
logoUrl,
devicePixelRatio,
}: GenerateImageURLOptionsType): Promise<Blob> {
const usernameLines = splitUsername(username);
const usernameHeight = PRINT_USERNAME_LINE_HEIGHT * usernameLines.length;
const isWhiteBackground = colorId === ColorEnum.WHITE;
const padding = isWhiteBackground ? PRINT_SHADOW_BLUR : 0;
const totalHeight =
DEFAULT_PRINT_HEIGHT - PRINT_USERNAME_LINE_HEIGHT + usernameHeight;
const [canvas, context] = createCanvasAndContext({
width: PRINT_WIDTH + 2 * padding,
height: totalHeight + 2 * padding,
devicePixelRatio,
});
// Draw card
context.save();
if (isWhiteBackground) {
context.shadowColor = 'rgba(0, 0, 0, 0.08)';
context.shadowBlur = PRINT_SHADOW_BLUR;
}
context.fillStyle = bgColor;
context.beginPath();
context.roundRect(
padding,
padding,
PRINT_WIDTH,
totalHeight,
PRINT_CARD_RADIUS
);
context.fill();
context.restore();
// Draw padding around QR code
context.save();
context.fillStyle = '#fff';
const sizeWithPadding = PRINT_QR_SIZE + 2 * PRINT_QR_PADDING;
context.beginPath();
context.roundRect(
padding + (PRINT_WIDTH - sizeWithPadding) / 2,
padding + PRINT_QR_Y - PRINT_QR_PADDING,
sizeWithPadding,
sizeWithPadding,
PRINT_QR_PADDING_RADIUS
);
context.fill();
if (isWhiteBackground) {
context.lineWidth = 2;
context.strokeStyle = '#e9e9e9';
context.stroke();
}
context.restore();
// Draw username
context.fillStyle = isWhiteBackground ? '#000' : '#fff';
for (const [i, line] of usernameLines.entries()) {
context.fillText(
line,
padding + PRINT_WIDTH / 2,
PRINT_USERNAME_Y + i * PRINT_USERNAME_LINE_HEIGHT
);
}
// Draw logo
context.drawImage(
await getLogoCanvas({ fgColor, imageUrl: logoUrl, devicePixelRatio }),
padding + (PRINT_WIDTH - PRINT_LOGO_SIZE) / 2,
padding + PRINT_QR_Y + (PRINT_QR_SIZE - PRINT_LOGO_SIZE) / 2,
PRINT_LOGO_SIZE,
PRINT_LOGO_SIZE
);
// Draw QR code
const svg = renderToStaticMarkup(Blotches({ link, color: fgColor }));
const svgURL = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
const img = new Image();
await new Promise((resolve, reject) => {
img.addEventListener('load', resolve);
img.addEventListener('error', () =>
reject(new Error('Failed to load image'))
);
img.src = svgURL;
});
context.drawImage(
img,
padding + (PRINT_WIDTH - PRINT_QR_SIZE) / 2,
PRINT_QR_Y + padding,
PRINT_QR_SIZE,
PRINT_QR_SIZE
);
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 (
<button
ref={onRef}
className={classnames(className, {
[`${className}--white-bg`]: isWhiteBackground,
})}
type="button"
aria-label={i18n('icu:UsernameLinkModalBody__color__radio', {
index: index + 1,
total: COLOR_MAP.size,
})}
aria-pressed={isSelected}
onClick={onClick}
>
<i />
</button>
);
}
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 (
<div className={className}>
<div className={`${className}__grid`}>
{[...COLOR_MAP.entries()].map(([colorId, { fg, bg }], index) => {
return (
<UsernameLinkColorRadio
key={colorId}
i18n={i18n}
colorId={colorId}
fgColor={fg}
bgColor={bg}
index={index}
isSelected={colorId === normalizedValue}
onSelect={onChange}
/>
);
})}
</div>
<Modal.ButtonFooter>
<Button variant={ButtonVariant.Secondary} onClick={onCancel}>
{i18n('icu:cancel')}
</Button>
<Button variant={ButtonVariant.Primary} onClick={onSave}>
{i18n('icu:save')}
</Button>
</Modal.ButtonFooter>
</div>
);
}
export function UsernameLinkModalBody({
i18n,
link,
username,
usernameLinkState,
colorId: initialColorId = ColorEnum.UNKNOWN,
setUsernameLinkColor,
resetUsernameLink,
saveAttachment,
showToast,
}: PropsType): JSX.Element {
const [pngData, setPngData] = useState<Uint8Array | undefined>();
const [showColors, setShowColors] = useState(false);
const [confirmReset, setConfirmReset] = 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,
bgColor,
fgColor,
});
const arrayBuffer = await blob.arrayBuffer();
if (isAborted) {
return;
}
setPngData(new Uint8Array(arrayBuffer));
}
drop(run());
return () => {
isAborted = true;
};
}, [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(() => {
setConfirmReset(false);
resetUsernameLink();
}, [resetUsernameLink]);
const info = (
<>
<div className={classnames(`${CLASS}__actions`)}>
<button
className={`${CLASS}__actions__save`}
type="button"
disabled={!link}
onClick={onSave}
>
<i />
{i18n('icu:UsernameLinkModalBody__save')}
</button>
<button
className={`${CLASS}__actions__color`}
type="button"
onClick={onStartColorChange}
>
<i />
{i18n('icu:UsernameLinkModalBody__color')}
</button>
</div>
<div className={classnames(`${CLASS}__link`)}>
<button
className={classnames(`${CLASS}__link__icon`)}
type="button"
disabled={!link}
onClick={onCopyLink}
aria-label={i18n('icu:UsernameLinkModalBody__copy')}
/>
<div className={classnames(`${CLASS}__link__text`)}>{link}</div>
</div>
<div className={classnames(`${CLASS}__help`)}>
{i18n('icu:UsernameLinkModalBody__help')}
</div>
<button
className={classnames(`${CLASS}__reset`)}
type="button"
onClick={onClickReset}
>
{i18n('icu:UsernameLinkModalBody__reset')}
</button>
</>
);
return (
<div className={`${CLASS}__container`}>
<div className={CLASS}>
<div
className={classnames(`${CLASS}__card`, {
[`${CLASS}__card--shadow`]: isWhiteBackground,
})}
ref={onCardRef}
>
<div className={`${CLASS}__card__qr`}>
{usernameLinkState === UsernameLinkState.Ready && link ? (
<>
<Blotches
className={`${CLASS}__card__qr__blotches`}
link={link}
color={fgColor}
/>
<div className={`${CLASS}__card__qr__logo`} />
</>
) : (
<Spinner
moduleClassName={`${CLASS}__card__qr__spinner`}
svgSize="small"
/>
)}
</div>
<div className={`${CLASS}__card__username`}>
{!showColors && (
<button
className={classnames(`${CLASS}__card__username__copy`)}
type="button"
onClick={onCopyUsername}
aria-label={i18n('icu:UsernameLinkModalBody__copy')}
/>
)}
<div className={`${CLASS}__card__username__text`}>{username}</div>
</div>
</div>
{confirmReset && (
<ConfirmationDialog
i18n={i18n}
dialogName="UsernameLinkModal__confirm-reset"
onClose={onCancelReset}
actions={[
{
action: onConfirmReset,
style: 'negative',
text: i18n('icu:UsernameLinkModalBody__reset'),
},
]}
>
{i18n('icu:UsernameLinkModalBody__reset__confirm')}
</ConfirmationDialog>
)}
{showColors ? (
<UsernameLinkColors
i18n={i18n}
value={colorId}
onChange={onUsernameLinkColorChange}
onSave={onUsernameLinkColorSave}
onCancel={onUsernameLinkColorCancel}
/>
) : (
info
)}
</div>
</div>
);
}