diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0fbadd01bf..6b0d8c9e1f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1637,8 +1637,12 @@ "messageformat": "My Computer", "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": { - "messageformat": "Device name", + "messageformat": "Device Name", "description": "The label in settings panel shown for the user-provided name for this desktop instance" }, "icu:chooseDeviceName": { @@ -5512,15 +5516,19 @@ "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.", + "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 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" + "description": "(Deleted 02/01/2024) Button text for deletion of the username in case of corruption" }, "icu:ProfileEditor--username--corrupted--create-button": { "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" }, "icu:ProfileEditor__username-link": { @@ -5651,9 +5659,25 @@ "messageformat": "Would you like to discard these 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": { "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. Learn More", - "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 aren’t 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 don’t 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": { "messageformat": "Speak Freely", @@ -6840,7 +6864,7 @@ "description": "Placeholder for the username field" }, "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" }, "icu:EditUsernameModalBody__learn-more": { @@ -6863,6 +6887,14 @@ "messageformat": "Continue", "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": { "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." @@ -6896,7 +6928,7 @@ "description": "ARIA label of button for selecting username link color" }, "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" }, "icu:UsernameLinkModalBody__resetting-link": { @@ -6904,9 +6936,17 @@ "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.", + "messageformat": "Something went wrong with your QR code and link, it’s 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" }, + "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": { "messageformat": "New ways to connect", "description": "Title of username onboarding modal" diff --git a/stylesheets/components/ProfileEditor.scss b/stylesheets/components/ProfileEditor.scss index cc5d31e2de..f4f4f1adb9 100644 --- a/stylesheets/components/ProfileEditor.scss +++ b/stylesheets/components/ProfileEditor.scss @@ -8,16 +8,16 @@ display: flex; font-size: 24px; justify-content: center; - width: 32px; - height: 32px; + width: 20px; + height: 20px; } &::after { -webkit-mask-size: 100%; content: ''; display: block; - height: 24px; - width: 24px; + height: 20px; + width: 20px; @include light-theme { background-color: $color-gray-75; @@ -96,7 +96,8 @@ } &__row { - padding-inline: 0; + padding-inline: 8px; + padding-block: 12px; } &__divider { @@ -112,10 +113,14 @@ } } + hr { + margin-block: 24px 12px; + } + &__info { @include font-body-2; - margin-block: 16px; - margin-inline: 0; + margin-block: 12px; + margin-inline: 8px; @include light-theme { color: $color-gray-60; @@ -134,7 +139,6 @@ &__button { width: 20px; height: 20px; - margin: 4px; @include dark-theme { @include color-svg( @@ -181,9 +185,8 @@ &__error-icon { -webkit-mask-size: 100%; display: block; - height: 24px; - width: 24px; - margin: 4px; + height: 20px; + width: 20px; @include light-theme { @include color-svg( @@ -210,8 +213,8 @@ } &__icon { - width: 24px; - height: 24px; + width: 20px; + height: 20px; margin-block-start: 4px; 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 { @@ -297,3 +292,18 @@ 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; +} diff --git a/ts/components/EditUsernameModalBody.stories.tsx b/ts/components/EditUsernameModalBody.stories.tsx index 28f0248823..ff7d4665b7 100644 --- a/ts/components/EditUsernameModalBody.stories.tsx +++ b/ts/components/EditUsernameModalBody.stories.tsx @@ -28,6 +28,9 @@ export default { component: EditUsernameModalBody, title: 'Components/EditUsernameModalBody', argTypes: { + usernameCorrupted: { + type: { name: 'boolean' }, + }, currentUsername: { type: { name: 'string', required: false }, }, @@ -57,6 +60,8 @@ export default { }, }, args: { + isRootModal: false, + usernameCorrupted: false, currentUsername: undefined, state: State.Open, error: undefined, diff --git a/ts/components/EditUsernameModalBody.tsx b/ts/components/EditUsernameModalBody.tsx index 85b3b69770..52e8ca541e 100644 --- a/ts/components/EditUsernameModalBody.tsx +++ b/ts/components/EditUsernameModalBody.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import type { LocalizerType } from '../types/Util'; import type { UsernameReservationType } from '../types/Username'; +import { ToastType } from '../types/Toast'; import { missingCaseError } from '../util/missingCaseError'; import { getNickname, getDiscriminator, isCaseChange } from '../types/Username'; import { @@ -13,6 +14,7 @@ import { UsernameReservationError, } from '../state/ducks/usernameEnums'; import type { ReserveUsernameOptionsType } from '../state/ducks/username'; +import type { ShowToastAction } from '../state/ducks/toast'; import { AutoSizeInput } from './AutoSizeInput'; import { ConfirmationDialog } from './ConfirmationDialog'; @@ -24,9 +26,11 @@ import { Button, ButtonVariant } from './Button'; export type PropsDataType = Readonly<{ i18n: LocalizerType; currentUsername?: string; + usernameCorrupted: boolean; reservation?: UsernameReservationType; error?: UsernameReservationError; state: UsernameReservationState; + recoveredUsername: string | undefined; minNickname: number; maxNickname: number; }>; @@ -38,10 +42,12 @@ export type ActionPropsDataType = Readonly<{ clearUsernameReservation(): void; reserveUsername(optiona: ReserveUsernameOptionsType): void; confirmUsername(): void; + showToast: ShowToastAction; }>; export type ExternalPropsDataType = Readonly<{ onClose(): void; + isRootModal: boolean; }>; export type PropsType = PropsDataType & @@ -59,8 +65,10 @@ const DISCRIMINATOR_MAX_LENGTH = 19; export function EditUsernameModalBody({ i18n, currentUsername, + usernameCorrupted, reserveUsername, confirmUsername, + showToast, minNickname, maxNickname, reservation, @@ -68,6 +76,8 @@ export function EditUsernameModalBody({ clearUsernameReservation, error, state, + recoveredUsername, + isRootModal, onClose, }: PropsType): JSX.Element { const currentNickname = useMemo(() => { @@ -87,6 +97,7 @@ export function EditUsernameModalBody({ const [nickname, setNickname] = useState(currentNickname); const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false); const [isConfirmingSave, setIsConfirmingSave] = useState(false); + const [isConfirmingReset, setIsConfirmingReset] = useState(false); const [customDiscriminator, setCustomDiscriminator] = useState< string | undefined @@ -148,6 +159,21 @@ export function EditUsernameModalBody({ } }, [state, onClose]); + useEffect(() => { + if ( + state === UsernameReservationState.Closed && + recoveredUsername && + isRootModal + ) { + showToast({ + toastType: ToastType.UsernameRecovered, + parameters: { + username: recoveredUsername, + }, + }); + } + }, [state, recoveredUsername, showToast, isRootModal]); + const errorString = useMemo(() => { if (!error) { return undefined; @@ -227,14 +253,17 @@ export function EditUsernameModalBody({ }, []); const onSave = useCallback(() => { - if (!currentUsername || (reservation && isCaseChange(reservation))) { + if (usernameCorrupted) { + setIsConfirmingReset(true); + } else if (!currentUsername || (reservation && isCaseChange(reservation))) { confirmUsername(); } else { setIsConfirmingSave(true); } - }, [confirmUsername, currentUsername, reservation]); + }, [confirmUsername, currentUsername, reservation, usernameCorrupted]); const onCancelSave = useCallback(() => { + setIsConfirmingReset(false); setIsConfirmingSave(false); }, []); @@ -406,6 +435,26 @@ export function EditUsernameModalBody({ {i18n('icu:EditUsernameModalBody__change-confirmation')} )} + + {isConfirmingReset && ( + + {i18n('icu:EditUsernameModalBody__recover-confirmation')} + + )} ); } diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index d8f58e4f6f..f5c1ea3fbb 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -267,6 +267,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { ), selectedConversationId: undefined, targetedMessageId: undefined, + openUsernameReservationModal: action('openUsernameReservationModal'), savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'), searchInConversation: action('searchInConversation'), setComposeSearchTerm: action('setComposeSearchTerm'), diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 5f00f7b820..2394ed1dcf 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -49,6 +49,7 @@ import { NavSidebarSearchHeader, } from './NavSidebar'; import { ContextMenu } from './ContextMenu'; +import { EditState as ProfileEditorEditState } from './ProfileEditor'; import type { UnreadStats } from '../util/countUnreadStats'; export enum LeftPaneMode { @@ -119,6 +120,7 @@ export type PropsType = { composeSaveAvatarToDisk: SaveAvatarToDiskActionType; createGroup: () => void; navTabsCollapsed: boolean; + openUsernameReservationModal: () => void; onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void; removeConversation: (conversationId: string) => void; @@ -138,7 +140,7 @@ export type PropsType = { toggleComposeEditingAvatar: () => unknown; toggleConversationInChooseMembers: (conversationId: string) => void; toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; - toggleProfileEditor: () => void; + toggleProfileEditor: (initialEditState?: ProfileEditorEditState) => void; updateSearchTerm: (_: string) => void; // Render Props @@ -193,6 +195,7 @@ export function LeftPane({ onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, + openUsernameReservationModal, preferredWidthFromStorage, removeConversation, renderCaptchaDialog, @@ -560,7 +563,10 @@ export function LeftPane({ maybeBanner = ( { + openUsernameReservationModal(); + toggleProfileEditor(ProfileEditorEditState.Username); + }} > {i18n('icu:LeftPane--corrupted-username--text')} @@ -569,7 +575,7 @@ export function LeftPane({ maybeBanner = ( toggleProfileEditor(ProfileEditorEditState.UsernameLink)} > {i18n('icu:LeftPane--corrupted-username-link--text')} diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 9c80209c4f..2271b5b377 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -75,6 +75,7 @@ export default { customColors: {}, defaultConversationColor: DEFAULT_CONVERSATION_COLOR, deviceName: 'Work Windows ME', + phoneNumber: '+1 555 123-4567', hasAudioNotifications: true, hasAutoConvertEmoji: true, hasAutoDownloadUpdate: true, diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 83cd55dd93..09cb726334 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -101,6 +101,7 @@ export type PropsDataType = { hasTypingIndicators: boolean; lastSyncTime?: number; notificationContent: NotificationSettingType; + phoneNumber: string | undefined; selectedCamera?: string; selectedMicrophone?: AudioDevice; selectedSpeaker?: AudioDevice; @@ -325,6 +326,7 @@ export function Preferences({ onWhoCanSeeMeChange, onWhoCanFindMeChange, onZoomFactorChange, + phoneNumber = '', preferredSystemLocales, removeCustomColor, removeCustomColorOnConversations, @@ -531,6 +533,10 @@ export function Preferences({ + ; function renderEditUsernameModalBody(props: { + isRootModal: boolean; onClose: () => void; }): JSX.Element { return ( @@ -98,10 +103,13 @@ function renderEditUsernameModalBody(props: { maxNickname={20} state={UsernameReservationState.Open} error={undefined} + recoveredUsername={undefined} + usernameCorrupted={false} setUsernameReservationError={action('setUsernameReservationError')} clearUsernameReservation={action('clearUsernameReservation')} reserveUsername={action('reserveUsername')} confirmUsername={action('confirmUsername')} + showToast={action('showToast')} {...props} /> ); @@ -164,3 +172,10 @@ ConfirmingDelete.args = { username: 'signaluser.123', usernameEditState: UsernameEditState.ConfirmingDelete, }; + +export const Corrupted = Template.bind({}); +Corrupted.args = { + isUsernameFlagEnabled: true, + username: 'signaluser.123', + usernameCorrupted: true, +}; diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index 0e258b1c7e..bd43628db2 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -21,7 +21,6 @@ import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import { EmojiButton, EmojiButtonVariant } from './emoji/EmojiButton'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import { Input } from './Input'; -import { Intl } from './Intl'; import type { LocalizerType } from '../types/Util'; import { Modal } from './Modal'; import { PanelRow } from './conversation/conversation-details/PanelRow'; @@ -62,7 +61,10 @@ type PropsExternalType = { profileData: ProfileDataType, avatar: AvatarUpdateType ) => unknown; - renderEditUsernameModalBody: (props: { onClose: () => void }) => JSX.Element; + renderEditUsernameModalBody: (props: { + isRootModal: boolean; + onClose: () => void; + }) => JSX.Element; }; export type PropsDataType = { @@ -76,12 +78,12 @@ export type PropsDataType = { hasCompletedUsernameLinkOnboarding: boolean; i18n: LocalizerType; isUsernameFlagEnabled: boolean; - phoneNumber?: string; userAvatarData: ReadonlyArray; username?: string; initialEditState?: EditState; usernameCorrupted: boolean; usernameEditState: UsernameEditState; + usernameLinkRecovered: boolean; usernameLinkState: UsernameLinkState; usernameLinkColor?: number; usernameLink?: string; @@ -97,7 +99,9 @@ type PropsActionType = { saveAvatarToDisk: SaveAvatarToDiskActionType; setUsernameEditState: (editState: UsernameEditState) => void; setUsernameLinkColor: (color: number) => void; + toggleProfileEditor: () => void; resetUsernameLink: () => void; + clearUsernameLinkRecovered: () => void; deleteUsername: () => void; showToast: ShowToastAction; openUsernameReservationModal: () => void; @@ -138,6 +142,7 @@ function getDefaultBios(i18n: LocalizerType): Array { export function ProfileEditor({ aboutEmoji, aboutText, + clearUsernameLinkRecovered, color, conversationId, deleteAvatarFromDisk, @@ -153,12 +158,12 @@ export function ProfileEditor({ onProfileChanged, onSetSkinTone, openUsernameReservationModal, - phoneNumber, profileAvatarPath, recentEmojis, renderEditUsernameModalBody, replaceAvatar, resetUsernameLink, + toggleProfileEditor, saveAttachment, saveAvatarToDisk, setUsernameEditState, @@ -169,6 +174,7 @@ export function ProfileEditor({ username, usernameCorrupted, usernameEditState, + usernameLinkRecovered, usernameLinkState, usernameLinkColor, usernameLink, @@ -209,6 +215,7 @@ export function ProfileEditor({ firstName, }); const [isResettingUsername, setIsResettingUsername] = useState(false); + const [isResettingUsernameLink, setIsResettingUsernameLink] = useState(false); // Reset username edit state when leaving useEffect(() => { @@ -276,6 +283,13 @@ export function ProfileEditor({ onEditStateChanged(editState); }, [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 const handleAvatarLoaded = useCallback( avatar => { @@ -512,6 +526,7 @@ export function ProfileEditor({ ); } else if (editState === EditState.Username) { content = renderEditUsernameModalBody({ + isRootModal: initialEditState === editState, onClose: () => setEditState(EditState.None), }); } else if (editState === EditState.UsernameLink) { @@ -522,9 +537,11 @@ export function ProfileEditor({ username={username ?? ''} colorId={usernameLinkColor} usernameLinkCorrupted={usernameLinkCorrupted} + usernameLinkRecovered={usernameLinkRecovered} usernameLinkState={usernameLinkState} setUsernameLinkColor={setUsernameLinkColor} resetUsernameLink={resetUsernameLink} + clearUsernameLinkRecovered={clearUsernameLinkRecovered} saveAttachment={saveAttachment} showToast={showToast} onBack={() => setEditState(EditState.None)} @@ -614,6 +631,11 @@ export function ProfileEditor({ } label={i18n('icu:ProfileEditor__username-link')} onClick={() => { + if (usernameLinkCorrupted) { + setIsResettingUsernameLink(true); + return; + } + setEditState(EditState.UsernameLink); }} alwaysShowActions @@ -656,6 +678,7 @@ export function ProfileEditor({ maybeUsernameRows = ( <> +
{maybeUsernameLinkRow} +
+ {username + ? i18n('icu:ProfileEditor--info--pnp') + : i18n('icu:ProfileEditor--info--pnp--no-username')} +
); } @@ -690,7 +718,6 @@ export function ProfileEditor({ avatarValue={avatarBuffer} conversationTitle={getFullNameText()} i18n={i18n} - isEditable onAvatarLoaded={handleAvatarLoaded} onClick={() => { setEditState(EditState.BetterAvatar); @@ -700,11 +727,17 @@ export function ProfileEditor({ width: 80, }} /> -

{getFullNameText()}

- {phoneNumber != null && ( -

{phoneNumber}

- )} -
+
+ +
- {maybeUsernameRows} -
- ( - - {parts} - - ), - }} - /> + {i18n('icu:ProfileEditor--info--general')}
+ {maybeUsernameRows} ); } else { @@ -791,6 +807,28 @@ export function ProfileEditor({ /> )} + {isResettingUsernameLink && ( + 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')} + + )} + {isResettingUsername && ( setIsResettingUsername(false)} actions={[ { - text: i18n( - 'icu:ProfileEditor--username--corrupted--delete-button' - ), - action: () => deleteUsername(), - }, - { - text: i18n( - 'icu:ProfileEditor--username--corrupted--create-button' - ), + text: i18n('icu:ProfileEditor--username--corrupted--fix-button'), style: 'affirmative', action: () => { openUsernameReservationModal(); diff --git a/ts/components/ProfileEditorModal.tsx b/ts/components/ProfileEditorModal.tsx index 2b08000c15..c8cff53f2f 100644 --- a/ts/components/ProfileEditorModal.tsx +++ b/ts/components/ProfileEditorModal.tsx @@ -74,6 +74,7 @@ export function ProfileEditorModal({ }} onProfileChanged={myProfileChanged} onSetSkinTone={onSetSkinTone} + toggleProfileEditor={toggleProfileEditor} /> ); diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 705ac327b5..7e5a31654d 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -150,6 +150,13 @@ function getToast(toastType: ToastType): AnyToast { return { toastType: ToastType.UnsupportedMultiAttachment }; case ToastType.UnsupportedOS: return { toastType: ToastType.UnsupportedOS }; + case ToastType.UsernameRecovered: + return { + toastType: ToastType.UsernameRecovered, + parameters: { + username: 'maya.45', + }, + }; case ToastType.UserAddedToGroup: return { toastType: ToastType.UserAddedToGroup, diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index 4abacb84d6..6d7750126d 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -476,6 +476,16 @@ export function renderToast({ ); } + if (toastType === ToastType.UsernameRecovered) { + return ( + + {i18n('icu:EditUsernameModalBody__username-recovered__text', { + username: toast.parameters.username, + })} + + ); + } + if (toastType === ToastType.UserAddedToGroup) { return ( diff --git a/ts/components/UsernameLinkModalBody.stories.tsx b/ts/components/UsernameLinkModalBody.stories.tsx index 4865cded67..737f10a822 100644 --- a/ts/components/UsernameLinkModalBody.stories.tsx +++ b/ts/components/UsernameLinkModalBody.stories.tsx @@ -35,6 +35,9 @@ export default { usernameLinkCorrupted: { control: 'boolean', }, + usernameLinkRecovered: { + control: 'boolean', + }, usernameLinkState: { control: { type: 'select' }, options: [ @@ -66,6 +69,7 @@ export default { showToast: action('showToast'), resetUsernameLink: action('resetUsernameLink'), setUsernameLinkColor: action('setUsernameLinkColor'), + clearUsernameLinkRecovered: action('clearUsernameLinkRecovered'), onBack: action('onBack'), }, } satisfies Meta; diff --git a/ts/components/UsernameLinkModalBody.tsx b/ts/components/UsernameLinkModalBody.tsx index 1adf8e698c..06d708375b 100644 --- a/ts/components/UsernameLinkModalBody.tsx +++ b/ts/components/UsernameLinkModalBody.tsx @@ -29,9 +29,11 @@ export type PropsType = Readonly<{ colorId?: number; usernameLinkCorrupted: boolean; usernameLinkState: UsernameLinkState; + usernameLinkRecovered: boolean; setUsernameLinkColor: (colorId: number) => void; resetUsernameLink: () => void; + clearUsernameLinkRecovered: () => void; saveAttachment: SaveAttachmentActionCreatorType; showToast: ShowToastAction; onBack: () => void; @@ -532,10 +534,12 @@ export function UsernameLinkModalBody({ username, usernameLinkCorrupted, usernameLinkState, + usernameLinkRecovered, colorId: initialColorId = ColorEnum.UNKNOWN, setUsernameLinkColor, resetUsernameLink, + clearUsernameLinkRecovered, saveAttachment, showToast, @@ -544,6 +548,7 @@ export function UsernameLinkModalBody({ const [pngData, setPngData] = useState(); const [showColors, setShowColors] = useState(false); const [confirmReset, setConfirmReset] = useState(false); + const [isRecovered, setIsRecovered] = useState(false); const [showError, setShowError] = useState(false); const [colorId, setColorId] = useState(initialColorId); @@ -662,10 +667,17 @@ export function UsernameLinkModalBody({ }, []); const onConfirmReset = useCallback(() => { + setShowError(false); setConfirmReset(false); resetUsernameLink(); }, [resetUsernameLink]); + const onCloseError = useCallback(() => { + if (showError) { + onBack(); + } + }, [showError, onBack]); + useEffect(() => { if (!usernameLinkCorrupted) { return; @@ -682,12 +694,21 @@ export function UsernameLinkModalBody({ setShowError(true); }, [usernameLinkState]); - const onClearError = useCallback(() => { - setShowError(false); + useEffect(() => { + if (usernameLinkRecovered) { + setIsRecovered(true); + + // Only show the modal once + clearUsernameLinkRecovered(); + } + }, [usernameLinkRecovered, clearUsernameLinkRecovered]); + + const onClearIsRecovered = useCallback(() => { + setIsRecovered(false); }, []); - const isResettingLink = - usernameLinkCorrupted || usernameLinkState !== UsernameLinkState.Ready; + const isReady = usernameLinkState === UsernameLinkState.Ready; + const isResettingLink = usernameLinkCorrupted || !isReady; const info = ( <> @@ -754,7 +775,7 @@ export function UsernameLinkModalBody({ ); let linkImage: JSX.Element | undefined; - if (usernameLinkState === UsernameLinkState.Ready && link) { + if (isReady && link) { linkImage = ( + {i18n('icu:UsernameLinkModalBody__error__text')} + + )} + + {isRecovered && ( + - {i18n('icu:UsernameLinkModalBody__error__text')} + {i18n('icu:UsernameLinkModalBody__recovered__text')} )} diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts index cfb6755aac..6dd4e7b8a0 100644 --- a/ts/main/settingsChannel.ts +++ b/ts/main/settingsChannel.ts @@ -44,6 +44,7 @@ export class SettingsChannel extends EventEmitter { public install(): void { this.installSetting('deviceName', { setter: false }); + this.installSetting('phoneNumber', { setter: false }); // ChatColorPicker redux hookups this.installCallback('getCustomColors'); diff --git a/ts/services/username.ts b/ts/services/username.ts index f071d0b9c8..874874ada4 100644 --- a/ts/services/username.ts +++ b/ts/services/username.ts @@ -16,6 +16,7 @@ import type { UsernameReservationType } from '../types/Username'; import { ReserveUsernameError, ConfirmUsernameResult, + ResetUsernameLinkResult, getNickname, getDiscriminator, isCaseChange, @@ -245,10 +246,10 @@ export async function confirmUsername( const { hash } = reservation; strictAssert(usernames.hash(username).equals(hash), 'username hash mismatch'); + const wasCorrupted = window.storage.get('usernameCorrupted'); + try { await window.storage.remove('usernameLink'); - await window.storage.remove('usernameCorrupted'); - await window.storage.remove('usernameLinkCorrupted'); let serverIdString: string; let entropy: Buffer; @@ -288,6 +289,8 @@ export async function confirmUsername( }); await updateUsernameAndSyncProfile(username); + await window.storage.remove('usernameCorrupted'); + await window.storage.remove('usernameLinkCorrupted'); } catch (error) { if (error instanceof HTTPError) { if (error.code === 413 || error.code === 429) { @@ -305,7 +308,9 @@ export async function confirmUsername( throw error; } - return ConfirmUsernameResult.Ok; + return wasCorrupted + ? ConfirmUsernameResult.OkRecovered + : ConfirmUsernameResult.Ok; } export async function deleteUsername( @@ -324,12 +329,14 @@ export async function deleteUsername( } await window.storage.remove('usernameLink'); - await window.storage.remove('usernameCorrupted'); await server.deleteUsername(abortSignal); + await window.storage.remove('usernameCorrupted'); await updateUsernameAndSyncProfile(undefined); } -export async function resetLink(username: string): Promise { +export async function resetLink( + username: string +): Promise { const { server } = window.textsecure; if (!server) { throw new Error('server interface is not available!'); @@ -343,8 +350,9 @@ export async function resetLink(username: string): Promise { const { entropy, encryptedUsername } = usernames.createUsernameLink(username); + const wasCorrupted = window.storage.get('usernameLinkCorrupted'); + await window.storage.remove('usernameLink'); - await window.storage.remove('usernameLinkCorrupted'); const { usernameLinkHandle: serverIdString } = await server.replaceUsernameLink({ @@ -356,9 +364,14 @@ export async function resetLink(username: string): Promise { entropy, serverId: uuidToBytes(serverIdString), }); + await window.storage.remove('usernameLinkCorrupted'); me.captureChange('usernameLink'); storageServiceUploadJob(); + + return wasCorrupted + ? ResetUsernameLinkResult.OkRecovered + : ResetUsernameLinkResult.Ok; } const USERNAME_LINK_ENTROPY_SIZE = 32; diff --git a/ts/state/ducks/username.ts b/ts/state/ducks/username.ts index f7e5a994a4..9f36638d92 100644 --- a/ts/state/ducks/username.ts +++ b/ts/state/ducks/username.ts @@ -8,6 +8,7 @@ import type { UsernameReservationType } from '../../types/Username'; import { ReserveUsernameError, ConfirmUsernameResult, + ResetUsernameLinkResult, } from '../../types/Username'; import * as usernameServices from '../../services/username'; import { storageServiceUploadJob } from '../../services/storage'; @@ -33,6 +34,7 @@ import { useBoundActions } from '../../hooks/useBoundActions'; export type UsernameReservationStateType = ReadonlyDeep<{ state: UsernameReservationState; + recoveredUsername?: string; reservation?: UsernameReservationType; error?: UsernameReservationError; abortController?: AbortController; @@ -44,6 +46,7 @@ export type UsernameStateType = ReadonlyDeep<{ // UsernameLinkModalBody linkState: UsernameLinkState; + linkRecovered: boolean; // EditUsernameModalBody usernameReservation: UsernameReservationStateType; @@ -60,6 +63,7 @@ const RESERVE_USERNAME = 'username/RESERVE_USERNAME'; const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME'; const DELETE_USERNAME = 'username/DELETE_USERNAME'; const RESET_USERNAME_LINK = 'username/RESET_USERNAME_LINK'; +const CLEAR_USERNAME_LINK_RECOVERED = 'username/CLEAR_USERNAME_LINK_RECOVERED'; type SetUsernameEditStateActionType = ReadonlyDeep<{ type: typeof SET_USERNAME_EDIT_STATE; @@ -83,7 +87,7 @@ type SetUsernameReservationErrorActionType = ReadonlyDeep<{ }; }>; -type ClearUsernameReservation = ReadonlyDeep<{ +type ClearUsernameReservationActionType = ReadonlyDeep<{ type: typeof CLEAR_USERNAME_RESERVATION; }>; @@ -101,19 +105,23 @@ type DeleteUsernameActionType = ReadonlyDeep< PromiseAction >; type ResetUsernameLinkActionType = ReadonlyDeep< - PromiseAction + PromiseAction >; +type ClearUsernameLinkRecoveredActionType = ReadonlyDeep<{ + type: typeof CLEAR_USERNAME_LINK_RECOVERED; +}>; export type UsernameActionType = ReadonlyDeep< | SetUsernameEditStateActionType | OpenUsernameReservationModalActionType | CloseUsernameReservationModalActionType | SetUsernameReservationErrorActionType - | ClearUsernameReservation + | ClearUsernameReservationActionType | ReserveUsernameActionType | ConfirmUsernameActionType | DeleteUsernameActionType | ResetUsernameLinkActionType + | ClearUsernameLinkRecoveredActionType >; export const actions = { @@ -127,6 +135,7 @@ export const actions = { deleteUsername, markCompletedUsernameOnboarding, resetUsernameLink, + clearUsernameLinkRecovered, setUsernameLinkColor, markCompletedUsernameLinkOnboarding, }; @@ -165,7 +174,7 @@ export function setUsernameReservationError( }; } -export function clearUsernameReservation(): ClearUsernameReservation { +export function clearUsernameReservation(): ClearUsernameReservationActionType { return { type: CLEAR_USERNAME_RESERVATION, }; @@ -352,12 +361,19 @@ function setUsernameLinkColor( }; } +export function clearUsernameLinkRecovered(): ClearUsernameLinkRecoveredActionType { + return { + type: CLEAR_USERNAME_LINK_RECOVERED, + }; +} + // Reducers export function getEmptyState(): UsernameStateType { return { editState: UsernameEditState.Editing, linkState: UsernameLinkState.Ready, + linkRecovered: false, usernameReservation: { state: UsernameReservationState.Closed, }, @@ -370,6 +386,17 @@ export function reducer( ): UsernameStateType { 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) { const { editState } = action.payload; 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) { return { ...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) { return { ...state, @@ -597,13 +629,16 @@ export function reducer( return { ...state, linkState: UsernameLinkState.Updating, + linkRecovered: false, }; } if (action.type === 'username/RESET_USERNAME_LINK_FULFILLED') { + const { payload } = action; return { ...state, linkState: UsernameLinkState.Ready, + linkRecovered: payload === ResetUsernameLinkResult.OkRecovered, }; } @@ -611,6 +646,14 @@ export function reducer( return { ...state, linkState: UsernameLinkState.Error, + linkRecovered: false, + }; + } + + if (action.type === 'username/CLEAR_USERNAME_LINK_RECOVERED') { + return { + ...state, + linkRecovered: false, }; } diff --git a/ts/state/selectors/username.ts b/ts/state/selectors/username.ts index 6a5bc4dd23..8e2c9497be 100644 --- a/ts/state/selectors/username.ts +++ b/ts/state/selectors/username.ts @@ -29,6 +29,11 @@ export const getUsernameLinkState = createSelector( (state: UsernameStateType): UsernameLinkState => state.linkState ); +export const getUsernameLinkRecovered = createSelector( + getUsernameState, + (state: UsernameStateType): boolean => state.linkRecovered +); + export const getUsernameReservation = createSelector( getUsernameState, (state: UsernameStateType): UsernameReservationStateType => @@ -54,3 +59,9 @@ export const getUsernameReservationError = createSelector( reservation: UsernameReservationStateType ): UsernameReservationError | undefined => reservation.error ); + +export const getRecoveredUsername = createSelector( + getUsernameReservation, + (reservation: UsernameReservationStateType): string | undefined => + reservation.recoveredUsername +); diff --git a/ts/state/smart/EditUsernameModalBody.tsx b/ts/state/smart/EditUsernameModalBody.tsx index 5f7aab0752..0f621b39e9 100644 --- a/ts/state/smart/EditUsernameModalBody.tsx +++ b/ts/state/smart/EditUsernameModalBody.tsx @@ -14,6 +14,7 @@ import { getUsernameReservationState, getUsernameReservationObject, getUsernameReservationError, + getRecoveredUsername, } from '../selectors/username'; import { getUsernameCorrupted } from '../selectors/items'; import { getMe } from '../selectors/conversations'; @@ -25,10 +26,12 @@ function mapStateToProps(state: StateType): PropsDataType { return { i18n, + usernameCorrupted, currentUsername: usernameCorrupted ? undefined : username, minNickname: getMinNickname(), maxNickname: getMaxNickname(), state: getUsernameReservationState(state), + recoveredUsername: getRecoveredUsername(state), reservation: getUsernameReservationObject(state), error: getUsernameReservationError(state), }; diff --git a/ts/state/smart/ProfileEditorModal.tsx b/ts/state/smart/ProfileEditorModal.tsx index 39c339ef38..953ad38507 100644 --- a/ts/state/smart/ProfileEditorModal.tsx +++ b/ts/state/smart/ProfileEditorModal.tsx @@ -25,9 +25,11 @@ import { selectRecentEmojis } from '../selectors/emojis'; import { getUsernameEditState, getUsernameLinkState, + getUsernameLinkRecovered, } from '../selectors/username'; function renderEditUsernameModalBody(props: { + isRootModal: boolean; onClose: () => void; }): JSX.Element { return ; @@ -46,7 +48,6 @@ function mapStateToProps( firstName, familyName, id: conversationId, - phoneNumber, username, } = getMe(state); const recentEmojis = selectRecentEmojis(state); @@ -56,6 +57,7 @@ function mapStateToProps( getHasCompletedUsernameLinkOnboarding(state); const usernameEditState = getUsernameEditState(state); const usernameLinkState = getUsernameLinkState(state); + const usernameLinkRecovered = getUsernameLinkRecovered(state); const usernameLinkColor = getUsernameLinkColor(state); const usernameLink = getUsernameLink(state); const usernameCorrupted = getUsernameCorrupted(state); @@ -76,7 +78,6 @@ function mapStateToProps( isUsernameFlagEnabled, recentEmojis, skinTone, - phoneNumber, userAvatarData, username, usernameCorrupted, @@ -84,6 +85,7 @@ function mapStateToProps( usernameLinkState, usernameLinkColor, usernameLinkCorrupted, + usernameLinkRecovered, usernameLink, renderEditUsernameModalBody, diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 60fb454cbb..669a79f7ed 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -58,6 +58,7 @@ export enum ToastType { UnsupportedMultiAttachment = 'UnsupportedMultiAttachment', UnsupportedOS = 'UnsupportedOS', UserAddedToGroup = 'UserAddedToGroup', + UsernameRecovered = 'UsernameRecovered', VoiceNoteLimit = 'VoiceNoteLimit', VoiceNoteMustBeTheOnlyAttachment = 'VoiceNoteMustBeTheOnlyAttachment', WhoCanFindMeReadOnly = 'WhoCanFindMeReadOnly', @@ -138,6 +139,10 @@ export type AnyToast = toastType: ToastType.UserAddedToGroup; parameters: { contact: string; group: string }; } + | { + toastType: ToastType.UsernameRecovered; + parameters: { username: string }; + } | { toastType: ToastType.VoiceNoteLimit } | { toastType: ToastType.VoiceNoteMustBeTheOnlyAttachment } | { toastType: ToastType.WhoCanFindMeReadOnly }; diff --git a/ts/types/Username.ts b/ts/types/Username.ts index 74b7d4e137..106e0a30e2 100644 --- a/ts/types/Username.ts +++ b/ts/types/Username.ts @@ -23,9 +23,15 @@ export enum ReserveUsernameError { export enum ConfirmUsernameResult { Ok = 'Ok', + OkRecovered = 'OkRecovered', ConflictOrGone = 'ConflictOrGone', } +export enum ResetUsernameLinkResult { + Ok = 'Ok', + OkRecovered = 'OkRecovered', +} + export function getUsernameFromSearch(searchTerm: string): string | undefined { try { window.SignalContext.usernames.hash(searchTerm); diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 6cc6bf6f54..727380781e 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -43,6 +43,7 @@ import { StoryViewModeType, StoryViewTargetType } from '../types/Stories'; import { isValidE164 } from './isValidE164'; import { fromWebSafeBase64 } from './webSafeBase64'; import { getConversation } from './getConversation'; +import { instance, PhoneNumberFormat } from './libphonenumberInstance'; type SentMediaQualityType = 'standard' | 'high'; type ThemeType = 'light' | 'dark' | 'system'; @@ -90,6 +91,7 @@ export type IPCEventsValuesType = { readReceiptSetting: boolean; typingIndicatorSetting: boolean; deviceName: string | undefined; + phoneNumber: string | undefined; }; export type IPCEventsCallbacksType = { @@ -158,6 +160,7 @@ type ValuesWithSetters = Omit< | 'readReceiptSetting' | 'typingIndicatorSetting' | 'deviceName' + | 'phoneNumber' // Optional | 'mediaPermissions' @@ -222,6 +225,11 @@ export function createIPCEvents( }, 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: () => { return ipcRenderer.invoke('getZoomFactor'); diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index 6368b53d97..b17e8333fe 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -44,6 +44,7 @@ installSetting('callRingtoneNotification'); installSetting('callSystemNotification'); installSetting('countMutedConversations'); installSetting('deviceName'); +installSetting('phoneNumber'); installSetting('hasStoriesDisabled'); installSetting('hideMenuBar'); installSetting('incomingCallNotification'); diff --git a/ts/windows/settings/app.tsx b/ts/windows/settings/app.tsx index ec6f42ea1c..3d12c15521 100644 --- a/ts/windows/settings/app.tsx +++ b/ts/windows/settings/app.tsx @@ -28,6 +28,7 @@ SettingsWindowProps.onRender( customColors, defaultConversationColor, deviceName, + phoneNumber, doDeleteAllData, doneRendering, editCustomColor, @@ -129,6 +130,7 @@ SettingsWindowProps.onRender( customColors={customColors} defaultConversationColor={defaultConversationColor} deviceName={deviceName} + phoneNumber={phoneNumber} doDeleteAllData={doDeleteAllData} doneRendering={doneRendering} editCustomColor={editCustomColor} diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts index aa8c826bcc..1d2a9cc05a 100644 --- a/ts/windows/settings/preload.ts +++ b/ts/windows/settings/preload.ts @@ -31,6 +31,7 @@ const settingCallRingtoneNotification = createSetting( const settingCallSystemNotification = createSetting('callSystemNotification'); const settingCountMutedConversations = createSetting('countMutedConversations'); const settingDeviceName = createSetting('deviceName', { setter: false }); +const settingPhoneNumber = createSetting('phoneNumber', { setter: false }); const settingHideMenuBar = createSetting('hideMenuBar'); const settingIncomingCallNotification = createSetting( 'incomingCallNotification' @@ -163,6 +164,7 @@ async function renderPreferences() { isPhoneNumberSharingSupported, lastSyncTime, notificationContent, + phoneNumber, selectedCamera, selectedMicrophone, selectedSpeaker, @@ -205,6 +207,7 @@ async function renderPreferences() { isPhoneNumberSharingSupported: ipcPNP(), lastSyncTime: settingLastSyncTime.getValue(), notificationContent: settingNotificationSetting.getValue(), + phoneNumber: settingPhoneNumber.getValue(), selectedCamera: settingVideoInput.getValue(), selectedMicrophone: settingAudioInput.getValue(), selectedSpeaker: settingAudioOutput.getValue(), @@ -275,6 +278,7 @@ async function renderPreferences() { lastSyncTime, localeOverride, notificationContent, + phoneNumber, preferredSystemLocales, resolvedLocale, selectedCamera,