Username and username link integrity check
This commit is contained in:
parent
1be90fff3d
commit
3664063d71
26 changed files with 636 additions and 35 deletions
|
@ -315,6 +315,22 @@
|
|||
"messageformat": "Chats",
|
||||
"description": "Shown as a header for non-pinned conversations in the left pane"
|
||||
},
|
||||
"icu:LeftPane--corrupted-username--text": {
|
||||
"messageformat": "Something went wrong with your username, it’s no longer assigned to your account. You can try and set it again or choose a new one.",
|
||||
"description": "Text of corrupted username banner in the left pane"
|
||||
},
|
||||
"icu:LeftPane--corrupted-username--action-text": {
|
||||
"messageformat": "Fix now",
|
||||
"description": "Text of the button in the corrupted username banner in the left pane"
|
||||
},
|
||||
"icu:LeftPane--corrupted-username-link--text": {
|
||||
"messageformat": "Something went wrong with your QR code and username link, it’s no longer valid. Create a new link to share with others.",
|
||||
"description": "Text of corrupted username link banner in the left pane"
|
||||
},
|
||||
"icu:LeftPane--corrupted-username-link--action-text": {
|
||||
"messageformat": "Fix now",
|
||||
"description": "Text of the button in the corrupted username link banner in the left pane"
|
||||
},
|
||||
"icu:NavTabsToggle__showTabs": {
|
||||
"messageformat": "Show Tabs",
|
||||
"description": "Show in the left pane when the nav tabs are hidden, shows the nav tabs"
|
||||
|
@ -5419,10 +5435,30 @@
|
|||
"messageformat": "Username",
|
||||
"description": "Default text for username field"
|
||||
},
|
||||
"icu:ProfileEditor--username--corrupted--body": {
|
||||
"messageformat": "Something went wrong with your username, it’s no longer assigned to your account.",
|
||||
"description": "Text of confirmation modal when the username gets corrupted"
|
||||
},
|
||||
"icu:ProfileEditor--username--corrupted--delete-button": {
|
||||
"messageformat": "Delete username",
|
||||
"description": "Button text for deletion of the username in case of corruption"
|
||||
},
|
||||
"icu:ProfileEditor--username--corrupted--create-button": {
|
||||
"messageformat": "Create username",
|
||||
"description": "Button text for creation of a new username in case of corruption"
|
||||
},
|
||||
"icu:ProfileEditor__username-link": {
|
||||
"messageformat": "QR code or link",
|
||||
"description": "Label of a profile editor row that navigates to username link and qr code modal"
|
||||
},
|
||||
"icu:ProfileEditor__username__error-icon": {
|
||||
"messageformat": "Username needs reset",
|
||||
"description": "Accessibility title of an icon in profile editor"
|
||||
},
|
||||
"icu:ProfileEditor__username-link__error-icon": {
|
||||
"messageformat": "Username link needs reset",
|
||||
"description": "Accessibility title of an icon in profile editor"
|
||||
},
|
||||
"icu:ProfileEditor__username-link__tooltip__title": {
|
||||
"messageformat": "Share your username",
|
||||
"description": "Title of tooltip displayed under 'QR code or link' button for getting username link"
|
||||
|
@ -6711,6 +6747,14 @@
|
|||
"messageformat": "If you reset your QR code, your existing QR code and link will no longer work.",
|
||||
"description": "Text of confirmation modal when resetting the username link"
|
||||
},
|
||||
"icu:UsernameLinkModalBody__resetting-link": {
|
||||
"messageformat": "Resetting link...",
|
||||
"description": "Text shown when resetting the username link"
|
||||
},
|
||||
"icu:UsernameLinkModalBody__error__text": {
|
||||
"messageformat": "QR code and link not set. Check your network connection and try again.",
|
||||
"description": "Text of the confirmation dialog shown on username link error"
|
||||
},
|
||||
"icu:UsernameOnboardingModalBody__title": {
|
||||
"messageformat": "Set up your Signal username",
|
||||
"description": "Title of username onboarding modal"
|
||||
|
|
45
stylesheets/components/LeftPaneBanner.scss
Normal file
45
stylesheets/components/LeftPaneBanner.scss
Normal file
|
@ -0,0 +1,45 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.LeftPaneBanner {
|
||||
margin-block: 4px 8px;
|
||||
margin-inline: 10px;
|
||||
padding-block: 12px 16px;
|
||||
padding-inline-start: 12px;
|
||||
padding-inline-end: 7px;
|
||||
|
||||
border-radius: 8px;
|
||||
@include light-theme {
|
||||
background-color: $color-gray-15;
|
||||
color: $color-gray-75;
|
||||
}
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-60;
|
||||
color: $color-gray-15;
|
||||
}
|
||||
|
||||
@include font-body-2;
|
||||
|
||||
&__content {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
margin-inline-end: 9px;
|
||||
|
||||
&__action-button {
|
||||
@include button-reset;
|
||||
@include button-focus-outline;
|
||||
@include font-body-1-bold;
|
||||
|
||||
@include light-theme {
|
||||
color: $color-gray-90;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -187,8 +187,8 @@
|
|||
padding-inline: 16px;
|
||||
gap: 8px;
|
||||
|
||||
.module-Button {
|
||||
margin-inline-start: 8px;
|
||||
.module-Button:not(:first-child) {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
&--one-button-per-line {
|
||||
|
|
|
@ -178,6 +178,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__error-icon {
|
||||
-webkit-mask-size: 100%;
|
||||
display: block;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin: 4px;
|
||||
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/error/error-circle.svg',
|
||||
$color-accent-red
|
||||
);
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/error/error-circle.svg',
|
||||
$color-accent-red
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__username-link {
|
||||
&__tooltip {
|
||||
padding: 12px;
|
||||
|
@ -234,6 +256,14 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__reset-username-modal__ModalHost__width-container {
|
||||
max-width: 438px;
|
||||
|
||||
.module-Modal__button-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProfileEditor__Title {
|
||||
|
|
|
@ -64,6 +64,18 @@
|
|||
height: var(--size);
|
||||
@include color-svg('../images/signal-qr-logo.svg', var(--fg-color));
|
||||
}
|
||||
|
||||
&__error-icon {
|
||||
-webkit-mask-size: 100%;
|
||||
display: block;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
|
||||
@include color-svg(
|
||||
'../images/icons/v3/error/error-circle.svg',
|
||||
$color-gray-25
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&__username {
|
||||
|
@ -209,11 +221,31 @@
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled:after {
|
||||
@include light-theme() {
|
||||
background-color: $color-gray-45;
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
background-color: $color-gray-25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
|
||||
&--resetting {
|
||||
@include light-theme() {
|
||||
color: $color-gray-45;
|
||||
}
|
||||
|
||||
@include dark-theme() {
|
||||
color: $color-gray-25;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -92,6 +92,7 @@
|
|||
@import './components/InstallScreenQrCodeNotScannedStep.scss';
|
||||
@import './components/InstallScreenSignalLogo.scss';
|
||||
@import './components/InstallScreenUpdateDialog.scss';
|
||||
@import './components/LeftPaneBanner.scss';
|
||||
@import './components/LeftPaneDialog.scss';
|
||||
@import './components/LeftPaneSearchInput.scss';
|
||||
@import './components/Lightbox.scss';
|
||||
|
|
|
@ -51,6 +51,7 @@ import { senderCertificateService } from './services/senderCertificate';
|
|||
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
||||
import * as KeyboardLayout from './services/keyboardLayout';
|
||||
import * as StorageService from './services/storage';
|
||||
import { usernameIntegrity } from './services/usernameIntegrity';
|
||||
import { RoutineProfileRefresher } from './routineProfileRefresh';
|
||||
import { isOlderThan, toDayMillis } from './util/timestamp';
|
||||
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
|
||||
|
@ -2051,6 +2052,8 @@ export async function startApp(): Promise<void> {
|
|||
|
||||
void routineProfileRefresher.start();
|
||||
}
|
||||
|
||||
drop(usernameIntegrity.start());
|
||||
}
|
||||
|
||||
let initialStartupCount = 0;
|
||||
|
|
|
@ -158,6 +158,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
hasRelinkDialog: false,
|
||||
hasUpdateDialog: false,
|
||||
unsupportedOSDialogType: undefined,
|
||||
usernameCorrupted: false,
|
||||
usernameLinkCorrupted: false,
|
||||
isUpdateDownloaded,
|
||||
navTabsCollapsed: false,
|
||||
|
||||
|
@ -269,6 +271,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
'toggleConversationInChooseMembers'
|
||||
),
|
||||
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
|
||||
toggleProfileEditor: action('toggleProfileEditor'),
|
||||
updateSearchTerm: action('updateSearchTerm'),
|
||||
|
||||
...overrideProps,
|
||||
|
@ -302,6 +305,42 @@ export function InboxNoConversations(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
export function InboxUsernameCorrupted(): JSX.Element {
|
||||
return (
|
||||
<LeftPaneInContainer
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
...defaultSearchProps,
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations: [],
|
||||
conversations: [],
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
},
|
||||
usernameCorrupted: true,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxUsernameLinkCorrupted(): JSX.Element {
|
||||
return (
|
||||
<LeftPaneInContainer
|
||||
{...useProps({
|
||||
modeSpecificProps: {
|
||||
...defaultSearchProps,
|
||||
mode: LeftPaneMode.Inbox,
|
||||
pinnedConversations: [],
|
||||
conversations: [],
|
||||
archivedConversations: [],
|
||||
isAboutToSearch: false,
|
||||
},
|
||||
usernameLinkCorrupted: true,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxOnlyPinnedConversations(): JSX.Element {
|
||||
return (
|
||||
<LeftPaneInContainer
|
||||
|
|
|
@ -35,6 +35,7 @@ import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/U
|
|||
import { ConversationList } from './ConversationList';
|
||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||
import type { PropsType as DialogExpiredBuildPropsType } from './DialogExpiredBuild';
|
||||
import { LeftPaneBanner } from './LeftPaneBanner';
|
||||
|
||||
import type {
|
||||
DeleteAvatarFromDiskActionType,
|
||||
|
@ -69,6 +70,8 @@ export type PropsType = {
|
|||
hasUpdateDialog: boolean;
|
||||
isUpdateDownloaded: boolean;
|
||||
unsupportedOSDialogType: 'error' | 'warning' | undefined;
|
||||
usernameCorrupted: boolean;
|
||||
usernameLinkCorrupted: boolean;
|
||||
|
||||
// These help prevent invalid states. For example, we don't need the list of pinned
|
||||
// conversations if we're trying to start a new conversation. Ideally these would be
|
||||
|
@ -135,6 +138,7 @@ export type PropsType = {
|
|||
toggleComposeEditingAvatar: () => unknown;
|
||||
toggleConversationInChooseMembers: (conversationId: string) => void;
|
||||
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||
toggleProfileEditor: () => void;
|
||||
updateSearchTerm: (_: string) => void;
|
||||
|
||||
// Render Props
|
||||
|
@ -201,6 +205,7 @@ export function LeftPane({
|
|||
selectedConversationId,
|
||||
targetedMessageId,
|
||||
toggleNavTabsCollapse,
|
||||
toggleProfileEditor,
|
||||
setChallengeStatus,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupExpireTimer,
|
||||
|
@ -219,6 +224,8 @@ export function LeftPane({
|
|||
toggleComposeEditingAvatar,
|
||||
toggleConversationInChooseMembers,
|
||||
unsupportedOSDialogType,
|
||||
usernameCorrupted,
|
||||
usernameLinkCorrupted,
|
||||
updateSearchTerm,
|
||||
}: PropsType): JSX.Element {
|
||||
const previousModeSpecificProps = usePrevious(
|
||||
|
@ -544,6 +551,31 @@ export function LeftPane({
|
|||
}
|
||||
}
|
||||
|
||||
let maybeBanner: JSX.Element | undefined;
|
||||
if (usernameCorrupted) {
|
||||
maybeBanner = (
|
||||
<LeftPaneBanner
|
||||
actionText={i18n('icu:LeftPane--corrupted-username--action-text')}
|
||||
onClick={toggleProfileEditor}
|
||||
>
|
||||
{i18n('icu:LeftPane--corrupted-username--text')}
|
||||
</LeftPaneBanner>
|
||||
);
|
||||
} else if (usernameLinkCorrupted) {
|
||||
maybeBanner = (
|
||||
<LeftPaneBanner
|
||||
actionText={i18n('icu:LeftPane--corrupted-username-link--action-text')}
|
||||
onClick={toggleProfileEditor}
|
||||
>
|
||||
{i18n('icu:LeftPane--corrupted-username-link--text')}
|
||||
</LeftPaneBanner>
|
||||
);
|
||||
}
|
||||
|
||||
if (maybeBanner) {
|
||||
dialogs.push({ key: 'banner', dialog: maybeBanner });
|
||||
}
|
||||
|
||||
return (
|
||||
<NavSidebar
|
||||
title="Chats"
|
||||
|
|
25
ts/components/LeftPaneBanner.stories.tsx
Normal file
25
ts/components/LeftPaneBanner.stories.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { LeftPaneBanner, type PropsType } from './LeftPaneBanner';
|
||||
|
||||
export default {
|
||||
title: 'Components/LeftPaneBanner',
|
||||
component: LeftPaneBanner,
|
||||
argTypes: {
|
||||
actionText: { control: { type: 'string' } },
|
||||
children: { control: { type: 'string' } },
|
||||
},
|
||||
args: {
|
||||
actionText: 'Fix now',
|
||||
children: 'Recoverable issue detected',
|
||||
onClick: action('onClick'),
|
||||
},
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
export function Defaults(args: PropsType): JSX.Element {
|
||||
return <LeftPaneBanner {...args} />;
|
||||
}
|
48
ts/components/LeftPaneBanner.tsx
Normal file
48
ts/components/LeftPaneBanner.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
const BASE_CLASS_NAME = 'LeftPaneBanner';
|
||||
|
||||
export type PropsType = Readonly<{
|
||||
children?: ReactNode;
|
||||
actionText: string;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
|
||||
export function LeftPaneBanner({
|
||||
children,
|
||||
actionText,
|
||||
onClick,
|
||||
}: PropsType): JSX.Element {
|
||||
const onClickWrap = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
onClick?.();
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={BASE_CLASS_NAME}>
|
||||
<div className={`${BASE_CLASS_NAME}__content`}>{children}</div>
|
||||
|
||||
<div className={`${BASE_CLASS_NAME}__footer`}>
|
||||
<button
|
||||
title={actionText}
|
||||
aria-label={actionText}
|
||||
className={`${BASE_CLASS_NAME}__footer__action-button`}
|
||||
onClick={onClickWrap}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
{actionText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -37,10 +37,16 @@ export default {
|
|||
Deleting: UsernameEditState.Deleting,
|
||||
},
|
||||
},
|
||||
usernameCorrupted: {
|
||||
control: 'boolean',
|
||||
},
|
||||
usernameLinkState: {
|
||||
control: { type: 'select' },
|
||||
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
|
||||
},
|
||||
usernameLinkCorrupted: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
aboutEmoji: '',
|
||||
|
|
|
@ -82,10 +82,12 @@ export type PropsDataType = {
|
|||
phoneNumber?: string;
|
||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||
username?: string;
|
||||
usernameCorrupted: boolean;
|
||||
usernameEditState: UsernameEditState;
|
||||
usernameLinkState: UsernameLinkState;
|
||||
usernameLinkColor?: number;
|
||||
usernameLink?: string;
|
||||
usernameLinkCorrupted: boolean;
|
||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||
|
||||
type PropsActionType = {
|
||||
|
@ -169,10 +171,12 @@ export function ProfileEditor({
|
|||
skinTone,
|
||||
userAvatarData,
|
||||
username,
|
||||
usernameCorrupted,
|
||||
usernameEditState,
|
||||
usernameLinkState,
|
||||
usernameLinkColor,
|
||||
usernameLink,
|
||||
usernameLinkCorrupted,
|
||||
}: PropsType): JSX.Element {
|
||||
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [editState, setEditState] = useState<EditState>(EditState.None);
|
||||
|
@ -208,6 +212,7 @@ export function ProfileEditor({
|
|||
familyName,
|
||||
firstName,
|
||||
});
|
||||
const [isResettingUsername, setIsResettingUsername] = useState(false);
|
||||
|
||||
// Reset username edit state when leaving
|
||||
useEffect(() => {
|
||||
|
@ -530,6 +535,7 @@ export function ProfileEditor({
|
|||
link={usernameLink}
|
||||
username={username ?? ''}
|
||||
colorId={usernameLinkColor}
|
||||
usernameLinkCorrupted={usernameLinkCorrupted}
|
||||
usernameLinkState={usernameLinkState}
|
||||
setUsernameLinkColor={setUsernameLinkColor}
|
||||
resetUsernameLink={resetUsernameLink}
|
||||
|
@ -542,6 +548,7 @@ export function ProfileEditor({
|
|||
let maybeUsernameRows: JSX.Element | undefined;
|
||||
if (isUsernameFlagEnabled) {
|
||||
let actions: JSX.Element | undefined;
|
||||
let alwaysShowActions = false;
|
||||
|
||||
if (usernameEditState === UsernameEditState.Deleting) {
|
||||
actions = (
|
||||
|
@ -579,7 +586,15 @@ export function ProfileEditor({
|
|||
},
|
||||
];
|
||||
|
||||
if (username) {
|
||||
if (usernameCorrupted) {
|
||||
actions = (
|
||||
<i
|
||||
className="ProfileEditor__error-icon"
|
||||
title={i18n('icu:ProfileEditor__username__error-icon')}
|
||||
/>
|
||||
);
|
||||
alwaysShowActions = true;
|
||||
} else if (username) {
|
||||
actions = (
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
|
@ -593,7 +608,18 @@ export function ProfileEditor({
|
|||
}
|
||||
|
||||
let maybeUsernameLinkRow: JSX.Element | undefined;
|
||||
if (username) {
|
||||
if (username && !usernameCorrupted) {
|
||||
let linkActions: JSX.Element | undefined;
|
||||
|
||||
if (usernameLinkCorrupted) {
|
||||
linkActions = (
|
||||
<i
|
||||
className="ProfileEditor__error-icon"
|
||||
title={i18n('icu:ProfileEditor__username-link__error-icon')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
maybeUsernameLinkRow = (
|
||||
<PanelRow
|
||||
className="ProfileEditor__row"
|
||||
|
@ -604,6 +630,8 @@ export function ProfileEditor({
|
|||
onClick={() => {
|
||||
setEditState(EditState.UsernameLink);
|
||||
}}
|
||||
alwaysShowActions
|
||||
actions={linkActions}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -647,8 +675,16 @@ export function ProfileEditor({
|
|||
icon={
|
||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
|
||||
}
|
||||
label={username || i18n('icu:ProfileEditor--username')}
|
||||
label={
|
||||
(!usernameCorrupted && username) ||
|
||||
i18n('icu:ProfileEditor--username')
|
||||
}
|
||||
onClick={() => {
|
||||
if (usernameCorrupted) {
|
||||
setIsResettingUsername(true);
|
||||
return;
|
||||
}
|
||||
|
||||
openUsernameReservationModal();
|
||||
if (username || hasCompletedUsernameOnboarding) {
|
||||
setEditState(EditState.Username);
|
||||
|
@ -656,6 +692,7 @@ export function ProfileEditor({
|
|||
setEditState(EditState.UsernameOnboarding);
|
||||
}
|
||||
}}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
actions={actions}
|
||||
/>
|
||||
{maybeUsernameLinkRow}
|
||||
|
@ -771,6 +808,36 @@ export function ProfileEditor({
|
|||
onClose={() => setConfirmDiscardAction(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isResettingUsername && (
|
||||
<ConfirmationDialog
|
||||
dialogName="ProfileEditor.confirmResetUsername"
|
||||
moduleClassName="ProfileEditor__reset-username-modal"
|
||||
i18n={i18n}
|
||||
onClose={() => setIsResettingUsername(false)}
|
||||
actions={[
|
||||
{
|
||||
text: i18n(
|
||||
'icu:ProfileEditor--username--corrupted--delete-button'
|
||||
),
|
||||
action: () => deleteUsername(),
|
||||
},
|
||||
{
|
||||
text: i18n(
|
||||
'icu:ProfileEditor--username--corrupted--create-button'
|
||||
),
|
||||
style: 'affirmative',
|
||||
action: () => {
|
||||
openUsernameReservationModal();
|
||||
setEditState(EditState.Username);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{i18n('icu:ProfileEditor--username--corrupted--body')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
<div className="ProfileEditor">{content}</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -28,9 +28,16 @@ export default {
|
|||
username: {
|
||||
control: { type: 'text' },
|
||||
},
|
||||
usernameLinkCorrupted: {
|
||||
control: 'boolean',
|
||||
},
|
||||
usernameLinkState: {
|
||||
control: { type: 'select' },
|
||||
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
|
||||
options: [
|
||||
UsernameLinkState.Ready,
|
||||
UsernameLinkState.Updating,
|
||||
UsernameLinkState.Error,
|
||||
],
|
||||
},
|
||||
colorId: {
|
||||
control: { type: 'select' },
|
||||
|
|
|
@ -26,6 +26,7 @@ export type PropsType = Readonly<{
|
|||
link?: string;
|
||||
username: string;
|
||||
colorId?: number;
|
||||
usernameLinkCorrupted: boolean;
|
||||
usernameLinkState: UsernameLinkState;
|
||||
|
||||
setUsernameLinkColor: (colorId: number) => void;
|
||||
|
@ -486,6 +487,7 @@ export function UsernameLinkModalBody({
|
|||
i18n,
|
||||
link,
|
||||
username,
|
||||
usernameLinkCorrupted,
|
||||
usernameLinkState,
|
||||
colorId: initialColorId = ColorEnum.UNKNOWN,
|
||||
|
||||
|
@ -499,6 +501,7 @@ export function UsernameLinkModalBody({
|
|||
const [pngData, setPngData] = useState<Uint8Array | undefined>();
|
||||
const [showColors, setShowColors] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [colorId, setColorId] = useState(initialColorId);
|
||||
|
||||
const { fg: fgColor, bg: bgColor } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET;
|
||||
|
@ -621,13 +624,36 @@ export function UsernameLinkModalBody({
|
|||
resetUsernameLink();
|
||||
}, [resetUsernameLink]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!usernameLinkCorrupted) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetUsernameLink();
|
||||
}, [usernameLinkCorrupted, resetUsernameLink]);
|
||||
|
||||
useEffect(() => {
|
||||
if (usernameLinkState !== UsernameLinkState.Error) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowError(true);
|
||||
}, [usernameLinkState]);
|
||||
|
||||
const onClearError = useCallback(() => {
|
||||
setShowError(false);
|
||||
}, []);
|
||||
|
||||
const isResettingLink =
|
||||
usernameLinkCorrupted || usernameLinkState !== UsernameLinkState.Ready;
|
||||
|
||||
const info = (
|
||||
<>
|
||||
<div className={classnames(`${CLASS}__actions`)}>
|
||||
<button
|
||||
className={`${CLASS}__actions__save`}
|
||||
type="button"
|
||||
disabled={!link}
|
||||
disabled={!link || isResettingLink}
|
||||
onClick={onSave}
|
||||
>
|
||||
<i />
|
||||
|
@ -648,11 +674,19 @@ export function UsernameLinkModalBody({
|
|||
<button
|
||||
className={classnames(`${CLASS}__link__icon`)}
|
||||
type="button"
|
||||
disabled={!link}
|
||||
disabled={!link || isResettingLink}
|
||||
onClick={onCopyLink}
|
||||
aria-label={i18n('icu:UsernameLinkModalBody__copy')}
|
||||
/>
|
||||
<div className={classnames(`${CLASS}__link__text`)}>{link}</div>
|
||||
<div
|
||||
className={classnames(`${CLASS}__link__text`, {
|
||||
[`${CLASS}__link__text--resetting`]: isResettingLink,
|
||||
})}
|
||||
>
|
||||
{isResettingLink
|
||||
? i18n('icu:UsernameLinkModalBody__resetting-link')
|
||||
: link}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classnames(`${CLASS}__help`)}>
|
||||
|
@ -677,6 +711,29 @@ export function UsernameLinkModalBody({
|
|||
</>
|
||||
);
|
||||
|
||||
let linkImage: JSX.Element | undefined;
|
||||
if (usernameLinkState === UsernameLinkState.Ready && link) {
|
||||
linkImage = (
|
||||
<>
|
||||
<Blotches
|
||||
className={`${CLASS}__card__qr__blotches`}
|
||||
link={link}
|
||||
color={fgColor}
|
||||
/>
|
||||
<div className={`${CLASS}__card__qr__logo`} />
|
||||
</>
|
||||
);
|
||||
} else if (usernameLinkState === UsernameLinkState.Error) {
|
||||
linkImage = <i className={`${CLASS}__card__qr__error-icon`} />;
|
||||
} else {
|
||||
linkImage = (
|
||||
<Spinner
|
||||
moduleClassName={`${CLASS}__card__qr__spinner`}
|
||||
svgSize="small"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${CLASS}__container`}>
|
||||
<div className={CLASS}>
|
||||
|
@ -686,23 +743,7 @@ export function UsernameLinkModalBody({
|
|||
})}
|
||||
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__qr`}>{linkImage}</div>
|
||||
<div className={`${CLASS}__card__username`}>
|
||||
{!showColors && (
|
||||
<button
|
||||
|
@ -733,6 +774,18 @@ export function UsernameLinkModalBody({
|
|||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<ConfirmationDialog
|
||||
i18n={i18n}
|
||||
dialogName="UsernameLinkModal__error"
|
||||
onClose={onClearError}
|
||||
cancelButtonVariant={ButtonVariant.Secondary}
|
||||
cancelText={i18n('icu:ok')}
|
||||
>
|
||||
{i18n('icu:UsernameLinkModalBody__error__text')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
{showColors ? (
|
||||
<UsernameLinkColors
|
||||
i18n={i18n}
|
||||
|
|
|
@ -1469,6 +1469,17 @@ export async function mergeAccountRecord(
|
|||
}
|
||||
|
||||
if (usernameLink?.entropy?.length && usernameLink?.serverId?.length) {
|
||||
const oldLink = window.storage.get('usernameLink');
|
||||
if (
|
||||
window.storage.get('usernameLinkCorrupted') &&
|
||||
(!oldLink ||
|
||||
!Bytes.areEqual(usernameLink.entropy, oldLink.entropy) ||
|
||||
!Bytes.areEqual(usernameLink.serverId, oldLink.serverId))
|
||||
) {
|
||||
details.push('clearing username link corruption');
|
||||
await window.storage.remove('usernameLinkCorrupted');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
usernameLink.color &&
|
||||
window.storage.put('usernameLinkColor', usernameLink.color),
|
||||
|
@ -1500,6 +1511,14 @@ export async function mergeAccountRecord(
|
|||
const oldStorageID = conversation.get('storageID');
|
||||
const oldStorageVersion = conversation.get('storageVersion');
|
||||
|
||||
if (
|
||||
window.storage.get('usernameCorrupted') &&
|
||||
username !== conversation.get('username')
|
||||
) {
|
||||
details.push('clearing username corruption');
|
||||
await window.storage.remove('usernameCorrupted');
|
||||
}
|
||||
|
||||
conversation.set({
|
||||
isArchived: Boolean(noteToSelfArchived),
|
||||
markedUnread: Boolean(noteToSelfMarkedUnread),
|
||||
|
|
|
@ -186,6 +186,8 @@ export async function confirmUsername(
|
|||
usernames.createUsernameLink(username);
|
||||
|
||||
await window.storage.remove('usernameLink');
|
||||
await window.storage.remove('usernameCorrupted');
|
||||
await window.storage.remove('usernameLinkCorrupted');
|
||||
|
||||
const { usernameLinkHandle: serverIdString } = await server.confirmUsername(
|
||||
{
|
||||
|
@ -223,7 +225,7 @@ export async function confirmUsername(
|
|||
}
|
||||
|
||||
export async function deleteUsername(
|
||||
previousUsername: string,
|
||||
previousUsername: string | undefined,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const { server } = window.textsecure;
|
||||
|
@ -238,6 +240,7 @@ export async function deleteUsername(
|
|||
}
|
||||
|
||||
await window.storage.remove('usernameLink');
|
||||
await window.storage.remove('usernameCorrupted');
|
||||
await server.deleteUsername(abortSignal);
|
||||
await updateUsernameAndSyncProfile(undefined);
|
||||
}
|
||||
|
@ -257,6 +260,7 @@ export async function resetLink(username: string): Promise<void> {
|
|||
const { entropy, encryptedUsername } = usernames.createUsernameLink(username);
|
||||
|
||||
await window.storage.remove('usernameLink');
|
||||
await window.storage.remove('usernameLinkCorrupted');
|
||||
|
||||
const { usernameLinkHandle: serverIdString } =
|
||||
await server.replaceUsernameLink({ encryptedUsername });
|
||||
|
@ -275,15 +279,27 @@ const USERNAME_LINK_ENTROPY_SIZE = 32;
|
|||
export async function resolveUsernameByLinkBase64(
|
||||
base64: string
|
||||
): Promise<string | undefined> {
|
||||
const content = Bytes.fromBase64(base64);
|
||||
const entropy = content.slice(0, USERNAME_LINK_ENTROPY_SIZE);
|
||||
const serverId = content.slice(USERNAME_LINK_ENTROPY_SIZE);
|
||||
|
||||
return resolveUsernameByLink({ entropy, serverId });
|
||||
}
|
||||
|
||||
export type ResolveUsernameByLinkOptionsType = Readonly<{
|
||||
entropy: Uint8Array;
|
||||
serverId: Uint8Array;
|
||||
}>;
|
||||
|
||||
export async function resolveUsernameByLink({
|
||||
entropy,
|
||||
serverId: serverIdBytes,
|
||||
}: ResolveUsernameByLinkOptionsType): Promise<string | undefined> {
|
||||
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');
|
||||
|
||||
|
|
104
ts/services/usernameIntegrity.ts
Normal file
104
ts/services/usernameIntegrity.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as Errors from '../types/errors';
|
||||
import { DAY } from '../util/durations';
|
||||
import { drop } from '../util/drop';
|
||||
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
|
||||
import { checkForUsername } from '../util/lookupConversationWithoutServiceId';
|
||||
import * as log from '../logging/log';
|
||||
import { resolveUsernameByLink } from './username';
|
||||
|
||||
const CHECK_INTERVAL = DAY;
|
||||
|
||||
class UsernameIntegrityService {
|
||||
private isStarted = false;
|
||||
private readonly backOff = new BackOff(FIBONACCI_TIMEOUTS);
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isStarted = true;
|
||||
|
||||
this.scheduleCheck();
|
||||
}
|
||||
|
||||
private scheduleCheck(): void {
|
||||
const lastCheckTimestamp = window.storage.get(
|
||||
'usernameLastIntegrityCheck',
|
||||
0
|
||||
);
|
||||
const delay = Math.max(0, lastCheckTimestamp + CHECK_INTERVAL - Date.now());
|
||||
if (delay === 0) {
|
||||
log.info('usernameIntegrity: running the check immediately');
|
||||
drop(this.safeCheck());
|
||||
} else {
|
||||
log.info(`usernameIntegrity: running the check in ${delay}ms`);
|
||||
setTimeout(() => drop(this.safeCheck()), delay);
|
||||
}
|
||||
}
|
||||
|
||||
private async safeCheck(): Promise<void> {
|
||||
try {
|
||||
await this.check();
|
||||
this.backOff.reset();
|
||||
await window.storage.put('usernameLastIntegrityCheck', Date.now());
|
||||
|
||||
this.scheduleCheck();
|
||||
} catch (error) {
|
||||
const delay = this.backOff.getAndIncrement();
|
||||
log.error(
|
||||
'usernameIntegrity: check failed with ' +
|
||||
`error: ${Errors.toLogFormat(error)} retrying in ${delay}ms`
|
||||
);
|
||||
setTimeout(() => drop(this.safeCheck()), delay);
|
||||
}
|
||||
}
|
||||
|
||||
private async check(): Promise<void> {
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
const username = me.get('username');
|
||||
const aci = me.getAci();
|
||||
|
||||
let failed = false;
|
||||
|
||||
if (!username) {
|
||||
log.info('usernameIntegrity: no username');
|
||||
return;
|
||||
}
|
||||
if (!aci) {
|
||||
log.info('usernameIntegrity: no aci');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await checkForUsername(username);
|
||||
if (result?.aci !== aci) {
|
||||
log.error('usernameIntegrity: no remote username');
|
||||
await window.storage.put('usernameCorrupted', true);
|
||||
failed = true;
|
||||
|
||||
// Intentional fall-through
|
||||
}
|
||||
|
||||
const link = window.storage.get('usernameLink');
|
||||
if (!link) {
|
||||
log.info('usernameIntegrity: no username link');
|
||||
return;
|
||||
}
|
||||
|
||||
const linkUsername = await resolveUsernameByLink(link);
|
||||
if (linkUsername !== username) {
|
||||
log.error('usernameIntegrity: invalid username link');
|
||||
await window.storage.put('usernameLinkCorrupted', true);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
log.info('usernameIntegrity: check pass');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const usernameIntegrity = new UsernameIntegrityService();
|
|
@ -18,6 +18,7 @@ import { assertDev } from '../../util/assert';
|
|||
import type { StateType as RootStateType } from '../reducer';
|
||||
import type { PromiseAction } from '../util';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { getUsernameCorrupted } from '../selectors/items';
|
||||
import {
|
||||
UsernameEditState,
|
||||
UsernameLinkState,
|
||||
|
@ -248,9 +249,10 @@ export function deleteUsername({
|
|||
> {
|
||||
return (dispatch, getState) => {
|
||||
const me = getMe(getState());
|
||||
const isUsernameCorrupted = getUsernameCorrupted(getState());
|
||||
const username = me.username ?? defaultUsername;
|
||||
|
||||
if (!username) {
|
||||
if (!username && !isUsernameCorrupted) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -568,7 +570,7 @@ export function reducer(
|
|||
if (action.type === 'username/RESET_USERNAME_LINK_REJECTED') {
|
||||
return {
|
||||
...state,
|
||||
linkState: UsernameLinkState.Ready,
|
||||
linkState: UsernameLinkState.Error,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ export enum UsernameEditState {
|
|||
export enum UsernameLinkState {
|
||||
Ready = 'Ready',
|
||||
Updating = 'Updating',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
//
|
||||
|
|
|
@ -93,6 +93,16 @@ export const getHasCompletedUsernameLinkOnboarding = createSelector(
|
|||
Boolean(state.hasCompletedUsernameLinkOnboarding)
|
||||
);
|
||||
|
||||
export const getUsernameCorrupted = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): boolean => Boolean(state.usernameCorrupted)
|
||||
);
|
||||
|
||||
export const getUsernameLinkCorrupted = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): boolean => Boolean(state.usernameLinkCorrupted)
|
||||
);
|
||||
|
||||
export const getUsernameLinkColor = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): number | undefined => state.usernameLinkColor
|
||||
|
|
|
@ -15,15 +15,17 @@ import {
|
|||
getUsernameReservationObject,
|
||||
getUsernameReservationError,
|
||||
} from '../selectors/username';
|
||||
import { getUsernameCorrupted } from '../selectors/items';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
|
||||
function mapStateToProps(state: StateType): PropsDataType {
|
||||
const i18n = getIntl(state);
|
||||
const { username } = getMe(state);
|
||||
const usernameCorrupted = getUsernameCorrupted(state);
|
||||
|
||||
return {
|
||||
i18n,
|
||||
currentUsername: username,
|
||||
currentUsername: usernameCorrupted ? undefined : username,
|
||||
minNickname: getMinNickname(),
|
||||
maxNickname: getMaxNickname(),
|
||||
state: getUsernameReservationState(state),
|
||||
|
|
|
@ -40,6 +40,8 @@ import { hasNetworkDialog } from '../selectors/network';
|
|||
import {
|
||||
getPreferredLeftPaneWidth,
|
||||
getUsernamesEnabled,
|
||||
getUsernameCorrupted,
|
||||
getUsernameLinkCorrupted,
|
||||
getNavTabsCollapsed,
|
||||
} from '../selectors/items';
|
||||
import {
|
||||
|
@ -203,6 +205,8 @@ const getModeSpecificProps = (
|
|||
const mapStateToProps = (state: StateType) => {
|
||||
const hasUpdateDialog = isUpdateDialogVisible(state);
|
||||
const hasUnsupportedOS = isOSUnsupported(state);
|
||||
const usernameCorrupted = getUsernameCorrupted(state);
|
||||
const usernameLinkCorrupted = getUsernameLinkCorrupted(state);
|
||||
|
||||
let hasExpiredDialog = false;
|
||||
let unsupportedOSDialogType: 'error' | 'warning' | undefined;
|
||||
|
@ -223,6 +227,8 @@ const mapStateToProps = (state: StateType) => {
|
|||
hasUpdateDialog,
|
||||
isUpdateDownloaded: isUpdateDownloaded(state),
|
||||
unsupportedOSDialogType,
|
||||
usernameCorrupted,
|
||||
usernameLinkCorrupted,
|
||||
|
||||
modeSpecificProps: getModeSpecificProps(state),
|
||||
navTabsCollapsed: getNavTabsCollapsed(state),
|
||||
|
|
|
@ -16,8 +16,10 @@ import {
|
|||
getUsernamesEnabled,
|
||||
getHasCompletedUsernameOnboarding,
|
||||
getHasCompletedUsernameLinkOnboarding,
|
||||
getUsernameCorrupted,
|
||||
getUsernameLinkColor,
|
||||
getUsernameLink,
|
||||
getUsernameLinkCorrupted,
|
||||
} from '../selectors/items';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
|
@ -59,6 +61,8 @@ function mapStateToProps(
|
|||
const usernameLinkState = getUsernameLinkState(state);
|
||||
const usernameLinkColor = getUsernameLinkColor(state);
|
||||
const usernameLink = getUsernameLink(state);
|
||||
const usernameCorrupted = getUsernameCorrupted(state);
|
||||
const usernameLinkCorrupted = getUsernameLinkCorrupted(state);
|
||||
|
||||
return {
|
||||
aboutEmoji,
|
||||
|
@ -78,9 +82,11 @@ function mapStateToProps(
|
|||
phoneNumber,
|
||||
userAvatarData,
|
||||
username,
|
||||
usernameCorrupted,
|
||||
usernameEditState,
|
||||
usernameLinkState,
|
||||
usernameLinkColor,
|
||||
usernameLinkCorrupted,
|
||||
usernameLink,
|
||||
|
||||
renderEditUsernameModalBody,
|
||||
|
|
3
ts/types/Storage.d.ts
vendored
3
ts/types/Storage.d.ts
vendored
|
@ -163,6 +163,9 @@ export type StorageAccessType = {
|
|||
subscriberCurrencyCode: string;
|
||||
displayBadgesOnProfile: boolean;
|
||||
keepMutedChatsArchived: boolean;
|
||||
usernameLastIntegrityCheck: number;
|
||||
usernameCorrupted: boolean;
|
||||
usernameLinkCorrupted: boolean;
|
||||
usernameLinkColor: number;
|
||||
usernameLink: {
|
||||
entropy: Uint8Array;
|
||||
|
|
|
@ -132,7 +132,7 @@ export async function lookupConversationWithoutServiceId(
|
|||
}
|
||||
}
|
||||
|
||||
async function checkForUsername(
|
||||
export async function checkForUsername(
|
||||
username: string
|
||||
): Promise<FoundUsernameType | undefined> {
|
||||
let hash: Buffer;
|
||||
|
|
Loading…
Reference in a new issue