Username recovery improvements

This commit is contained in:
Fedor Indutny 2024-02-06 10:35:59 -08:00 committed by GitHub
parent a70ae1060d
commit 533a1b32d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 423 additions and 99 deletions

View file

@ -1637,8 +1637,12 @@
"messageformat": "My Computer", "messageformat": "My Computer",
"description": "The placeholder for the 'choose device name' input" "description": "The placeholder for the 'choose device name' input"
}, },
"icu:Preferences--phone-number": {
"messageformat": "Phone Number",
"description": "The label in settings panel shown for the phone number associated with user's account"
},
"icu:Preferences--device-name": { "icu:Preferences--device-name": {
"messageformat": "Device name", "messageformat": "Device Name",
"description": "The label in settings panel shown for the user-provided name for this desktop instance" "description": "The label in settings panel shown for the user-provided name for this desktop instance"
}, },
"icu:chooseDeviceName": { "icu:chooseDeviceName": {
@ -5512,15 +5516,19 @@
"description": "Default text for username field" "description": "Default text for username field"
}, },
"icu:ProfileEditor--username--corrupted--body": { "icu:ProfileEditor--username--corrupted--body": {
"messageformat": "Something went wrong with your username, its no longer assigned to your account.", "messageformat": "Something went wrong with your username, its no longer assigned to your account. You can try and set it again or choose a new one.",
"description": "Text of confirmation modal when the username gets corrupted" "description": "Text of confirmation modal when the username gets corrupted"
}, },
"icu:ProfileEditor--username--corrupted--delete-button": { "icu:ProfileEditor--username--corrupted--delete-button": {
"messageformat": "Delete username", "messageformat": "Delete username",
"description": "Button text for deletion of the username in case of corruption" "description": "(Deleted 02/01/2024) Button text for deletion of the username in case of corruption"
}, },
"icu:ProfileEditor--username--corrupted--create-button": { "icu:ProfileEditor--username--corrupted--create-button": {
"messageformat": "Create username", "messageformat": "Create username",
"description": "(Deleted 02/01/2024) Button text for creation of a new username in case of corruption"
},
"icu:ProfileEditor--username--corrupted--fix-button": {
"messageformat": "Fix now",
"description": "Button text for creation of a new username in case of corruption" "description": "Button text for creation of a new username in case of corruption"
}, },
"icu:ProfileEditor__username-link": { "icu:ProfileEditor__username-link": {
@ -5651,9 +5659,25 @@
"messageformat": "Would you like to discard these changes?", "messageformat": "Would you like to discard these changes?",
"description": "ConfirmationDialog text for discarding changes" "description": "ConfirmationDialog text for discarding changes"
}, },
"icu:ProfileEditor--edit-photo": {
"messageformat": "Edit photo",
"description": "Text of a button on profile editor that leads to the avatar editor"
},
"icu:ProfileEditor--info--link": { "icu:ProfileEditor--info--link": {
"messageformat": "Your profile is encrypted. Your profile and changes to it will be visible to your contacts and when you start or accept new chats. <learnMoreLink>Learn More</learnMoreLink>", "messageformat": "Your profile is encrypted. Your profile and changes to it will be visible to your contacts and when you start or accept new chats. <learnMoreLink>Learn More</learnMoreLink>",
"description": "Information shown at the bottom of the profile editor section" "description": "(Deleted 02/01/2024) Information shown at the bottom of the profile editor section"
},
"icu:ProfileEditor--info--general": {
"messageformat": "Your profile and changes to it will be visible to people you message, contacts and groups.",
"description": "Information shown in profile editor below profile name and about fields"
},
"icu:ProfileEditor--info--pnp": {
"messageformat": "Your username, QR code & link arent visible on your profile. Only share them with people you trust.",
"description": "Information shown in profile editor below pnp settings when username is set"
},
"icu:ProfileEditor--info--pnp--no-username": {
"messageformat": "People can now message you using your optional username so you dont have to give out your phone number.",
"description": "Information shown in profile editor below pnp settings when no username is set"
}, },
"icu:Bio--speak-freely": { "icu:Bio--speak-freely": {
"messageformat": "Speak Freely", "messageformat": "Speak Freely",
@ -6840,7 +6864,7 @@
"description": "Placeholder for the username field" "description": "Placeholder for the username field"
}, },
"icu:EditUsernameModalBody__username-helper": { "icu:EditUsernameModalBody__username-helper": {
"messageformat": "Usernames let others message you without needing your phone number. They are paired with a set of digits to help keep your address private.", "messageformat": "Usernames are always paired with a set of numbers.",
"description": "Shown on the edit username screen" "description": "Shown on the edit username screen"
}, },
"icu:EditUsernameModalBody__learn-more": { "icu:EditUsernameModalBody__learn-more": {
@ -6863,6 +6887,14 @@
"messageformat": "Continue", "messageformat": "Continue",
"description": "Text of the primary button on username change confirmation modal" "description": "Text of the primary button on username change confirmation modal"
}, },
"icu:EditUsernameModalBody__recover-confirmation": {
"messageformat": "Recovering your username will reset your existing QR code and link. Are you sure?",
"description": "Body of the confirmation dialog displayed when user is about to recover their username"
},
"icu:EditUsernameModalBody__username-recovered__text": {
"messageformat": "Your QR code and link have been reset and your username is {username}",
"description": "Text of toast displayed upon successful recovery of username"
},
"icu:UsernameLinkModalBody__hint": { "icu:UsernameLinkModalBody__hint": {
"messageformat": "Scan this QR code with your phone to chat with me on Signal.", "messageformat": "Scan this QR code with your phone to chat with me on Signal.",
"descrption": "Text of the hint displayed below generated QR code on the printable image." "descrption": "Text of the hint displayed below generated QR code on the printable image."
@ -6896,7 +6928,7 @@
"description": "ARIA label of button for selecting username link color" "description": "ARIA label of button for selecting username link color"
}, },
"icu:UsernameLinkModalBody__reset__confirm": { "icu:UsernameLinkModalBody__reset__confirm": {
"messageformat": "If you reset your QR code, your existing QR code and link will no longer work.", "messageformat": "If you reset your QR code and link, 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": { "icu:UsernameLinkModalBody__resetting-link": {
@ -6904,9 +6936,17 @@
"description": "Text shown when resetting the username link" "description": "Text shown when resetting the username link"
}, },
"icu:UsernameLinkModalBody__error__text": { "icu:UsernameLinkModalBody__error__text": {
"messageformat": "QR code and link not set. Check your network connection and try again.", "messageformat": "Something went wrong with your QR code and link, its no longer valid. Try resetting it to create a new QR code and link.",
"description": "Text of the confirmation dialog shown on username link error" "description": "Text of the confirmation dialog shown on username link error"
}, },
"icu:UsernameLinkModalBody__error__fix-now": {
"messageformat": "Fix now",
"description": "Text of the button in a confirmation dialog shown on username link error"
},
"icu:UsernameLinkModalBody__recovered__text": {
"messageformat": "Your QR code and link have been reset and a new QR code and link has been created.",
"description": "Text of the confirmation dialog shown on successful username link recovery"
},
"icu:UsernameOnboardingModalBody__title": { "icu:UsernameOnboardingModalBody__title": {
"messageformat": "New ways to connect", "messageformat": "New ways to connect",
"description": "Title of username onboarding modal" "description": "Title of username onboarding modal"

View file

@ -8,16 +8,16 @@
display: flex; display: flex;
font-size: 24px; font-size: 24px;
justify-content: center; justify-content: center;
width: 32px; width: 20px;
height: 32px; height: 20px;
} }
&::after { &::after {
-webkit-mask-size: 100%; -webkit-mask-size: 100%;
content: ''; content: '';
display: block; display: block;
height: 24px; height: 20px;
width: 24px; width: 20px;
@include light-theme { @include light-theme {
background-color: $color-gray-75; background-color: $color-gray-75;
@ -96,7 +96,8 @@
} }
&__row { &__row {
padding-inline: 0; padding-inline: 8px;
padding-block: 12px;
} }
&__divider { &__divider {
@ -112,10 +113,14 @@
} }
} }
hr {
margin-block: 24px 12px;
}
&__info { &__info {
@include font-body-2; @include font-body-2;
margin-block: 16px; margin-block: 12px;
margin-inline: 0; margin-inline: 8px;
@include light-theme { @include light-theme {
color: $color-gray-60; color: $color-gray-60;
@ -134,7 +139,6 @@
&__button { &__button {
width: 20px; width: 20px;
height: 20px; height: 20px;
margin: 4px;
@include dark-theme { @include dark-theme {
@include color-svg( @include color-svg(
@ -181,9 +185,8 @@
&__error-icon { &__error-icon {
-webkit-mask-size: 100%; -webkit-mask-size: 100%;
display: block; display: block;
height: 24px; height: 20px;
width: 24px; width: 20px;
margin: 4px;
@include light-theme { @include light-theme {
@include color-svg( @include color-svg(
@ -210,8 +213,8 @@
} }
&__icon { &__icon {
width: 24px; width: 20px;
height: 24px; height: 20px;
margin-block-start: 4px; margin-block-start: 4px;
margin-inline: 4px 12px; margin-inline: 4px 12px;
@ -256,14 +259,6 @@
} }
} }
} }
&__reset-username-modal__ModalHost__width-container {
max-width: 438px;
.module-Modal__button-footer {
justify-content: space-between;
}
}
} }
.ProfileEditor__Title { .ProfileEditor__Title {
@ -297,3 +292,18 @@
color: $color-white; color: $color-white;
} }
} }
.ProfileEditor__EditPhotoContainer {
display: flex;
justify-content: center;
margin-block-end: 16px;
}
.ProfileEditor__EditPhoto {
@include font-subtitle;
padding-block: 5px;
padding-inline: 10px;
border-radius: 14px;
font-weight: 600;
}

View file

@ -28,6 +28,9 @@ export default {
component: EditUsernameModalBody, component: EditUsernameModalBody,
title: 'Components/EditUsernameModalBody', title: 'Components/EditUsernameModalBody',
argTypes: { argTypes: {
usernameCorrupted: {
type: { name: 'boolean' },
},
currentUsername: { currentUsername: {
type: { name: 'string', required: false }, type: { name: 'string', required: false },
}, },
@ -57,6 +60,8 @@ export default {
}, },
}, },
args: { args: {
isRootModal: false,
usernameCorrupted: false,
currentUsername: undefined, currentUsername: undefined,
state: State.Open, state: State.Open,
error: undefined, error: undefined,

View file

@ -6,6 +6,7 @@ import classNames from 'classnames';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import type { UsernameReservationType } from '../types/Username'; import type { UsernameReservationType } from '../types/Username';
import { ToastType } from '../types/Toast';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { getNickname, getDiscriminator, isCaseChange } from '../types/Username'; import { getNickname, getDiscriminator, isCaseChange } from '../types/Username';
import { import {
@ -13,6 +14,7 @@ import {
UsernameReservationError, UsernameReservationError,
} from '../state/ducks/usernameEnums'; } from '../state/ducks/usernameEnums';
import type { ReserveUsernameOptionsType } from '../state/ducks/username'; import type { ReserveUsernameOptionsType } from '../state/ducks/username';
import type { ShowToastAction } from '../state/ducks/toast';
import { AutoSizeInput } from './AutoSizeInput'; import { AutoSizeInput } from './AutoSizeInput';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
@ -24,9 +26,11 @@ import { Button, ButtonVariant } from './Button';
export type PropsDataType = Readonly<{ export type PropsDataType = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
currentUsername?: string; currentUsername?: string;
usernameCorrupted: boolean;
reservation?: UsernameReservationType; reservation?: UsernameReservationType;
error?: UsernameReservationError; error?: UsernameReservationError;
state: UsernameReservationState; state: UsernameReservationState;
recoveredUsername: string | undefined;
minNickname: number; minNickname: number;
maxNickname: number; maxNickname: number;
}>; }>;
@ -38,10 +42,12 @@ export type ActionPropsDataType = Readonly<{
clearUsernameReservation(): void; clearUsernameReservation(): void;
reserveUsername(optiona: ReserveUsernameOptionsType): void; reserveUsername(optiona: ReserveUsernameOptionsType): void;
confirmUsername(): void; confirmUsername(): void;
showToast: ShowToastAction;
}>; }>;
export type ExternalPropsDataType = Readonly<{ export type ExternalPropsDataType = Readonly<{
onClose(): void; onClose(): void;
isRootModal: boolean;
}>; }>;
export type PropsType = PropsDataType & export type PropsType = PropsDataType &
@ -59,8 +65,10 @@ const DISCRIMINATOR_MAX_LENGTH = 19;
export function EditUsernameModalBody({ export function EditUsernameModalBody({
i18n, i18n,
currentUsername, currentUsername,
usernameCorrupted,
reserveUsername, reserveUsername,
confirmUsername, confirmUsername,
showToast,
minNickname, minNickname,
maxNickname, maxNickname,
reservation, reservation,
@ -68,6 +76,8 @@ export function EditUsernameModalBody({
clearUsernameReservation, clearUsernameReservation,
error, error,
state, state,
recoveredUsername,
isRootModal,
onClose, onClose,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const currentNickname = useMemo(() => { const currentNickname = useMemo(() => {
@ -87,6 +97,7 @@ export function EditUsernameModalBody({
const [nickname, setNickname] = useState(currentNickname); const [nickname, setNickname] = useState(currentNickname);
const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false); const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false);
const [isConfirmingSave, setIsConfirmingSave] = useState(false); const [isConfirmingSave, setIsConfirmingSave] = useState(false);
const [isConfirmingReset, setIsConfirmingReset] = useState(false);
const [customDiscriminator, setCustomDiscriminator] = useState< const [customDiscriminator, setCustomDiscriminator] = useState<
string | undefined string | undefined
@ -148,6 +159,21 @@ export function EditUsernameModalBody({
} }
}, [state, onClose]); }, [state, onClose]);
useEffect(() => {
if (
state === UsernameReservationState.Closed &&
recoveredUsername &&
isRootModal
) {
showToast({
toastType: ToastType.UsernameRecovered,
parameters: {
username: recoveredUsername,
},
});
}
}, [state, recoveredUsername, showToast, isRootModal]);
const errorString = useMemo(() => { const errorString = useMemo(() => {
if (!error) { if (!error) {
return undefined; return undefined;
@ -227,14 +253,17 @@ export function EditUsernameModalBody({
}, []); }, []);
const onSave = useCallback(() => { const onSave = useCallback(() => {
if (!currentUsername || (reservation && isCaseChange(reservation))) { if (usernameCorrupted) {
setIsConfirmingReset(true);
} else if (!currentUsername || (reservation && isCaseChange(reservation))) {
confirmUsername(); confirmUsername();
} else { } else {
setIsConfirmingSave(true); setIsConfirmingSave(true);
} }
}, [confirmUsername, currentUsername, reservation]); }, [confirmUsername, currentUsername, reservation, usernameCorrupted]);
const onCancelSave = useCallback(() => { const onCancelSave = useCallback(() => {
setIsConfirmingReset(false);
setIsConfirmingSave(false); setIsConfirmingSave(false);
}, []); }, []);
@ -406,6 +435,26 @@ export function EditUsernameModalBody({
{i18n('icu:EditUsernameModalBody__change-confirmation')} {i18n('icu:EditUsernameModalBody__change-confirmation')}
</ConfirmationDialog> </ConfirmationDialog>
)} )}
{isConfirmingReset && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.confirmReset"
cancelText={i18n('icu:cancel')}
actions={[
{
action: onConfirmUsername,
style: 'negative',
text: i18n(
'icu:EditUsernameModalBody__change-confirmation__continue'
),
},
]}
i18n={i18n}
onClose={onCancelSave}
>
{i18n('icu:EditUsernameModalBody__recover-confirmation')}
</ConfirmationDialog>
)}
</> </>
); );
} }

View file

@ -267,6 +267,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
), ),
selectedConversationId: undefined, selectedConversationId: undefined,
targetedMessageId: undefined, targetedMessageId: undefined,
openUsernameReservationModal: action('openUsernameReservationModal'),
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'), savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
searchInConversation: action('searchInConversation'), searchInConversation: action('searchInConversation'),
setComposeSearchTerm: action('setComposeSearchTerm'), setComposeSearchTerm: action('setComposeSearchTerm'),

View file

@ -49,6 +49,7 @@ import {
NavSidebarSearchHeader, NavSidebarSearchHeader,
} from './NavSidebar'; } from './NavSidebar';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import { EditState as ProfileEditorEditState } from './ProfileEditor';
import type { UnreadStats } from '../util/countUnreadStats'; import type { UnreadStats } from '../util/countUnreadStats';
export enum LeftPaneMode { export enum LeftPaneMode {
@ -119,6 +120,7 @@ export type PropsType = {
composeSaveAvatarToDisk: SaveAvatarToDiskActionType; composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => void; createGroup: () => void;
navTabsCollapsed: boolean; navTabsCollapsed: boolean;
openUsernameReservationModal: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
removeConversation: (conversationId: string) => void; removeConversation: (conversationId: string) => void;
@ -138,7 +140,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; toggleProfileEditor: (initialEditState?: ProfileEditorEditState) => void;
updateSearchTerm: (_: string) => void; updateSearchTerm: (_: string) => void;
// Render Props // Render Props
@ -193,6 +195,7 @@ export function LeftPane({
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
openUsernameReservationModal,
preferredWidthFromStorage, preferredWidthFromStorage,
removeConversation, removeConversation,
renderCaptchaDialog, renderCaptchaDialog,
@ -560,7 +563,10 @@ export function LeftPane({
maybeBanner = ( maybeBanner = (
<LeftPaneBanner <LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username--action-text')} actionText={i18n('icu:LeftPane--corrupted-username--action-text')}
onClick={toggleProfileEditor} onClick={() => {
openUsernameReservationModal();
toggleProfileEditor(ProfileEditorEditState.Username);
}}
> >
{i18n('icu:LeftPane--corrupted-username--text')} {i18n('icu:LeftPane--corrupted-username--text')}
</LeftPaneBanner> </LeftPaneBanner>
@ -569,7 +575,7 @@ export function LeftPane({
maybeBanner = ( maybeBanner = (
<LeftPaneBanner <LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username-link--action-text')} actionText={i18n('icu:LeftPane--corrupted-username-link--action-text')}
onClick={toggleProfileEditor} onClick={() => toggleProfileEditor(ProfileEditorEditState.UsernameLink)}
> >
{i18n('icu:LeftPane--corrupted-username-link--text')} {i18n('icu:LeftPane--corrupted-username-link--text')}
</LeftPaneBanner> </LeftPaneBanner>

View file

@ -75,6 +75,7 @@ export default {
customColors: {}, customColors: {},
defaultConversationColor: DEFAULT_CONVERSATION_COLOR, defaultConversationColor: DEFAULT_CONVERSATION_COLOR,
deviceName: 'Work Windows ME', deviceName: 'Work Windows ME',
phoneNumber: '+1 555 123-4567',
hasAudioNotifications: true, hasAudioNotifications: true,
hasAutoConvertEmoji: true, hasAutoConvertEmoji: true,
hasAutoDownloadUpdate: true, hasAutoDownloadUpdate: true,

View file

@ -101,6 +101,7 @@ export type PropsDataType = {
hasTypingIndicators: boolean; hasTypingIndicators: boolean;
lastSyncTime?: number; lastSyncTime?: number;
notificationContent: NotificationSettingType; notificationContent: NotificationSettingType;
phoneNumber: string | undefined;
selectedCamera?: string; selectedCamera?: string;
selectedMicrophone?: AudioDevice; selectedMicrophone?: AudioDevice;
selectedSpeaker?: AudioDevice; selectedSpeaker?: AudioDevice;
@ -325,6 +326,7 @@ export function Preferences({
onWhoCanSeeMeChange, onWhoCanSeeMeChange,
onWhoCanFindMeChange, onWhoCanFindMeChange,
onZoomFactorChange, onZoomFactorChange,
phoneNumber = '',
preferredSystemLocales, preferredSystemLocales,
removeCustomColor, removeCustomColor,
removeCustomColorOnConversations, removeCustomColorOnConversations,
@ -531,6 +533,10 @@ export function Preferences({
</div> </div>
</div> </div>
<SettingsRow> <SettingsRow>
<Control
left={i18n('icu:Preferences--phone-number')}
right={phoneNumber}
/>
<Control <Control
left={i18n('icu:Preferences--device-name')} left={i18n('icu:Preferences--device-name')}
right={deviceName} right={deviceName}

View file

@ -47,6 +47,9 @@ export default {
usernameLinkCorrupted: { usernameLinkCorrupted: {
control: 'boolean', control: 'boolean',
}, },
usernameLinkRecovered: {
control: 'boolean',
},
}, },
args: { args: {
aboutEmoji: '', aboutEmoji: '',
@ -78,6 +81,7 @@ export default {
showToast: action('showToast'), showToast: action('showToast'),
replaceAvatar: action('replaceAvatar'), replaceAvatar: action('replaceAvatar'),
resetUsernameLink: action('resetUsernameLink'), resetUsernameLink: action('resetUsernameLink'),
clearUsernameLinkRecovered: action('clearUsernameLinkRecovered'),
saveAvatarToDisk: action('saveAvatarToDisk'), saveAvatarToDisk: action('saveAvatarToDisk'),
markCompletedUsernameLinkOnboarding: action( markCompletedUsernameLinkOnboarding: action(
'markCompletedUsernameLinkOnboarding' 'markCompletedUsernameLinkOnboarding'
@ -89,6 +93,7 @@ export default {
} satisfies Meta<PropsType>; } satisfies Meta<PropsType>;
function renderEditUsernameModalBody(props: { function renderEditUsernameModalBody(props: {
isRootModal: boolean;
onClose: () => void; onClose: () => void;
}): JSX.Element { }): JSX.Element {
return ( return (
@ -98,10 +103,13 @@ function renderEditUsernameModalBody(props: {
maxNickname={20} maxNickname={20}
state={UsernameReservationState.Open} state={UsernameReservationState.Open}
error={undefined} error={undefined}
recoveredUsername={undefined}
usernameCorrupted={false}
setUsernameReservationError={action('setUsernameReservationError')} setUsernameReservationError={action('setUsernameReservationError')}
clearUsernameReservation={action('clearUsernameReservation')} clearUsernameReservation={action('clearUsernameReservation')}
reserveUsername={action('reserveUsername')} reserveUsername={action('reserveUsername')}
confirmUsername={action('confirmUsername')} confirmUsername={action('confirmUsername')}
showToast={action('showToast')}
{...props} {...props}
/> />
); );
@ -164,3 +172,10 @@ ConfirmingDelete.args = {
username: 'signaluser.123', username: 'signaluser.123',
usernameEditState: UsernameEditState.ConfirmingDelete, usernameEditState: UsernameEditState.ConfirmingDelete,
}; };
export const Corrupted = Template.bind({});
Corrupted.args = {
isUsernameFlagEnabled: true,
username: 'signaluser.123',
usernameCorrupted: true,
};

View file

@ -21,7 +21,6 @@ import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton'; import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton';
import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { EmojiPickDataType } from './emoji/EmojiPicker';
import { Input } from './Input'; import { Input } from './Input';
import { Intl } from './Intl';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { PanelRow } from './conversation/conversation-details/PanelRow'; import { PanelRow } from './conversation/conversation-details/PanelRow';
@ -62,7 +61,10 @@ type PropsExternalType = {
profileData: ProfileDataType, profileData: ProfileDataType,
avatar: AvatarUpdateType avatar: AvatarUpdateType
) => unknown; ) => unknown;
renderEditUsernameModalBody: (props: { onClose: () => void }) => JSX.Element; renderEditUsernameModalBody: (props: {
isRootModal: boolean;
onClose: () => void;
}) => JSX.Element;
}; };
export type PropsDataType = { export type PropsDataType = {
@ -76,12 +78,12 @@ export type PropsDataType = {
hasCompletedUsernameLinkOnboarding: boolean; hasCompletedUsernameLinkOnboarding: boolean;
i18n: LocalizerType; i18n: LocalizerType;
isUsernameFlagEnabled: boolean; isUsernameFlagEnabled: boolean;
phoneNumber?: string;
userAvatarData: ReadonlyArray<AvatarDataType>; userAvatarData: ReadonlyArray<AvatarDataType>;
username?: string; username?: string;
initialEditState?: EditState; initialEditState?: EditState;
usernameCorrupted: boolean; usernameCorrupted: boolean;
usernameEditState: UsernameEditState; usernameEditState: UsernameEditState;
usernameLinkRecovered: boolean;
usernameLinkState: UsernameLinkState; usernameLinkState: UsernameLinkState;
usernameLinkColor?: number; usernameLinkColor?: number;
usernameLink?: string; usernameLink?: string;
@ -97,7 +99,9 @@ type PropsActionType = {
saveAvatarToDisk: SaveAvatarToDiskActionType; saveAvatarToDisk: SaveAvatarToDiskActionType;
setUsernameEditState: (editState: UsernameEditState) => void; setUsernameEditState: (editState: UsernameEditState) => void;
setUsernameLinkColor: (color: number) => void; setUsernameLinkColor: (color: number) => void;
toggleProfileEditor: () => void;
resetUsernameLink: () => void; resetUsernameLink: () => void;
clearUsernameLinkRecovered: () => void;
deleteUsername: () => void; deleteUsername: () => void;
showToast: ShowToastAction; showToast: ShowToastAction;
openUsernameReservationModal: () => void; openUsernameReservationModal: () => void;
@ -138,6 +142,7 @@ function getDefaultBios(i18n: LocalizerType): Array<DefaultBio> {
export function ProfileEditor({ export function ProfileEditor({
aboutEmoji, aboutEmoji,
aboutText, aboutText,
clearUsernameLinkRecovered,
color, color,
conversationId, conversationId,
deleteAvatarFromDisk, deleteAvatarFromDisk,
@ -153,12 +158,12 @@ export function ProfileEditor({
onProfileChanged, onProfileChanged,
onSetSkinTone, onSetSkinTone,
openUsernameReservationModal, openUsernameReservationModal,
phoneNumber,
profileAvatarPath, profileAvatarPath,
recentEmojis, recentEmojis,
renderEditUsernameModalBody, renderEditUsernameModalBody,
replaceAvatar, replaceAvatar,
resetUsernameLink, resetUsernameLink,
toggleProfileEditor,
saveAttachment, saveAttachment,
saveAvatarToDisk, saveAvatarToDisk,
setUsernameEditState, setUsernameEditState,
@ -169,6 +174,7 @@ export function ProfileEditor({
username, username,
usernameCorrupted, usernameCorrupted,
usernameEditState, usernameEditState,
usernameLinkRecovered,
usernameLinkState, usernameLinkState,
usernameLinkColor, usernameLinkColor,
usernameLink, usernameLink,
@ -209,6 +215,7 @@ export function ProfileEditor({
firstName, firstName,
}); });
const [isResettingUsername, setIsResettingUsername] = useState(false); const [isResettingUsername, setIsResettingUsername] = useState(false);
const [isResettingUsernameLink, setIsResettingUsernameLink] = useState(false);
// Reset username edit state when leaving // Reset username edit state when leaving
useEffect(() => { useEffect(() => {
@ -276,6 +283,13 @@ export function ProfileEditor({
onEditStateChanged(editState); onEditStateChanged(editState);
}, [editState, onEditStateChanged]); }, [editState, onEditStateChanged]);
useEffect(() => {
// If we opened at a nested sub-modal - close when leaving it.
if (editState === EditState.None && initialEditState !== EditState.None) {
toggleProfileEditor();
}
}, [initialEditState, editState, toggleProfileEditor]);
// To make AvatarEditor re-render less often // To make AvatarEditor re-render less often
const handleAvatarLoaded = useCallback( const handleAvatarLoaded = useCallback(
avatar => { avatar => {
@ -512,6 +526,7 @@ export function ProfileEditor({
); );
} else if (editState === EditState.Username) { } else if (editState === EditState.Username) {
content = renderEditUsernameModalBody({ content = renderEditUsernameModalBody({
isRootModal: initialEditState === editState,
onClose: () => setEditState(EditState.None), onClose: () => setEditState(EditState.None),
}); });
} else if (editState === EditState.UsernameLink) { } else if (editState === EditState.UsernameLink) {
@ -522,9 +537,11 @@ export function ProfileEditor({
username={username ?? ''} username={username ?? ''}
colorId={usernameLinkColor} colorId={usernameLinkColor}
usernameLinkCorrupted={usernameLinkCorrupted} usernameLinkCorrupted={usernameLinkCorrupted}
usernameLinkRecovered={usernameLinkRecovered}
usernameLinkState={usernameLinkState} usernameLinkState={usernameLinkState}
setUsernameLinkColor={setUsernameLinkColor} setUsernameLinkColor={setUsernameLinkColor}
resetUsernameLink={resetUsernameLink} resetUsernameLink={resetUsernameLink}
clearUsernameLinkRecovered={clearUsernameLinkRecovered}
saveAttachment={saveAttachment} saveAttachment={saveAttachment}
showToast={showToast} showToast={showToast}
onBack={() => setEditState(EditState.None)} onBack={() => setEditState(EditState.None)}
@ -614,6 +631,11 @@ export function ProfileEditor({
} }
label={i18n('icu:ProfileEditor__username-link')} label={i18n('icu:ProfileEditor__username-link')}
onClick={() => { onClick={() => {
if (usernameLinkCorrupted) {
setIsResettingUsernameLink(true);
return;
}
setEditState(EditState.UsernameLink); setEditState(EditState.UsernameLink);
}} }}
alwaysShowActions alwaysShowActions
@ -656,6 +678,7 @@ export function ProfileEditor({
maybeUsernameRows = ( maybeUsernameRows = (
<> <>
<hr className="ProfileEditor__divider" />
<PanelRow <PanelRow
className="ProfileEditor__row" className="ProfileEditor__row"
icon={ icon={
@ -678,6 +701,11 @@ export function ProfileEditor({
actions={actions} actions={actions}
/> />
{maybeUsernameLinkRow} {maybeUsernameLinkRow}
<div className="ProfileEditor__info">
{username
? i18n('icu:ProfileEditor--info--pnp')
: i18n('icu:ProfileEditor--info--pnp--no-username')}
</div>
</> </>
); );
} }
@ -690,7 +718,6 @@ export function ProfileEditor({
avatarValue={avatarBuffer} avatarValue={avatarBuffer}
conversationTitle={getFullNameText()} conversationTitle={getFullNameText()}
i18n={i18n} i18n={i18n}
isEditable
onAvatarLoaded={handleAvatarLoaded} onAvatarLoaded={handleAvatarLoaded}
onClick={() => { onClick={() => {
setEditState(EditState.BetterAvatar); setEditState(EditState.BetterAvatar);
@ -700,11 +727,17 @@ export function ProfileEditor({
width: 80, width: 80,
}} }}
/> />
<h1 className="ProfileEditor__Title">{getFullNameText()}</h1> <div className="ProfileEditor__EditPhotoContainer">
{phoneNumber != null && ( <Button
<p className="ProfileEditor__PhoneNumber">{phoneNumber}</p> onClick={() => {
)} setEditState(EditState.BetterAvatar);
<hr className="ProfileEditor__divider" /> }}
variant={ButtonVariant.Secondary}
className="ProfileEditor__EditPhoto"
>
{i18n('icu:ProfileEditor--edit-photo')}
</Button>
</div>
<PanelRow <PanelRow
className="ProfileEditor__row" className="ProfileEditor__row"
icon={ icon={
@ -715,7 +748,6 @@ export function ProfileEditor({
setEditState(EditState.ProfileName); setEditState(EditState.ProfileName);
}} }}
/> />
{maybeUsernameRows}
<PanelRow <PanelRow
className="ProfileEditor__row" className="ProfileEditor__row"
icon={ icon={
@ -736,26 +768,10 @@ export function ProfileEditor({
setEditState(EditState.Bio); setEditState(EditState.Bio);
}} }}
/> />
<hr className="ProfileEditor__divider" />
<div className="ProfileEditor__info"> <div className="ProfileEditor__info">
<Intl {i18n('icu:ProfileEditor--info--general')}
i18n={i18n}
id="icu:ProfileEditor--info--link"
components={{
// This is a render prop, not a component
// eslint-disable-next-line react/no-unstable-nested-components
learnMoreLink: parts => (
<a
href="https://support.signal.org/hc/en-us/articles/360007459591"
target="_blank"
rel="noreferrer"
>
{parts}
</a>
),
}}
/>
</div> </div>
{maybeUsernameRows}
</> </>
); );
} else { } else {
@ -791,6 +807,28 @@ export function ProfileEditor({
/> />
)} )}
{isResettingUsernameLink && (
<ConfirmationDialog
i18n={i18n}
dialogName="UsernameLinkModal__error"
onClose={() => setIsResettingUsernameLink(false)}
cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:cancel')}
actions={[
{
action: () => {
setIsResettingUsernameLink(false);
setEditState(EditState.UsernameLink);
},
style: 'affirmative',
text: i18n('icu:UsernameLinkModalBody__error__fix-now'),
},
]}
>
{i18n('icu:UsernameLinkModalBody__error__text')}
</ConfirmationDialog>
)}
{isResettingUsername && ( {isResettingUsername && (
<ConfirmationDialog <ConfirmationDialog
dialogName="ProfileEditor.confirmResetUsername" dialogName="ProfileEditor.confirmResetUsername"
@ -799,15 +837,7 @@ export function ProfileEditor({
onClose={() => setIsResettingUsername(false)} onClose={() => setIsResettingUsername(false)}
actions={[ actions={[
{ {
text: i18n( text: i18n('icu:ProfileEditor--username--corrupted--fix-button'),
'icu:ProfileEditor--username--corrupted--delete-button'
),
action: () => deleteUsername(),
},
{
text: i18n(
'icu:ProfileEditor--username--corrupted--create-button'
),
style: 'affirmative', style: 'affirmative',
action: () => { action: () => {
openUsernameReservationModal(); openUsernameReservationModal();

View file

@ -74,6 +74,7 @@ export function ProfileEditorModal({
}} }}
onProfileChanged={myProfileChanged} onProfileChanged={myProfileChanged}
onSetSkinTone={onSetSkinTone} onSetSkinTone={onSetSkinTone}
toggleProfileEditor={toggleProfileEditor}
/> />
</Modal> </Modal>
); );

View file

@ -150,6 +150,13 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.UnsupportedMultiAttachment }; return { toastType: ToastType.UnsupportedMultiAttachment };
case ToastType.UnsupportedOS: case ToastType.UnsupportedOS:
return { toastType: ToastType.UnsupportedOS }; return { toastType: ToastType.UnsupportedOS };
case ToastType.UsernameRecovered:
return {
toastType: ToastType.UsernameRecovered,
parameters: {
username: 'maya.45',
},
};
case ToastType.UserAddedToGroup: case ToastType.UserAddedToGroup:
return { return {
toastType: ToastType.UserAddedToGroup, toastType: ToastType.UserAddedToGroup,

View file

@ -476,6 +476,16 @@ export function renderToast({
); );
} }
if (toastType === ToastType.UsernameRecovered) {
return (
<Toast onClose={hideToast}>
{i18n('icu:EditUsernameModalBody__username-recovered__text', {
username: toast.parameters.username,
})}
</Toast>
);
}
if (toastType === ToastType.UserAddedToGroup) { if (toastType === ToastType.UserAddedToGroup) {
return ( return (
<Toast onClose={hideToast}> <Toast onClose={hideToast}>

View file

@ -35,6 +35,9 @@ export default {
usernameLinkCorrupted: { usernameLinkCorrupted: {
control: 'boolean', control: 'boolean',
}, },
usernameLinkRecovered: {
control: 'boolean',
},
usernameLinkState: { usernameLinkState: {
control: { type: 'select' }, control: { type: 'select' },
options: [ options: [
@ -66,6 +69,7 @@ export default {
showToast: action('showToast'), showToast: action('showToast'),
resetUsernameLink: action('resetUsernameLink'), resetUsernameLink: action('resetUsernameLink'),
setUsernameLinkColor: action('setUsernameLinkColor'), setUsernameLinkColor: action('setUsernameLinkColor'),
clearUsernameLinkRecovered: action('clearUsernameLinkRecovered'),
onBack: action('onBack'), onBack: action('onBack'),
}, },
} satisfies Meta<PropsType>; } satisfies Meta<PropsType>;

View file

@ -29,9 +29,11 @@ export type PropsType = Readonly<{
colorId?: number; colorId?: number;
usernameLinkCorrupted: boolean; usernameLinkCorrupted: boolean;
usernameLinkState: UsernameLinkState; usernameLinkState: UsernameLinkState;
usernameLinkRecovered: boolean;
setUsernameLinkColor: (colorId: number) => void; setUsernameLinkColor: (colorId: number) => void;
resetUsernameLink: () => void; resetUsernameLink: () => void;
clearUsernameLinkRecovered: () => void;
saveAttachment: SaveAttachmentActionCreatorType; saveAttachment: SaveAttachmentActionCreatorType;
showToast: ShowToastAction; showToast: ShowToastAction;
onBack: () => void; onBack: () => void;
@ -532,10 +534,12 @@ export function UsernameLinkModalBody({
username, username,
usernameLinkCorrupted, usernameLinkCorrupted,
usernameLinkState, usernameLinkState,
usernameLinkRecovered,
colorId: initialColorId = ColorEnum.UNKNOWN, colorId: initialColorId = ColorEnum.UNKNOWN,
setUsernameLinkColor, setUsernameLinkColor,
resetUsernameLink, resetUsernameLink,
clearUsernameLinkRecovered,
saveAttachment, saveAttachment,
showToast, showToast,
@ -544,6 +548,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 [isRecovered, setIsRecovered] = useState(false);
const [showError, setShowError] = useState(false); const [showError, setShowError] = useState(false);
const [colorId, setColorId] = useState(initialColorId); const [colorId, setColorId] = useState(initialColorId);
@ -662,10 +667,17 @@ export function UsernameLinkModalBody({
}, []); }, []);
const onConfirmReset = useCallback(() => { const onConfirmReset = useCallback(() => {
setShowError(false);
setConfirmReset(false); setConfirmReset(false);
resetUsernameLink(); resetUsernameLink();
}, [resetUsernameLink]); }, [resetUsernameLink]);
const onCloseError = useCallback(() => {
if (showError) {
onBack();
}
}, [showError, onBack]);
useEffect(() => { useEffect(() => {
if (!usernameLinkCorrupted) { if (!usernameLinkCorrupted) {
return; return;
@ -682,12 +694,21 @@ export function UsernameLinkModalBody({
setShowError(true); setShowError(true);
}, [usernameLinkState]); }, [usernameLinkState]);
const onClearError = useCallback(() => { useEffect(() => {
setShowError(false); if (usernameLinkRecovered) {
setIsRecovered(true);
// Only show the modal once
clearUsernameLinkRecovered();
}
}, [usernameLinkRecovered, clearUsernameLinkRecovered]);
const onClearIsRecovered = useCallback(() => {
setIsRecovered(false);
}, []); }, []);
const isResettingLink = const isReady = usernameLinkState === UsernameLinkState.Ready;
usernameLinkCorrupted || usernameLinkState !== UsernameLinkState.Ready; const isResettingLink = usernameLinkCorrupted || !isReady;
const info = ( const info = (
<> <>
@ -754,7 +775,7 @@ export function UsernameLinkModalBody({
); );
let linkImage: JSX.Element | undefined; let linkImage: JSX.Element | undefined;
if (usernameLinkState === UsernameLinkState.Ready && link) { if (isReady && link) {
linkImage = ( linkImage = (
<svg <svg
className={`${CLASS}__card__qr__blotches`} className={`${CLASS}__card__qr__blotches`}
@ -820,11 +841,30 @@ export function UsernameLinkModalBody({
<ConfirmationDialog <ConfirmationDialog
i18n={i18n} i18n={i18n}
dialogName="UsernameLinkModal__error" dialogName="UsernameLinkModal__error"
onClose={onClearError} onClose={onCloseError}
cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:cancel')}
actions={[
{
action: onConfirmReset,
style: 'affirmative',
text: i18n('icu:UsernameLinkModalBody__error__fix-now'),
},
]}
>
{i18n('icu:UsernameLinkModalBody__error__text')}
</ConfirmationDialog>
)}
{isRecovered && (
<ConfirmationDialog
i18n={i18n}
dialogName="UsernameLinkModal__error"
onClose={onClearIsRecovered}
cancelButtonVariant={ButtonVariant.Secondary} cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:ok')} cancelText={i18n('icu:ok')}
> >
{i18n('icu:UsernameLinkModalBody__error__text')} {i18n('icu:UsernameLinkModalBody__recovered__text')}
</ConfirmationDialog> </ConfirmationDialog>
)} )}

View file

@ -44,6 +44,7 @@ export class SettingsChannel extends EventEmitter {
public install(): void { public install(): void {
this.installSetting('deviceName', { setter: false }); this.installSetting('deviceName', { setter: false });
this.installSetting('phoneNumber', { setter: false });
// ChatColorPicker redux hookups // ChatColorPicker redux hookups
this.installCallback('getCustomColors'); this.installCallback('getCustomColors');

View file

@ -16,6 +16,7 @@ import type { UsernameReservationType } from '../types/Username';
import { import {
ReserveUsernameError, ReserveUsernameError,
ConfirmUsernameResult, ConfirmUsernameResult,
ResetUsernameLinkResult,
getNickname, getNickname,
getDiscriminator, getDiscriminator,
isCaseChange, isCaseChange,
@ -245,10 +246,10 @@ export async function confirmUsername(
const { hash } = reservation; const { hash } = reservation;
strictAssert(usernames.hash(username).equals(hash), 'username hash mismatch'); strictAssert(usernames.hash(username).equals(hash), 'username hash mismatch');
const wasCorrupted = window.storage.get('usernameCorrupted');
try { try {
await window.storage.remove('usernameLink'); await window.storage.remove('usernameLink');
await window.storage.remove('usernameCorrupted');
await window.storage.remove('usernameLinkCorrupted');
let serverIdString: string; let serverIdString: string;
let entropy: Buffer; let entropy: Buffer;
@ -288,6 +289,8 @@ export async function confirmUsername(
}); });
await updateUsernameAndSyncProfile(username); await updateUsernameAndSyncProfile(username);
await window.storage.remove('usernameCorrupted');
await window.storage.remove('usernameLinkCorrupted');
} catch (error) { } catch (error) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
if (error.code === 413 || error.code === 429) { if (error.code === 413 || error.code === 429) {
@ -305,7 +308,9 @@ export async function confirmUsername(
throw error; throw error;
} }
return ConfirmUsernameResult.Ok; return wasCorrupted
? ConfirmUsernameResult.OkRecovered
: ConfirmUsernameResult.Ok;
} }
export async function deleteUsername( export async function deleteUsername(
@ -324,12 +329,14 @@ 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 window.storage.remove('usernameCorrupted');
await updateUsernameAndSyncProfile(undefined); await updateUsernameAndSyncProfile(undefined);
} }
export async function resetLink(username: string): Promise<void> { export async function resetLink(
username: string
): Promise<ResetUsernameLinkResult> {
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!');
@ -343,8 +350,9 @@ export async function resetLink(username: string): Promise<void> {
const { entropy, encryptedUsername } = usernames.createUsernameLink(username); const { entropy, encryptedUsername } = usernames.createUsernameLink(username);
const wasCorrupted = window.storage.get('usernameLinkCorrupted');
await window.storage.remove('usernameLink'); await window.storage.remove('usernameLink');
await window.storage.remove('usernameLinkCorrupted');
const { usernameLinkHandle: serverIdString } = const { usernameLinkHandle: serverIdString } =
await server.replaceUsernameLink({ await server.replaceUsernameLink({
@ -356,9 +364,14 @@ export async function resetLink(username: string): Promise<void> {
entropy, entropy,
serverId: uuidToBytes(serverIdString), serverId: uuidToBytes(serverIdString),
}); });
await window.storage.remove('usernameLinkCorrupted');
me.captureChange('usernameLink'); me.captureChange('usernameLink');
storageServiceUploadJob(); storageServiceUploadJob();
return wasCorrupted
? ResetUsernameLinkResult.OkRecovered
: ResetUsernameLinkResult.Ok;
} }
const USERNAME_LINK_ENTROPY_SIZE = 32; const USERNAME_LINK_ENTROPY_SIZE = 32;

View file

@ -8,6 +8,7 @@ import type { UsernameReservationType } from '../../types/Username';
import { import {
ReserveUsernameError, ReserveUsernameError,
ConfirmUsernameResult, ConfirmUsernameResult,
ResetUsernameLinkResult,
} from '../../types/Username'; } from '../../types/Username';
import * as usernameServices from '../../services/username'; import * as usernameServices from '../../services/username';
import { storageServiceUploadJob } from '../../services/storage'; import { storageServiceUploadJob } from '../../services/storage';
@ -33,6 +34,7 @@ import { useBoundActions } from '../../hooks/useBoundActions';
export type UsernameReservationStateType = ReadonlyDeep<{ export type UsernameReservationStateType = ReadonlyDeep<{
state: UsernameReservationState; state: UsernameReservationState;
recoveredUsername?: string;
reservation?: UsernameReservationType; reservation?: UsernameReservationType;
error?: UsernameReservationError; error?: UsernameReservationError;
abortController?: AbortController; abortController?: AbortController;
@ -44,6 +46,7 @@ export type UsernameStateType = ReadonlyDeep<{
// UsernameLinkModalBody // UsernameLinkModalBody
linkState: UsernameLinkState; linkState: UsernameLinkState;
linkRecovered: boolean;
// EditUsernameModalBody // EditUsernameModalBody
usernameReservation: UsernameReservationStateType; usernameReservation: UsernameReservationStateType;
@ -60,6 +63,7 @@ const RESERVE_USERNAME = 'username/RESERVE_USERNAME';
const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME'; const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME';
const DELETE_USERNAME = 'username/DELETE_USERNAME'; const DELETE_USERNAME = 'username/DELETE_USERNAME';
const RESET_USERNAME_LINK = 'username/RESET_USERNAME_LINK'; const RESET_USERNAME_LINK = 'username/RESET_USERNAME_LINK';
const CLEAR_USERNAME_LINK_RECOVERED = 'username/CLEAR_USERNAME_LINK_RECOVERED';
type SetUsernameEditStateActionType = ReadonlyDeep<{ type SetUsernameEditStateActionType = ReadonlyDeep<{
type: typeof SET_USERNAME_EDIT_STATE; type: typeof SET_USERNAME_EDIT_STATE;
@ -83,7 +87,7 @@ type SetUsernameReservationErrorActionType = ReadonlyDeep<{
}; };
}>; }>;
type ClearUsernameReservation = ReadonlyDeep<{ type ClearUsernameReservationActionType = ReadonlyDeep<{
type: typeof CLEAR_USERNAME_RESERVATION; type: typeof CLEAR_USERNAME_RESERVATION;
}>; }>;
@ -101,19 +105,23 @@ type DeleteUsernameActionType = ReadonlyDeep<
PromiseAction<typeof DELETE_USERNAME, void> PromiseAction<typeof DELETE_USERNAME, void>
>; >;
type ResetUsernameLinkActionType = ReadonlyDeep< type ResetUsernameLinkActionType = ReadonlyDeep<
PromiseAction<typeof RESET_USERNAME_LINK, void> PromiseAction<typeof RESET_USERNAME_LINK, ResetUsernameLinkResult>
>; >;
type ClearUsernameLinkRecoveredActionType = ReadonlyDeep<{
type: typeof CLEAR_USERNAME_LINK_RECOVERED;
}>;
export type UsernameActionType = ReadonlyDeep< export type UsernameActionType = ReadonlyDeep<
| SetUsernameEditStateActionType | SetUsernameEditStateActionType
| OpenUsernameReservationModalActionType | OpenUsernameReservationModalActionType
| CloseUsernameReservationModalActionType | CloseUsernameReservationModalActionType
| SetUsernameReservationErrorActionType | SetUsernameReservationErrorActionType
| ClearUsernameReservation | ClearUsernameReservationActionType
| ReserveUsernameActionType | ReserveUsernameActionType
| ConfirmUsernameActionType | ConfirmUsernameActionType
| DeleteUsernameActionType | DeleteUsernameActionType
| ResetUsernameLinkActionType | ResetUsernameLinkActionType
| ClearUsernameLinkRecoveredActionType
>; >;
export const actions = { export const actions = {
@ -127,6 +135,7 @@ export const actions = {
deleteUsername, deleteUsername,
markCompletedUsernameOnboarding, markCompletedUsernameOnboarding,
resetUsernameLink, resetUsernameLink,
clearUsernameLinkRecovered,
setUsernameLinkColor, setUsernameLinkColor,
markCompletedUsernameLinkOnboarding, markCompletedUsernameLinkOnboarding,
}; };
@ -165,7 +174,7 @@ export function setUsernameReservationError(
}; };
} }
export function clearUsernameReservation(): ClearUsernameReservation { export function clearUsernameReservation(): ClearUsernameReservationActionType {
return { return {
type: CLEAR_USERNAME_RESERVATION, type: CLEAR_USERNAME_RESERVATION,
}; };
@ -352,12 +361,19 @@ function setUsernameLinkColor(
}; };
} }
export function clearUsernameLinkRecovered(): ClearUsernameLinkRecoveredActionType {
return {
type: CLEAR_USERNAME_LINK_RECOVERED,
};
}
// Reducers // Reducers
export function getEmptyState(): UsernameStateType { export function getEmptyState(): UsernameStateType {
return { return {
editState: UsernameEditState.Editing, editState: UsernameEditState.Editing,
linkState: UsernameLinkState.Ready, linkState: UsernameLinkState.Ready,
linkRecovered: false,
usernameReservation: { usernameReservation: {
state: UsernameReservationState.Closed, state: UsernameReservationState.Closed,
}, },
@ -370,6 +386,17 @@ export function reducer(
): UsernameStateType { ): UsernameStateType {
const { usernameReservation } = state; const { usernameReservation } = state;
if (action.type === OPEN_USERNAME_RESERVATION_MODAL) {
return {
...state,
editState: UsernameEditState.Editing,
linkState: UsernameLinkState.Ready,
usernameReservation: {
state: UsernameReservationState.Open,
},
};
}
if (action.type === SET_USERNAME_EDIT_STATE) { if (action.type === SET_USERNAME_EDIT_STATE) {
const { editState } = action.payload; const { editState } = action.payload;
return { return {
@ -378,15 +405,6 @@ export function reducer(
}; };
} }
if (action.type === OPEN_USERNAME_RESERVATION_MODAL) {
return {
...state,
usernameReservation: {
state: UsernameReservationState.Open,
},
};
}
if (action.type === CLOSE_USERNAME_RESERVATION_MODAL) { if (action.type === CLOSE_USERNAME_RESERVATION_MODAL) {
return { return {
...state, ...state,
@ -546,6 +564,20 @@ export function reducer(
}, },
}; };
} }
if (payload === ConfirmUsernameResult.OkRecovered) {
const { reservation } = state.usernameReservation;
assertDev(
reservation !== undefined,
'Must be reserving before resolving confirmation'
);
return {
...state,
usernameReservation: {
state: UsernameReservationState.Closed,
recoveredUsername: reservation.username,
},
};
}
if (payload === ConfirmUsernameResult.ConflictOrGone) { if (payload === ConfirmUsernameResult.ConflictOrGone) {
return { return {
...state, ...state,
@ -597,13 +629,16 @@ export function reducer(
return { return {
...state, ...state,
linkState: UsernameLinkState.Updating, linkState: UsernameLinkState.Updating,
linkRecovered: false,
}; };
} }
if (action.type === 'username/RESET_USERNAME_LINK_FULFILLED') { if (action.type === 'username/RESET_USERNAME_LINK_FULFILLED') {
const { payload } = action;
return { return {
...state, ...state,
linkState: UsernameLinkState.Ready, linkState: UsernameLinkState.Ready,
linkRecovered: payload === ResetUsernameLinkResult.OkRecovered,
}; };
} }
@ -611,6 +646,14 @@ export function reducer(
return { return {
...state, ...state,
linkState: UsernameLinkState.Error, linkState: UsernameLinkState.Error,
linkRecovered: false,
};
}
if (action.type === 'username/CLEAR_USERNAME_LINK_RECOVERED') {
return {
...state,
linkRecovered: false,
}; };
} }

View file

@ -29,6 +29,11 @@ export const getUsernameLinkState = createSelector(
(state: UsernameStateType): UsernameLinkState => state.linkState (state: UsernameStateType): UsernameLinkState => state.linkState
); );
export const getUsernameLinkRecovered = createSelector(
getUsernameState,
(state: UsernameStateType): boolean => state.linkRecovered
);
export const getUsernameReservation = createSelector( export const getUsernameReservation = createSelector(
getUsernameState, getUsernameState,
(state: UsernameStateType): UsernameReservationStateType => (state: UsernameStateType): UsernameReservationStateType =>
@ -54,3 +59,9 @@ export const getUsernameReservationError = createSelector(
reservation: UsernameReservationStateType reservation: UsernameReservationStateType
): UsernameReservationError | undefined => reservation.error ): UsernameReservationError | undefined => reservation.error
); );
export const getRecoveredUsername = createSelector(
getUsernameReservation,
(reservation: UsernameReservationStateType): string | undefined =>
reservation.recoveredUsername
);

View file

@ -14,6 +14,7 @@ import {
getUsernameReservationState, getUsernameReservationState,
getUsernameReservationObject, getUsernameReservationObject,
getUsernameReservationError, getUsernameReservationError,
getRecoveredUsername,
} from '../selectors/username'; } from '../selectors/username';
import { getUsernameCorrupted } from '../selectors/items'; import { getUsernameCorrupted } from '../selectors/items';
import { getMe } from '../selectors/conversations'; import { getMe } from '../selectors/conversations';
@ -25,10 +26,12 @@ function mapStateToProps(state: StateType): PropsDataType {
return { return {
i18n, i18n,
usernameCorrupted,
currentUsername: usernameCorrupted ? undefined : username, currentUsername: usernameCorrupted ? undefined : username,
minNickname: getMinNickname(), minNickname: getMinNickname(),
maxNickname: getMaxNickname(), maxNickname: getMaxNickname(),
state: getUsernameReservationState(state), state: getUsernameReservationState(state),
recoveredUsername: getRecoveredUsername(state),
reservation: getUsernameReservationObject(state), reservation: getUsernameReservationObject(state),
error: getUsernameReservationError(state), error: getUsernameReservationError(state),
}; };

View file

@ -25,9 +25,11 @@ import { selectRecentEmojis } from '../selectors/emojis';
import { import {
getUsernameEditState, getUsernameEditState,
getUsernameLinkState, getUsernameLinkState,
getUsernameLinkRecovered,
} from '../selectors/username'; } from '../selectors/username';
function renderEditUsernameModalBody(props: { function renderEditUsernameModalBody(props: {
isRootModal: boolean;
onClose: () => void; onClose: () => void;
}): JSX.Element { }): JSX.Element {
return <SmartEditUsernameModalBody {...props} />; return <SmartEditUsernameModalBody {...props} />;
@ -46,7 +48,6 @@ function mapStateToProps(
firstName, firstName,
familyName, familyName,
id: conversationId, id: conversationId,
phoneNumber,
username, username,
} = getMe(state); } = getMe(state);
const recentEmojis = selectRecentEmojis(state); const recentEmojis = selectRecentEmojis(state);
@ -56,6 +57,7 @@ function mapStateToProps(
getHasCompletedUsernameLinkOnboarding(state); getHasCompletedUsernameLinkOnboarding(state);
const usernameEditState = getUsernameEditState(state); const usernameEditState = getUsernameEditState(state);
const usernameLinkState = getUsernameLinkState(state); const usernameLinkState = getUsernameLinkState(state);
const usernameLinkRecovered = getUsernameLinkRecovered(state);
const usernameLinkColor = getUsernameLinkColor(state); const usernameLinkColor = getUsernameLinkColor(state);
const usernameLink = getUsernameLink(state); const usernameLink = getUsernameLink(state);
const usernameCorrupted = getUsernameCorrupted(state); const usernameCorrupted = getUsernameCorrupted(state);
@ -76,7 +78,6 @@ function mapStateToProps(
isUsernameFlagEnabled, isUsernameFlagEnabled,
recentEmojis, recentEmojis,
skinTone, skinTone,
phoneNumber,
userAvatarData, userAvatarData,
username, username,
usernameCorrupted, usernameCorrupted,
@ -84,6 +85,7 @@ function mapStateToProps(
usernameLinkState, usernameLinkState,
usernameLinkColor, usernameLinkColor,
usernameLinkCorrupted, usernameLinkCorrupted,
usernameLinkRecovered,
usernameLink, usernameLink,
renderEditUsernameModalBody, renderEditUsernameModalBody,

View file

@ -58,6 +58,7 @@ export enum ToastType {
UnsupportedMultiAttachment = 'UnsupportedMultiAttachment', UnsupportedMultiAttachment = 'UnsupportedMultiAttachment',
UnsupportedOS = 'UnsupportedOS', UnsupportedOS = 'UnsupportedOS',
UserAddedToGroup = 'UserAddedToGroup', UserAddedToGroup = 'UserAddedToGroup',
UsernameRecovered = 'UsernameRecovered',
VoiceNoteLimit = 'VoiceNoteLimit', VoiceNoteLimit = 'VoiceNoteLimit',
VoiceNoteMustBeTheOnlyAttachment = 'VoiceNoteMustBeTheOnlyAttachment', VoiceNoteMustBeTheOnlyAttachment = 'VoiceNoteMustBeTheOnlyAttachment',
WhoCanFindMeReadOnly = 'WhoCanFindMeReadOnly', WhoCanFindMeReadOnly = 'WhoCanFindMeReadOnly',
@ -138,6 +139,10 @@ export type AnyToast =
toastType: ToastType.UserAddedToGroup; toastType: ToastType.UserAddedToGroup;
parameters: { contact: string; group: string }; parameters: { contact: string; group: string };
} }
| {
toastType: ToastType.UsernameRecovered;
parameters: { username: string };
}
| { toastType: ToastType.VoiceNoteLimit } | { toastType: ToastType.VoiceNoteLimit }
| { toastType: ToastType.VoiceNoteMustBeTheOnlyAttachment } | { toastType: ToastType.VoiceNoteMustBeTheOnlyAttachment }
| { toastType: ToastType.WhoCanFindMeReadOnly }; | { toastType: ToastType.WhoCanFindMeReadOnly };

View file

@ -23,9 +23,15 @@ export enum ReserveUsernameError {
export enum ConfirmUsernameResult { export enum ConfirmUsernameResult {
Ok = 'Ok', Ok = 'Ok',
OkRecovered = 'OkRecovered',
ConflictOrGone = 'ConflictOrGone', ConflictOrGone = 'ConflictOrGone',
} }
export enum ResetUsernameLinkResult {
Ok = 'Ok',
OkRecovered = 'OkRecovered',
}
export function getUsernameFromSearch(searchTerm: string): string | undefined { export function getUsernameFromSearch(searchTerm: string): string | undefined {
try { try {
window.SignalContext.usernames.hash(searchTerm); window.SignalContext.usernames.hash(searchTerm);

View file

@ -43,6 +43,7 @@ import { StoryViewModeType, StoryViewTargetType } from '../types/Stories';
import { isValidE164 } from './isValidE164'; import { isValidE164 } from './isValidE164';
import { fromWebSafeBase64 } from './webSafeBase64'; import { fromWebSafeBase64 } from './webSafeBase64';
import { getConversation } from './getConversation'; import { getConversation } from './getConversation';
import { instance, PhoneNumberFormat } from './libphonenumberInstance';
type SentMediaQualityType = 'standard' | 'high'; type SentMediaQualityType = 'standard' | 'high';
type ThemeType = 'light' | 'dark' | 'system'; type ThemeType = 'light' | 'dark' | 'system';
@ -90,6 +91,7 @@ export type IPCEventsValuesType = {
readReceiptSetting: boolean; readReceiptSetting: boolean;
typingIndicatorSetting: boolean; typingIndicatorSetting: boolean;
deviceName: string | undefined; deviceName: string | undefined;
phoneNumber: string | undefined;
}; };
export type IPCEventsCallbacksType = { export type IPCEventsCallbacksType = {
@ -158,6 +160,7 @@ type ValuesWithSetters = Omit<
| 'readReceiptSetting' | 'readReceiptSetting'
| 'typingIndicatorSetting' | 'typingIndicatorSetting'
| 'deviceName' | 'deviceName'
| 'phoneNumber'
// Optional // Optional
| 'mediaPermissions' | 'mediaPermissions'
@ -222,6 +225,11 @@ export function createIPCEvents(
}, },
getDeviceName: () => window.textsecure.storage.user.getDeviceName(), getDeviceName: () => window.textsecure.storage.user.getDeviceName(),
getPhoneNumber: () => {
const e164 = window.textsecure.storage.user.getNumber();
const parsedNumber = instance.parse(e164);
return instance.format(parsedNumber, PhoneNumberFormat.INTERNATIONAL);
},
getZoomFactor: () => { getZoomFactor: () => {
return ipcRenderer.invoke('getZoomFactor'); return ipcRenderer.invoke('getZoomFactor');

View file

@ -44,6 +44,7 @@ installSetting('callRingtoneNotification');
installSetting('callSystemNotification'); installSetting('callSystemNotification');
installSetting('countMutedConversations'); installSetting('countMutedConversations');
installSetting('deviceName'); installSetting('deviceName');
installSetting('phoneNumber');
installSetting('hasStoriesDisabled'); installSetting('hasStoriesDisabled');
installSetting('hideMenuBar'); installSetting('hideMenuBar');
installSetting('incomingCallNotification'); installSetting('incomingCallNotification');

View file

@ -28,6 +28,7 @@ SettingsWindowProps.onRender(
customColors, customColors,
defaultConversationColor, defaultConversationColor,
deviceName, deviceName,
phoneNumber,
doDeleteAllData, doDeleteAllData,
doneRendering, doneRendering,
editCustomColor, editCustomColor,
@ -129,6 +130,7 @@ SettingsWindowProps.onRender(
customColors={customColors} customColors={customColors}
defaultConversationColor={defaultConversationColor} defaultConversationColor={defaultConversationColor}
deviceName={deviceName} deviceName={deviceName}
phoneNumber={phoneNumber}
doDeleteAllData={doDeleteAllData} doDeleteAllData={doDeleteAllData}
doneRendering={doneRendering} doneRendering={doneRendering}
editCustomColor={editCustomColor} editCustomColor={editCustomColor}

View file

@ -31,6 +31,7 @@ const settingCallRingtoneNotification = createSetting(
const settingCallSystemNotification = createSetting('callSystemNotification'); const settingCallSystemNotification = createSetting('callSystemNotification');
const settingCountMutedConversations = createSetting('countMutedConversations'); const settingCountMutedConversations = createSetting('countMutedConversations');
const settingDeviceName = createSetting('deviceName', { setter: false }); const settingDeviceName = createSetting('deviceName', { setter: false });
const settingPhoneNumber = createSetting('phoneNumber', { setter: false });
const settingHideMenuBar = createSetting('hideMenuBar'); const settingHideMenuBar = createSetting('hideMenuBar');
const settingIncomingCallNotification = createSetting( const settingIncomingCallNotification = createSetting(
'incomingCallNotification' 'incomingCallNotification'
@ -163,6 +164,7 @@ async function renderPreferences() {
isPhoneNumberSharingSupported, isPhoneNumberSharingSupported,
lastSyncTime, lastSyncTime,
notificationContent, notificationContent,
phoneNumber,
selectedCamera, selectedCamera,
selectedMicrophone, selectedMicrophone,
selectedSpeaker, selectedSpeaker,
@ -205,6 +207,7 @@ async function renderPreferences() {
isPhoneNumberSharingSupported: ipcPNP(), isPhoneNumberSharingSupported: ipcPNP(),
lastSyncTime: settingLastSyncTime.getValue(), lastSyncTime: settingLastSyncTime.getValue(),
notificationContent: settingNotificationSetting.getValue(), notificationContent: settingNotificationSetting.getValue(),
phoneNumber: settingPhoneNumber.getValue(),
selectedCamera: settingVideoInput.getValue(), selectedCamera: settingVideoInput.getValue(),
selectedMicrophone: settingAudioInput.getValue(), selectedMicrophone: settingAudioInput.getValue(),
selectedSpeaker: settingAudioOutput.getValue(), selectedSpeaker: settingAudioOutput.getValue(),
@ -275,6 +278,7 @@ async function renderPreferences() {
lastSyncTime, lastSyncTime,
localeOverride, localeOverride,
notificationContent, notificationContent,
phoneNumber,
preferredSystemLocales, preferredSystemLocales,
resolvedLocale, resolvedLocale,
selectedCamera, selectedCamera,