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",
|
"messageformat": "Chats",
|
||||||
"description": "Shown as a header for non-pinned conversations in the left pane"
|
"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": {
|
"icu:NavTabsToggle__showTabs": {
|
||||||
"messageformat": "Show Tabs",
|
"messageformat": "Show Tabs",
|
||||||
"description": "Show in the left pane when the nav tabs are hidden, shows the nav tabs"
|
"description": "Show in the left pane when the nav tabs are hidden, shows the nav tabs"
|
||||||
|
@ -5419,10 +5435,30 @@
|
||||||
"messageformat": "Username",
|
"messageformat": "Username",
|
||||||
"description": "Default text for username field"
|
"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": {
|
"icu:ProfileEditor__username-link": {
|
||||||
"messageformat": "QR code or link",
|
"messageformat": "QR code or link",
|
||||||
"description": "Label of a profile editor row that navigates to username link and qr code modal"
|
"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": {
|
"icu:ProfileEditor__username-link__tooltip__title": {
|
||||||
"messageformat": "Share your username",
|
"messageformat": "Share your username",
|
||||||
"description": "Title of tooltip displayed under 'QR code or link' button for getting username link"
|
"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.",
|
"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"
|
"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": {
|
"icu:UsernameOnboardingModalBody__title": {
|
||||||
"messageformat": "Set up your Signal username",
|
"messageformat": "Set up your Signal username",
|
||||||
"description": "Title of username onboarding modal"
|
"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;
|
padding-inline: 16px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
.module-Button {
|
.module-Button:not(:first-child) {
|
||||||
margin-inline-start: 8px;
|
margin-inline-start: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--one-button-per-line {
|
&--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 {
|
&__username-link {
|
||||||
&__tooltip {
|
&__tooltip {
|
||||||
padding: 12px;
|
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 {
|
.ProfileEditor__Title {
|
||||||
|
|
|
@ -64,6 +64,18 @@
|
||||||
height: var(--size);
|
height: var(--size);
|
||||||
@include color-svg('../images/signal-qr-logo.svg', var(--fg-color));
|
@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 {
|
&__username {
|
||||||
|
@ -209,11 +221,31 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled:after {
|
||||||
|
@include light-theme() {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme() {
|
||||||
|
background-color: $color-gray-25;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
user-select: text;
|
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/InstallScreenQrCodeNotScannedStep.scss';
|
||||||
@import './components/InstallScreenSignalLogo.scss';
|
@import './components/InstallScreenSignalLogo.scss';
|
||||||
@import './components/InstallScreenUpdateDialog.scss';
|
@import './components/InstallScreenUpdateDialog.scss';
|
||||||
|
@import './components/LeftPaneBanner.scss';
|
||||||
@import './components/LeftPaneDialog.scss';
|
@import './components/LeftPaneDialog.scss';
|
||||||
@import './components/LeftPaneSearchInput.scss';
|
@import './components/LeftPaneSearchInput.scss';
|
||||||
@import './components/Lightbox.scss';
|
@import './components/Lightbox.scss';
|
||||||
|
|
|
@ -51,6 +51,7 @@ import { senderCertificateService } from './services/senderCertificate';
|
||||||
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
||||||
import * as KeyboardLayout from './services/keyboardLayout';
|
import * as KeyboardLayout from './services/keyboardLayout';
|
||||||
import * as StorageService from './services/storage';
|
import * as StorageService from './services/storage';
|
||||||
|
import { usernameIntegrity } from './services/usernameIntegrity';
|
||||||
import { RoutineProfileRefresher } from './routineProfileRefresh';
|
import { RoutineProfileRefresher } from './routineProfileRefresh';
|
||||||
import { isOlderThan, toDayMillis } from './util/timestamp';
|
import { isOlderThan, toDayMillis } from './util/timestamp';
|
||||||
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
|
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
|
||||||
|
@ -2051,6 +2052,8 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
void routineProfileRefresher.start();
|
void routineProfileRefresher.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drop(usernameIntegrity.start());
|
||||||
}
|
}
|
||||||
|
|
||||||
let initialStartupCount = 0;
|
let initialStartupCount = 0;
|
||||||
|
|
|
@ -158,6 +158,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
hasRelinkDialog: false,
|
hasRelinkDialog: false,
|
||||||
hasUpdateDialog: false,
|
hasUpdateDialog: false,
|
||||||
unsupportedOSDialogType: undefined,
|
unsupportedOSDialogType: undefined,
|
||||||
|
usernameCorrupted: false,
|
||||||
|
usernameLinkCorrupted: false,
|
||||||
isUpdateDownloaded,
|
isUpdateDownloaded,
|
||||||
navTabsCollapsed: false,
|
navTabsCollapsed: false,
|
||||||
|
|
||||||
|
@ -269,6 +271,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
'toggleConversationInChooseMembers'
|
'toggleConversationInChooseMembers'
|
||||||
),
|
),
|
||||||
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
|
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
|
||||||
|
toggleProfileEditor: action('toggleProfileEditor'),
|
||||||
updateSearchTerm: action('updateSearchTerm'),
|
updateSearchTerm: action('updateSearchTerm'),
|
||||||
|
|
||||||
...overrideProps,
|
...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 {
|
export function InboxOnlyPinnedConversations(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<LeftPaneInContainer
|
<LeftPaneInContainer
|
||||||
|
|
|
@ -35,6 +35,7 @@ import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/U
|
||||||
import { ConversationList } from './ConversationList';
|
import { ConversationList } from './ConversationList';
|
||||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||||
import type { PropsType as DialogExpiredBuildPropsType } from './DialogExpiredBuild';
|
import type { PropsType as DialogExpiredBuildPropsType } from './DialogExpiredBuild';
|
||||||
|
import { LeftPaneBanner } from './LeftPaneBanner';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
DeleteAvatarFromDiskActionType,
|
DeleteAvatarFromDiskActionType,
|
||||||
|
@ -69,6 +70,8 @@ export type PropsType = {
|
||||||
hasUpdateDialog: boolean;
|
hasUpdateDialog: boolean;
|
||||||
isUpdateDownloaded: boolean;
|
isUpdateDownloaded: boolean;
|
||||||
unsupportedOSDialogType: 'error' | 'warning' | undefined;
|
unsupportedOSDialogType: 'error' | 'warning' | undefined;
|
||||||
|
usernameCorrupted: boolean;
|
||||||
|
usernameLinkCorrupted: boolean;
|
||||||
|
|
||||||
// These help prevent invalid states. For example, we don't need the list of pinned
|
// 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
|
// conversations if we're trying to start a new conversation. Ideally these would be
|
||||||
|
@ -135,6 +138,7 @@ export type PropsType = {
|
||||||
toggleComposeEditingAvatar: () => unknown;
|
toggleComposeEditingAvatar: () => unknown;
|
||||||
toggleConversationInChooseMembers: (conversationId: string) => void;
|
toggleConversationInChooseMembers: (conversationId: string) => void;
|
||||||
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||||
|
toggleProfileEditor: () => void;
|
||||||
updateSearchTerm: (_: string) => void;
|
updateSearchTerm: (_: string) => void;
|
||||||
|
|
||||||
// Render Props
|
// Render Props
|
||||||
|
@ -201,6 +205,7 @@ export function LeftPane({
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
targetedMessageId,
|
targetedMessageId,
|
||||||
toggleNavTabsCollapse,
|
toggleNavTabsCollapse,
|
||||||
|
toggleProfileEditor,
|
||||||
setChallengeStatus,
|
setChallengeStatus,
|
||||||
setComposeGroupAvatar,
|
setComposeGroupAvatar,
|
||||||
setComposeGroupExpireTimer,
|
setComposeGroupExpireTimer,
|
||||||
|
@ -219,6 +224,8 @@ export function LeftPane({
|
||||||
toggleComposeEditingAvatar,
|
toggleComposeEditingAvatar,
|
||||||
toggleConversationInChooseMembers,
|
toggleConversationInChooseMembers,
|
||||||
unsupportedOSDialogType,
|
unsupportedOSDialogType,
|
||||||
|
usernameCorrupted,
|
||||||
|
usernameLinkCorrupted,
|
||||||
updateSearchTerm,
|
updateSearchTerm,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const previousModeSpecificProps = usePrevious(
|
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 (
|
return (
|
||||||
<NavSidebar
|
<NavSidebar
|
||||||
title="Chats"
|
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,
|
Deleting: UsernameEditState.Deleting,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
usernameCorrupted: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
usernameLinkState: {
|
usernameLinkState: {
|
||||||
control: { type: 'select' },
|
control: { type: 'select' },
|
||||||
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
|
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
|
||||||
},
|
},
|
||||||
|
usernameLinkCorrupted: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
aboutEmoji: '',
|
aboutEmoji: '',
|
||||||
|
|
|
@ -82,10 +82,12 @@ export type PropsDataType = {
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
usernameCorrupted: boolean;
|
||||||
usernameEditState: UsernameEditState;
|
usernameEditState: UsernameEditState;
|
||||||
usernameLinkState: UsernameLinkState;
|
usernameLinkState: UsernameLinkState;
|
||||||
usernameLinkColor?: number;
|
usernameLinkColor?: number;
|
||||||
usernameLink?: string;
|
usernameLink?: string;
|
||||||
|
usernameLinkCorrupted: boolean;
|
||||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||||
|
|
||||||
type PropsActionType = {
|
type PropsActionType = {
|
||||||
|
@ -169,10 +171,12 @@ export function ProfileEditor({
|
||||||
skinTone,
|
skinTone,
|
||||||
userAvatarData,
|
userAvatarData,
|
||||||
username,
|
username,
|
||||||
|
usernameCorrupted,
|
||||||
usernameEditState,
|
usernameEditState,
|
||||||
usernameLinkState,
|
usernameLinkState,
|
||||||
usernameLinkColor,
|
usernameLinkColor,
|
||||||
usernameLink,
|
usernameLink,
|
||||||
|
usernameLinkCorrupted,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
const focusInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [editState, setEditState] = useState<EditState>(EditState.None);
|
const [editState, setEditState] = useState<EditState>(EditState.None);
|
||||||
|
@ -208,6 +212,7 @@ export function ProfileEditor({
|
||||||
familyName,
|
familyName,
|
||||||
firstName,
|
firstName,
|
||||||
});
|
});
|
||||||
|
const [isResettingUsername, setIsResettingUsername] = useState(false);
|
||||||
|
|
||||||
// Reset username edit state when leaving
|
// Reset username edit state when leaving
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -530,6 +535,7 @@ export function ProfileEditor({
|
||||||
link={usernameLink}
|
link={usernameLink}
|
||||||
username={username ?? ''}
|
username={username ?? ''}
|
||||||
colorId={usernameLinkColor}
|
colorId={usernameLinkColor}
|
||||||
|
usernameLinkCorrupted={usernameLinkCorrupted}
|
||||||
usernameLinkState={usernameLinkState}
|
usernameLinkState={usernameLinkState}
|
||||||
setUsernameLinkColor={setUsernameLinkColor}
|
setUsernameLinkColor={setUsernameLinkColor}
|
||||||
resetUsernameLink={resetUsernameLink}
|
resetUsernameLink={resetUsernameLink}
|
||||||
|
@ -542,6 +548,7 @@ export function ProfileEditor({
|
||||||
let maybeUsernameRows: JSX.Element | undefined;
|
let maybeUsernameRows: JSX.Element | undefined;
|
||||||
if (isUsernameFlagEnabled) {
|
if (isUsernameFlagEnabled) {
|
||||||
let actions: JSX.Element | undefined;
|
let actions: JSX.Element | undefined;
|
||||||
|
let alwaysShowActions = false;
|
||||||
|
|
||||||
if (usernameEditState === UsernameEditState.Deleting) {
|
if (usernameEditState === UsernameEditState.Deleting) {
|
||||||
actions = (
|
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 = (
|
actions = (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -593,7 +608,18 @@ export function ProfileEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
let maybeUsernameLinkRow: JSX.Element | undefined;
|
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 = (
|
maybeUsernameLinkRow = (
|
||||||
<PanelRow
|
<PanelRow
|
||||||
className="ProfileEditor__row"
|
className="ProfileEditor__row"
|
||||||
|
@ -604,6 +630,8 @@ export function ProfileEditor({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditState(EditState.UsernameLink);
|
setEditState(EditState.UsernameLink);
|
||||||
}}
|
}}
|
||||||
|
alwaysShowActions
|
||||||
|
actions={linkActions}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -647,8 +675,16 @@ export function ProfileEditor({
|
||||||
icon={
|
icon={
|
||||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
|
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
|
||||||
}
|
}
|
||||||
label={username || i18n('icu:ProfileEditor--username')}
|
label={
|
||||||
|
(!usernameCorrupted && username) ||
|
||||||
|
i18n('icu:ProfileEditor--username')
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (usernameCorrupted) {
|
||||||
|
setIsResettingUsername(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
openUsernameReservationModal();
|
openUsernameReservationModal();
|
||||||
if (username || hasCompletedUsernameOnboarding) {
|
if (username || hasCompletedUsernameOnboarding) {
|
||||||
setEditState(EditState.Username);
|
setEditState(EditState.Username);
|
||||||
|
@ -656,6 +692,7 @@ export function ProfileEditor({
|
||||||
setEditState(EditState.UsernameOnboarding);
|
setEditState(EditState.UsernameOnboarding);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
alwaysShowActions={alwaysShowActions}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
{maybeUsernameLinkRow}
|
{maybeUsernameLinkRow}
|
||||||
|
@ -771,6 +808,36 @@ export function ProfileEditor({
|
||||||
onClose={() => setConfirmDiscardAction(undefined)}
|
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>
|
<div className="ProfileEditor">{content}</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,9 +28,16 @@ export default {
|
||||||
username: {
|
username: {
|
||||||
control: { type: 'text' },
|
control: { type: 'text' },
|
||||||
},
|
},
|
||||||
|
usernameLinkCorrupted: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
usernameLinkState: {
|
usernameLinkState: {
|
||||||
control: { type: 'select' },
|
control: { type: 'select' },
|
||||||
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
|
options: [
|
||||||
|
UsernameLinkState.Ready,
|
||||||
|
UsernameLinkState.Updating,
|
||||||
|
UsernameLinkState.Error,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
colorId: {
|
colorId: {
|
||||||
control: { type: 'select' },
|
control: { type: 'select' },
|
||||||
|
|
|
@ -26,6 +26,7 @@ export type PropsType = Readonly<{
|
||||||
link?: string;
|
link?: string;
|
||||||
username: string;
|
username: string;
|
||||||
colorId?: number;
|
colorId?: number;
|
||||||
|
usernameLinkCorrupted: boolean;
|
||||||
usernameLinkState: UsernameLinkState;
|
usernameLinkState: UsernameLinkState;
|
||||||
|
|
||||||
setUsernameLinkColor: (colorId: number) => void;
|
setUsernameLinkColor: (colorId: number) => void;
|
||||||
|
@ -486,6 +487,7 @@ export function UsernameLinkModalBody({
|
||||||
i18n,
|
i18n,
|
||||||
link,
|
link,
|
||||||
username,
|
username,
|
||||||
|
usernameLinkCorrupted,
|
||||||
usernameLinkState,
|
usernameLinkState,
|
||||||
colorId: initialColorId = ColorEnum.UNKNOWN,
|
colorId: initialColorId = ColorEnum.UNKNOWN,
|
||||||
|
|
||||||
|
@ -499,6 +501,7 @@ export function UsernameLinkModalBody({
|
||||||
const [pngData, setPngData] = useState<Uint8Array | undefined>();
|
const [pngData, setPngData] = useState<Uint8Array | undefined>();
|
||||||
const [showColors, setShowColors] = useState(false);
|
const [showColors, setShowColors] = useState(false);
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const [showError, setShowError] = useState(false);
|
||||||
const [colorId, setColorId] = useState(initialColorId);
|
const [colorId, setColorId] = useState(initialColorId);
|
||||||
|
|
||||||
const { fg: fgColor, bg: bgColor } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET;
|
const { fg: fgColor, bg: bgColor } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET;
|
||||||
|
@ -621,13 +624,36 @@ export function UsernameLinkModalBody({
|
||||||
resetUsernameLink();
|
resetUsernameLink();
|
||||||
}, [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 = (
|
const info = (
|
||||||
<>
|
<>
|
||||||
<div className={classnames(`${CLASS}__actions`)}>
|
<div className={classnames(`${CLASS}__actions`)}>
|
||||||
<button
|
<button
|
||||||
className={`${CLASS}__actions__save`}
|
className={`${CLASS}__actions__save`}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!link}
|
disabled={!link || isResettingLink}
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
>
|
>
|
||||||
<i />
|
<i />
|
||||||
|
@ -648,11 +674,19 @@ export function UsernameLinkModalBody({
|
||||||
<button
|
<button
|
||||||
className={classnames(`${CLASS}__link__icon`)}
|
className={classnames(`${CLASS}__link__icon`)}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!link}
|
disabled={!link || isResettingLink}
|
||||||
onClick={onCopyLink}
|
onClick={onCopyLink}
|
||||||
aria-label={i18n('icu:UsernameLinkModalBody__copy')}
|
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>
|
||||||
|
|
||||||
<div className={classnames(`${CLASS}__help`)}>
|
<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 (
|
return (
|
||||||
<div className={`${CLASS}__container`}>
|
<div className={`${CLASS}__container`}>
|
||||||
<div className={CLASS}>
|
<div className={CLASS}>
|
||||||
|
@ -686,23 +743,7 @@ export function UsernameLinkModalBody({
|
||||||
})}
|
})}
|
||||||
ref={onCardRef}
|
ref={onCardRef}
|
||||||
>
|
>
|
||||||
<div className={`${CLASS}__card__qr`}>
|
<div className={`${CLASS}__card__qr`}>{linkImage}</div>
|
||||||
{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`}>
|
<div className={`${CLASS}__card__username`}>
|
||||||
{!showColors && (
|
{!showColors && (
|
||||||
<button
|
<button
|
||||||
|
@ -733,6 +774,18 @@ export function UsernameLinkModalBody({
|
||||||
</ConfirmationDialog>
|
</ConfirmationDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showError && (
|
||||||
|
<ConfirmationDialog
|
||||||
|
i18n={i18n}
|
||||||
|
dialogName="UsernameLinkModal__error"
|
||||||
|
onClose={onClearError}
|
||||||
|
cancelButtonVariant={ButtonVariant.Secondary}
|
||||||
|
cancelText={i18n('icu:ok')}
|
||||||
|
>
|
||||||
|
{i18n('icu:UsernameLinkModalBody__error__text')}
|
||||||
|
</ConfirmationDialog>
|
||||||
|
)}
|
||||||
|
|
||||||
{showColors ? (
|
{showColors ? (
|
||||||
<UsernameLinkColors
|
<UsernameLinkColors
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
|
|
@ -1469,6 +1469,17 @@ export async function mergeAccountRecord(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usernameLink?.entropy?.length && usernameLink?.serverId?.length) {
|
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([
|
await Promise.all([
|
||||||
usernameLink.color &&
|
usernameLink.color &&
|
||||||
window.storage.put('usernameLinkColor', usernameLink.color),
|
window.storage.put('usernameLinkColor', usernameLink.color),
|
||||||
|
@ -1500,6 +1511,14 @@ export async function mergeAccountRecord(
|
||||||
const oldStorageID = conversation.get('storageID');
|
const oldStorageID = conversation.get('storageID');
|
||||||
const oldStorageVersion = conversation.get('storageVersion');
|
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({
|
conversation.set({
|
||||||
isArchived: Boolean(noteToSelfArchived),
|
isArchived: Boolean(noteToSelfArchived),
|
||||||
markedUnread: Boolean(noteToSelfMarkedUnread),
|
markedUnread: Boolean(noteToSelfMarkedUnread),
|
||||||
|
|
|
@ -186,6 +186,8 @@ export async function confirmUsername(
|
||||||
usernames.createUsernameLink(username);
|
usernames.createUsernameLink(username);
|
||||||
|
|
||||||
await window.storage.remove('usernameLink');
|
await window.storage.remove('usernameLink');
|
||||||
|
await window.storage.remove('usernameCorrupted');
|
||||||
|
await window.storage.remove('usernameLinkCorrupted');
|
||||||
|
|
||||||
const { usernameLinkHandle: serverIdString } = await server.confirmUsername(
|
const { usernameLinkHandle: serverIdString } = await server.confirmUsername(
|
||||||
{
|
{
|
||||||
|
@ -223,7 +225,7 @@ export async function confirmUsername(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUsername(
|
export async function deleteUsername(
|
||||||
previousUsername: string,
|
previousUsername: string | undefined,
|
||||||
abortSignal?: AbortSignal
|
abortSignal?: AbortSignal
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { server } = window.textsecure;
|
const { server } = window.textsecure;
|
||||||
|
@ -238,6 +240,7 @@ export async function deleteUsername(
|
||||||
}
|
}
|
||||||
|
|
||||||
await window.storage.remove('usernameLink');
|
await window.storage.remove('usernameLink');
|
||||||
|
await window.storage.remove('usernameCorrupted');
|
||||||
await server.deleteUsername(abortSignal);
|
await server.deleteUsername(abortSignal);
|
||||||
await updateUsernameAndSyncProfile(undefined);
|
await updateUsernameAndSyncProfile(undefined);
|
||||||
}
|
}
|
||||||
|
@ -257,6 +260,7 @@ export async function resetLink(username: string): Promise<void> {
|
||||||
const { entropy, encryptedUsername } = usernames.createUsernameLink(username);
|
const { entropy, encryptedUsername } = usernames.createUsernameLink(username);
|
||||||
|
|
||||||
await window.storage.remove('usernameLink');
|
await window.storage.remove('usernameLink');
|
||||||
|
await window.storage.remove('usernameLinkCorrupted');
|
||||||
|
|
||||||
const { usernameLinkHandle: serverIdString } =
|
const { usernameLinkHandle: serverIdString } =
|
||||||
await server.replaceUsernameLink({ encryptedUsername });
|
await server.replaceUsernameLink({ encryptedUsername });
|
||||||
|
@ -275,15 +279,27 @@ const USERNAME_LINK_ENTROPY_SIZE = 32;
|
||||||
export async function resolveUsernameByLinkBase64(
|
export async function resolveUsernameByLinkBase64(
|
||||||
base64: string
|
base64: string
|
||||||
): Promise<string | undefined> {
|
): 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;
|
const { server } = window.textsecure;
|
||||||
if (!server) {
|
if (!server) {
|
||||||
throw new Error('server interface is not available!');
|
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);
|
const serverId = bytesToUuid(serverIdBytes);
|
||||||
strictAssert(serverId, 'Failed to re-encode server id as uuid');
|
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 { StateType as RootStateType } from '../reducer';
|
||||||
import type { PromiseAction } from '../util';
|
import type { PromiseAction } from '../util';
|
||||||
import { getMe } from '../selectors/conversations';
|
import { getMe } from '../selectors/conversations';
|
||||||
|
import { getUsernameCorrupted } from '../selectors/items';
|
||||||
import {
|
import {
|
||||||
UsernameEditState,
|
UsernameEditState,
|
||||||
UsernameLinkState,
|
UsernameLinkState,
|
||||||
|
@ -248,9 +249,10 @@ export function deleteUsername({
|
||||||
> {
|
> {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const me = getMe(getState());
|
const me = getMe(getState());
|
||||||
|
const isUsernameCorrupted = getUsernameCorrupted(getState());
|
||||||
const username = me.username ?? defaultUsername;
|
const username = me.username ?? defaultUsername;
|
||||||
|
|
||||||
if (!username) {
|
if (!username && !isUsernameCorrupted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -568,7 +570,7 @@ export function reducer(
|
||||||
if (action.type === 'username/RESET_USERNAME_LINK_REJECTED') {
|
if (action.type === 'username/RESET_USERNAME_LINK_REJECTED') {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
linkState: UsernameLinkState.Ready,
|
linkState: UsernameLinkState.Error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ export enum UsernameEditState {
|
||||||
export enum UsernameLinkState {
|
export enum UsernameLinkState {
|
||||||
Ready = 'Ready',
|
Ready = 'Ready',
|
||||||
Updating = 'Updating',
|
Updating = 'Updating',
|
||||||
|
Error = 'Error',
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|
|
@ -93,6 +93,16 @@ export const getHasCompletedUsernameLinkOnboarding = createSelector(
|
||||||
Boolean(state.hasCompletedUsernameLinkOnboarding)
|
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(
|
export const getUsernameLinkColor = createSelector(
|
||||||
getItems,
|
getItems,
|
||||||
(state: ItemsStateType): number | undefined => state.usernameLinkColor
|
(state: ItemsStateType): number | undefined => state.usernameLinkColor
|
||||||
|
|
|
@ -15,15 +15,17 @@ import {
|
||||||
getUsernameReservationObject,
|
getUsernameReservationObject,
|
||||||
getUsernameReservationError,
|
getUsernameReservationError,
|
||||||
} from '../selectors/username';
|
} from '../selectors/username';
|
||||||
|
import { getUsernameCorrupted } from '../selectors/items';
|
||||||
import { getMe } from '../selectors/conversations';
|
import { getMe } from '../selectors/conversations';
|
||||||
|
|
||||||
function mapStateToProps(state: StateType): PropsDataType {
|
function mapStateToProps(state: StateType): PropsDataType {
|
||||||
const i18n = getIntl(state);
|
const i18n = getIntl(state);
|
||||||
const { username } = getMe(state);
|
const { username } = getMe(state);
|
||||||
|
const usernameCorrupted = getUsernameCorrupted(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
i18n,
|
i18n,
|
||||||
currentUsername: username,
|
currentUsername: usernameCorrupted ? undefined : username,
|
||||||
minNickname: getMinNickname(),
|
minNickname: getMinNickname(),
|
||||||
maxNickname: getMaxNickname(),
|
maxNickname: getMaxNickname(),
|
||||||
state: getUsernameReservationState(state),
|
state: getUsernameReservationState(state),
|
||||||
|
|
|
@ -40,6 +40,8 @@ import { hasNetworkDialog } from '../selectors/network';
|
||||||
import {
|
import {
|
||||||
getPreferredLeftPaneWidth,
|
getPreferredLeftPaneWidth,
|
||||||
getUsernamesEnabled,
|
getUsernamesEnabled,
|
||||||
|
getUsernameCorrupted,
|
||||||
|
getUsernameLinkCorrupted,
|
||||||
getNavTabsCollapsed,
|
getNavTabsCollapsed,
|
||||||
} from '../selectors/items';
|
} from '../selectors/items';
|
||||||
import {
|
import {
|
||||||
|
@ -203,6 +205,8 @@ const getModeSpecificProps = (
|
||||||
const mapStateToProps = (state: StateType) => {
|
const mapStateToProps = (state: StateType) => {
|
||||||
const hasUpdateDialog = isUpdateDialogVisible(state);
|
const hasUpdateDialog = isUpdateDialogVisible(state);
|
||||||
const hasUnsupportedOS = isOSUnsupported(state);
|
const hasUnsupportedOS = isOSUnsupported(state);
|
||||||
|
const usernameCorrupted = getUsernameCorrupted(state);
|
||||||
|
const usernameLinkCorrupted = getUsernameLinkCorrupted(state);
|
||||||
|
|
||||||
let hasExpiredDialog = false;
|
let hasExpiredDialog = false;
|
||||||
let unsupportedOSDialogType: 'error' | 'warning' | undefined;
|
let unsupportedOSDialogType: 'error' | 'warning' | undefined;
|
||||||
|
@ -223,6 +227,8 @@ const mapStateToProps = (state: StateType) => {
|
||||||
hasUpdateDialog,
|
hasUpdateDialog,
|
||||||
isUpdateDownloaded: isUpdateDownloaded(state),
|
isUpdateDownloaded: isUpdateDownloaded(state),
|
||||||
unsupportedOSDialogType,
|
unsupportedOSDialogType,
|
||||||
|
usernameCorrupted,
|
||||||
|
usernameLinkCorrupted,
|
||||||
|
|
||||||
modeSpecificProps: getModeSpecificProps(state),
|
modeSpecificProps: getModeSpecificProps(state),
|
||||||
navTabsCollapsed: getNavTabsCollapsed(state),
|
navTabsCollapsed: getNavTabsCollapsed(state),
|
||||||
|
|
|
@ -16,8 +16,10 @@ import {
|
||||||
getUsernamesEnabled,
|
getUsernamesEnabled,
|
||||||
getHasCompletedUsernameOnboarding,
|
getHasCompletedUsernameOnboarding,
|
||||||
getHasCompletedUsernameLinkOnboarding,
|
getHasCompletedUsernameLinkOnboarding,
|
||||||
|
getUsernameCorrupted,
|
||||||
getUsernameLinkColor,
|
getUsernameLinkColor,
|
||||||
getUsernameLink,
|
getUsernameLink,
|
||||||
|
getUsernameLinkCorrupted,
|
||||||
} from '../selectors/items';
|
} from '../selectors/items';
|
||||||
import { getMe } from '../selectors/conversations';
|
import { getMe } from '../selectors/conversations';
|
||||||
import { selectRecentEmojis } from '../selectors/emojis';
|
import { selectRecentEmojis } from '../selectors/emojis';
|
||||||
|
@ -59,6 +61,8 @@ function mapStateToProps(
|
||||||
const usernameLinkState = getUsernameLinkState(state);
|
const usernameLinkState = getUsernameLinkState(state);
|
||||||
const usernameLinkColor = getUsernameLinkColor(state);
|
const usernameLinkColor = getUsernameLinkColor(state);
|
||||||
const usernameLink = getUsernameLink(state);
|
const usernameLink = getUsernameLink(state);
|
||||||
|
const usernameCorrupted = getUsernameCorrupted(state);
|
||||||
|
const usernameLinkCorrupted = getUsernameLinkCorrupted(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
aboutEmoji,
|
aboutEmoji,
|
||||||
|
@ -78,9 +82,11 @@ function mapStateToProps(
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
userAvatarData,
|
userAvatarData,
|
||||||
username,
|
username,
|
||||||
|
usernameCorrupted,
|
||||||
usernameEditState,
|
usernameEditState,
|
||||||
usernameLinkState,
|
usernameLinkState,
|
||||||
usernameLinkColor,
|
usernameLinkColor,
|
||||||
|
usernameLinkCorrupted,
|
||||||
usernameLink,
|
usernameLink,
|
||||||
|
|
||||||
renderEditUsernameModalBody,
|
renderEditUsernameModalBody,
|
||||||
|
|
3
ts/types/Storage.d.ts
vendored
3
ts/types/Storage.d.ts
vendored
|
@ -163,6 +163,9 @@ export type StorageAccessType = {
|
||||||
subscriberCurrencyCode: string;
|
subscriberCurrencyCode: string;
|
||||||
displayBadgesOnProfile: boolean;
|
displayBadgesOnProfile: boolean;
|
||||||
keepMutedChatsArchived: boolean;
|
keepMutedChatsArchived: boolean;
|
||||||
|
usernameLastIntegrityCheck: number;
|
||||||
|
usernameCorrupted: boolean;
|
||||||
|
usernameLinkCorrupted: boolean;
|
||||||
usernameLinkColor: number;
|
usernameLinkColor: number;
|
||||||
usernameLink: {
|
usernameLink: {
|
||||||
entropy: Uint8Array;
|
entropy: Uint8Array;
|
||||||
|
|
|
@ -132,7 +132,7 @@ export async function lookupConversationWithoutServiceId(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkForUsername(
|
export async function checkForUsername(
|
||||||
username: string
|
username: string
|
||||||
): Promise<FoundUsernameType | undefined> {
|
): Promise<FoundUsernameType | undefined> {
|
||||||
let hash: Buffer;
|
let hash: Buffer;
|
||||||
|
|
Loading…
Reference in a new issue