Username and username link integrity check

This commit is contained in:
Fedor Indutny 2023-11-03 23:05:11 +01:00 committed by GitHub
parent 1be90fff3d
commit 3664063d71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 636 additions and 35 deletions

View file

@ -158,6 +158,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
hasRelinkDialog: false,
hasUpdateDialog: false,
unsupportedOSDialogType: undefined,
usernameCorrupted: false,
usernameLinkCorrupted: false,
isUpdateDownloaded,
navTabsCollapsed: false,
@ -269,6 +271,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
'toggleConversationInChooseMembers'
),
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
toggleProfileEditor: action('toggleProfileEditor'),
updateSearchTerm: action('updateSearchTerm'),
...overrideProps,
@ -302,6 +305,42 @@ export function InboxNoConversations(): JSX.Element {
);
}
export function InboxUsernameCorrupted(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
conversations: [],
archivedConversations: [],
isAboutToSearch: false,
},
usernameCorrupted: true,
})}
/>
);
}
export function InboxUsernameLinkCorrupted(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
conversations: [],
archivedConversations: [],
isAboutToSearch: false,
},
usernameLinkCorrupted: true,
})}
/>
);
}
export function InboxOnlyPinnedConversations(): JSX.Element {
return (
<LeftPaneInContainer

View file

@ -35,6 +35,7 @@ import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/U
import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import type { PropsType as DialogExpiredBuildPropsType } from './DialogExpiredBuild';
import { LeftPaneBanner } from './LeftPaneBanner';
import type {
DeleteAvatarFromDiskActionType,
@ -69,6 +70,8 @@ export type PropsType = {
hasUpdateDialog: boolean;
isUpdateDownloaded: boolean;
unsupportedOSDialogType: 'error' | 'warning' | undefined;
usernameCorrupted: boolean;
usernameLinkCorrupted: boolean;
// These help prevent invalid states. For example, we don't need the list of pinned
// conversations if we're trying to start a new conversation. Ideally these would be
@ -135,6 +138,7 @@ export type PropsType = {
toggleComposeEditingAvatar: () => unknown;
toggleConversationInChooseMembers: (conversationId: string) => void;
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
toggleProfileEditor: () => void;
updateSearchTerm: (_: string) => void;
// Render Props
@ -201,6 +205,7 @@ export function LeftPane({
selectedConversationId,
targetedMessageId,
toggleNavTabsCollapse,
toggleProfileEditor,
setChallengeStatus,
setComposeGroupAvatar,
setComposeGroupExpireTimer,
@ -219,6 +224,8 @@ export function LeftPane({
toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
unsupportedOSDialogType,
usernameCorrupted,
usernameLinkCorrupted,
updateSearchTerm,
}: PropsType): JSX.Element {
const previousModeSpecificProps = usePrevious(
@ -544,6 +551,31 @@ export function LeftPane({
}
}
let maybeBanner: JSX.Element | undefined;
if (usernameCorrupted) {
maybeBanner = (
<LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username--action-text')}
onClick={toggleProfileEditor}
>
{i18n('icu:LeftPane--corrupted-username--text')}
</LeftPaneBanner>
);
} else if (usernameLinkCorrupted) {
maybeBanner = (
<LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username-link--action-text')}
onClick={toggleProfileEditor}
>
{i18n('icu:LeftPane--corrupted-username-link--text')}
</LeftPaneBanner>
);
}
if (maybeBanner) {
dialogs.push({ key: 'banner', dialog: maybeBanner });
}
return (
<NavSidebar
title="Chats"

View file

@ -0,0 +1,25 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import { LeftPaneBanner, type PropsType } from './LeftPaneBanner';
export default {
title: 'Components/LeftPaneBanner',
component: LeftPaneBanner,
argTypes: {
actionText: { control: { type: 'string' } },
children: { control: { type: 'string' } },
},
args: {
actionText: 'Fix now',
children: 'Recoverable issue detected',
onClick: action('onClick'),
},
} satisfies Meta<PropsType>;
export function Defaults(args: PropsType): JSX.Element {
return <LeftPaneBanner {...args} />;
}

View file

@ -0,0 +1,48 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useCallback } from 'react';
const BASE_CLASS_NAME = 'LeftPaneBanner';
export type PropsType = Readonly<{
children?: ReactNode;
actionText: string;
onClick: () => void;
}>;
export function LeftPaneBanner({
children,
actionText,
onClick,
}: PropsType): JSX.Element {
const onClickWrap = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onClick?.();
},
[onClick]
);
return (
<div className={BASE_CLASS_NAME}>
<div className={`${BASE_CLASS_NAME}__content`}>{children}</div>
<div className={`${BASE_CLASS_NAME}__footer`}>
<button
title={actionText}
aria-label={actionText}
className={`${BASE_CLASS_NAME}__footer__action-button`}
onClick={onClickWrap}
tabIndex={0}
type="button"
>
{actionText}
</button>
</div>
</div>
);
}

View file

@ -37,10 +37,16 @@ export default {
Deleting: UsernameEditState.Deleting,
},
},
usernameCorrupted: {
control: 'boolean',
},
usernameLinkState: {
control: { type: 'select' },
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
},
usernameLinkCorrupted: {
control: 'boolean',
},
},
args: {
aboutEmoji: '',

View file

@ -82,10 +82,12 @@ export type PropsDataType = {
phoneNumber?: string;
userAvatarData: ReadonlyArray<AvatarDataType>;
username?: string;
usernameCorrupted: boolean;
usernameEditState: UsernameEditState;
usernameLinkState: UsernameLinkState;
usernameLinkColor?: number;
usernameLink?: string;
usernameLinkCorrupted: boolean;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type PropsActionType = {
@ -169,10 +171,12 @@ export function ProfileEditor({
skinTone,
userAvatarData,
username,
usernameCorrupted,
usernameEditState,
usernameLinkState,
usernameLinkColor,
usernameLink,
usernameLinkCorrupted,
}: PropsType): JSX.Element {
const focusInputRef = useRef<HTMLInputElement | null>(null);
const [editState, setEditState] = useState<EditState>(EditState.None);
@ -208,6 +212,7 @@ export function ProfileEditor({
familyName,
firstName,
});
const [isResettingUsername, setIsResettingUsername] = useState(false);
// Reset username edit state when leaving
useEffect(() => {
@ -530,6 +535,7 @@ export function ProfileEditor({
link={usernameLink}
username={username ?? ''}
colorId={usernameLinkColor}
usernameLinkCorrupted={usernameLinkCorrupted}
usernameLinkState={usernameLinkState}
setUsernameLinkColor={setUsernameLinkColor}
resetUsernameLink={resetUsernameLink}
@ -542,6 +548,7 @@ export function ProfileEditor({
let maybeUsernameRows: JSX.Element | undefined;
if (isUsernameFlagEnabled) {
let actions: JSX.Element | undefined;
let alwaysShowActions = false;
if (usernameEditState === UsernameEditState.Deleting) {
actions = (
@ -579,7 +586,15 @@ export function ProfileEditor({
},
];
if (username) {
if (usernameCorrupted) {
actions = (
<i
className="ProfileEditor__error-icon"
title={i18n('icu:ProfileEditor__username__error-icon')}
/>
);
alwaysShowActions = true;
} else if (username) {
actions = (
<ContextMenu
i18n={i18n}
@ -593,7 +608,18 @@ export function ProfileEditor({
}
let maybeUsernameLinkRow: JSX.Element | undefined;
if (username) {
if (username && !usernameCorrupted) {
let linkActions: JSX.Element | undefined;
if (usernameLinkCorrupted) {
linkActions = (
<i
className="ProfileEditor__error-icon"
title={i18n('icu:ProfileEditor__username-link__error-icon')}
/>
);
}
maybeUsernameLinkRow = (
<PanelRow
className="ProfileEditor__row"
@ -604,6 +630,8 @@ export function ProfileEditor({
onClick={() => {
setEditState(EditState.UsernameLink);
}}
alwaysShowActions
actions={linkActions}
/>
);
@ -647,8 +675,16 @@ export function ProfileEditor({
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
}
label={username || i18n('icu:ProfileEditor--username')}
label={
(!usernameCorrupted && username) ||
i18n('icu:ProfileEditor--username')
}
onClick={() => {
if (usernameCorrupted) {
setIsResettingUsername(true);
return;
}
openUsernameReservationModal();
if (username || hasCompletedUsernameOnboarding) {
setEditState(EditState.Username);
@ -656,6 +692,7 @@ export function ProfileEditor({
setEditState(EditState.UsernameOnboarding);
}
}}
alwaysShowActions={alwaysShowActions}
actions={actions}
/>
{maybeUsernameLinkRow}
@ -771,6 +808,36 @@ export function ProfileEditor({
onClose={() => setConfirmDiscardAction(undefined)}
/>
)}
{isResettingUsername && (
<ConfirmationDialog
dialogName="ProfileEditor.confirmResetUsername"
moduleClassName="ProfileEditor__reset-username-modal"
i18n={i18n}
onClose={() => setIsResettingUsername(false)}
actions={[
{
text: i18n(
'icu:ProfileEditor--username--corrupted--delete-button'
),
action: () => deleteUsername(),
},
{
text: i18n(
'icu:ProfileEditor--username--corrupted--create-button'
),
style: 'affirmative',
action: () => {
openUsernameReservationModal();
setEditState(EditState.Username);
},
},
]}
>
{i18n('icu:ProfileEditor--username--corrupted--body')}
</ConfirmationDialog>
)}
<div className="ProfileEditor">{content}</div>
</>
);

View file

@ -28,9 +28,16 @@ export default {
username: {
control: { type: 'text' },
},
usernameLinkCorrupted: {
control: 'boolean',
},
usernameLinkState: {
control: { type: 'select' },
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
options: [
UsernameLinkState.Ready,
UsernameLinkState.Updating,
UsernameLinkState.Error,
],
},
colorId: {
control: { type: 'select' },

View file

@ -26,6 +26,7 @@ export type PropsType = Readonly<{
link?: string;
username: string;
colorId?: number;
usernameLinkCorrupted: boolean;
usernameLinkState: UsernameLinkState;
setUsernameLinkColor: (colorId: number) => void;
@ -486,6 +487,7 @@ export function UsernameLinkModalBody({
i18n,
link,
username,
usernameLinkCorrupted,
usernameLinkState,
colorId: initialColorId = ColorEnum.UNKNOWN,
@ -499,6 +501,7 @@ export function UsernameLinkModalBody({
const [pngData, setPngData] = useState<Uint8Array | undefined>();
const [showColors, setShowColors] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [showError, setShowError] = useState(false);
const [colorId, setColorId] = useState(initialColorId);
const { fg: fgColor, bg: bgColor } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET;
@ -621,13 +624,36 @@ export function UsernameLinkModalBody({
resetUsernameLink();
}, [resetUsernameLink]);
useEffect(() => {
if (!usernameLinkCorrupted) {
return;
}
resetUsernameLink();
}, [usernameLinkCorrupted, resetUsernameLink]);
useEffect(() => {
if (usernameLinkState !== UsernameLinkState.Error) {
return;
}
setShowError(true);
}, [usernameLinkState]);
const onClearError = useCallback(() => {
setShowError(false);
}, []);
const isResettingLink =
usernameLinkCorrupted || usernameLinkState !== UsernameLinkState.Ready;
const info = (
<>
<div className={classnames(`${CLASS}__actions`)}>
<button
className={`${CLASS}__actions__save`}
type="button"
disabled={!link}
disabled={!link || isResettingLink}
onClick={onSave}
>
<i />
@ -648,11 +674,19 @@ export function UsernameLinkModalBody({
<button
className={classnames(`${CLASS}__link__icon`)}
type="button"
disabled={!link}
disabled={!link || isResettingLink}
onClick={onCopyLink}
aria-label={i18n('icu:UsernameLinkModalBody__copy')}
/>
<div className={classnames(`${CLASS}__link__text`)}>{link}</div>
<div
className={classnames(`${CLASS}__link__text`, {
[`${CLASS}__link__text--resetting`]: isResettingLink,
})}
>
{isResettingLink
? i18n('icu:UsernameLinkModalBody__resetting-link')
: link}
</div>
</div>
<div className={classnames(`${CLASS}__help`)}>
@ -677,6 +711,29 @@ export function UsernameLinkModalBody({
</>
);
let linkImage: JSX.Element | undefined;
if (usernameLinkState === UsernameLinkState.Ready && link) {
linkImage = (
<>
<Blotches
className={`${CLASS}__card__qr__blotches`}
link={link}
color={fgColor}
/>
<div className={`${CLASS}__card__qr__logo`} />
</>
);
} else if (usernameLinkState === UsernameLinkState.Error) {
linkImage = <i className={`${CLASS}__card__qr__error-icon`} />;
} else {
linkImage = (
<Spinner
moduleClassName={`${CLASS}__card__qr__spinner`}
svgSize="small"
/>
);
}
return (
<div className={`${CLASS}__container`}>
<div className={CLASS}>
@ -686,23 +743,7 @@ export function UsernameLinkModalBody({
})}
ref={onCardRef}
>
<div className={`${CLASS}__card__qr`}>
{usernameLinkState === UsernameLinkState.Ready && link ? (
<>
<Blotches
className={`${CLASS}__card__qr__blotches`}
link={link}
color={fgColor}
/>
<div className={`${CLASS}__card__qr__logo`} />
</>
) : (
<Spinner
moduleClassName={`${CLASS}__card__qr__spinner`}
svgSize="small"
/>
)}
</div>
<div className={`${CLASS}__card__qr`}>{linkImage}</div>
<div className={`${CLASS}__card__username`}>
{!showColors && (
<button
@ -733,6 +774,18 @@ export function UsernameLinkModalBody({
</ConfirmationDialog>
)}
{showError && (
<ConfirmationDialog
i18n={i18n}
dialogName="UsernameLinkModal__error"
onClose={onClearError}
cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:ok')}
>
{i18n('icu:UsernameLinkModalBody__error__text')}
</ConfirmationDialog>
)}
{showColors ? (
<UsernameLinkColors
i18n={i18n}