diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d2dcec80b62..a05321a0949 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -315,6 +315,22 @@ "messageformat": "Chats", "description": "Shown as a header for non-pinned conversations in the left pane" }, + "icu:LeftPane--corrupted-username--text": { + "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 corrupted username banner in the left pane" + }, + "icu:LeftPane--corrupted-username--action-text": { + "messageformat": "Fix now", + "description": "Text of the button in the corrupted username banner in the left pane" + }, + "icu:LeftPane--corrupted-username-link--text": { + "messageformat": "Something went wrong with your QR code and username link, it’s no longer valid. Create a new link to share with others.", + "description": "Text of corrupted username link banner in the left pane" + }, + "icu:LeftPane--corrupted-username-link--action-text": { + "messageformat": "Fix now", + "description": "Text of the button in the corrupted username link banner in the left pane" + }, "icu:NavTabsToggle__showTabs": { "messageformat": "Show Tabs", "description": "Show in the left pane when the nav tabs are hidden, shows the nav tabs" @@ -5419,10 +5435,30 @@ "messageformat": "Username", "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.", + "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" + }, + "icu:ProfileEditor--username--corrupted--create-button": { + "messageformat": "Create username", + "description": "Button text for creation of a new username in case of corruption" + }, "icu:ProfileEditor__username-link": { "messageformat": "QR code or link", "description": "Label of a profile editor row that navigates to username link and qr code modal" }, + "icu:ProfileEditor__username__error-icon": { + "messageformat": "Username needs reset", + "description": "Accessibility title of an icon in profile editor" + }, + "icu:ProfileEditor__username-link__error-icon": { + "messageformat": "Username link needs reset", + "description": "Accessibility title of an icon in profile editor" + }, "icu:ProfileEditor__username-link__tooltip__title": { "messageformat": "Share your username", "description": "Title of tooltip displayed under 'QR code or link' button for getting username link" @@ -6711,6 +6747,14 @@ "messageformat": "If you reset your QR code, your existing QR code and link will no longer work.", "description": "Text of confirmation modal when resetting the username link" }, + "icu:UsernameLinkModalBody__resetting-link": { + "messageformat": "Resetting link...", + "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.", + "description": "Text of the confirmation dialog shown on username link error" + }, "icu:UsernameOnboardingModalBody__title": { "messageformat": "Set up your Signal username", "description": "Title of username onboarding modal" diff --git a/stylesheets/components/LeftPaneBanner.scss b/stylesheets/components/LeftPaneBanner.scss new file mode 100644 index 00000000000..deaf28d0eff --- /dev/null +++ b/stylesheets/components/LeftPaneBanner.scss @@ -0,0 +1,45 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.LeftPaneBanner { + margin-block: 4px 8px; + margin-inline: 10px; + padding-block: 12px 16px; + padding-inline-start: 12px; + padding-inline-end: 7px; + + border-radius: 8px; + @include light-theme { + background-color: $color-gray-15; + color: $color-gray-75; + } + @include dark-theme { + background-color: $color-gray-60; + color: $color-gray-15; + } + + @include font-body-2; + + &__content { + margin-bottom: 8px; + } + + &__footer { + display: flex; + justify-content: end; + margin-inline-end: 9px; + + &__action-button { + @include button-reset; + @include button-focus-outline; + @include font-body-1-bold; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } + } + } +} diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss index 567f4fe16af..e427cba2a97 100644 --- a/stylesheets/components/Modal.scss +++ b/stylesheets/components/Modal.scss @@ -187,8 +187,8 @@ padding-inline: 16px; gap: 8px; - .module-Button { - margin-inline-start: 8px; + .module-Button:not(:first-child) { + margin-inline-start: 4px; } &--one-button-per-line { diff --git a/stylesheets/components/ProfileEditor.scss b/stylesheets/components/ProfileEditor.scss index af4e0634d26..cc5d31e2deb 100644 --- a/stylesheets/components/ProfileEditor.scss +++ b/stylesheets/components/ProfileEditor.scss @@ -178,6 +178,28 @@ } } + &__error-icon { + -webkit-mask-size: 100%; + display: block; + height: 24px; + width: 24px; + margin: 4px; + + @include light-theme { + @include color-svg( + '../images/icons/v3/error/error-circle.svg', + $color-accent-red + ); + } + + @include dark-theme { + @include color-svg( + '../images/icons/v3/error/error-circle.svg', + $color-accent-red + ); + } + } + &__username-link { &__tooltip { padding: 12px; @@ -234,6 +256,14 @@ } } } + + &__reset-username-modal__ModalHost__width-container { + max-width: 438px; + + .module-Modal__button-footer { + justify-content: space-between; + } + } } .ProfileEditor__Title { diff --git a/stylesheets/components/UsernameLinkModalBody.scss b/stylesheets/components/UsernameLinkModalBody.scss index 282d4bd94cc..5d57b8cf2b0 100644 --- a/stylesheets/components/UsernameLinkModalBody.scss +++ b/stylesheets/components/UsernameLinkModalBody.scss @@ -64,6 +64,18 @@ height: var(--size); @include color-svg('../images/signal-qr-logo.svg', var(--fg-color)); } + + &__error-icon { + -webkit-mask-size: 100%; + display: block; + height: 28px; + width: 28px; + + @include color-svg( + '../images/icons/v3/error/error-circle.svg', + $color-gray-25 + ); + } } &__username { @@ -209,11 +221,31 @@ ); } } + + &:disabled:after { + @include light-theme() { + background-color: $color-gray-45; + } + + @include dark-theme() { + background-color: $color-gray-25; + } + } } &__text { word-break: break-all; user-select: text; + + &--resetting { + @include light-theme() { + color: $color-gray-45; + } + + @include dark-theme() { + color: $color-gray-25; + } + } } } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 46ff009710a..8f3664cff4c 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -92,6 +92,7 @@ @import './components/InstallScreenQrCodeNotScannedStep.scss'; @import './components/InstallScreenSignalLogo.scss'; @import './components/InstallScreenUpdateDialog.scss'; +@import './components/LeftPaneBanner.scss'; @import './components/LeftPaneDialog.scss'; @import './components/LeftPaneSearchInput.scss'; @import './components/Lightbox.scss'; diff --git a/ts/background.ts b/ts/background.ts index fbf6449c53f..769577c72b1 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -51,6 +51,7 @@ import { senderCertificateService } from './services/senderCertificate'; import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher'; import * as KeyboardLayout from './services/keyboardLayout'; import * as StorageService from './services/storage'; +import { usernameIntegrity } from './services/usernameIntegrity'; import { RoutineProfileRefresher } from './routineProfileRefresh'; import { isOlderThan, toDayMillis } from './util/timestamp'; import { isValidReactionEmoji } from './reactions/isValidReactionEmoji'; @@ -2051,6 +2052,8 @@ export async function startApp(): Promise { void routineProfileRefresher.start(); } + + drop(usernameIntegrity.start()); } let initialStartupCount = 0; diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 484ee134471..97bc4d3bebc 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -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 ( + + ); +} + +export function InboxUsernameLinkCorrupted(): JSX.Element { + return ( + + ); +} + export function InboxOnlyPinnedConversations(): JSX.Element { return ( 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 = ( + + {i18n('icu:LeftPane--corrupted-username--text')} + + ); + } else if (usernameLinkCorrupted) { + maybeBanner = ( + + {i18n('icu:LeftPane--corrupted-username-link--text')} + + ); + } + + if (maybeBanner) { + dialogs.push({ key: 'banner', dialog: maybeBanner }); + } + return ( ; + +export function Defaults(args: PropsType): JSX.Element { + return ; +} diff --git a/ts/components/LeftPaneBanner.tsx b/ts/components/LeftPaneBanner.tsx new file mode 100644 index 00000000000..403a9196f88 --- /dev/null +++ b/ts/components/LeftPaneBanner.tsx @@ -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 ( +
+
{children}
+ +
+ +
+
+ ); +} diff --git a/ts/components/ProfileEditor.stories.tsx b/ts/components/ProfileEditor.stories.tsx index 9fe216309f8..7a8e5f3ef10 100644 --- a/ts/components/ProfileEditor.stories.tsx +++ b/ts/components/ProfileEditor.stories.tsx @@ -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: '', diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index ef1ae5aabb2..95a10e9b9de 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -82,10 +82,12 @@ export type PropsDataType = { phoneNumber?: string; userAvatarData: ReadonlyArray; username?: string; + usernameCorrupted: boolean; usernameEditState: UsernameEditState; usernameLinkState: UsernameLinkState; usernameLinkColor?: number; usernameLink?: string; + usernameLinkCorrupted: boolean; } & Pick; type PropsActionType = { @@ -169,10 +171,12 @@ export function ProfileEditor({ skinTone, userAvatarData, username, + usernameCorrupted, usernameEditState, usernameLinkState, usernameLinkColor, usernameLink, + usernameLinkCorrupted, }: PropsType): JSX.Element { const focusInputRef = useRef(null); const [editState, setEditState] = useState(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 = ( + + ); + alwaysShowActions = true; + } else if (username) { actions = ( + ); + } + maybeUsernameLinkRow = ( { setEditState(EditState.UsernameLink); }} + alwaysShowActions + actions={linkActions} /> ); @@ -647,8 +675,16 @@ export function ProfileEditor({ icon={ } - 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 && ( + 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')} + + )} +
{content}
); diff --git a/ts/components/UsernameLinkModalBody.stories.tsx b/ts/components/UsernameLinkModalBody.stories.tsx index a74069e4e96..808cf808dc2 100644 --- a/ts/components/UsernameLinkModalBody.stories.tsx +++ b/ts/components/UsernameLinkModalBody.stories.tsx @@ -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' }, diff --git a/ts/components/UsernameLinkModalBody.tsx b/ts/components/UsernameLinkModalBody.tsx index e518a14c913..e29176cfc1b 100644 --- a/ts/components/UsernameLinkModalBody.tsx +++ b/ts/components/UsernameLinkModalBody.tsx @@ -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(); 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 = ( <>
@@ -677,6 +711,29 @@ export function UsernameLinkModalBody({ ); + let linkImage: JSX.Element | undefined; + if (usernameLinkState === UsernameLinkState.Ready && link) { + linkImage = ( + <> + +
+ + ); + } else if (usernameLinkState === UsernameLinkState.Error) { + linkImage = ; + } else { + linkImage = ( + + ); + } + return (
@@ -686,23 +743,7 @@ export function UsernameLinkModalBody({ })} ref={onCardRef} > -
- {usernameLinkState === UsernameLinkState.Ready && link ? ( - <> - -
- - ) : ( - - )} -
+
{linkImage}
{!showColors && (