Username Link QR Code
This commit is contained in:
7
ts/changedpi.d.ts
vendored
Normal file
7
ts/changedpi.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
declare module 'changedpi' {
|
||||
function changeDpiBlob(blob: Blob, dpi: number): Promise<Blob>;
|
||||
function changeDpiDataUrl(url: string, dpi: number): string;
|
||||
}
|
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -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 },
|
||||
|
@@ -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>
|
||||
)}
|
||||
|
||||
|
@@ -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(
|
||||
|
104
ts/components/UsernameLinkModalBody.stories.tsx
Normal file
104
ts/components/UsernameLinkModalBody.stories.tsx
Normal 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',
|
||||
};
|
739
ts/components/UsernameLinkModalBody.tsx
Normal file
739
ts/components/UsernameLinkModalBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -419,6 +419,20 @@ export function toAccountRecord(
|
||||
accountRecord.storyViewReceiptsEnabled = Proto.OptionalBool.UNSET;
|
||||
}
|
||||
|
||||
// Username link
|
||||
{
|
||||
const color = window.storage.get('usernameLinkColor');
|
||||
const linkData = window.storage.get('usernameLink');
|
||||
|
||||
if (linkData?.entropy.length && linkData?.serverId.length) {
|
||||
accountRecord.usernameLink = {
|
||||
color,
|
||||
entropy: linkData.entropy,
|
||||
serverId: linkData.serverId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
applyUnknownFields(accountRecord, conversation);
|
||||
|
||||
return accountRecord;
|
||||
@@ -1171,6 +1185,7 @@ export async function mergeAccountRecord(
|
||||
storiesDisabled,
|
||||
storyViewReceiptsEnabled,
|
||||
username,
|
||||
usernameLink,
|
||||
} = accountRecord;
|
||||
|
||||
const updatedConversations = new Array<ConversationModel>();
|
||||
@@ -1425,6 +1440,22 @@ export async function mergeAccountRecord(
|
||||
break;
|
||||
}
|
||||
|
||||
if (usernameLink?.entropy?.length && usernameLink?.serverId?.length) {
|
||||
await Promise.all([
|
||||
usernameLink.color &&
|
||||
window.storage.put('usernameLinkColor', usernameLink.color),
|
||||
window.storage.put('usernameLink', {
|
||||
entropy: usernameLink.entropy,
|
||||
serverId: usernameLink.serverId,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await Promise.all([
|
||||
window.storage.remove('usernameLinkColor'),
|
||||
window.storage.remove('usernameLink'),
|
||||
]);
|
||||
}
|
||||
|
||||
const ourID = window.ConversationController.getOurConversationId();
|
||||
|
||||
if (!ourID) {
|
||||
|
@@ -11,6 +11,8 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { sleep } from '../util/sleep';
|
||||
import { getMinNickname, getMaxNickname } from '../util/Username';
|
||||
import { bytesToUuid } from '../Crypto';
|
||||
import { uuidToBytes } from '../util/uuidToBytes';
|
||||
import type { UsernameReservationType } from '../types/Username';
|
||||
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
|
||||
import * as Errors from '../types/errors';
|
||||
@@ -18,6 +20,8 @@ import * as log from '../logging/log';
|
||||
import MessageSender from '../textsecure/SendMessage';
|
||||
import { HTTPError } from '../textsecure/Errors';
|
||||
import { findRetryAfterTimeFromError } from '../jobs/helpers/findRetryAfterTimeFromError';
|
||||
import * as Bytes from '../Bytes';
|
||||
import { storageServiceUploadJob } from './storage';
|
||||
|
||||
export type WriteUsernameOptionsType = Readonly<
|
||||
| {
|
||||
@@ -186,6 +190,9 @@ export async function confirmUsername(
|
||||
});
|
||||
|
||||
await updateUsernameAndSyncProfile(username);
|
||||
|
||||
// TODO: DESKTOP-5687
|
||||
await resetLink(username);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) {
|
||||
if (error.code === 413 || error.code === 429) {
|
||||
@@ -221,6 +228,67 @@ export async function deleteUsername(
|
||||
throw new Error('Username has changed on another device');
|
||||
}
|
||||
|
||||
await window.storage.remove('usernameLink');
|
||||
await server.deleteUsername(abortSignal);
|
||||
await updateUsernameAndSyncProfile(undefined);
|
||||
}
|
||||
|
||||
export async function resetLink(username: string): Promise<void> {
|
||||
const { server } = window.textsecure;
|
||||
if (!server) {
|
||||
throw new Error('server interface is not available!');
|
||||
}
|
||||
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
|
||||
if (me.get('username') !== username) {
|
||||
throw new Error('Username has changed on another device');
|
||||
}
|
||||
|
||||
const link = usernames.createUsernameLink(username);
|
||||
|
||||
await window.storage.remove('usernameLink');
|
||||
|
||||
const { usernameLinkHandle: serverIdString } =
|
||||
await server.replaceUsernameLink({
|
||||
encryptedUsername: link.encryptedUsername,
|
||||
});
|
||||
|
||||
await window.storage.put('usernameLink', {
|
||||
entropy: link.entropy,
|
||||
serverId: uuidToBytes(serverIdString),
|
||||
});
|
||||
|
||||
me.captureChange('usernameLink');
|
||||
storageServiceUploadJob();
|
||||
}
|
||||
|
||||
const USERNAME_LINK_ENTROPY_SIZE = 32;
|
||||
|
||||
export async function resolveUsernameByLinkBase64(
|
||||
base64: string
|
||||
): Promise<string> {
|
||||
const { server } = window.textsecure;
|
||||
if (!server) {
|
||||
throw new Error('server interface is not available!');
|
||||
}
|
||||
|
||||
const content = Bytes.fromBase64(base64);
|
||||
const entropy = content.slice(0, USERNAME_LINK_ENTROPY_SIZE);
|
||||
const serverIdBytes = content.slice(USERNAME_LINK_ENTROPY_SIZE);
|
||||
|
||||
const serverId = bytesToUuid(serverIdBytes);
|
||||
strictAssert(serverId, 'Failed to re-encode server id as uuid');
|
||||
|
||||
strictAssert(window.textsecure.server, 'WebAPI must be available');
|
||||
const { usernameLinkEncryptedValue } = await server.resolveUsernameLink(
|
||||
serverId
|
||||
);
|
||||
|
||||
const link = new usernames.UsernameLink(
|
||||
Buffer.from(entropy),
|
||||
Buffer.from(usernameLinkEncryptedValue)
|
||||
);
|
||||
|
||||
return link.decryptUsername();
|
||||
}
|
||||
|
@@ -379,6 +379,13 @@ const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
|
||||
senderCertificate: ['value.serialized'],
|
||||
senderCertificateNoE164: ['value.serialized'],
|
||||
subscriberId: ['value'],
|
||||
usernameLink: {
|
||||
key: 'value',
|
||||
valueSpec: {
|
||||
isMap: true,
|
||||
valueSpec: ['entropy', 'serverId'],
|
||||
},
|
||||
},
|
||||
};
|
||||
async function createOrUpdateItem<K extends ItemKeyType>(
|
||||
data: ItemType<K>
|
||||
|
@@ -13,8 +13,6 @@ import { drop } from '../../util/drop';
|
||||
import type {
|
||||
ConversationColorType,
|
||||
CustomColorType,
|
||||
CustomColorsItemType,
|
||||
DefaultConversationColorType,
|
||||
} from '../../types/Colors';
|
||||
import { ConversationColors } from '../../types/Colors';
|
||||
import { reloadSelectedConversation } from '../../shims/reloadSelectedConversation';
|
||||
@@ -24,25 +22,26 @@ import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
|
||||
|
||||
// State
|
||||
|
||||
export type ItemsStateType = ReadonlyDeep<{
|
||||
universalExpireTimer?: number;
|
||||
export type ItemsStateType = ReadonlyDeep<
|
||||
{
|
||||
[key: string]: unknown;
|
||||
|
||||
[key: string]: unknown;
|
||||
|
||||
remoteConfig?: RemoteConfigType;
|
||||
serverTimeSkew?: number;
|
||||
|
||||
// This property should always be set and this is ensured in background.ts
|
||||
defaultConversationColor?: DefaultConversationColorType;
|
||||
|
||||
customColors?: CustomColorsItemType;
|
||||
|
||||
preferredLeftPaneWidth?: number;
|
||||
|
||||
preferredReactionEmoji?: Array<string>;
|
||||
|
||||
areWeASubscriber?: boolean;
|
||||
}>;
|
||||
remoteConfig?: RemoteConfigType;
|
||||
serverTimeSkew?: number;
|
||||
} & Partial<
|
||||
Pick<
|
||||
StorageAccessType,
|
||||
| 'universalExpireTimer'
|
||||
| 'defaultConversationColor'
|
||||
| 'customColors'
|
||||
| 'preferredLeftPaneWidth'
|
||||
| 'preferredReactionEmoji'
|
||||
| 'areWeASubscriber'
|
||||
| 'usernameLinkColor'
|
||||
| 'usernameLink'
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
// Actions
|
||||
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
ConfirmUsernameResult,
|
||||
} from '../../types/Username';
|
||||
import * as usernameServices from '../../services/username';
|
||||
import { storageServiceUploadJob } from '../../services/storage';
|
||||
import type { ReserveUsernameResultType } from '../../services/username';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { sleep } from '../../util/sleep';
|
||||
@@ -19,6 +20,7 @@ import type { PromiseAction } from '../util';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import {
|
||||
UsernameEditState,
|
||||
UsernameLinkState,
|
||||
UsernameReservationState,
|
||||
UsernameReservationError,
|
||||
} from './usernameEnums';
|
||||
@@ -37,6 +39,9 @@ export type UsernameStateType = ReadonlyDeep<{
|
||||
// ProfileEditor
|
||||
editState: UsernameEditState;
|
||||
|
||||
// UsernameLinkModalBody
|
||||
linkState: UsernameLinkState;
|
||||
|
||||
// EditUsernameModalBody
|
||||
usernameReservation: UsernameReservationStateType;
|
||||
}>;
|
||||
@@ -50,6 +55,7 @@ const SET_USERNAME_RESERVATION_ERROR = 'username/SET_RESERVATION_ERROR';
|
||||
const RESERVE_USERNAME = 'username/RESERVE_USERNAME';
|
||||
const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME';
|
||||
const DELETE_USERNAME = 'username/DELETE_USERNAME';
|
||||
const RESET_USERNAME_LINK = 'username/RESET_USERNAME_LINK';
|
||||
|
||||
type SetUsernameEditStateActionType = ReadonlyDeep<{
|
||||
type: typeof SET_USERNAME_EDIT_STATE;
|
||||
@@ -86,6 +92,9 @@ type ConfirmUsernameActionType = ReadonlyDeep<
|
||||
type DeleteUsernameActionType = ReadonlyDeep<
|
||||
PromiseAction<typeof DELETE_USERNAME, void>
|
||||
>;
|
||||
type ResetUsernameLinkActionType = ReadonlyDeep<
|
||||
PromiseAction<typeof RESET_USERNAME_LINK, void>
|
||||
>;
|
||||
|
||||
export type UsernameActionType = ReadonlyDeep<
|
||||
| SetUsernameEditStateActionType
|
||||
@@ -95,6 +104,7 @@ export type UsernameActionType = ReadonlyDeep<
|
||||
| ReserveUsernameActionType
|
||||
| ConfirmUsernameActionType
|
||||
| DeleteUsernameActionType
|
||||
| ResetUsernameLinkActionType
|
||||
>;
|
||||
|
||||
export const actions = {
|
||||
@@ -105,6 +115,10 @@ export const actions = {
|
||||
reserveUsername,
|
||||
confirmUsername,
|
||||
deleteUsername,
|
||||
markCompletedUsernameOnboarding,
|
||||
resetUsernameLink,
|
||||
setUsernameLinkColor,
|
||||
markCompletedUsernameLinkOnboarding,
|
||||
};
|
||||
|
||||
export function setUsernameEditState(
|
||||
@@ -255,11 +269,72 @@ export function deleteUsername({
|
||||
};
|
||||
}
|
||||
|
||||
export type ResetUsernameLinkOptionsType = ReadonlyDeep<{
|
||||
doResetLink?: typeof usernameServices.resetLink;
|
||||
}>;
|
||||
|
||||
export function resetUsernameLink({
|
||||
doResetLink = usernameServices.resetLink,
|
||||
}: ResetUsernameLinkOptionsType = {}): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
ResetUsernameLinkActionType
|
||||
> {
|
||||
return dispatch => {
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
const username = me.get('username');
|
||||
assertDev(username, 'Username is required for resetting link');
|
||||
|
||||
dispatch({
|
||||
type: RESET_USERNAME_LINK,
|
||||
payload: doResetLink(username),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function markCompletedUsernameOnboarding(): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
never
|
||||
> {
|
||||
return async () => {
|
||||
await window.storage.put('hasCompletedUsernameOnboarding', true);
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
me.captureChange('usernameOnboarding');
|
||||
storageServiceUploadJob();
|
||||
};
|
||||
}
|
||||
|
||||
function markCompletedUsernameLinkOnboarding(): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
never
|
||||
> {
|
||||
return async () => {
|
||||
await window.storage.put('hasCompletedUsernameLinkOnboarding', true);
|
||||
};
|
||||
}
|
||||
|
||||
function setUsernameLinkColor(
|
||||
color: number
|
||||
): ThunkAction<void, RootStateType, unknown, never> {
|
||||
return async () => {
|
||||
await window.storage.put('usernameLinkColor', color);
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
me.captureChange('usernameLinkColor');
|
||||
storageServiceUploadJob();
|
||||
};
|
||||
}
|
||||
|
||||
// Reducers
|
||||
|
||||
export function getEmptyState(): UsernameStateType {
|
||||
return {
|
||||
editState: UsernameEditState.Editing,
|
||||
linkState: UsernameLinkState.Ready,
|
||||
usernameReservation: {
|
||||
state: UsernameReservationState.Closed,
|
||||
},
|
||||
@@ -476,5 +551,26 @@ export function reducer(
|
||||
return state;
|
||||
}
|
||||
|
||||
if (action.type === 'username/RESET_USERNAME_LINK_PENDING') {
|
||||
return {
|
||||
...state,
|
||||
linkState: UsernameLinkState.Updating,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/RESET_USERNAME_LINK_FULFILLED') {
|
||||
return {
|
||||
...state,
|
||||
linkState: UsernameLinkState.Ready,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'username/RESET_USERNAME_LINK_REJECTED') {
|
||||
return {
|
||||
...state,
|
||||
linkState: UsernameLinkState.Ready,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
@@ -11,6 +11,15 @@ export enum UsernameEditState {
|
||||
Deleting = 'Deleting',
|
||||
}
|
||||
|
||||
//
|
||||
// UsernameLinkModalBody
|
||||
//
|
||||
|
||||
export enum UsernameLinkState {
|
||||
Ready = 'Ready',
|
||||
Updating = 'Updating',
|
||||
}
|
||||
|
||||
//
|
||||
// EditUsernameModalBody
|
||||
//
|
||||
|
@@ -19,6 +19,8 @@ import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors';
|
||||
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
|
||||
import { isBeta } from '../../util/version';
|
||||
import { DurationInSeconds } from '../../util/durations';
|
||||
import { generateUsernameLink } from '../../util/sgnlHref';
|
||||
import * as Bytes from '../../Bytes';
|
||||
import { getUserNumber, getUserACI } from './user';
|
||||
|
||||
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
|
||||
@@ -86,12 +88,41 @@ export const getHasCompletedUsernameOnboarding = createSelector(
|
||||
Boolean(state.hasCompletedUsernameOnboarding)
|
||||
);
|
||||
|
||||
export const getHasCompletedUsernameLinkOnboarding = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): boolean =>
|
||||
Boolean(state.hasCompletedUsernameLinkOnboarding)
|
||||
);
|
||||
|
||||
export const getHasCompletedSafetyNumberOnboarding = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): boolean =>
|
||||
Boolean(state.hasCompletedSafetyNumberOnboarding)
|
||||
);
|
||||
|
||||
export const getUsernameLinkColor = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): number | undefined => state.usernameLinkColor
|
||||
);
|
||||
|
||||
export const getUsernameLink = createSelector(
|
||||
getItems,
|
||||
({ usernameLink }: ItemsStateType): string | undefined => {
|
||||
if (!usernameLink) {
|
||||
return undefined;
|
||||
}
|
||||
const { entropy, serverId } = usernameLink;
|
||||
|
||||
if (!entropy.length || !serverId.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content = Bytes.concatenate([entropy, serverId]);
|
||||
|
||||
return generateUsernameLink(Bytes.toBase64(content));
|
||||
}
|
||||
);
|
||||
|
||||
export const isInternalUser = createSelector(
|
||||
getRemoteConfig,
|
||||
(remoteConfig: ConfigMapType): boolean => {
|
||||
|
@@ -11,6 +11,7 @@ import type {
|
||||
} from '../ducks/username';
|
||||
import type {
|
||||
UsernameEditState,
|
||||
UsernameLinkState,
|
||||
UsernameReservationState,
|
||||
UsernameReservationError,
|
||||
} from '../ducks/usernameEnums';
|
||||
@@ -23,6 +24,11 @@ export const getUsernameEditState = createSelector(
|
||||
(state: UsernameStateType): UsernameEditState => state.editState
|
||||
);
|
||||
|
||||
export const getUsernameLinkState = createSelector(
|
||||
getUsernameState,
|
||||
(state: UsernameStateType): UsernameLinkState => state.linkState
|
||||
);
|
||||
|
||||
export const getUsernameReservation = createSelector(
|
||||
getUsernameState,
|
||||
(state: UsernameStateType): UsernameReservationStateType =>
|
||||
|
@@ -8,7 +8,6 @@ import { mapDispatchToProps } from '../actions';
|
||||
import type { PropsDataType as ProfileEditorModalPropsType } from '../../components/ProfileEditorModal';
|
||||
import { ProfileEditorModal } from '../../components/ProfileEditorModal';
|
||||
import type { PropsDataType } from '../../components/ProfileEditor';
|
||||
import { storageServiceUploadJob } from '../../services/storage';
|
||||
import { SmartEditUsernameModalBody } from './EditUsernameModalBody';
|
||||
import type { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
@@ -16,10 +15,16 @@ import {
|
||||
getEmojiSkinTone,
|
||||
getUsernamesEnabled,
|
||||
getHasCompletedUsernameOnboarding,
|
||||
getHasCompletedUsernameLinkOnboarding,
|
||||
getUsernameLinkColor,
|
||||
getUsernameLink,
|
||||
} from '../selectors/items';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
import { getUsernameEditState } from '../selectors/username';
|
||||
import {
|
||||
getUsernameEditState,
|
||||
getUsernameLinkState,
|
||||
} from '../selectors/username';
|
||||
|
||||
function renderEditUsernameModalBody(props: {
|
||||
onClose: () => void;
|
||||
@@ -27,12 +32,6 @@ function renderEditUsernameModalBody(props: {
|
||||
return <SmartEditUsernameModalBody {...props} />;
|
||||
}
|
||||
|
||||
async function markCompletedUsernameOnboarding(): Promise<void> {
|
||||
await window.storage.put('hasCompletedUsernameOnboarding', true);
|
||||
|
||||
storageServiceUploadJob();
|
||||
}
|
||||
|
||||
function mapStateToProps(
|
||||
state: StateType
|
||||
): Omit<PropsDataType, 'onEditStateChange' | 'onProfileChanged'> &
|
||||
@@ -53,7 +52,12 @@ function mapStateToProps(
|
||||
const isUsernameFlagEnabled = getUsernamesEnabled(state);
|
||||
const hasCompletedUsernameOnboarding =
|
||||
getHasCompletedUsernameOnboarding(state);
|
||||
const hasCompletedUsernameLinkOnboarding =
|
||||
getHasCompletedUsernameLinkOnboarding(state);
|
||||
const usernameEditState = getUsernameEditState(state);
|
||||
const usernameLinkState = getUsernameLinkState(state);
|
||||
const usernameLinkColor = getUsernameLinkColor(state);
|
||||
const usernameLink = getUsernameLink(state);
|
||||
|
||||
return {
|
||||
aboutEmoji,
|
||||
@@ -64,15 +68,18 @@ function mapStateToProps(
|
||||
familyName,
|
||||
firstName: String(firstName),
|
||||
hasCompletedUsernameOnboarding,
|
||||
hasCompletedUsernameLinkOnboarding,
|
||||
hasError: state.globalModals.profileEditorHasError,
|
||||
i18n: getIntl(state),
|
||||
isUsernameFlagEnabled,
|
||||
markCompletedUsernameOnboarding,
|
||||
recentEmojis,
|
||||
skinTone,
|
||||
userAvatarData,
|
||||
username,
|
||||
usernameEditState,
|
||||
usernameLinkState,
|
||||
usernameLinkColor,
|
||||
usernameLink,
|
||||
|
||||
renderEditUsernameModalBody,
|
||||
};
|
||||
|
136
ts/test-electron/components/UsernameLinkModalBody_test.ts
Normal file
136
ts/test-electron/components/UsernameLinkModalBody_test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import path from 'path';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { pathExists, writeFile, readFile } from 'fs-extra';
|
||||
|
||||
import {
|
||||
_generateImageBlob,
|
||||
COLOR_MAP,
|
||||
} from '../../components/UsernameLinkModalBody';
|
||||
import { SignalService as Proto } from '../../protobuf';
|
||||
|
||||
const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
|
||||
|
||||
async function getImageData(blob: Blob): Promise<ImageData> {
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise(resolve => {
|
||||
img.addEventListener('load', resolve);
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
const canvas = new OffscreenCanvas(img.width, img.height);
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Failed to get 2d context');
|
||||
}
|
||||
|
||||
context.drawImage(img, 0, 0);
|
||||
return context.getImageData(0, 0, img.width, img.height);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_COLORS: ReadonlyArray<[string, number]> = [
|
||||
['white', ColorEnum.WHITE],
|
||||
['blue', ColorEnum.BLUE],
|
||||
];
|
||||
|
||||
describe('<UsernameLinkModalBody>', () => {
|
||||
before(async () => {
|
||||
// We need to load the font first, otherwise the first test render will use
|
||||
// default font (not Inter)
|
||||
const f = new FontFace(
|
||||
'Inter',
|
||||
'url(../fonts/inter-v3.19/Inter-SemiBold.woff2)',
|
||||
{
|
||||
weight: '600',
|
||||
}
|
||||
);
|
||||
|
||||
await f.load();
|
||||
|
||||
document.fonts.add(f);
|
||||
});
|
||||
|
||||
for (const [colorName, colorId] of TEST_COLORS) {
|
||||
it(`should generate correct ${colorName} QR code image`, async () => {
|
||||
const scheme = COLOR_MAP.get(colorId);
|
||||
if (!scheme) {
|
||||
throw new Error(`Missing color scheme for: ${colorId}`);
|
||||
}
|
||||
|
||||
const { bg: bgColor, fg: fgColor } = scheme;
|
||||
|
||||
const generatedBlob = await _generateImageBlob({
|
||||
link:
|
||||
'https://signal.me#eu/' +
|
||||
'E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe',
|
||||
username: 'signal.12',
|
||||
colorId,
|
||||
bgColor,
|
||||
fgColor,
|
||||
|
||||
// Just because we run from `test/` folder, and not `/`
|
||||
logoUrl: '../images/signal-qr-logo.svg',
|
||||
|
||||
// Force pixel ratio since test runner might not be on Retina
|
||||
devicePixelRatio: 2,
|
||||
});
|
||||
|
||||
// Create fixture if not present
|
||||
const fileName = `username-link-${colorName}-${process.platform}.png`;
|
||||
const fixture = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'fixtures',
|
||||
fileName
|
||||
);
|
||||
if (!(await pathExists(fixture))) {
|
||||
await writeFile(
|
||||
fixture,
|
||||
Buffer.from(await generatedBlob.arrayBuffer())
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise compare against existing fixture
|
||||
const expectedData = new Blob([await readFile(fixture)], {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
const expected = await getImageData(expectedData);
|
||||
const actual = await getImageData(generatedBlob);
|
||||
|
||||
try {
|
||||
assert.strictEqual(actual.width, expected.width, 'Wrong image width');
|
||||
assert.strictEqual(
|
||||
actual.height,
|
||||
expected.height,
|
||||
'Wrong image height'
|
||||
);
|
||||
assert.deepEqual(actual.data, expected.data, 'Wrong image data');
|
||||
} catch (error) {
|
||||
const { ARTIFACTS_DIR } = process.env;
|
||||
if (ARTIFACTS_DIR) {
|
||||
await mkdir(ARTIFACTS_DIR, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(ARTIFACTS_DIR, fileName),
|
||||
Buffer.from(await generatedBlob.arrayBuffer())
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
14
ts/test-mock/helpers.ts
Normal file
14
ts/test-mock/helpers.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function bufferToUuid(buffer: Buffer): string {
|
||||
const hex = buffer.toString('hex');
|
||||
|
||||
return [
|
||||
hex.substring(0, 8),
|
||||
hex.substring(8, 12),
|
||||
hex.substring(12, 16),
|
||||
hex.substring(16, 20),
|
||||
hex.substring(20),
|
||||
].join('-');
|
||||
}
|
@@ -4,13 +4,16 @@
|
||||
import { assert } from 'chai';
|
||||
import { Proto, StorageState } from '@signalapp/mock-server';
|
||||
import type { PrimaryDevice } from '@signalapp/mock-server';
|
||||
import { usernames } from '@signalapp/libsignal-client';
|
||||
import createDebug from 'debug';
|
||||
|
||||
import * as durations from '../../util/durations';
|
||||
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||
import { generateUsernameLink } from '../../util/sgnlHref';
|
||||
import { MY_STORY_ID } from '../../types/Stories';
|
||||
import { Bootstrap } from '../bootstrap';
|
||||
import type { App } from '../bootstrap';
|
||||
import { bufferToUuid } from '../helpers';
|
||||
|
||||
export const debug = createDebug('mock:test:username');
|
||||
|
||||
@@ -206,6 +209,32 @@ describe('pnp/username', function needsName() {
|
||||
assert.strictEqual(removed.length, 1, 'only one record must be removed');
|
||||
|
||||
assert.strictEqual(added[0]?.account?.username, username);
|
||||
const usernameLink = added[0]?.account?.usernameLink;
|
||||
if (!usernameLink) {
|
||||
throw new Error('No username link in AccountRecord');
|
||||
}
|
||||
if (!usernameLink.entropy) {
|
||||
throw new Error('No username link entropy in AccountRecord');
|
||||
}
|
||||
if (!usernameLink.serverId) {
|
||||
throw new Error('No username link serverId in AccountRecord');
|
||||
}
|
||||
|
||||
const linkUuid = bufferToUuid(Buffer.from(usernameLink.serverId));
|
||||
|
||||
const encryptedLinkBase64 = await server.lookupByUsernameLink(linkUuid);
|
||||
if (!encryptedLinkBase64) {
|
||||
throw new Error('Could not find link on the sever');
|
||||
}
|
||||
|
||||
const encryptedLink = Buffer.from(encryptedLinkBase64, 'base64');
|
||||
|
||||
const link = new usernames.UsernameLink(
|
||||
Buffer.from(usernameLink.entropy),
|
||||
encryptedLink
|
||||
);
|
||||
const linkUsername = link.decryptUsername();
|
||||
assert.strictEqual(linkUsername, username);
|
||||
|
||||
state = newState;
|
||||
}
|
||||
@@ -233,7 +262,17 @@ describe('pnp/username', function needsName() {
|
||||
assert.strictEqual(added.length, 1, 'only one record must be added');
|
||||
assert.strictEqual(removed.length, 1, 'only one record must be removed');
|
||||
|
||||
assert.strictEqual(added[0]?.account?.username, '');
|
||||
assert.strictEqual(added[0]?.account?.username, '', 'clears username');
|
||||
assert.strictEqual(
|
||||
added[0]?.account?.usernameLink?.entropy?.length ?? 0,
|
||||
0,
|
||||
'clears usernameLink.entropy'
|
||||
);
|
||||
assert.strictEqual(
|
||||
added[0]?.account?.usernameLink?.serverId?.length ?? 0,
|
||||
0,
|
||||
'clears usernameLink.serverId'
|
||||
);
|
||||
|
||||
state = newState;
|
||||
}
|
||||
@@ -272,4 +311,56 @@ describe('pnp/username', function needsName() {
|
||||
assert.strictEqual(source, desktop);
|
||||
}
|
||||
});
|
||||
|
||||
it('looks up contacts by username link', async () => {
|
||||
const { desktop, phone, server } = bootstrap;
|
||||
|
||||
debug('creating a contact with username link');
|
||||
const carl = await server.createPrimaryDevice({
|
||||
profileName: 'Devin',
|
||||
});
|
||||
|
||||
await server.setUsername(carl.device.uuid, CARL_USERNAME);
|
||||
const { entropy, serverId } = await server.setUsernameLink(
|
||||
carl.device.uuid,
|
||||
CARL_USERNAME
|
||||
);
|
||||
|
||||
const linkUrl = generateUsernameLink(
|
||||
Buffer.concat([entropy, uuidToBytes(serverId)]).toString('base64')
|
||||
);
|
||||
|
||||
debug('sending link to Note to Self');
|
||||
await phone.sendText(desktop, linkUrl, {
|
||||
withProfileKey: true,
|
||||
});
|
||||
|
||||
const window = await app.getWindow();
|
||||
|
||||
debug('opening note to self');
|
||||
const leftPane = window.locator('.left-pane-wrapper');
|
||||
await leftPane.locator(`[data-testid="${desktop.uuid}"]`).click();
|
||||
|
||||
debug('clicking link');
|
||||
await window.locator('.module-message__text a').click({
|
||||
noWaitAfter: true,
|
||||
});
|
||||
|
||||
debug('waiting for conversation to open');
|
||||
await window
|
||||
.locator(`.module-conversation-hero >> "${CARL_USERNAME}"`)
|
||||
.waitFor();
|
||||
|
||||
debug('sending a message');
|
||||
{
|
||||
const compositionInput = await app.waitForEnabledComposer();
|
||||
|
||||
await compositionInput.type('Hello Carl');
|
||||
await compositionInput.press('Enter');
|
||||
|
||||
const { body, source } = await carl.waitForMessage();
|
||||
assert.strictEqual(body, 'Hello Carl');
|
||||
assert.strictEqual(source, desktop);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@@ -12,7 +12,7 @@ import {
|
||||
parseSgnlHref,
|
||||
parseCaptchaHref,
|
||||
parseE164FromSignalDotMeHash,
|
||||
parseUsernameFromSignalDotMeHash,
|
||||
parseUsernameBase64FromSignalDotMeHash,
|
||||
parseSignalHttpsLink,
|
||||
generateUsernameLink,
|
||||
rewriteSignalHrefsIfNecessary,
|
||||
@@ -375,21 +375,19 @@ describe('sgnlHref', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUsernameFromSignalDotMeHash', () => {
|
||||
describe('parseUsernameBase64FromSignalDotMeHash', () => {
|
||||
it('returns undefined for invalid inputs', () => {
|
||||
['', ' u/+18885551234', 'z/18885551234'].forEach(hash => {
|
||||
assert.isUndefined(parseUsernameFromSignalDotMeHash(hash));
|
||||
['', ' eu/+18885551234', 'z/18885551234'].forEach(hash => {
|
||||
assert.isUndefined(parseUsernameBase64FromSignalDotMeHash(hash));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the username for valid inputs', () => {
|
||||
assert.strictEqual(
|
||||
parseUsernameFromSignalDotMeHash('u/signal.03'),
|
||||
'signal.03'
|
||||
);
|
||||
assert.strictEqual(
|
||||
parseUsernameFromSignalDotMeHash('u/signal%2F03'),
|
||||
'signal/03'
|
||||
parseUsernameBase64FromSignalDotMeHash(
|
||||
'eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
|
||||
),
|
||||
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -397,22 +395,20 @@ describe('sgnlHref', () => {
|
||||
describe('generateUsernameLink', () => {
|
||||
it('generates regular link', () => {
|
||||
assert.strictEqual(
|
||||
generateUsernameLink('signal.03'),
|
||||
'https://signal.me/#u/signal.03'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates encoded link', () => {
|
||||
assert.strictEqual(
|
||||
generateUsernameLink('signal/03'),
|
||||
'https://signal.me/#u/signal%2F03'
|
||||
generateUsernameLink(
|
||||
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe'
|
||||
),
|
||||
'https://signal.me#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates short link', () => {
|
||||
assert.strictEqual(
|
||||
generateUsernameLink('signal/03', { short: true }),
|
||||
'signal.me/#u/signal%2F03'
|
||||
generateUsernameLink(
|
||||
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe',
|
||||
{ short: true }
|
||||
),
|
||||
'signal.me#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@@ -56,10 +56,10 @@ export class Storage implements StorageInterface {
|
||||
defaultValue: V
|
||||
): V;
|
||||
|
||||
public get<K extends keyof Access, V extends Access[K]>(
|
||||
public get<K extends keyof Access>(
|
||||
key: K,
|
||||
defaultValue?: V
|
||||
): V | undefined {
|
||||
defaultValue?: Access[K]
|
||||
): Access[K] | undefined {
|
||||
if (!this.ready) {
|
||||
log.warn('Called storage.get before storage is ready. key:', key);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export class Storage implements StorageInterface {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return item as V;
|
||||
return item;
|
||||
}
|
||||
|
||||
public async put<K extends keyof Access>(
|
||||
|
@@ -520,6 +520,7 @@ const URL_CALLS = {
|
||||
username: 'v1/accounts/username_hash',
|
||||
reserveUsername: 'v1/accounts/username_hash/reserve',
|
||||
confirmUsername: 'v1/accounts/username_hash/confirm',
|
||||
usernameLink: 'v1/accounts/username_link',
|
||||
whoami: 'v1/accounts/whoami',
|
||||
};
|
||||
|
||||
@@ -823,6 +824,10 @@ export type ReserveUsernameOptionsType = Readonly<{
|
||||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type ReplaceUsernameLinkOptionsType = Readonly<{
|
||||
encryptedUsername: Uint8Array;
|
||||
}>;
|
||||
|
||||
export type ConfirmUsernameOptionsType = Readonly<{
|
||||
hash: Uint8Array;
|
||||
proof: Uint8Array;
|
||||
@@ -838,6 +843,20 @@ export type ReserveUsernameResultType = z.infer<
|
||||
typeof reserveUsernameResultZod
|
||||
>;
|
||||
|
||||
const replaceUsernameLinkResultZod = z.object({
|
||||
usernameLinkHandle: z.string(),
|
||||
});
|
||||
export type ReplaceUsernameLinkResultType = z.infer<
|
||||
typeof replaceUsernameLinkResultZod
|
||||
>;
|
||||
|
||||
const resolveUsernameLinkResultZod = z.object({
|
||||
usernameLinkEncryptedValue: z.string().transform(x => Bytes.fromBase64(x)),
|
||||
});
|
||||
export type ResolveUsernameLinkResultType = z.infer<
|
||||
typeof resolveUsernameLinkResultZod
|
||||
>;
|
||||
|
||||
export type ConfirmCodeOptionsType = Readonly<{
|
||||
number: string;
|
||||
code: string;
|
||||
@@ -1002,6 +1021,13 @@ export type WebAPIType = {
|
||||
options: ReserveUsernameOptionsType
|
||||
) => Promise<ReserveUsernameResultType>;
|
||||
confirmUsername(options: ConfirmUsernameOptionsType): Promise<void>;
|
||||
replaceUsernameLink: (
|
||||
options: ReplaceUsernameLinkOptionsType
|
||||
) => Promise<ReplaceUsernameLinkResultType>;
|
||||
deleteUsernameLink: () => Promise<void>;
|
||||
resolveUsernameLink: (
|
||||
serverId: string
|
||||
) => Promise<ResolveUsernameLinkResultType>;
|
||||
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
||||
registerKeys: (genKeys: UploadKeysType, uuidKind: UUIDKind) => Promise<void>;
|
||||
registerSupportForUnauthenticatedDelivery: () => Promise<void>;
|
||||
@@ -1284,6 +1310,7 @@ export function initialize({
|
||||
confirmUsername,
|
||||
createGroup,
|
||||
deleteUsername,
|
||||
deleteUsernameLink,
|
||||
downloadOnboardingStories,
|
||||
fetchLinkPreviewImage,
|
||||
fetchLinkPreviewMetadata,
|
||||
@@ -1336,6 +1363,8 @@ export function initialize({
|
||||
registerKeys,
|
||||
registerRequestHandler,
|
||||
registerSupportForUnauthenticatedDelivery,
|
||||
resolveUsernameLink,
|
||||
replaceUsernameLink,
|
||||
reportMessage,
|
||||
requestVerificationSMS,
|
||||
requestVerificationVoice,
|
||||
@@ -1900,6 +1929,43 @@ export function initialize({
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceUsernameLink({
|
||||
encryptedUsername,
|
||||
}: ReplaceUsernameLinkOptionsType): Promise<ReplaceUsernameLinkResultType> {
|
||||
return replaceUsernameLinkResultZod.parse(
|
||||
await _ajax({
|
||||
call: 'usernameLink',
|
||||
httpType: 'PUT',
|
||||
responseType: 'json',
|
||||
jsonData: {
|
||||
usernameLinkEncryptedValue: Bytes.toBase64(encryptedUsername),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteUsernameLink(): Promise<void> {
|
||||
await _ajax({
|
||||
call: 'usernameLink',
|
||||
httpType: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveUsernameLink(
|
||||
serverId: string
|
||||
): Promise<ResolveUsernameLinkResultType> {
|
||||
return resolveUsernameLinkResultZod.parse(
|
||||
await _ajax({
|
||||
httpType: 'GET',
|
||||
call: 'usernameLink',
|
||||
urlParameters: `/${encodeURIComponent(serverId)}`,
|
||||
responseType: 'json',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function reportMessage({
|
||||
senderUuid,
|
||||
serverGuid,
|
||||
|
6
ts/types/Storage.d.ts
vendored
6
ts/types/Storage.d.ts
vendored
@@ -74,6 +74,7 @@ export type StorageAccessType = {
|
||||
hasRegisterSupportForUnauthenticatedDelivery: boolean;
|
||||
hasSetMyStoriesPrivacy: boolean;
|
||||
hasCompletedUsernameOnboarding: boolean;
|
||||
hasCompletedUsernameLinkOnboarding: boolean;
|
||||
hasCompletedSafetyNumberOnboarding: boolean;
|
||||
hasViewedOnboardingStory: boolean;
|
||||
hasStoriesDisabled: boolean;
|
||||
@@ -158,6 +159,11 @@ export type StorageAccessType = {
|
||||
subscriberCurrencyCode: string;
|
||||
displayBadgesOnProfile: boolean;
|
||||
keepMutedChatsArchived: boolean;
|
||||
usernameLinkColor: number;
|
||||
usernameLink: {
|
||||
entropy: Uint8Array;
|
||||
serverId: Uint8Array;
|
||||
};
|
||||
|
||||
// Deprecated
|
||||
'challenge:retry-message-ids': never;
|
||||
|
@@ -1,8 +1,6 @@
|
||||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { usernames } from '@signalapp/libsignal-client';
|
||||
|
||||
export type UsernameReservationType = Readonly<{
|
||||
username: string;
|
||||
previousUsername: string | undefined;
|
||||
@@ -27,7 +25,7 @@ export enum ConfirmUsernameResult {
|
||||
|
||||
export function getUsernameFromSearch(searchTerm: string): string | undefined {
|
||||
try {
|
||||
usernames.hash(searchTerm);
|
||||
window.SignalContext.usernames.hash(searchTerm);
|
||||
return searchTerm;
|
||||
} catch {
|
||||
return undefined;
|
||||
|
@@ -21,6 +21,7 @@ import { parseSystemTraySetting } from '../types/SystemTraySetting';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { AuthorizeArtCreatorDataType } from '../state/ducks/globalModals';
|
||||
import { calling } from '../services/calling';
|
||||
import { resolveUsernameByLinkBase64 } from '../services/username';
|
||||
import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations';
|
||||
import { getCustomColors } from '../state/selectors/items';
|
||||
import { themeChanged } from '../shims/themeChanged';
|
||||
@@ -36,7 +37,7 @@ import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled';
|
||||
import * as Registration from './registration';
|
||||
import {
|
||||
parseE164FromSignalDotMeHash,
|
||||
parseUsernameFromSignalDotMeHash,
|
||||
parseUsernameBase64FromSignalDotMeHash,
|
||||
} from './sgnlHref';
|
||||
import { lookupConversationWithoutUuid } from './lookupConversationWithoutUuid';
|
||||
import * as log from '../logging/log';
|
||||
@@ -538,11 +539,13 @@ export function createIPCEvents(
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeUsername = parseUsernameFromSignalDotMeHash(hash);
|
||||
if (maybeUsername) {
|
||||
const maybeUsernameBase64 = parseUsernameBase64FromSignalDotMeHash(hash);
|
||||
if (maybeUsernameBase64) {
|
||||
const username = await resolveUsernameByLinkBase64(maybeUsernameBase64);
|
||||
|
||||
const convoId = await lookupConversationWithoutUuid({
|
||||
type: 'username',
|
||||
username: maybeUsername,
|
||||
username,
|
||||
showUserNotFoundModal,
|
||||
setIsFetchingUUID: noop,
|
||||
});
|
||||
|
@@ -4,10 +4,10 @@
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import { maybeParseUrl } from './url';
|
||||
import { isValidE164 } from './isValidE164';
|
||||
import { fromWebSafeBase64, toWebSafeBase64 } from './webSafeBase64';
|
||||
|
||||
const SIGNAL_HOSTS = new Set(['signal.group', 'signal.art', 'signal.me']);
|
||||
const SIGNAL_DOT_ME_E164_PREFIX = 'p/';
|
||||
const SIGNAL_DOT_ME_USERNAME_PREFIX = 'u/';
|
||||
|
||||
function parseUrl(value: string | URL, logger: LoggerType): undefined | URL {
|
||||
if (value instanceof URL) {
|
||||
@@ -147,14 +147,15 @@ export function parseE164FromSignalDotMeHash(hash: string): undefined | string {
|
||||
return isValidE164(maybeE164, true) ? maybeE164 : undefined;
|
||||
}
|
||||
|
||||
export function parseUsernameFromSignalDotMeHash(
|
||||
export function parseUsernameBase64FromSignalDotMeHash(
|
||||
hash: string
|
||||
): undefined | string {
|
||||
if (!hash.startsWith(SIGNAL_DOT_ME_USERNAME_PREFIX)) {
|
||||
const match = hash.match(/^eu\/([a-zA-Z0-9_-]{64})$/);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
return decodeURIComponent(hash.slice(SIGNAL_DOT_ME_USERNAME_PREFIX.length));
|
||||
return fromWebSafeBase64(match[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,10 +185,10 @@ export type GenerateUsernameLinkOptionsType = Readonly<{
|
||||
}>;
|
||||
|
||||
export function generateUsernameLink(
|
||||
username: string,
|
||||
base64: string,
|
||||
{ short = false }: GenerateUsernameLinkOptionsType = {}
|
||||
): string {
|
||||
const shortVersion = `signal.me/#u/${encodeURIComponent(username)}`;
|
||||
const shortVersion = `signal.me#eu/${toWebSafeBase64(base64)}`;
|
||||
if (short) {
|
||||
return shortVersion;
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { usernames } from '@signalapp/libsignal-client';
|
||||
|
||||
import type { MenuOptionsType, MenuActionType } from '../types/menu';
|
||||
import type { IPCEventsValuesType } from '../util/createIPCEvents';
|
||||
@@ -61,6 +62,7 @@ export type MinimalSignalContextType = {
|
||||
export type SignalContextType = {
|
||||
bytes: Bytes;
|
||||
crypto: Crypto;
|
||||
usernames: typeof usernames;
|
||||
i18n: LocalizerType;
|
||||
log: LoggerType;
|
||||
renderWindow?: () => void;
|
||||
@@ -72,6 +74,7 @@ export const SignalContext: SignalContextType = {
|
||||
...MinimalSignalContext,
|
||||
bytes: new Bytes(),
|
||||
crypto: new Crypto(),
|
||||
usernames,
|
||||
i18n,
|
||||
log: window.SignalContext.log,
|
||||
setIsCallActive(isCallActive: boolean): void {
|
||||
|
Reference in New Issue
Block a user