Username and username link integrity check
This commit is contained in:
parent
1be90fff3d
commit
3664063d71
26 changed files with 636 additions and 35 deletions
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
25
ts/components/LeftPaneBanner.stories.tsx
Normal file
25
ts/components/LeftPaneBanner.stories.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { LeftPaneBanner, type PropsType } from './LeftPaneBanner';
|
||||
|
||||
export default {
|
||||
title: 'Components/LeftPaneBanner',
|
||||
component: LeftPaneBanner,
|
||||
argTypes: {
|
||||
actionText: { control: { type: 'string' } },
|
||||
children: { control: { type: 'string' } },
|
||||
},
|
||||
args: {
|
||||
actionText: 'Fix now',
|
||||
children: 'Recoverable issue detected',
|
||||
onClick: action('onClick'),
|
||||
},
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
export function Defaults(args: PropsType): JSX.Element {
|
||||
return <LeftPaneBanner {...args} />;
|
||||
}
|
48
ts/components/LeftPaneBanner.tsx
Normal file
48
ts/components/LeftPaneBanner.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
const BASE_CLASS_NAME = 'LeftPaneBanner';
|
||||
|
||||
export type PropsType = Readonly<{
|
||||
children?: ReactNode;
|
||||
actionText: string;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
|
||||
export function LeftPaneBanner({
|
||||
children,
|
||||
actionText,
|
||||
onClick,
|
||||
}: PropsType): JSX.Element {
|
||||
const onClickWrap = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
onClick?.();
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={BASE_CLASS_NAME}>
|
||||
<div className={`${BASE_CLASS_NAME}__content`}>{children}</div>
|
||||
|
||||
<div className={`${BASE_CLASS_NAME}__footer`}>
|
||||
<button
|
||||
title={actionText}
|
||||
aria-label={actionText}
|
||||
className={`${BASE_CLASS_NAME}__footer__action-button`}
|
||||
onClick={onClickWrap}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
{actionText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -37,10 +37,16 @@ export default {
|
|||
Deleting: UsernameEditState.Deleting,
|
||||
},
|
||||
},
|
||||
usernameCorrupted: {
|
||||
control: 'boolean',
|
||||
},
|
||||
usernameLinkState: {
|
||||
control: { type: 'select' },
|
||||
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
|
||||
},
|
||||
usernameLinkCorrupted: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
aboutEmoji: '',
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue