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

@ -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,

View file

@ -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')}
</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,
targetedMessageId: undefined,
openUsernameReservationModal: action('openUsernameReservationModal'),
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
searchInConversation: action('searchInConversation'),
setComposeSearchTerm: action('setComposeSearchTerm'),

View file

@ -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 = (
<LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username--action-text')}
onClick={toggleProfileEditor}
onClick={() => {
openUsernameReservationModal();
toggleProfileEditor(ProfileEditorEditState.Username);
}}
>
{i18n('icu:LeftPane--corrupted-username--text')}
</LeftPaneBanner>
@ -569,7 +575,7 @@ export function LeftPane({
maybeBanner = (
<LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username-link--action-text')}
onClick={toggleProfileEditor}
onClick={() => toggleProfileEditor(ProfileEditorEditState.UsernameLink)}
>
{i18n('icu:LeftPane--corrupted-username-link--text')}
</LeftPaneBanner>

View file

@ -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,

View file

@ -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({
</div>
</div>
<SettingsRow>
<Control
left={i18n('icu:Preferences--phone-number')}
right={phoneNumber}
/>
<Control
left={i18n('icu:Preferences--device-name')}
right={deviceName}

View file

@ -47,6 +47,9 @@ export default {
usernameLinkCorrupted: {
control: 'boolean',
},
usernameLinkRecovered: {
control: 'boolean',
},
},
args: {
aboutEmoji: '',
@ -78,6 +81,7 @@ export default {
showToast: action('showToast'),
replaceAvatar: action('replaceAvatar'),
resetUsernameLink: action('resetUsernameLink'),
clearUsernameLinkRecovered: action('clearUsernameLinkRecovered'),
saveAvatarToDisk: action('saveAvatarToDisk'),
markCompletedUsernameLinkOnboarding: action(
'markCompletedUsernameLinkOnboarding'
@ -89,6 +93,7 @@ export default {
} satisfies Meta<PropsType>;
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,
};

View file

@ -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<AvatarDataType>;
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<DefaultBio> {
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 = (
<>
<hr className="ProfileEditor__divider" />
<PanelRow
className="ProfileEditor__row"
icon={
@ -678,6 +701,11 @@ export function ProfileEditor({
actions={actions}
/>
{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}
conversationTitle={getFullNameText()}
i18n={i18n}
isEditable
onAvatarLoaded={handleAvatarLoaded}
onClick={() => {
setEditState(EditState.BetterAvatar);
@ -700,11 +727,17 @@ export function ProfileEditor({
width: 80,
}}
/>
<h1 className="ProfileEditor__Title">{getFullNameText()}</h1>
{phoneNumber != null && (
<p className="ProfileEditor__PhoneNumber">{phoneNumber}</p>
)}
<hr className="ProfileEditor__divider" />
<div className="ProfileEditor__EditPhotoContainer">
<Button
onClick={() => {
setEditState(EditState.BetterAvatar);
}}
variant={ButtonVariant.Secondary}
className="ProfileEditor__EditPhoto"
>
{i18n('icu:ProfileEditor--edit-photo')}
</Button>
</div>
<PanelRow
className="ProfileEditor__row"
icon={
@ -715,7 +748,6 @@ export function ProfileEditor({
setEditState(EditState.ProfileName);
}}
/>
{maybeUsernameRows}
<PanelRow
className="ProfileEditor__row"
icon={
@ -736,26 +768,10 @@ export function ProfileEditor({
setEditState(EditState.Bio);
}}
/>
<hr className="ProfileEditor__divider" />
<div className="ProfileEditor__info">
<Intl
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>
),
}}
/>
{i18n('icu:ProfileEditor--info--general')}
</div>
{maybeUsernameRows}
</>
);
} 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 && (
<ConfirmationDialog
dialogName="ProfileEditor.confirmResetUsername"
@ -799,15 +837,7 @@ export function ProfileEditor({
onClose={() => 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();

View file

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

View file

@ -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,

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) {
return (
<Toast onClose={hideToast}>

View file

@ -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<PropsType>;

View file

@ -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<Uint8Array | undefined>();
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 = (
<svg
className={`${CLASS}__card__qr__blotches`}
@ -820,11 +841,30 @@ export function UsernameLinkModalBody({
<ConfirmationDialog
i18n={i18n}
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}
cancelText={i18n('icu:ok')}
>
{i18n('icu:UsernameLinkModalBody__error__text')}
{i18n('icu:UsernameLinkModalBody__recovered__text')}
</ConfirmationDialog>
)}