From 799a0dcc54bbf8d79e28e1fd05fda576124dd34e Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 3 Jun 2025 09:46:52 +1000 Subject: [PATCH] Move Profile Editor into the new Settings Tab --- _locales/en/messages.json | 4 + stylesheets/components/Preferences.scss | 120 +++++- stylesheets/components/ProfileEditor.scss | 16 + ...nameModalBody.scss => UsernameEditor.scss} | 15 +- ...ModalBody.scss => UsernameLinkEditor.scss} | 20 +- stylesheets/manifest.scss | 4 +- ts/background.ts | 40 +- ts/components/AvatarEditor.tsx | 46 ++- ts/components/AvatarPreview.stories.tsx | 2 + ts/components/AvatarPreview.tsx | 6 +- ts/components/GlobalModalContainer.tsx | 10 - ts/components/LeftPane.stories.tsx | 2 +- ts/components/LeftPane.tsx | 27 +- ts/components/ModalHost.tsx | 6 + ts/components/NavTabs.tsx | 100 +++-- ts/components/Preferences.stories.tsx | 133 ++++++- ts/components/Preferences.tsx | 352 ++++++++++++------ ts/components/ProfileEditor.stories.tsx | 18 +- ts/components/ProfileEditor.tsx | 223 ++++++----- ts/components/ProfileEditorModal.tsx | 143 ------- ts/components/TextStoryCreator.tsx | 2 +- ...stories.tsx => UsernameEditor.stories.tsx} | 11 +- ...ernameModalBody.tsx => UsernameEditor.tsx} | 100 +++-- ...ies.tsx => UsernameLinkEditor.stories.tsx} | 12 +- ...nkModalBody.tsx => UsernameLinkEditor.tsx} | 77 ++-- .../EditConversationAttributesModal.tsx | 1 + .../LeftPaneSetGroupMetadataHelper.tsx | 1 + ts/services/BeforeNavigate.ts | 19 +- ts/services/writeProfile.ts | 6 +- ts/state/ducks/conversations.ts | 94 +++-- ts/state/ducks/globalModals.ts | 49 --- ts/state/ducks/nav.ts | 81 +++- ts/state/ducks/username.ts | 4 +- ts/state/ducks/usernameEnums.ts | 4 +- ts/state/selectors/conversations.ts | 5 + ts/state/selectors/globalModals.ts | 10 - ts/state/selectors/nav.ts | 6 +- ts/state/smart/EditUsernameModalBody.tsx | 67 ---- ts/state/smart/GlobalModalContainer.tsx | 8 - ts/state/smart/LeftPane.tsx | 7 +- ts/state/smart/NavTabs.tsx | 27 +- ts/state/smart/Preferences.tsx | 86 ++++- ts/state/smart/ProfileEditor.tsx | 167 +++++++++ ts/state/smart/ProfileEditorModal.tsx | 127 ------- ts/state/smart/UsernameEditor.tsx | 62 +++ ts/state/smart/UsernameOnboardingModal.tsx | 20 +- ts/test-both/state/ducks/globalModals_test.ts | 15 - ts/test-mock/pnp/username_test.ts | 6 +- ts/textsecure/WebAPI.ts | 37 +- ts/util/getConversation.ts | 10 +- ts/util/lint/exceptions.json | 32 ++ 51 files changed, 1480 insertions(+), 960 deletions(-) rename stylesheets/components/{EditUsernameModalBody.scss => UsernameEditor.scss} (92%) rename stylesheets/components/{UsernameLinkModalBody.scss => UsernameLinkEditor.scss} (95%) delete mode 100644 ts/components/ProfileEditorModal.tsx rename ts/components/{EditUsernameModalBody.stories.tsx => UsernameEditor.stories.tsx} (92%) rename ts/components/{EditUsernameModalBody.tsx => UsernameEditor.tsx} (85%) rename ts/components/{UsernameLinkModalBody.stories.tsx => UsernameLinkEditor.stories.tsx} (90%) rename ts/components/{UsernameLinkModalBody.tsx => UsernameLinkEditor.tsx} (92%) delete mode 100644 ts/state/smart/EditUsernameModalBody.tsx create mode 100644 ts/state/smart/ProfileEditor.tsx delete mode 100644 ts/state/smart/ProfileEditorModal.tsx create mode 100644 ts/state/smart/UsernameEditor.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 737e4ccf22..602771dc1f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6556,6 +6556,10 @@ "messageformat": "Your profile could not be updated. Please try again.", "description": "Error message when something goes wrong updating your profile." }, + "icu:ProfileEditorModal--sharing": { + "messageformat": "Sharing", + "description": "Title for username QR code and link screen" + }, "icu:AnnouncementsOnlyGroupBanner--modal": { "messageformat": "Message an admin", "description": "Modal title for the list of admins in a group" diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index ef28667775..ce769146c8 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -98,6 +98,115 @@ $secondary-text-color: light-dark( font-weight: 600; } + &__profile-chip { + @include mixins.button-reset; + & { + display: flex; + flex-direction: row; + align-items: center; + + width: calc(100% - 11px); + margin-inline-start: 10px; + margin-inline-end: 1px; + + margin-bottom: 4px; + border-radius: 8px; + + padding-top: 14px; + padding-bottom: 14px; + padding-inline-start: 10px; + padding-inline-end: 10px; + } + + &--selected { + @include mixins.light-theme { + background: variables.$color-gray-15; + } + @include mixins.dark-theme { + background: variables.$color-gray-65; + } + } + + &:focus { + @include mixins.keyboard-mode { + background: variables.$color-gray-05; + } + @include mixins.dark-keyboard-mode { + background: variables.$color-gray-75; + } + } + &:hover:not(&--selected) { + @include mixins.mouse-mode { + background: variables.$color-gray-05; + } + @include mixins.dark-mouse-mode { + background: variables.$color-gray-75; + } + } + + &__avatar { + margin-inline-end: 10px; + } + + &__text-container { + flex-grow: 1; + // Aligning the top of capital letters one pixel below the top of the avatar + margin-top: -4px; + margin-bottom: -5px; + overflow-x: hidden; + } + + &__name { + @include mixins.font-body-1-bold; + overflow-x: hidden; + white-space: nowrap; + overflow-wrap: anywhere; + text-overflow: ellipsis; + } + &__number { + @include mixins.font-body-small; + margin-top: 2px; + overflow-x: hidden; + white-space: nowrap; + overflow-wrap: anywhere; + text-overflow: ellipsis; + } + &__username { + @include mixins.font-body-small; + margin-top: 2px; + overflow-x: hidden; + white-space: nowrap; + overflow-wrap: anywhere; + text-overflow: ellipsis; + } + + &__qr-icon-container { + margin-inline-start: 2px; + flex-shrink: 0; + position: relative; + height: 36px; + width: 36px; + border-radius: 50%; + @include mixins.light-theme { + background-color: variables.$color-gray-15; + } + @include mixins.dark-theme { + background-color: variables.$color-gray-65; + } + } + &__qr-icon { + height: 20px; + width: 20px; + @include mixins.position-absolute-center; + + @include mixins.color-svg-themed( + '../images/icons/v3/qr_code/qr_code.svg', + variables.$color-black, + variables.$color-white + ); + } + } + &__button { @include mixins.button-reset; & { @@ -105,11 +214,11 @@ $secondary-text-color: light-dark( align-items: center; display: flex; height: 40px; - width: calc(100% - 20px); + width: calc(100% - 11px); padding-block: 14px; padding-inline: 0; margin-inline-start: 10px; - margin-inline-end: 10px; + margin-inline-end: 1px; border-radius: 10px; margin-bottom: 4px; } @@ -227,6 +336,7 @@ $secondary-text-color: light-dark( text-align: center; flex-grow: 0; flex-shrink: 0; + position: relative; border-bottom: 1px solid variables.$color-gray-15; @include mixins.light-theme { @@ -378,11 +488,11 @@ $secondary-text-color: light-dark( & { display: inline-block; + inset-inline-start: 12px; height: 20px; - margin-inline-start: 12px; - min-width: 20px; - vertical-align: text-bottom; width: 20px; + vertical-align: text-bottom; + @include mixins.position-absolute-center-y; } @include mixins.light-theme { diff --git a/stylesheets/components/ProfileEditor.scss b/stylesheets/components/ProfileEditor.scss index 3aeff95b13..22b1b1bb53 100644 --- a/stylesheets/components/ProfileEditor.scss +++ b/stylesheets/components/ProfileEditor.scss @@ -5,6 +5,9 @@ @use '../variables'; .ProfileEditor { + margin-inline-start: 24px; + margin-inline-end: 24px; + &__icon { &--container { align-items: center; @@ -337,6 +340,19 @@ } } } + + &__button-footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + padding-block: 1em 16px; + gap: 8px; + + .module-Button:not(:first-child) { + margin-inline-start: 4px; + } + } } .ProfileEditor__Title { diff --git a/stylesheets/components/EditUsernameModalBody.scss b/stylesheets/components/UsernameEditor.scss similarity index 92% rename from stylesheets/components/EditUsernameModalBody.scss rename to stylesheets/components/UsernameEditor.scss index 84785cae37..d734715b44 100644 --- a/stylesheets/components/EditUsernameModalBody.scss +++ b/stylesheets/components/UsernameEditor.scss @@ -4,7 +4,7 @@ @use '../mixins'; @use '../variables'; -.EditUsernameModalBody { +.UsernameEditor { &__header { display: flex; flex-direction: column; @@ -143,6 +143,19 @@ } } + &__button-footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + padding-block: 1em 16px; + gap: 8px; + + .module-Button:not(:first-child) { + margin-inline-start: 4px; + } + } + &__input__container.Input__container { /** * Discriminator should always be to the right of the nickname. diff --git a/stylesheets/components/UsernameLinkModalBody.scss b/stylesheets/components/UsernameLinkEditor.scss similarity index 95% rename from stylesheets/components/UsernameLinkModalBody.scss rename to stylesheets/components/UsernameLinkEditor.scss index c09479069b..3ca0ef8d19 100644 --- a/stylesheets/components/UsernameLinkModalBody.scss +++ b/stylesheets/components/UsernameLinkEditor.scss @@ -4,12 +4,12 @@ @use '../mixins'; @use '../variables'; -.UsernameLinkModalBody { +.UsernameLinkEditor { display: flex; flex-direction: column; align-items: center; user-select: none; - max-width: 295px; + max-width: 500px; width: 100%; &__container { @@ -47,7 +47,7 @@ width: 148px; height: 148px; - .UsernameLinkModalBody__card--shadow & { + .UsernameLinkEditor__card--shadow & { outline: 2px solid variables.$color-gray-05; } @@ -199,7 +199,6 @@ padding-inline: 16px; border-radius: 12px; margin-block-start: 20px; - max-width: 296px; width: 100%; @include mixins.light-theme() { border: 2px solid variables.$color-gray-05; @@ -297,6 +296,19 @@ } } + &__button-footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + padding-block: 1em 16px; + gap: 8px; + + .module-Button:not(:first-child) { + margin-inline-start: 4px; + } + } + &__done { width: 100%; margin-block-end: 8px; diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 15b0315147..5ec954edc5 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -99,7 +99,6 @@ @use 'components/EditConversationAttributesModal.scss'; @use 'components/EditHistoryMessagesModal.scss'; @use 'components/EditNicknameAndNoteModal.scss'; -@use 'components/EditUsernameModalBody.scss'; @use 'components/ForwardMessageModal.scss'; @use 'components/fun/Fun.scss'; @use 'components/GradientDial.scss'; @@ -192,7 +191,8 @@ @use 'components/ToastManager.scss'; @use 'components/Waveform.scss'; @use 'components/WaveformScrubber.scss'; -@use 'components/UsernameLinkModalBody.scss'; +@use 'components/UsernameEditor.scss'; +@use 'components/UsernameLinkEditor.scss'; @use 'components/UsernameMegaphone.scss'; @use 'components/UsernameOnboardingModal.scss'; @use 'components/WhatsNew.scss'; diff --git a/ts/background.ts b/ts/background.ts index a3a4009914..a83f7762a9 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -216,6 +216,8 @@ import { sendSyncRequests } from './textsecure/syncRequests'; import { handleServerAlerts } from './util/handleServerAlerts'; import { isLocalBackupsEnabled } from './util/isLocalBackupsEnabled'; import { NavTab } from './state/ducks/nav'; +import { Page } from './components/Preferences'; +import { EditState } from './components/ProfileEditor'; export function isOverHourIntoPast(timestamp: number): boolean { return isNumber(timestamp) && isOlderThan(timestamp, HOUR); @@ -1348,38 +1350,14 @@ export async function startApp(): Promise { window.reduxActions.app.openStandalone(); }); - let openingSettingsTab = false; window.Whisper.events.on('openSettingsTab', async () => { - const logId = 'openSettingsTab'; - try { - if (openingSettingsTab) { - log.info( - `${logId}: Already attempting to open settings tab, returning early` - ); - return; - } - - openingSettingsTab = true; - - const newTab = NavTab.Settings; - const needToCancel = - await window.Signal.Services.beforeNavigate.shouldCancelNavigation({ - context: logId, - newTab, - }); - - if (needToCancel) { - log.info(`${logId}: Cancelling navigation to the settings tab`); - return; - } - - window.reduxActions.nav.changeNavTab(newTab); - } finally { - if (!openingSettingsTab) { - log.warn(`${logId}: openingSettingsTab was already false in finally!`); - } - openingSettingsTab = false; - } + window.reduxActions.nav.changeLocation({ + tab: NavTab.Settings, + details: { + page: Page.Profile, + state: EditState.None, + }, + }); }); window.Whisper.events.on('stageLocalBackupForImport', () => { diff --git a/ts/components/AvatarEditor.tsx b/ts/components/AvatarEditor.tsx index 1a837e8f8a..4b212d4168 100644 --- a/ts/components/AvatarEditor.tsx +++ b/ts/components/AvatarEditor.tsx @@ -1,7 +1,8 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { isEqual } from 'lodash'; import type { AvatarColorType } from '../types/Colors'; import type { @@ -21,6 +22,7 @@ import { avatarDataToBytes } from '../util/avatarDataToBytes'; import { createAvatarData } from '../util/createAvatarData'; import { isSameAvatarData } from '../util/isSameAvatarData'; import { missingCaseError } from '../util/missingCaseError'; +import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; export type PropsType = { avatarColor?: AvatarColorType; @@ -83,6 +85,22 @@ export function AvatarEditor({ [localAvatarData] ); + const tryClose = useRef<() => void | undefined>(); + const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ + i18n, + name: 'AvatarEditor', + tryClose, + }); + + const hasChanges = + !isEqual(initialAvatar, avatarPreview) || + Boolean(pendingClear && avatarUrl); + const onTryClose = useCallback(() => { + const onDiscard = () => undefined; + confirmDiscardIf(hasChanges, onDiscard); + }, [confirmDiscardIf, hasChanges]); + tryClose.current = onTryClose; + const selectedAvatar = getSelectedAvatar(provisionalSelectedAvatar); // Caching the Uint8Array produced into avatarData as buffer because @@ -151,9 +169,6 @@ export function AvatarEditor({ setInitialAvatar(avatarBuffer); }, []); - const hasChanges = - initialAvatar !== avatarPreview || Boolean(pendingClear && avatarUrl); - let content: JSX.Element | undefined; if (editMode === EditMode.Main) { @@ -166,6 +181,7 @@ export function AvatarEditor({ avatarValue={avatarPreview} conversationTitle={conversationTitle} i18n={i18n} + isEditable isGroup={isGroup} onAvatarLoaded={handleAvatarLoaded} onClear={() => { @@ -233,12 +249,23 @@ export function AvatarEditor({ { + setAvatarPreview(initialAvatar); + setPendingClear(false); + + // Delay navigation until new avatar data resolves and we are no longer dirty + setTimeout(() => onCancel(), 500); + }} onSave={() => { if (selectedAvatar) { replaceAvatar(selectedAvatar, selectedAvatar, conversationId); } - onSave(avatarPreview); + + setInitialAvatar(avatarPreview); + setPendingClear(false); + + // Delay navigation until new avatar data resolves and we are no longer dirty + setTimeout(() => onSave(avatarPreview), 500); }} /> @@ -297,5 +324,10 @@ export function AvatarEditor({ throw missingCaseError(editMode); } - return
{content}
; + return ( +
+ {confirmDiscardModal} + {content} +
+ ); } diff --git a/ts/components/AvatarPreview.stories.tsx b/ts/components/AvatarPreview.stories.tsx index b394ba6636..a5e71a4473 100644 --- a/ts/components/AvatarPreview.stories.tsx +++ b/ts/components/AvatarPreview.stories.tsx @@ -31,6 +31,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ onAvatarLoaded: action('onAvatarLoaded'), onClear: action('onClear'), onClick: action('onClick'), + showUploadButton: Boolean(overrideProps.showUploadButton), style: overrideProps.style, }); @@ -67,6 +68,7 @@ export function NoStateGroupUploadMe(): JSX.Element { avatarColor: AvatarColors[1], isEditable: true, isGroup: true, + showUploadButton: true, })} /> ); diff --git a/ts/components/AvatarPreview.tsx b/ts/components/AvatarPreview.tsx index 2742fe0179..7c388bbb7b 100644 --- a/ts/components/AvatarPreview.tsx +++ b/ts/components/AvatarPreview.tsx @@ -26,6 +26,7 @@ export type PropsType = { onAvatarLoaded?: (avatarBuffer: Uint8Array) => unknown; onClear?: () => unknown; onClick?: () => unknown; + showUploadButton?: boolean; style?: CSSProperties; } & Pick; @@ -50,6 +51,7 @@ export function AvatarPreview({ onAvatarLoaded, onClear, onClick, + showUploadButton, style = {}, }: PropsType): JSX.Element { const [avatarPreview, setAvatarPreview] = useState(); @@ -184,7 +186,7 @@ export function AvatarPreview({ style={componentStyle} > {content} - {isEditable &&
} + {showUploadButton &&
}
); @@ -232,7 +234,7 @@ export function AvatarPreview({ type="button" /> )} - {isEditable &&
} + {showUploadButton &&
}
); diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index ba19db34bb..3ad1fcce79 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -104,9 +104,6 @@ export type PropsType = { // NotePreviewModal notePreviewModalProps: { conversationId: string } | null; renderNotePreviewModal: () => JSX.Element; - // ProfileEditor - isProfileEditorVisible: boolean; - renderProfileEditor: () => JSX.Element; // SafetyNumberModal safetyNumberModalContactId: string | undefined; renderSafetyNumber: () => JSX.Element; @@ -208,9 +205,6 @@ export function GlobalModalContainer({ // NotePreviewModal notePreviewModalProps, renderNotePreviewModal, - // ProfileEditor - isProfileEditorVisible, - renderProfileEditor, // SafetyNumberModal safetyNumberModalContactId, renderSafetyNumber, @@ -333,10 +327,6 @@ export function GlobalModalContainer({ return renderNotePreviewModal(); } - if (isProfileEditorVisible) { - return renderProfileEditor(); - } - if (isProfileNameWarningModalVisible) { return renderProfileNameWarningModal(); } diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index be02bde4a5..d40f738744 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -152,6 +152,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { totalBytes: 0, downloadedBytes: 0, }, + changeLocation: action('changeLocation'), clearConversationSearch: action('clearConversationSearch'), clearGroupCreationError: action('clearGroupCreationError'), clearSearchQuery: action('clearSearchQuery'), @@ -320,7 +321,6 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { 'toggleConversationInChooseMembers' ), toggleNavTabsCollapse: action('toggleNavTabsCollapse'), - toggleProfileEditor: action('toggleProfileEditor'), updateFilterByUnread: action('updateFilterByUnread'), updateSearchTerm: action('updateSearchTerm'), diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 19a34a632f..58cde97e47 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -54,11 +54,14 @@ import { NavSidebarSearchHeader, } from './NavSidebar'; import { ContextMenu } from './ContextMenu'; -import { EditState as ProfileEditorEditState } from './ProfileEditor'; import type { UnreadStats } from '../util/countUnreadStats'; import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress'; import type { ServerAlertsType } from '../util/handleServerAlerts'; import { getServerAlertDialog } from './ServerAlerts'; +import { NavTab } from '../state/ducks/nav'; +import type { Location } from '../state/ducks/nav'; +import { Page } from './Preferences'; +import { EditState } from './ProfileEditor'; export type PropsType = { backupMediaDownloadProgress: { @@ -122,6 +125,7 @@ export type PropsType = { // Action Creators blockConversation: (conversationId: string) => void; + changeLocation: (location: Location) => void; clearConversationSearch: () => void; clearGroupCreationError: () => void; clearSearchQuery: () => void; @@ -163,7 +167,6 @@ export type PropsType = { toggleComposeEditingAvatar: () => unknown; toggleConversationInChooseMembers: (conversationId: string) => void; toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; - toggleProfileEditor: (initialEditState?: ProfileEditorEditState) => void; updateSearchTerm: (query: string) => void; updateFilterByUnread: (filterByUnread: boolean) => void; @@ -195,6 +198,7 @@ export function LeftPane({ blockConversation, cancelBackupMediaDownload, challengeStatus, + changeLocation, clearConversationSearch, clearGroupCreationError, clearSearchQuery, @@ -244,7 +248,6 @@ export function LeftPane({ selectedConversationId, targetedMessageId, toggleNavTabsCollapse, - toggleProfileEditor, setChallengeStatus, setComposeGroupAvatar, setComposeGroupExpireTimer, @@ -667,7 +670,13 @@ export function LeftPane({ actionText={i18n('icu:LeftPane--corrupted-username--action-text')} onClick={() => { openUsernameReservationModal(); - toggleProfileEditor(ProfileEditorEditState.Username); + changeLocation({ + tab: NavTab.Settings, + details: { + page: Page.Profile, + state: EditState.Username, + }, + }); }} > {i18n('icu:LeftPane--corrupted-username--text')} @@ -677,7 +686,15 @@ export function LeftPane({ maybeBanner = ( toggleProfileEditor(ProfileEditorEditState.UsernameLink)} + onClick={() => { + changeLocation({ + tab: NavTab.Settings, + details: { + page: Page.Profile, + state: EditState.UsernameLink, + }, + }); + }} > {i18n('icu:LeftPane--corrupted-username-link--text')} diff --git a/ts/components/ModalHost.tsx b/ts/components/ModalHost.tsx index ca86c3a77c..81c2d7fc30 100644 --- a/ts/components/ModalHost.tsx +++ b/ts/components/ModalHost.tsx @@ -66,6 +66,12 @@ export const ModalHost = React.memo(function ModalHostInner({ } return handleOutsideClick( node => { + // In strange event propagation situations we can get the actual document.body + // node here. We don't want to handle those events. + if (node === document.body) { + return false; + } + // ignore clicks that originate in the calling/pip // when we're not handling a component in the calling/pip if ( diff --git a/ts/components/NavTabs.tsx b/ts/components/NavTabs.tsx index a030de0294..3eae1f1b67 100644 --- a/ts/components/NavTabs.tsx +++ b/ts/components/NavTabs.tsx @@ -10,9 +10,12 @@ import type { LocalizerType, ThemeType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; import type { BadgeType } from '../badges/types'; import { NavTab } from '../state/ducks/nav'; +import type { Location } from '../state/ducks/nav'; import { Tooltip, TooltipPlacement } from './Tooltip'; import { Theme } from '../util/theme'; import type { UnreadStats } from '../util/countUnreadStats'; +import { Page } from './Preferences'; +import { EditState } from './ProfileEditor'; type NavTabsItemBadgesProps = Readonly<{ i18n: LocalizerType; @@ -193,11 +196,11 @@ export type NavTabsProps = Readonly<{ hasFailedStorySends: boolean; hasPendingUpdate: boolean; i18n: LocalizerType; + isInternalUser: boolean; me: ConversationType; navTabsCollapsed: boolean; - onNavTabSelected: (tab: NavTab) => void; + onChangeLocation: (location: Location) => void; onToggleNavTabsCollapse: (collapsed: boolean) => void; - onToggleProfileEditor: () => void; renderCallsTab: () => ReactNode; renderChatsTab: () => ReactNode; renderStoriesTab: () => ReactNode; @@ -215,11 +218,11 @@ export function NavTabs({ hasFailedStorySends, hasPendingUpdate, i18n, + isInternalUser, me, navTabsCollapsed, - onNavTabSelected, + onChangeLocation, onToggleNavTabsCollapse, - onToggleProfileEditor, renderCallsTab, renderChatsTab, renderStoriesTab, @@ -232,7 +235,18 @@ export function NavTabs({ unreadStoriesCount, }: NavTabsProps): JSX.Element { function handleSelectionChange(key: Key) { - onNavTabSelected(key as NavTab); + const tab = key as NavTab; + if (tab === NavTab.Settings) { + onChangeLocation({ + tab: NavTab.Settings, + details: { + page: Page.Profile, + state: EditState.None, + }, + }); + } else { + onChangeLocation({ tab }); + } } const isRTL = i18n.getLocaleDirection() === 'rtl'; @@ -309,44 +323,48 @@ export function NavTabs({ hasPendingUpdate={hasPendingUpdate} /> -
- -
+ + + + )} {renderChatsTab} diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 5f5f4d3726..0e8f21f063 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryFn } from '@storybook/react'; -import React from 'react'; +import React, { useRef, useState } from 'react'; import { action } from '@storybook/addon-actions'; import { Page, Preferences } from './Preferences'; @@ -13,6 +13,13 @@ import { EmojiSkinTone } from './fun/data/emojis'; import { DAY, DurationInSeconds, WEEK } from '../util/durations'; import { DialogUpdate } from './DialogUpdate'; import { DialogType } from '../types/Dialogs'; +import { ThemeType } from '../types/Util'; +import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { EditState, ProfileEditor } from './ProfileEditor'; +import { + UsernameEditState, + UsernameLinkState, +} from '../state/ducks/usernameEnums'; import type { PropsType } from './Preferences'; import type { WidthBreakpoint } from './_util'; @@ -20,6 +27,12 @@ import type { MessageAttributesType } from '../model-types'; const { i18n } = window.SignalContext; +const me = { + ...getDefaultConversation(), + phoneNumber: '(215) 555-2345', + username: 'someone.243', +}; + const availableMicrophones = [ { name: 'DefAuLt (Headphones)', @@ -89,6 +102,55 @@ function renderUpdateDialog( /> ); } +function RenderProfileEditor(): JSX.Element { + const contentsRef = useRef(null); + return ( +
} + replaceAvatar={action('replaceAvatar')} + resetUsernameLink={action('resetUsernameLink')} + saveAttachment={action('saveAttachment')} + saveAvatarToDisk={action('saveAvatarToDisk')} + setEditState={action('setEditState')} + setUsernameEditState={action('setUsernameEditState')} + setUsernameLinkColor={action('setUsernameLinkColor')} + showToast={action('showToast')} + emojiSkinToneDefault={null} + userAvatarData={[]} + username={undefined} + usernameCorrupted={false} + usernameEditState={UsernameEditState.Editing} + usernameLink={undefined} + usernameLinkColor={undefined} + usernameLinkCorrupted={false} + usernameLinkState={UsernameLinkState.Ready} + /> + ); +} + +function renderToastManager(): JSX.Element { + return
; +} export default { title: 'Components/Preferences', @@ -124,6 +186,7 @@ export default { availableMicrophones, availableSpeakers, backupFeatureEnabled: false, + badge: undefined, blockedCount: 0, customColors: {}, defaultConversationColor: DEFAULT_CONVERSATION_COLOR, @@ -170,6 +233,7 @@ export default { isUpdateDownloaded: false, lastSyncTime: Date.now(), localeOverride: null, + me, navTabsCollapsed: false, notificationContent: 'name', otherTabsUnreadStats: { @@ -177,6 +241,7 @@ export default { unreadMentionsCount: 0, markedUnread: false, }, + page: Page.Profile, preferredSystemLocales: ['en'], resolvedLocale: 'en', selectedCamera: @@ -185,11 +250,14 @@ export default { selectedSpeaker: availableSpeakers[1], sentMediaQualitySetting: 'standard', themeSetting: 'system', + theme: ThemeType.light, universalExpireTimer: DurationInSeconds.HOUR, whoCanFindMe: PhoneNumberDiscoverability.Discoverable, whoCanSeeMe: PhoneNumberSharingMode.Everybody, zoomFactor: 1, + renderProfileEditor: RenderProfileEditor, + renderToastManager, renderUpdateDialog, getConversationsWithCustomColor: () => [], @@ -263,6 +331,8 @@ export default { setGlobalDefaultConversationColor: action( 'setGlobalDefaultConversationColor' ), + setPage: action('setPage'), + showToast: action('showToast'), validateBackup: async () => { return { result: validateBackupResult, @@ -272,40 +342,82 @@ export default { } satisfies Meta; // eslint-disable-next-line react/function-component-definition -const Template: StoryFn = args => ; +const Template: StoryFn = args => { + const [page, setPage] = useState(args.page); + return ; +}; export const _Preferences = Template.bind({}); +export const General = Template.bind({}); +General.args = { + page: Page.General, +}; +export const Appearance = Template.bind({}); +Appearance.args = { + page: Page.Appearance, +}; +export const Chats = Template.bind({}); +Chats.args = { + page: Page.Chats, +}; +export const Calls = Template.bind({}); +Calls.args = { + page: Page.Calls, +}; +export const Notifications = Template.bind({}); +Notifications.args = { + page: Page.Notifications, +}; +export const Privacy = Template.bind({}); +Privacy.args = { + page: Page.Privacy, +}; +export const DataUsage = Template.bind({}); +DataUsage.args = { + page: Page.DataUsage, +}; +export const Internal = Template.bind({}); +Internal.args = { + page: Page.Internal, + isInternalUser: true, +}; + export const Blocked1 = Template.bind({}); Blocked1.args = { blockedCount: 1, + page: Page.Privacy, }; export const BlockedMany = Template.bind({}); BlockedMany.args = { blockedCount: 55, + page: Page.Privacy, }; export const CustomUniversalExpireTimer = Template.bind({}); CustomUniversalExpireTimer.args = { universalExpireTimer: DurationInSeconds.fromSeconds(9000), + page: Page.Privacy, }; export const PNPSharingDisabled = Template.bind({}); PNPSharingDisabled.args = { whoCanSeeMe: PhoneNumberSharingMode.Nobody, whoCanFindMe: PhoneNumberDiscoverability.Discoverable, + page: Page.PNP, }; export const PNPDiscoverabilityDisabled = Template.bind({}); PNPDiscoverabilityDisabled.args = { whoCanSeeMe: PhoneNumberSharingMode.Nobody, whoCanFindMe: PhoneNumberDiscoverability.NotDiscoverable, + page: Page.PNP, }; export const BackupsPaidActive = Template.bind({}); BackupsPaidActive.args = { - initialPage: Page.Backups, + page: Page.Backups, backupFeatureEnabled: true, cloudBackupStatus: { mediaSize: 539_249_410_039, @@ -324,7 +436,7 @@ BackupsPaidActive.args = { export const BackupsPaidCancelled = Template.bind({}); BackupsPaidCancelled.args = { - initialPage: Page.Backups, + page: Page.Backups, backupFeatureEnabled: true, cloudBackupStatus: { mediaSize: 539_249_410_039, @@ -343,7 +455,7 @@ BackupsPaidCancelled.args = { export const BackupsFree = Template.bind({}); BackupsFree.args = { - initialPage: Page.Backups, + page: Page.Backups, backupFeatureEnabled: true, backupSubscriptionStatus: { status: 'free', @@ -353,13 +465,12 @@ BackupsFree.args = { export const BackupsOff = Template.bind({}); BackupsOff.args = { - initialPage: Page.Backups, backupFeatureEnabled: true, }; export const BackupsSubscriptionNotFound = Template.bind({}); BackupsSubscriptionNotFound.args = { - initialPage: Page.Backups, + page: Page.Backups, backupFeatureEnabled: true, backupSubscriptionStatus: { status: 'not-found', @@ -373,19 +484,13 @@ BackupsSubscriptionNotFound.args = { export const BackupsSubscriptionExpired = Template.bind({}); BackupsSubscriptionExpired.args = { - initialPage: Page.Backups, + page: Page.Backups, backupFeatureEnabled: true, backupSubscriptionStatus: { status: 'expired', }, }; -export const Internal = Template.bind({}); -Internal.args = { - initialPage: Page.Internal, - isInternalUser: true, -}; - export const UpdateAvailable = Template.bind({}); UpdateAvailable.args = { hasPendingUpdate: true, diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 0a7b7d4a52..9747078478 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -1,5 +1,6 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only + import type { AudioDevice } from '@signalapp/ringrtc'; import React, { useCallback, @@ -12,6 +13,45 @@ import React, { import { isNumber, noop, partition } from 'lodash'; import classNames from 'classnames'; import * as LocaleMatcher from '@formatjs/intl-localematcher'; + +import type { MutableRefObject } from 'react'; + +import { Button, ButtonVariant } from './Button'; +import { ChatColorPicker } from './ChatColorPicker'; +import { Checkbox } from './Checkbox'; +import { WidthBreakpoint } from './_util'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { DisappearingTimeDialog } from './DisappearingTimeDialog'; +import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; +import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; +import { Select } from './Select'; +import { Spinner } from './Spinner'; +import { getCustomColorStyle } from '../util/getCustomColorStyle'; +import { + DEFAULT_DURATIONS_IN_SECONDS, + DEFAULT_DURATIONS_SET, + format as formatExpirationTimer, +} from '../util/expirationTimer'; +import { DurationInSeconds } from '../util/durations'; +import { focusableSelector } from '../util/focusableSelectors'; +import { Modal } from './Modal'; +import { SearchInput } from './SearchInput'; +import { removeDiacritics } from '../util/removeDiacritics'; +import { assertDev } from '../util/assert'; +import { I18n } from './I18n'; +import { FunSkinTonesList } from './fun/FunSkinTones'; +import { emojiParentKeyConstant, type EmojiSkinTone } from './fun/data/emojis'; +import { + SettingsControl as Control, + SettingsRadio, + SettingsRow, +} from './PreferencesUtil'; +import { PreferencesBackups } from './PreferencesBackups'; +import { PreferencesInternal } from './PreferencesInternal'; +import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider'; +import { NavTabsToggle } from './NavTabs'; +import { Avatar, AvatarSize } from './Avatar'; + import type { MediaDeviceSettings } from '../types/Calling'; import type { ValidationResultType as BackupValidationResultType } from '../services/backups'; import type { @@ -34,47 +74,12 @@ import type { SentMediaQualityType, ThemeType, } from '../types/Util'; -import { Button, ButtonVariant } from './Button'; -import { ChatColorPicker } from './ChatColorPicker'; -import { Checkbox } from './Checkbox'; -import { WidthBreakpoint } from './_util'; -import { ConfirmationDialog } from './ConfirmationDialog'; -import { DisappearingTimeDialog } from './DisappearingTimeDialog'; -import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; -import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; -import { Select } from './Select'; -import { Spinner } from './Spinner'; -import { ToastManager } from './ToastManager'; -import { getCustomColorStyle } from '../util/getCustomColorStyle'; -import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; -import { - DEFAULT_DURATIONS_IN_SECONDS, - DEFAULT_DURATIONS_SET, - format as formatExpirationTimer, -} from '../util/expirationTimer'; -import { DurationInSeconds } from '../util/durations'; -import { focusableSelector } from '../util/focusableSelectors'; -import { Modal } from './Modal'; -import { SearchInput } from './SearchInput'; -import { removeDiacritics } from '../util/removeDiacritics'; -import { assertDev } from '../util/assert'; -import { I18n } from './I18n'; -import { FunSkinTonesList } from './fun/FunSkinTones'; -import { emojiParentKeyConstant, type EmojiSkinTone } from './fun/data/emojis'; import type { BackupsSubscriptionType, BackupStatusType, } from '../types/backups'; -import { - SettingsControl as Control, - SettingsRadio, - SettingsRow, -} from './PreferencesUtil'; -import { PreferencesBackups } from './PreferencesBackups'; -import { PreferencesInternal } from './PreferencesInternal'; -import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider'; -import { NavTabsToggle } from './NavTabs'; import type { UnreadStats } from '../util/countUnreadStats'; +import type { BadgeType } from '../badges/types'; import type { MessageCountBySchemaVersionType } from '../sql/Interface'; import type { MessageAttributesType } from '../model-types'; @@ -116,7 +121,7 @@ export type PropsDataType = { hasStoriesDisabled: boolean; hasTextFormatting: boolean; hasTypingIndicators: boolean; - initialPage?: Page; + page: Page; lastSyncTime?: number; notificationContent: NotificationSettingType; phoneNumber: string | undefined; @@ -143,6 +148,9 @@ export type PropsDataType = { isUpdateDownloaded: boolean; navTabsCollapsed: boolean; otherTabsUnreadStats: UnreadStats; + me: ConversationType; + badge: BadgeType | undefined; + theme: ThemeType; // Limited support features isAutoDownloadUpdatesSupported: boolean; @@ -164,6 +172,12 @@ export type PropsDataType = { type PropsFunctionType = { // Render props + renderProfileEditor: (options: { + contentsRef: MutableRefObject; + }) => JSX.Element; + renderToastManager: ( + _: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> + ) => JSX.Element; renderUpdateDialog: ( _: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> ) => JSX.Element; @@ -193,6 +207,8 @@ type PropsFunctionType = { value: CustomColorType; } ) => unknown; + setPage: (page: Page) => unknown; + showToast: (toast: AnyToast) => unknown; validateBackup: () => Promise; // Change handlers @@ -245,6 +261,7 @@ export type PropsPreloadType = Omit; export enum Page { // Accessible through left nav + Profile = 'Profile', General = 'General', Appearance = 'Appearance', Chats = 'Chats', @@ -297,6 +314,7 @@ export function Preferences({ availableSpeakers, backupFeatureEnabled, backupSubscriptionStatus, + badge, blockedCount, cloudBackupStatus, customColors, @@ -336,7 +354,6 @@ export function Preferences({ hasTextFormatting, hasTypingIndicators, i18n, - initialPage = Page.General, initialSpellCheckSetting, isAutoDownloadUpdatesSupported, isAutoLaunchSupported, @@ -351,6 +368,7 @@ export function Preferences({ isUpdateDownloaded, lastSyncTime, makeSyncRequest, + me, navTabsCollapsed, notificationContent, onAudioNotificationsChange, @@ -390,12 +408,15 @@ export function Preferences({ onWhoCanFindMeChange, onZoomFactorChange, otherTabsUnreadStats, + page, phoneNumber = '', preferredSystemLocales, refreshCloudBackupStatus, refreshBackupSubscriptionStatus, removeCustomColor, removeCustomColorOnConversations, + renderProfileEditor, + renderToastManager, renderUpdateDialog, resetAllChatColors, resetDefaultChatColor, @@ -405,7 +426,10 @@ export function Preferences({ selectedSpeaker, sentMediaQualitySetting, setGlobalDefaultConversationColor, + setPage, + showToast, localeOverride, + theme, themeSetting, universalExpireTimer = DurationInSeconds.ZERO, validateBackup, @@ -422,7 +446,6 @@ export function Preferences({ const [confirmStoriesOff, setConfirmStoriesOff] = useState(false); const [confirmContentProtection, setConfirmContentProtection] = useState(false); - const [page, setPage] = useState(initialPage); const [showSyncFailed, setShowSyncFailed] = useState(false); const [nowSyncing, setNowSyncing] = useState(false); const [showDisappearingTimerDialog, setShowDisappearingTimerDialog] = @@ -434,7 +457,6 @@ export function Preferences({ string | null | undefined >(localeOverride); const [languageSearchInput, setLanguageSearchInput] = useState(''); - const [toast, setToast] = useState(); const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] = useState(false); @@ -616,12 +638,14 @@ export function Preferences({ }); }, [localeSearchOptions, languageSearchInput]); - let pageTitle: string | undefined; - let pageBackButton: JSX.Element | undefined; - let pageContents: JSX.Element | undefined; - if (page === Page.General) { - pageTitle = i18n('icu:Preferences__button--general'); - pageContents = ( + let content: JSX.Element | undefined; + + if (page === Page.Profile) { + content = renderProfileEditor({ + contentsRef: settingsPaneRef, + }); + } else if (page === Page.General) { + const pageContents = ( <> ); + content = ( + + ); } else if (page === Page.Appearance) { let zoomFactors = DEFAULT_ZOOM_FACTORS; @@ -742,8 +773,7 @@ export function Preferences({ : i18n('icu:Preferences__Language__SystemLanguage'); } - pageTitle = i18n('icu:Preferences__button--appearance'); - pageContents = ( + const pageContents = ( ); + content = ( + + ); } else if (page === Page.Chats) { let spellCheckDirtyText: string | undefined; if ( @@ -946,8 +983,7 @@ export function Preferences({ const lastSyncDate = new Date(lastSyncTime || 0); - pageTitle = i18n('icu:Preferences__button--chats'); - pageContents = ( + const pageContents = ( <> ); + content = ( + + ); } else if (page === Page.Calls) { - pageTitle = i18n('icu:Preferences__button--calls'); - pageContents = ( + const pageContents = ( <> ); + content = ( + + ); } else if (page === Page.Notifications) { - pageTitle = i18n('icu:Preferences__button--notifications'); - pageContents = ( + const pageContents = ( <> ); + content = ( + + ); } else if (page === Page.Privacy) { const isCustomDisappearingMessageValue = !DEFAULT_DURATIONS_SET.has(universalExpireTimer); - - pageTitle = i18n('icu:Preferences__button--privacy'); - pageContents = ( + const pageContents = ( <> ); + content = ( + + ); } else if (page === Page.DataUsage) { - pageTitle = i18n('icu:Preferences__button--data-usage'); - pageContents = ( + const pageContents = ( <> ); + content = ( + + ); } else if (page === Page.ChatColor) { - pageTitle = i18n('icu:ChatColorPicker__menu-title'); - pageBackButton = ( + const backButton = (
) : null}
+
-
-
- {pageBackButton} -
{pageTitle}
-
-
-
-
- {pageContents} -
-
-
-
+ {content}
- setToast(undefined)} - i18n={i18n} - onShowDebugLog={shouldNeverBeCalled} - onUndoArchive={shouldNeverBeCalled} - openFileInFolder={shouldNeverBeCalled} - showAttachmentNotAvailableModal={shouldNeverBeCalled} - toast={toast} - containerWidthBreakpoint={WidthBreakpoint.Narrow} - isInFullScreenCall={false} - /> + {renderToastManager({ + containerWidthBreakpoint: WidthBreakpoint.Wide, + })} ); } @@ -1989,3 +2101,31 @@ function localizeDefault(i18n: LocalizerType, deviceLabel: string): string { ) : deviceLabel; } + +export function PreferencesContent({ + backButton, + contents, + contentsRef, + title, +}: { + backButton?: JSX.Element | undefined; + contents: JSX.Element | undefined; + contentsRef: MutableRefObject; + title: string | undefined; +}): JSX.Element { + return ( +
+
+ {backButton} +
{title}
+
+
+
+
+ {contents} +
+
+
+
+ ); +} diff --git a/ts/components/ProfileEditor.stories.tsx b/ts/components/ProfileEditor.stories.tsx index 36aef8b82a..8e3477c7a7 100644 --- a/ts/components/ProfileEditor.stories.tsx +++ b/ts/components/ProfileEditor.stories.tsx @@ -8,8 +8,8 @@ import casual from 'casual'; import { v4 as generateUuid } from 'uuid'; import type { PropsType } from './ProfileEditor'; -import { ProfileEditor } from './ProfileEditor'; -import { EditUsernameModalBody } from './EditUsernameModalBody'; +import { EditState, ProfileEditor } from './ProfileEditor'; +import { UsernameEditor } from './UsernameEditor'; import { UsernameEditState, UsernameLinkState, @@ -51,6 +51,7 @@ export default { conversationId: generateUuid(), color: getRandomColor(), deleteAvatarFromDisk: action('deleteAvatarFromDisk'), + editState: EditState.None, familyName: casual.last_name, firstName: casual.first_name, i18n, @@ -65,7 +66,6 @@ export default { userAvatarData: [], username: undefined, - onEditStateChanged: action('onEditStateChanged'), onProfileChanged: action('onProfileChanged'), onEmojiSkinToneDefaultChange: action('onEmojiSkinToneDefaultChange'), saveAttachment: action('saveAttachment'), @@ -83,12 +83,9 @@ export default { }, } satisfies Meta; -function renderEditUsernameModalBody(props: { - isRootModal: boolean; - onClose: () => void; -}): JSX.Element { +function renderUsernameEditor(props: { onClose: () => void }): JSX.Element { return ( - = args => { const [emojiSkinToneDefault, setEmojiSkinToneDefault] = useState( EmojiSkinTone.None ); + const [editState, setEditState] = useState(args.editState); return ( ); }; diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index 0d57f45634..9ca8661d66 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -10,40 +10,23 @@ import React, { } from 'react'; import { useSpring, animated } from '@react-spring/web'; -import type { AvatarColorType } from '../types/Colors'; +import type { MutableRefObject } from 'react'; + import { AvatarColors } from '../types/Colors'; -import type { - AvatarDataType, - AvatarUpdateOptionsType, - DeleteAvatarFromDiskActionType, - ReplaceAvatarActionType, - SaveAvatarToDiskActionType, -} from '../types/Avatar'; import { AvatarEditor } from './AvatarEditor'; import { AvatarPreview } from './AvatarPreview'; import { Button, ButtonVariant } from './Button'; -import { ConfirmDiscardDialog } from './ConfirmDiscardDialog'; -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 type { LocalizerType } from '../types/Util'; -import { Modal } from './Modal'; import { PanelRow } from './conversation/conversation-details/PanelRow'; -import type { - ProfileDataType, - SaveAttachmentActionCreatorType, -} from '../state/ducks/conversations'; import { UsernameEditState } from '../state/ducks/usernameEnums'; -import type { UsernameLinkState } from '../state/ducks/usernameEnums'; import { ToastType } from '../types/Toast'; -import type { ShowToastAction } from '../state/ducks/toast'; import { getEmojiData, unifiedToEmoji } from './emoji/lib'; import { assertDev, strictAssert } from '../util/assert'; import { missingCaseError } from '../util/missingCaseError'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; -import { UsernameLinkModalBody } from './UsernameLinkModalBody'; +import { UsernameLinkEditor } from './UsernameLinkEditor'; import { ConversationDetailsIcon, IconType, @@ -54,7 +37,6 @@ import { Tooltip, TooltipPlacement } from './Tooltip'; import { offsetDistanceModifier } from '../util/popperUtil'; import { useReducedMotion } from '../hooks/useReducedMotion'; import { FunStaticEmoji } from './fun/FunEmoji'; -import type { EmojiVariantKey } from './fun/data/emojis'; import { EmojiSkinTone, getEmojiParentKeyByEnglishShortName, @@ -66,9 +48,30 @@ import { } from './fun/data/emojis'; import { FunEmojiPicker } from './fun/FunEmojiPicker'; import { FunEmojiPickerButton } from './fun/FunButton'; -import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis'; import { isFunPickerEnabled } from './fun/isFunPickerEnabled'; import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer'; +import { PreferencesContent } from './Preferences'; + +import type { AvatarColorType } from '../types/Colors'; +import type { + AvatarDataType, + AvatarUpdateOptionsType, + DeleteAvatarFromDiskActionType, + ReplaceAvatarActionType, + SaveAvatarToDiskActionType, +} from '../types/Avatar'; +import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; +import type { EmojiPickDataType } from './emoji/EmojiPicker'; +import type { LocalizerType } from '../types/Util'; +import type { + ProfileDataType, + SaveAttachmentActionCreatorType, +} from '../state/ducks/conversations'; +import type { UsernameLinkState } from '../state/ducks/usernameEnums'; +import type { ShowToastAction } from '../state/ducks/toast'; +import type { EmojiVariantKey } from './fun/data/emojis'; +import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis'; +import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; export enum EditState { None = 'None', @@ -80,36 +83,33 @@ export enum EditState { } type PropsExternalType = { - onEditStateChanged: (editState: EditState) => unknown; onProfileChanged: ( profileData: ProfileDataType, avatarUpdateOptions: AvatarUpdateOptionsType ) => unknown; - renderEditUsernameModalBody: (props: { - isRootModal: boolean; - onClose: () => void; - }) => JSX.Element; + renderUsernameEditor: (props: { onClose: () => void }) => JSX.Element; }; export type PropsDataType = { aboutEmoji?: string; aboutText?: string; - profileAvatarUrl?: string; color?: AvatarColorType; + contentsRef: MutableRefObject; conversationId: string; familyName?: string; firstName: string; hasCompletedUsernameLinkOnboarding: boolean; i18n: LocalizerType; + editState: EditState; + profileAvatarUrl?: string; userAvatarData: ReadonlyArray; username?: string; - initialEditState?: EditState; usernameCorrupted: boolean; usernameEditState: UsernameEditState; - usernameLinkState: UsernameLinkState; - usernameLinkColor?: number; usernameLink?: string; + usernameLinkColor?: number; usernameLinkCorrupted: boolean; + usernameLinkState: UsernameLinkState; } & Pick; type PropsActionType = { @@ -121,9 +121,9 @@ type PropsActionType = { saveAvatarToDisk: SaveAvatarToDiskActionType; setUsernameEditState: (editState: UsernameEditState) => void; setUsernameLinkColor: (color: number) => void; - toggleProfileEditor: () => void; resetUsernameLink: () => void; deleteUsername: () => void; + setEditState: (editState: EditState) => void; showToast: ShowToastAction; openUsernameReservationModal: () => void; }; @@ -178,26 +178,26 @@ export function ProfileEditor({ aboutText, color, conversationId, + contentsRef, deleteAvatarFromDisk, deleteUsername, familyName, firstName, hasCompletedUsernameLinkOnboarding, i18n, - initialEditState = EditState.None, + editState, markCompletedUsernameLinkOnboarding, - onEditStateChanged, onProfileChanged, onEmojiSkinToneDefaultChange, openUsernameReservationModal, profileAvatarUrl, recentEmojis, - renderEditUsernameModalBody, + renderUsernameEditor, replaceAvatar, resetUsernameLink, - toggleProfileEditor, saveAttachment, saveAvatarToDisk, + setEditState, setUsernameEditState, setUsernameLinkColor, showToast, @@ -212,10 +212,21 @@ export function ProfileEditor({ usernameLinkCorrupted, }: PropsType): JSX.Element { const focusInputRef = useRef(null); - const [editState, setEditState] = useState(initialEditState); - const [confirmDiscardAction, setConfirmDiscardAction] = useState< - (() => unknown) | undefined - >(undefined); + const tryClose = useRef<() => void | undefined>(); + const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ + i18n, + name: 'ProfileEditor', + tryClose, + }); + + const TITLES_BY_EDIT_STATE: Record = { + [EditState.BetterAvatar]: i18n('icu:ProfileEditorModal--avatar'), + [EditState.Bio]: i18n('icu:ProfileEditorModal--about'), + [EditState.None]: i18n('icu:ProfileEditorModal--profile'), + [EditState.ProfileName]: i18n('icu:ProfileEditorModal--name'), + [EditState.Username]: i18n('icu:ProfileEditorModal--username'), + [EditState.UsernameLink]: i18n('icu:ProfileEditorModal--sharing'), + }; // This is here to avoid component re-render jitters in the time it takes // redux to come back with the correct state @@ -265,8 +276,7 @@ export function ProfileEditor({ // To make AvatarEditor re-render less often const handleBack = useCallback(() => { setEditState(EditState.None); - onEditStateChanged(EditState.None); - }, [setEditState, onEditStateChanged]); + }, [setEditState]); const handleEmojiPickerOpenChange = useCallback((open: boolean) => { setEmojiPickerOpen(open); @@ -306,7 +316,6 @@ export function ProfileEditor({ setStartingAvatarUrl(undefined); setAvatarBuffer(avatar); - setEditState(EditState.None); onProfileChanged( { ...stagedProfile, @@ -321,8 +330,9 @@ export function ProfileEditor({ } ); setOldAvatarBuffer(avatar); + handleBack(); }, - [onProfileChanged, stagedProfile, oldAvatarBuffer] + [handleBack, oldAvatarBuffer, onProfileChanged, stagedProfile] ); const getFullNameText = () => { @@ -339,17 +349,6 @@ export function ProfileEditor({ focusNode.setSelectionRange(focusNode.value.length, focusNode.value.length); }, [editState]); - useEffect(() => { - 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: Uint8Array) => { @@ -359,6 +358,25 @@ export function ProfileEditor({ [setAvatarBuffer, setOldAvatarBuffer] ); + const onTryClose = useCallback(() => { + const hasNameChanges = + stagedProfile.familyName !== fullName.familyName || + stagedProfile.firstName !== fullName.firstName; + const hasAboutChanges = + stagedProfile.aboutText !== fullBio.aboutText || + stagedProfile.aboutEmoji !== fullBio.aboutEmoji; + const onDiscard = () => { + setStagedProfile(profileData => ({ + ...profileData, + ...fullName, + ...fullBio, + })); + }; + + confirmDiscardIf(hasNameChanges || hasAboutChanges, onDiscard); + }, [confirmDiscardIf, stagedProfile, fullName, fullBio, setStagedProfile]); + tryClose.current = onTryClose; + let content: JSX.Element; if (editState === EditState.BetterAvatar) { @@ -414,29 +432,8 @@ export function ProfileEditor({ placeholder={i18n('icu:ProfileEditor--last-name')} value={stagedProfile.familyName} /> - - - +
); } else if (editState === EditState.Bio) { @@ -565,28 +564,8 @@ export function ProfileEditor({ ); })} - - - +
); } else if (editState === EditState.Username) { - content = renderEditUsernameModalBody({ - isRootModal: initialEditState === editState, - onClose: () => setEditState(EditState.None), + content = renderUsernameEditor({ + onClose: handleBack, }); } else if (editState === EditState.UsernameLink) { content = ( - + ) : undefined; + return ( <> {usernameEditState === UsernameEditState.ConfirmingDelete && ( @@ -859,18 +849,12 @@ export function ProfileEditor({ )} - {confirmDiscardAction && ( - setConfirmDiscardAction(undefined)} - /> - )} + {confirmDiscardModal} {isResettingUsernameLink && ( setIsResettingUsernameLink(false)} cancelButtonVariant={ButtonVariant.Secondary} cancelText={i18n('icu:cancel')} @@ -910,7 +894,12 @@ export function ProfileEditor({ )} -
{content}
+ {content}
} + contentsRef={contentsRef} + title={TITLES_BY_EDIT_STATE[editState]} + /> ); } diff --git a/ts/components/ProfileEditorModal.tsx b/ts/components/ProfileEditorModal.tsx deleted file mode 100644 index b8b5c1f4f6..0000000000 --- a/ts/components/ProfileEditorModal.tsx +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { useState } from 'react'; -import { Modal } from './Modal'; -import { ConfirmationDialog } from './ConfirmationDialog'; -import type { PropsType as ProfileEditorPropsType } from './ProfileEditor'; -import { ProfileEditor, EditState } from './ProfileEditor'; -import type { ProfileDataType } from '../state/ducks/conversations'; -import type { AvatarUpdateOptionsType } from '../types/Avatar'; - -export type PropsDataType = { - hasError: boolean; -} & Pick; - -type PropsType = { - myProfileChanged: ( - profileData: ProfileDataType, - avatarUpdateOptions: AvatarUpdateOptionsType - ) => unknown; - toggleProfileEditor: () => unknown; - toggleProfileEditorHasError: () => unknown; -} & PropsDataType & - Omit; - -export function ProfileEditorModal({ - aboutEmoji, - aboutText, - color, - conversationId, - deleteAvatarFromDisk, - deleteUsername, - familyName, - firstName, - hasCompletedUsernameLinkOnboarding, - hasError, - i18n, - initialEditState, - markCompletedUsernameLinkOnboarding, - myProfileChanged, - onEmojiSkinToneDefaultChange, - openUsernameReservationModal, - profileAvatarUrl, - recentEmojis, - renderEditUsernameModalBody, - replaceAvatar, - resetUsernameLink, - saveAttachment, - saveAvatarToDisk, - setUsernameEditState, - setUsernameLinkColor, - showToast, - emojiSkinToneDefault, - toggleProfileEditor, - toggleProfileEditorHasError, - userAvatarData, - username, - usernameCorrupted, - usernameEditState, - usernameLink, - usernameLinkColor, - usernameLinkCorrupted, - usernameLinkState, -}: PropsType): JSX.Element { - const MODAL_TITLES_BY_EDIT_STATE: Record = { - [EditState.BetterAvatar]: i18n('icu:ProfileEditorModal--avatar'), - [EditState.Bio]: i18n('icu:ProfileEditorModal--about'), - [EditState.None]: i18n('icu:ProfileEditorModal--profile'), - [EditState.ProfileName]: i18n('icu:ProfileEditorModal--name'), - [EditState.Username]: i18n('icu:ProfileEditorModal--username'), - [EditState.UsernameLink]: undefined, - }; - - const [modalTitle, setModalTitle] = useState( - MODAL_TITLES_BY_EDIT_STATE[EditState.None] - ); - - if (hasError) { - return ( - - {i18n('icu:ProfileEditorModal--error')} - - ); - } - - return ( - - { - setModalTitle(MODAL_TITLES_BY_EDIT_STATE[editState]); - }} - onProfileChanged={myProfileChanged} - onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange} - openUsernameReservationModal={openUsernameReservationModal} - profileAvatarUrl={profileAvatarUrl} - recentEmojis={recentEmojis} - renderEditUsernameModalBody={renderEditUsernameModalBody} - replaceAvatar={replaceAvatar} - resetUsernameLink={resetUsernameLink} - saveAttachment={saveAttachment} - saveAvatarToDisk={saveAvatarToDisk} - setUsernameEditState={setUsernameEditState} - setUsernameLinkColor={setUsernameLinkColor} - showToast={showToast} - emojiSkinToneDefault={emojiSkinToneDefault} - toggleProfileEditor={toggleProfileEditor} - userAvatarData={userAvatarData} - username={username} - usernameCorrupted={usernameCorrupted} - usernameEditState={usernameEditState} - usernameLink={usernameLink} - usernameLinkColor={usernameLinkColor} - usernameLinkCorrupted={usernameLinkCorrupted} - usernameLinkState={usernameLinkState} - /> - - ); -} diff --git a/ts/components/TextStoryCreator.tsx b/ts/components/TextStoryCreator.tsx index bfc5d4cb31..f0ad94a918 100644 --- a/ts/components/TextStoryCreator.tsx +++ b/ts/components/TextStoryCreator.tsx @@ -151,7 +151,7 @@ export function TextStoryCreator({ const tryClose = useRef<() => void | undefined>(); const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ i18n, - name: 'SendStoryModal', + name: 'TextStoryCreator', tryClose, }); const onTryClose = useCallback(() => { diff --git a/ts/components/EditUsernameModalBody.stories.tsx b/ts/components/UsernameEditor.stories.tsx similarity index 92% rename from ts/components/EditUsernameModalBody.stories.tsx rename to ts/components/UsernameEditor.stories.tsx index 52dc0acf9a..852fcc3adb 100644 --- a/ts/components/EditUsernameModalBody.stories.tsx +++ b/ts/components/UsernameEditor.stories.tsx @@ -7,8 +7,8 @@ import type { Meta, StoryFn } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import type { UsernameReservationType } from '../types/Username'; -import type { PropsType } from './EditUsernameModalBody'; -import { EditUsernameModalBody } from './EditUsernameModalBody'; +import type { PropsType } from './UsernameEditor'; +import { UsernameEditor } from './UsernameEditor'; import { UsernameReservationState as State, UsernameReservationError, @@ -23,8 +23,8 @@ const DEFAULT_RESERVATION: UsernameReservationType = { }; export default { - component: EditUsernameModalBody, - title: 'Components/EditUsernameModalBody', + component: UsernameEditor, + title: 'Components/UsernameEditor', argTypes: { usernameCorrupted: { type: { name: 'boolean' }, @@ -54,7 +54,6 @@ export default { }, }, args: { - isRootModal: false, usernameCorrupted: false, currentUsername: undefined, state: State.Open, @@ -86,7 +85,7 @@ const Template: StoryFn = args => { hash: new Uint8Array(), }; } - return ; + return ; }; export const WithoutUsername = Template.bind({}); diff --git a/ts/components/EditUsernameModalBody.tsx b/ts/components/UsernameEditor.tsx similarity index 85% rename from ts/components/EditUsernameModalBody.tsx rename to ts/components/UsernameEditor.tsx index 861ace5218..cfdb5c0ed5 100644 --- a/ts/components/EditUsernameModalBody.tsx +++ b/ts/components/UsernameEditor.tsx @@ -1,8 +1,15 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { + useEffect, + useState, + useCallback, + useMemo, + useRef, +} from 'react'; import classNames from 'classnames'; +import { noop } from 'lodash'; import type { LocalizerType } from '../types/Util'; import type { UsernameReservationType } from '../types/Username'; @@ -22,6 +29,7 @@ import { Input } from './Input'; import { Spinner } from './Spinner'; import { Modal } from './Modal'; import { Button, ButtonVariant } from './Button'; +import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; export type PropsDataType = Readonly<{ i18n: LocalizerType; @@ -47,7 +55,6 @@ export type ActionPropsDataType = Readonly<{ export type ExternalPropsDataType = Readonly<{ onClose(): void; - isRootModal: boolean; }>; export type PropsType = PropsDataType & @@ -62,7 +69,7 @@ enum UpdateState { const DISCRIMINATOR_MAX_LENGTH = 9; -export function EditUsernameModalBody({ +export function UsernameEditor({ i18n, currentUsername, usernameCorrupted, @@ -77,7 +84,6 @@ export function EditUsernameModalBody({ error, state, recoveredUsername, - isRootModal, onClose, }: PropsType): JSX.Element { const currentNickname = useMemo(() => { @@ -155,16 +161,12 @@ export function EditUsernameModalBody({ useEffect(() => { if (state === UsernameReservationState.Closed) { - onClose(); + setTimeout(() => onClose(), 500); } }, [state, onClose]); useEffect(() => { - if ( - state === UsernameReservationState.Closed && - recoveredUsername && - isRootModal - ) { + if (state === UsernameReservationState.Closed && recoveredUsername) { showToast({ toastType: ToastType.UsernameRecovered, parameters: { @@ -172,7 +174,7 @@ export function EditUsernameModalBody({ }, }); } - }, [state, recoveredUsername, showToast, isRootModal]); + }, [state, recoveredUsername, showToast]); const errorString = useMemo(() => { if (!error) { @@ -284,6 +286,31 @@ export function EditUsernameModalBody({ setIsLearnMoreVisible(true); }, []); + const tryClose = useRef<() => void | undefined>(); + const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ + i18n, + name: 'UsernameEditor', + tryClose, + }); + + const onTryClose = useCallback(() => { + const onDiscard = noop; + confirmDiscardIf( + Boolean( + currentNickname !== nickname || + (customDiscriminator && customDiscriminator !== currentDiscriminator) + ), + onDiscard + ); + }, [ + confirmDiscardIf, + currentDiscriminator, + currentNickname, + customDiscriminator, + nickname, + ]); + tryClose.current = onTryClose; + let title = i18n('icu:ProfileEditor--username--title'); if (nickname && discriminator) { title = `${nickname}.${discriminator}`; @@ -291,21 +318,20 @@ export function EditUsernameModalBody({ const learnMoreTitle = ( <> - + {i18n('icu:EditUsernameModalBody__learn-more__title')} ); return ( <> -
-
+
+
-
{title}
+
{title}
- } {isDiscriminatorVisible ? ( <> -
+
) : null} - {errorString && ( -
{errorString}
+
{errorString}
)}
{i18n('icu:EditUsernameModalBody__username-helper')}  
- - +
+ + {confirmDiscardModal} {isLearnMoreVisible && ( setIsLearnMoreVisible(false)} title={learnMoreTitle} > {i18n('icu:EditUsernameModalBody__learn-more__body')} - +
- +
)} - {error === UsernameReservationError.General && ( )} - {error === UsernameReservationError.ConflictOrGone && ( )} - {isConfirmingSave && ( )} - {isConfirmingReset && ( = args => { return ( <> - + {attachment && ( = new Map([ [ColorEnum.PURPLE, { fg: '#7651c5', bg: '#a183d4', tint: '#f5f3fb' }], ]); -const CLASS = 'UsernameLinkModalBody'; +const CLASS = 'UsernameLinkEditor'; export const PRINT_WIDTH = 424; export const PRINT_HEIGHT = 576; @@ -396,25 +397,25 @@ function UsernameLinkColors({ ); })}
- +
- +
); } -enum ResetModalVisibility { +enum RecoveryModalVisibility { NotMounted = 'NotMounted', Closed = 'Closed', Open = 'Open', } -export function UsernameLinkModalBody({ +export function UsernameLinkEditor({ i18n, link, username, @@ -432,8 +433,8 @@ export function UsernameLinkModalBody({ const [pngData, setPngData] = useState(); const [showColors, setShowColors] = useState(false); const [confirmReset, setConfirmReset] = useState(false); - const [resetModalVisibility, setResetModalVisibility] = useState( - ResetModalVisibility.NotMounted + const [recoveryModalVisibility, setRecoveryModalVisibility] = useState( + RecoveryModalVisibility.NotMounted ); const [showError, setShowError] = useState(false); const [colorId, setColorId] = useState(initialColorId); @@ -538,11 +539,6 @@ export function UsernameLinkModalBody({ setShowColors(false); }, [setUsernameLinkColor, colorId]); - const onUsernameLinkColorCancel = useCallback(() => { - setShowColors(false); - setColorId(initialColorId); - }, [initialColorId]); - // Reset sub modal const onClickReset = useCallback(() => { @@ -581,24 +577,51 @@ export function UsernameLinkModalBody({ setShowError(true); }, [usernameLinkState]); - const onResetModalClose = useCallback(() => { - setResetModalVisibility(ResetModalVisibility.Closed); + const onRecoveryModalClose = useCallback(() => { + setRecoveryModalVisibility(RecoveryModalVisibility.Closed); }, []); const isReady = usernameLinkState === UsernameLinkState.Ready; const isResettingLink = usernameLinkCorrupted || !isReady; useEffect(() => { - setResetModalVisibility(x => { + setRecoveryModalVisibility(x => { // Initial mount shouldn't show the modal - if (x === ResetModalVisibility.NotMounted || isResettingLink) { - return ResetModalVisibility.Closed; + if (x === RecoveryModalVisibility.NotMounted || isResettingLink) { + return RecoveryModalVisibility.Closed; } - return ResetModalVisibility.Open; + return RecoveryModalVisibility.Open; }); }, [isResettingLink]); + const tryClose = useRef<() => void | undefined>(); + const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ + i18n, + name: 'UsernameLinkEditor', + tryClose, + }); + + const onTryClose = useCallback(() => { + const onDiscard = noop; + confirmDiscardIf(showColors && colorId !== initialColorId, onDiscard); + }, [colorId, confirmDiscardIf, initialColorId, showColors]); + tryClose.current = onTryClose; + const onUsernameLinkColorCancel = useCallback(() => { + const onDiscard = () => { + setShowColors(false); + setColorId(initialColorId); + }; + confirmDiscardIf(showColors && colorId !== initialColorId, onDiscard); + }, [ + colorId, + confirmDiscardIf, + initialColorId, + setColorId, + setShowColors, + showColors, + ]); + const info = ( <>
@@ -652,14 +675,6 @@ export function UsernameLinkModalBody({ > {i18n('icu:UsernameLinkModalBody__reset')} - - ); @@ -751,11 +766,11 @@ export function UsernameLinkModalBody({ )} - {resetModalVisibility === ResetModalVisibility.Open && ( + {recoveryModalVisibility === RecoveryModalVisibility.Open && ( @@ -774,6 +789,8 @@ export function UsernameLinkModalBody({ ) : ( info )} + + {confirmDiscardModal}
); diff --git a/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx b/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx index a5fd79ec12..1ac9aef967 100644 --- a/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx +++ b/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx @@ -195,6 +195,7 @@ export function EditConversationAttributesModal({ onClick={() => { setEditingAvatar(true); }} + showUploadButton style={{ height: 96, width: 96, diff --git a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx index 0273474f1a..c5f18f889b 100644 --- a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx +++ b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx @@ -177,6 +177,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper Promise; +export type BeforeNavigateCallback = (options: { + existingLocation?: Location; + newLocation: Location; +}) => Promise; export type BeforeNavigateEntry = { name: string; callback: BeforeNavigateCallback; @@ -63,10 +64,12 @@ export class BeforeNavigateService { async shouldCancelNavigation({ context, - newTab, + existingLocation, + newLocation, }: { context: string; - newTab: NavTab; + existingLocation: Location; + newLocation: Location; }): Promise { const logId = `shouldCancelNavigation/${context}`; const entries = Array.from(this.#beforeNavigateCallbacks); @@ -75,8 +78,8 @@ export class BeforeNavigateService { const entry = entries[i]; // eslint-disable-next-line no-await-in-loop const response = await Promise.race([ - entry.callback(newTab), - timeOutAfter(5 * SECOND), + entry.callback({ existingLocation, newLocation }), + timeOutAfter(30 * SECOND), ]); if (response === BeforeNavigateResponse.Noop) { continue; diff --git a/ts/services/writeProfile.ts b/ts/services/writeProfile.ts index 45d169126c..7b8dcc2f5f 100644 --- a/ts/services/writeProfile.ts +++ b/ts/services/writeProfile.ts @@ -96,7 +96,11 @@ export async function writeProfile( } = {}; if (profileData.sameAvatar) { log.info('writeProfile: not updating avatar'); - } else if (avatarRequestHeaders && encryptedAvatarData && newAvatar) { + } else if ( + typeof avatarRequestHeaders === 'object' && + encryptedAvatarData && + newAvatar + ) { log.info('writeProfile: uploading new avatar'); const avatarUrl = await server.uploadAvatar( avatarRequestHeaders, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 34362f6d27..25631470c4 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -36,13 +36,8 @@ import { instance as libphonenumberInstance } from '../../util/libphonenumberIns import type { ShowSendAnywayDialogActionType, ShowErrorModalActionType, - ToggleProfileEditorErrorActionType, -} from './globalModals'; -import { - SHOW_SEND_ANYWAY_DIALOG, - SHOW_ERROR_MODAL, - TOGGLE_PROFILE_EDITOR_ERROR, } from './globalModals'; +import { SHOW_SEND_ANYWAY_DIALOG, SHOW_ERROR_MODAL } from './globalModals'; import { MODIFY_LIST, DELETE_LIST, @@ -183,8 +178,13 @@ import { isWithinMaxEdits, MESSAGE_MAX_EDIT_COUNT, } from '../../util/canEditMessage'; -import type { ChangeNavTabActionType } from './nav'; -import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav'; +import type { ChangeLocationAction } from './nav'; +import { + CHANGE_LOCATION, + NavTab, + changeLocation, + actions as navActions, +} from './nav'; import { sortByMessageOrder } from '../../types/ForwardDraft'; import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation'; import { @@ -220,6 +220,8 @@ import { markFailed } from '../../test-node/util/messageFailures'; import { cleanupMessages } from '../../util/cleanup'; import { MessageModel } from '../../models/messages'; import type { ConversationModel } from '../../models/conversations'; +import { EditState } from '../../components/ProfileEditor'; +import { Page } from '../../components/Preferences'; // State @@ -586,6 +588,7 @@ export type ConversationsStateType = ReadonlyDeep<{ pendingRequestedAvatarDownload: Record; preloadData?: ConversationPreloadDataType; + hasProfileUpdateError?: boolean; }>; // Helpers @@ -649,6 +652,8 @@ export const CONVERSATION_UNLOADED = 'CONVERSATION_UNLOADED'; export const SHOW_SPOILER = 'conversations/SHOW_SPOILER'; export const SET_PENDING_REQUESTED_AVATAR_DOWNLOAD = 'conversations/SET_PENDING_REQUESTED_AVATAR_DOWNLOAD'; +export const SET_PROFILE_UPDATE_ERROR = + 'conversations/SET_PROFILE_UPDATE_ERROR'; export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{ type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION; @@ -846,6 +851,12 @@ export type SetPendingRequestedAvatarDownloadActionType = ReadonlyDeep<{ value: boolean; }; }>; +export type SetProfileUpdateErrorActionType = ReadonlyDeep<{ + type: typeof SET_PROFILE_UPDATE_ERROR; + payload: { + newErrorState: boolean; + }; +}>; export type MessagesAddedActionType = ReadonlyDeep<{ type: 'MESSAGES_ADDED'; @@ -1082,6 +1093,7 @@ export type ConversationActionType = | ReviewConversationNameCollisionActionType | ScrollToMessageActionType | SetPendingRequestedAvatarDownloadActionType + | SetProfileUpdateErrorActionType | TargetedConversationChangedActionType | SetComposeGroupAvatarActionType | SetComposeGroupExpireTimerActionType @@ -1219,6 +1231,7 @@ export const actions = { setMuteExpiration, setPinned, setPreJoinConversation, + setProfileUpdateError, setVoiceNotePlaybackRate, showArchivedConversations, showAttachmentDownloadStillInProgressToast, @@ -2214,12 +2227,7 @@ function saveAvatarToDisk( function myProfileChanged( profileData: ProfileDataType, avatarUpdateOptions: AvatarUpdateOptionsType -): ThunkAction< - void, - RootStateType, - unknown, - NoopActionType | ToggleProfileEditorErrorActionType -> { +): ThunkAction { return async (dispatch, getState) => { const conversation = getMe(getState()); @@ -2235,13 +2243,32 @@ function myProfileChanged( // writeProfile above updates the backbone model which in turn updates // redux through it's on:change event listener. Once we lose Backbone // we'll need to manually sync these new changes. + + // We just want to clear whatever error was there before: dispatch({ - type: 'NOOP', - payload: null, + type: SET_PROFILE_UPDATE_ERROR, + payload: { + newErrorState: false, + }, }); } catch (err) { log.error('myProfileChanged', Errors.toLogFormat(err)); - dispatch({ type: TOGGLE_PROFILE_EDITOR_ERROR }); + + // Make sure the user sees an error dialog + dispatch({ + type: SET_PROFILE_UPDATE_ERROR, + payload: { + newErrorState: true, + }, + }); + // And take them to the profile editor to resolve it + changeLocation({ + tab: NavTab.Settings, + details: { + page: Page.Profile, + state: EditState.None, + }, + }); } }; } @@ -3067,7 +3094,7 @@ export function markOpenConversationRead( const state = getState(); const { nav } = state; - if (nav.selectedNavTab !== NavTab.Chats) { + if (nav.selectedLocation.tab !== NavTab.Chats) { return; } @@ -3305,6 +3332,16 @@ function setIsFetchingUUID( }, }; } +function setProfileUpdateError( + newErrorState: boolean +): SetProfileUpdateErrorActionType { + return { + type: SET_PROFILE_UPDATE_ERROR, + payload: { + newErrorState, + }, + }; +} export type PushPanelForConversationActionType = ReadonlyDeep< (panel: PanelRequestType) => unknown @@ -4676,13 +4713,13 @@ function showConversation({ void, RootStateType, unknown, - TargetedConversationChangedActionType | ChangeNavTabActionType + TargetedConversationChangedActionType | ChangeLocationAction > { return (dispatch, getState) => { const { conversations, nav } = getState(); - if (nav.selectedNavTab !== NavTab.Chats) { - dispatch(navActions.changeNavTab(NavTab.Chats)); + if (nav.selectedLocation.tab !== NavTab.Chats) { + dispatch(navActions.changeLocation({ tab: NavTab.Chats })); const conversation = window.ConversationController.get(conversationId); conversation?.setMarkedUnread(false); } @@ -5469,7 +5506,7 @@ export function reducer( action: Readonly< | ConversationActionType | StoryDistributionListsActionType - | ChangeNavTabActionType + | ChangeLocationAction > ): ConversationsStateType { if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) { @@ -5656,6 +5693,15 @@ export function reducer( preJoinConversation: data, }; } + if (action.type === SET_PROFILE_UPDATE_ERROR) { + const { payload } = action; + const { newErrorState } = payload; + + return { + ...state, + hasProfileUpdateError: newErrorState, + }; + } if (action.type === 'CONVERSATIONS_UPDATED') { const { payload } = action; const { data: conversations } = payload; @@ -7299,8 +7345,8 @@ export function reducer( } if ( - action.type === CHANGE_NAV_TAB && - action.payload.selectedNavTab === NavTab.Chats + action.type === CHANGE_LOCATION && + action.payload.selectedLocation.tab === NavTab.Chats ) { const { messagesByConversation, selectedConversationId } = state; if (selectedConversationId == null) { diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 3ef89b8932..fcfd47537a 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -17,7 +17,6 @@ import type { import type { MessagePropsType } from '../selectors/message'; import type { RecipientsByConversation } from './stories'; import type { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog'; -import type { EditState as ProfileEditorEditState } from '../../components/ProfileEditor'; import type { StateType as RootStateType } from '../reducer'; import * as SingleServePromise from '../../services/singleServePromise'; import * as Stickers from '../../types/Stickers'; @@ -125,7 +124,6 @@ export type GlobalModalsStateType = ReadonlyDeep<{ forwardMessagesProps?: ForwardMessagesPropsType; gv2MigrationProps?: MigrateToGV2PropsType; hasConfirmationModal: boolean; - isProfileEditorVisible: boolean; isProfileNameWarningModalVisible: boolean; profileNameWarningModalConversationType?: string; isShortcutGuideModalVisible: boolean; @@ -143,8 +141,6 @@ export type GlobalModalsStateType = ReadonlyDeep<{ requestor: 'call' | 'voiceNote'; abortController: AbortController; }; - profileEditorHasError: boolean; - profileEditorInitialEditState: ProfileEditorEditState | undefined; safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType; safetyNumberModalContactId?: string; stickerPackPreviewId?: string; @@ -181,9 +177,6 @@ const TOGGLE_DRAFT_GIF_MESSAGE_SEND_MODAL = const TOGGLE_FORWARD_MESSAGES_MODAL = 'globalModals/TOGGLE_FORWARD_MESSAGES_MODAL'; const TOGGLE_NOTE_PREVIEW_MODAL = 'globalModals/TOGGLE_NOTE_PREVIEW_MODAL'; -const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR'; -export const TOGGLE_PROFILE_EDITOR_ERROR = - 'globalModals/TOGGLE_PROFILE_EDITOR_ERROR'; const TOGGLE_PROFILE_NAME_WARNING_MODAL = 'globalModals/TOGGLE_PROFILE_NAME_WARNING_MODAL'; const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL'; @@ -324,17 +317,6 @@ type ToggleNotePreviewModalActionType = ReadonlyDeep<{ payload: NotePreviewModalPropsType | null; }>; -type ToggleProfileEditorActionType = ReadonlyDeep<{ - type: typeof TOGGLE_PROFILE_EDITOR; - payload: { - initialEditState?: ProfileEditorEditState; - }; -}>; - -export type ToggleProfileEditorErrorActionType = ReadonlyDeep<{ - type: typeof TOGGLE_PROFILE_EDITOR_ERROR; -}>; - export type ToggleProfileNameWarningModalActionType = ReadonlyDeep<{ type: typeof TOGGLE_PROFILE_NAME_WARNING_MODAL; payload?: { @@ -545,8 +527,6 @@ export type GlobalModalsActionType = ReadonlyDeep< | ToggleForwardMessagesModalActionType | ToggleMessageRequestActionsConfirmationActionType | ToggleNotePreviewModalActionType - | ToggleProfileEditorActionType - | ToggleProfileEditorErrorActionType | ToggleProfileNameWarningModalActionType | ToggleSafetyNumberModalActionType | ToggleSignalConnectionsModalActionType @@ -602,8 +582,6 @@ export const actions = { toggleForwardMessagesModal, toggleMessageRequestActionsConfirmation, toggleNotePreviewModal, - toggleProfileEditor, - toggleProfileEditorHasError, toggleProfileNameWarningModal, toggleSafetyNumberModal, toggleSignalConnectionsModal, @@ -949,16 +927,6 @@ function toggleNotePreviewModal( }; } -function toggleProfileEditor( - initialEditState?: ProfileEditorEditState -): ToggleProfileEditorActionType { - return { type: TOGGLE_PROFILE_EDITOR, payload: { initialEditState } }; -} - -function toggleProfileEditorHasError(): ToggleProfileEditorErrorActionType { - return { type: TOGGLE_PROFILE_EDITOR_ERROR }; -} - function toggleProfileNameWarningModal( conversationType?: string ): ToggleProfileNameWarningModalActionType { @@ -1335,7 +1303,6 @@ export function getEmptyState(): GlobalModalsStateType { criticalIdlePrimaryDeviceModal: false, draftGifMessageSendModalProps: null, editNicknameAndNoteModalProps: null, - isProfileEditorVisible: false, isProfileNameWarningModalVisible: false, profileNameWarningModalConversationType: undefined, isShortcutGuideModalVisible: false, @@ -1344,8 +1311,6 @@ export function getEmptyState(): GlobalModalsStateType { isWhatsNewVisible: false, lowDiskSpaceBackupImportModal: null, usernameOnboardingState: UsernameOnboardingState.NeverShown, - profileEditorHasError: false, - profileEditorInitialEditState: undefined, messageRequestActionsConfirmationProps: null, tapToViewNotAvailableModalProps: undefined, notePreviewModalProps: null, @@ -1377,20 +1342,6 @@ export function reducer( }; } - if (action.type === TOGGLE_PROFILE_EDITOR) { - return { - ...state, - isProfileEditorVisible: !state.isProfileEditorVisible, - profileEditorInitialEditState: action.payload.initialEditState, - }; - } - - if (action.type === TOGGLE_PROFILE_EDITOR_ERROR) { - return { - ...state, - profileEditorHasError: !state.profileEditorHasError, - }; - } if (action.type === TOGGLE_PROFILE_NAME_WARNING_MODAL) { return { ...state, diff --git a/ts/state/ducks/nav.ts b/ts/state/ducks/nav.ts index 9f567b30e5..1f6026e201 100644 --- a/ts/state/ducks/nav.ts +++ b/ts/state/ducks/nav.ts @@ -2,8 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ReadonlyDeep } from 'type-fest'; -import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import type { ThunkAction } from 'redux-thunk'; + +import * as log from '../../logging/log'; import { useBoundActions } from '../../hooks/useBoundActions'; +import { Page } from '../../components/Preferences'; + +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import type { StateType as RootStateType } from '../reducer'; +import type { EditState } from '../../components/ProfileEditor'; // Types @@ -13,35 +20,77 @@ export enum NavTab { Stories = 'Stories', Settings = 'Settings', } +export type Location = ReadonlyDeep< + | { + tab: NavTab.Settings; + details: + | { + page: Page.Profile; + state: EditState; + } + | { page: Exclude }; + } + | { tab: Exclude } +>; + +function printLocation(location: Location): string { + if (location.tab === NavTab.Settings) { + if (location.details.page === Page.Profile) { + return `${location.tab}/${location.details.page}/${location.details.state}`; + } + return `${location.tab}/${location.details.page}`; + } + + return `${location.tab}`; +} // State export type NavStateType = ReadonlyDeep<{ - selectedNavTab: NavTab; + selectedLocation: Location; }>; // Actions -export const CHANGE_NAV_TAB = 'nav/CHANGE_NAV_TAB'; +export const CHANGE_LOCATION = 'nav/CHANGE_LOCATION'; -export type ChangeNavTabActionType = ReadonlyDeep<{ - type: typeof CHANGE_NAV_TAB; - payload: { selectedNavTab: NavTab }; +export type ChangeLocationAction = ReadonlyDeep<{ + type: typeof CHANGE_LOCATION; + payload: { selectedLocation: Location }; }>; -export type NavActionType = ReadonlyDeep; +export type NavActionType = ReadonlyDeep; // Action Creators -function changeNavTab(selectedNavTab: NavTab): NavActionType { - return { - type: CHANGE_NAV_TAB, - payload: { selectedNavTab }, +export function changeLocation( + newLocation: Location +): ThunkAction { + return async (dispatch, getState) => { + const existingLocation = getState().nav.selectedLocation; + const logId = `changeLocation/${printLocation(newLocation)}`; + + const needToCancel = + await window.Signal.Services.beforeNavigate.shouldCancelNavigation({ + context: logId, + existingLocation, + newLocation, + }); + + if (needToCancel) { + log.info(`${logId}: Cancelling navigation`); + return; + } + + dispatch({ + type: CHANGE_LOCATION, + payload: { selectedLocation: newLocation }, + }); }; } export const actions = { - changeNavTab, + changeLocation, }; export const useNavActions = (): BoundActionCreatorsMapObject => @@ -51,7 +100,9 @@ export const useNavActions = (): BoundActionCreatorsMapObject => export function getEmptyState(): NavStateType { return { - selectedNavTab: NavTab.Chats, + selectedLocation: { + tab: NavTab.Chats, + }, }; } @@ -59,10 +110,10 @@ export function reducer( state: Readonly = getEmptyState(), action: Readonly ): NavStateType { - if (action.type === CHANGE_NAV_TAB) { + if (action.type === CHANGE_LOCATION) { return { ...state, - selectedNavTab: action.payload.selectedNavTab, + selectedLocation: action.payload.selectedLocation, }; } diff --git a/ts/state/ducks/username.ts b/ts/state/ducks/username.ts index c05d6356d5..508877ad5d 100644 --- a/ts/state/ducks/username.ts +++ b/ts/state/ducks/username.ts @@ -43,10 +43,10 @@ export type UsernameStateType = ReadonlyDeep<{ // ProfileEditor editState: UsernameEditState; - // UsernameLinkModalBody + // UsernameLinkEditor linkState: UsernameLinkState; - // EditUsernameModalBody + // UsernameEditor usernameReservation: UsernameReservationStateType; }>; diff --git a/ts/state/ducks/usernameEnums.ts b/ts/state/ducks/usernameEnums.ts index fb701d2e53..dcec0e9096 100644 --- a/ts/state/ducks/usernameEnums.ts +++ b/ts/state/ducks/usernameEnums.ts @@ -12,7 +12,7 @@ export enum UsernameEditState { } // -// UsernameLinkModalBody +// UsernameLinkEditor // export enum UsernameLinkState { @@ -22,7 +22,7 @@ export enum UsernameLinkState { } // -// EditUsernameModalBody +// UsernameEditor // export enum UsernameReservationState { diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 52963a3890..985a3b396b 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -1340,6 +1340,11 @@ export const getPreloadedConversationId = createSelector( ({ preloadData }): string | undefined => preloadData?.conversationId ); +export const getProfileUpdateError = createSelector( + getConversations, + ({ hasProfileUpdateError }): boolean => Boolean(hasProfileUpdateError) +); + export const getPendingAvatarDownloadSelector = createSelector( getConversations, (conversations: ConversationsStateType) => { diff --git a/ts/state/selectors/globalModals.ts b/ts/state/selectors/globalModals.ts index 8c4008d824..09b18e154d 100644 --- a/ts/state/selectors/globalModals.ts +++ b/ts/state/selectors/globalModals.ts @@ -83,16 +83,6 @@ export const getForwardMessagesProps = createSelector( ({ forwardMessagesProps }) => forwardMessagesProps ); -export const getProfileEditorHasError = createSelector( - getGlobalModalsState, - ({ profileEditorHasError }) => profileEditorHasError -); - -export const getProfileEditorInitialEditState = createSelector( - getGlobalModalsState, - ({ profileEditorInitialEditState }) => profileEditorInitialEditState -); - export const getEditNicknameAndNoteModalProps = createSelector( getGlobalModalsState, ({ editNicknameAndNoteModalProps }) => editNicknameAndNoteModalProps diff --git a/ts/state/selectors/nav.ts b/ts/state/selectors/nav.ts index d64ce4d790..95878e8d3d 100644 --- a/ts/state/selectors/nav.ts +++ b/ts/state/selectors/nav.ts @@ -14,7 +14,11 @@ function getNav(state: StateType): NavStateType { } export const getSelectedNavTab = createSelector(getNav, nav => { - return nav.selectedNavTab; + return nav.selectedLocation.tab; +}); + +export const getSelectedLocation = createSelector(getNav, nav => { + return nav.selectedLocation; }); export const getOtherTabsUnreadStats = createSelector( diff --git a/ts/state/smart/EditUsernameModalBody.tsx b/ts/state/smart/EditUsernameModalBody.tsx deleted file mode 100644 index 8de4472c68..0000000000 --- a/ts/state/smart/EditUsernameModalBody.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -import React, { memo } from 'react'; -import { useSelector } from 'react-redux'; -import { EditUsernameModalBody } from '../../components/EditUsernameModalBody'; -import { getMinNickname, getMaxNickname } from '../../util/Username'; -import { getIntl } from '../selectors/user'; -import { - getUsernameReservationState, - getUsernameReservationObject, - getUsernameReservationError, - getRecoveredUsername, -} from '../selectors/username'; -import { getUsernameCorrupted } from '../selectors/items'; -import { getMe } from '../selectors/conversations'; -import { useUsernameActions } from '../ducks/username'; -import { useToastActions } from '../ducks/toast'; - -export type SmartEditUsernameModalBodyProps = Readonly<{ - isRootModal: boolean; - onClose(): void; -}>; - -export const SmartEditUsernameModalBody = memo( - function SmartEditUsernameModalBody({ - isRootModal, - onClose, - }: SmartEditUsernameModalBodyProps) { - const i18n = useSelector(getIntl); - const { username } = useSelector(getMe); - const usernameCorrupted = useSelector(getUsernameCorrupted); - const currentUsername = usernameCorrupted ? undefined : username; - const minNickname = getMinNickname(); - const maxNickname = getMaxNickname(); - const state = useSelector(getUsernameReservationState); - const recoveredUsername = useSelector(getRecoveredUsername); - const reservation = useSelector(getUsernameReservationObject); - const error = useSelector(getUsernameReservationError); - const { - setUsernameReservationError, - clearUsernameReservation, - reserveUsername, - confirmUsername, - } = useUsernameActions(); - const { showToast } = useToastActions(); - return ( - - ); - } -); diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index db7cbbc4b2..50ab3ada37 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -11,7 +11,6 @@ import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal'; import { SmartContactModal } from './ContactModal'; import { SmartEditHistoryMessagesModal } from './EditHistoryMessagesModal'; import { SmartForwardMessagesModal } from './ForwardMessagesModal'; -import { SmartProfileEditorModal } from './ProfileEditorModal'; import { SmartUsernameOnboardingModal } from './UsernameOnboardingModal'; import { SmartSafetyNumberModal } from './SafetyNumberModal'; import { SmartSendAnywayDialog } from './SendAnywayDialog'; @@ -58,10 +57,6 @@ function renderEditNicknameAndNoteModal(): JSX.Element { return ; } -function renderProfileEditor(): JSX.Element { - return ; -} - function renderProfileNameWarningModal(): JSX.Element { return ; } @@ -143,7 +138,6 @@ export const SmartGlobalModalContainer = memo( mediaPermissionsModalProps, messageRequestActionsConfirmationProps, notePreviewModalProps, - isProfileEditorVisible, isProfileNameWarningModalVisible, profileNameWarningModalConversationType, isShortcutGuideModalVisible, @@ -254,7 +248,6 @@ export const SmartGlobalModalContainer = memo( hideTapToViewNotAvailableModal={hideTapToViewNotAvailableModal} i18n={i18n} isAboutContactModalVisible={aboutContactModalContactId != null} - isProfileEditorVisible={isProfileEditorVisible} isProfileNameWarningModalVisible={isProfileNameWarningModalVisible} isShortcutGuideModalVisible={isShortcutGuideModalVisible} isSignalConnectionsVisible={isSignalConnectionsVisible} @@ -280,7 +273,6 @@ export const SmartGlobalModalContainer = memo( renderMessageRequestActionsConfirmation } renderNotePreviewModal={renderNotePreviewModal} - renderProfileEditor={renderProfileEditor} renderProfileNameWarningModal={renderProfileNameWarningModal} renderUsernameOnboarding={renderUsernameOnboarding} renderSafetyNumber={renderSafetyNumber} diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 119cbda7da..c6f8dfec15 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -106,6 +106,7 @@ import { pauseBackupMediaDownload, resumeBackupMediaDownload, } from '../../util/backupMediaDownload'; +import { useNavActions } from '../ducks/nav'; function renderMessageSearchResult(id: string): JSX.Element { return ; @@ -347,8 +348,8 @@ export const SmartLeftPane = memo(function SmartLeftPane({ const { savePreferredLeftPaneWidth, toggleNavTabsCollapse } = useItemsActions(); const { setChallengeStatus } = useNetworkActions(); - const { showUserNotFoundModal, toggleProfileEditor } = - useGlobalModalActions(); + const { showUserNotFoundModal } = useGlobalModalActions(); + const { changeLocation } = useNavActions(); let hasExpiredDialog = false; let unsupportedOSDialogType: 'error' | 'warning' | undefined; @@ -377,6 +378,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ blockConversation={blockConversation} cancelBackupMediaDownload={cancelBackupMediaDownload} challengeStatus={challengeStatus} + changeLocation={changeLocation} clearConversationSearch={clearConversationSearch} clearGroupCreationError={clearGroupCreationError} clearSearchQuery={clearSearchQuery} @@ -448,7 +450,6 @@ export const SmartLeftPane = memo(function SmartLeftPane({ toggleComposeEditingAvatar={toggleComposeEditingAvatar} toggleConversationInChooseMembers={toggleConversationInChooseMembers} toggleNavTabsCollapse={toggleNavTabsCollapse} - toggleProfileEditor={toggleProfileEditor} unsupportedOSDialogType={unsupportedOSDialogType} updateSearchTerm={updateSearchTerm} usernameCorrupted={usernameCorrupted} diff --git a/ts/state/smart/NavTabs.tsx b/ts/state/smart/NavTabs.tsx index 4814237a91..2aa507ee72 100644 --- a/ts/state/smart/NavTabs.tsx +++ b/ts/state/smart/NavTabs.tsx @@ -15,10 +15,12 @@ import { getHasAnyFailedStorySends, getStoriesNotificationCount, } from '../selectors/stories'; -import { useGlobalModalActions } from '../ducks/globalModals'; -import { getStoriesEnabled } from '../selectors/items'; +import { + getStoriesEnabled, + isInternalUser as isInternalUserSelector, +} from '../selectors/items'; import { getSelectedNavTab } from '../selectors/nav'; -import type { NavTab } from '../ducks/nav'; +import type { Location } from '../ducks/nav'; import { useNavActions } from '../ducks/nav'; import { getHasPendingUpdate } from '../selectors/updates'; import { getCallHistoryUnreadCount } from '../selectors/callHistory'; @@ -42,7 +44,7 @@ export const SmartNavTabs = memo(function SmartNavTabs({ }: SmartNavTabsProps): JSX.Element { const i18n = useSelector(getIntl); const selectedNavTab = useSelector(getSelectedNavTab); - const { changeNavTab } = useNavActions(); + const { changeLocation } = useNavActions(); const me = useSelector(getMe); const badge = useSelector(getPreferredBadgeSelector)(me.badges); const theme = useSelector(getTheme); @@ -52,18 +54,17 @@ export const SmartNavTabs = memo(function SmartNavTabs({ const unreadCallsCount = useSelector(getCallHistoryUnreadCount); const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); const hasPendingUpdate = useSelector(getHasPendingUpdate); + const isInternalUser = useSelector(isInternalUserSelector); - const { toggleProfileEditor } = useGlobalModalActions(); - - const onNavTabSelected = useCallback( - (tab: NavTab) => { + const onChangeLocation = useCallback( + (location: Location) => { // For some reason react-aria will call this more often than the tab // actually changing. - if (tab !== selectedNavTab) { - changeNavTab(tab); + if (location.tab !== selectedNavTab) { + changeLocation(location); } }, - [changeNavTab, selectedNavTab] + [changeLocation, selectedNavTab] ); return ( @@ -72,11 +73,11 @@ export const SmartNavTabs = memo(function SmartNavTabs({ hasFailedStorySends={hasFailedStorySends} hasPendingUpdate={hasPendingUpdate} i18n={i18n} + isInternalUser={isInternalUser} me={me} navTabsCollapsed={navTabsCollapsed} - onNavTabSelected={onNavTabSelected} + onChangeLocation={onChangeLocation} onToggleNavTabsCollapse={onToggleNavTabsCollapse} - onToggleProfileEditor={toggleProfileEditor} renderCallsTab={renderCallsTab} renderChatsTab={renderChatsTab} renderStoriesTab={renderStoriesTab} diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 731bd69b51..52f5f444a4 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -3,11 +3,16 @@ import React, { StrictMode, useEffect } from 'react'; import { useSelector } from 'react-redux'; + import type { AudioDevice } from '@signalapp/ringrtc'; +import type { MutableRefObject } from 'react'; import { useItemsActions } from '../ducks/items'; import { useConversationsActions } from '../ducks/conversations'; -import { getConversationsWithCustomColorSelector } from '../selectors/conversations'; +import { + getConversationsWithCustomColorSelector, + getMe, +} from '../selectors/conversations'; import { getCustomColors, getItems, @@ -17,7 +22,12 @@ import { DEFAULT_AUTO_DOWNLOAD_ATTACHMENT } from '../../textsecure/Storage'; import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors'; import { isBackupFeatureEnabledForRedux } from '../../util/isBackupEnabled'; import { format } from '../../types/PhoneNumber'; -import { getIntl, getUserDeviceId, getUserNumber } from '../selectors/user'; +import { + getIntl, + getTheme, + getUserDeviceId, + getUserNumber, +} from '../selectors/user'; import { EmojiSkinTone } from '../../components/fun/data/emojis'; import { renderClearingDataView } from '../../shims/renderClearingDataView'; import OS from '../../util/os/osPreload'; @@ -41,22 +51,27 @@ import { getConversation } from '../../util/getConversation'; import { waitForEvent } from '../../shims/events'; import { MINUTE } from '../../util/durations'; import { sendSyncRequests } from '../../textsecure/syncRequests'; - import { SmartUpdateDialog } from './UpdateDialog'; -import { Preferences } from '../../components/Preferences'; - -import type { StorageAccessType, ZoomFactorType } from '../../types/Storage'; -import type { ThemeType } from '../../util/preload'; -import type { WidthBreakpoint } from '../../components/_util'; +import { Page, Preferences } from '../../components/Preferences'; import { useUpdatesActions } from '../ducks/updates'; import { getHasPendingUpdate, isUpdateDownloaded as getIsUpdateDownloaded, } from '../selectors/updates'; import { getHasAnyFailedStorySends } from '../selectors/stories'; -import { getOtherTabsUnreadStats } from '../selectors/nav'; +import { getOtherTabsUnreadStats, getSelectedLocation } from '../selectors/nav'; +import { getPreferredBadgeSelector } from '../selectors/badges'; +import { SmartProfileEditor } from './ProfileEditor'; +import { NavTab, useNavActions } from '../ducks/nav'; +import { EditState } from '../../components/ProfileEditor'; +import { SmartToastManager } from './ToastManager'; +import { useToastActions } from '../ducks/toast'; import { DataReader } from '../../sql/Client'; +import type { StorageAccessType, ZoomFactorType } from '../../types/Storage'; +import type { ThemeType } from '../../util/preload'; +import type { WidthBreakpoint } from '../../components/_util'; + const DEFAULT_NOTIFICATION_SETTING = 'message'; function renderUpdateDialog( @@ -65,6 +80,18 @@ function renderUpdateDialog( return ; } +function renderProfileEditor(options: { + contentsRef: MutableRefObject; +}): JSX.Element { + return ; +} + +function renderToastManager(props: { + containerWidthBreakpoint: WidthBreakpoint; +}): JSX.Element { + return ; +} + function getSystemTraySettingValues( systemTraySetting: SystemTraySetting | undefined ): { @@ -92,7 +119,7 @@ function getSystemTraySettingValues( }; } -export function SmartPreferences(): JSX.Element { +export function SmartPreferences(): JSX.Element | null { const { addCustomColor, editCustomColor, @@ -106,9 +133,12 @@ export function SmartPreferences(): JSX.Element { const { removeCustomColorOnConversations, resetAllChatColors } = useConversationsActions(); const { startUpdate } = useUpdatesActions(); + const { changeLocation } = useNavActions(); + const { showToast } = useToastActions(); // Selectors + const currentLocation = useSelector(getSelectedLocation); const customColors = useSelector(getCustomColors) ?? {}; const getConversationsWithCustomColor = useSelector( getConversationsWithCustomColorSelector @@ -120,6 +150,9 @@ export function SmartPreferences(): JSX.Element { const navTabsCollapsed = useSelector(getNavTabsCollapsed); const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats); + const me = useSelector(getMe); + const badge = useSelector(getPreferredBadgeSelector)(me.badges); + const theme = useSelector(getTheme); // The weird ones @@ -583,6 +616,31 @@ export function SmartPreferences(): JSX.Element { } ); + if (currentLocation.tab !== NavTab.Settings) { + return null; + } + + const { page } = currentLocation.details; + const setPage = (newPage: Page, editState?: EditState) => { + if (newPage === Page.Profile) { + changeLocation({ + tab: NavTab.Settings, + details: { + page: newPage, + state: editState || EditState.None, + }, + }); + return; + } + + changeLocation({ + tab: NavTab.Settings, + details: { + page: newPage, + }, + }); + }; + return ( ; +} + +export const SmartProfileEditor = memo(function SmartProfileEditor(props: { + contentsRef: MutableRefObject; +}) { + const i18n = useSelector(getIntl); + const { + aboutEmoji, + aboutText, + avatars: userAvatarData = [], + color, + familyName, + firstName, + id: conversationId, + profileAvatarUrl, + username, + } = useSelector(getMe); + const selectedLocation = useSelector(getSelectedLocation); + const hasCompletedUsernameLinkOnboarding = useSelector( + getHasCompletedUsernameLinkOnboarding + ); + const hasError = useSelector(getProfileUpdateError); + const recentEmojis = useSelector(selectRecentEmojis); + const emojiSkinToneDefault = useSelector(getEmojiSkinToneDefault); + const usernameCorrupted = useSelector(getUsernameCorrupted); + const usernameEditState = useSelector(getUsernameEditState); + const usernameLink = useSelector(getUsernameLink); + const usernameLinkColor = useSelector(getUsernameLinkColor); + const usernameLinkCorrupted = useSelector(getUsernameLinkCorrupted); + const usernameLinkState = useSelector(getUsernameLinkState); + + const { + deleteAvatarFromDisk, + myProfileChanged, + replaceAvatar, + saveAttachment, + saveAvatarToDisk, + setProfileUpdateError, + } = useConversationsActions(); + const { + resetUsernameLink, + setUsernameLinkColor, + setUsernameEditState, + openUsernameReservationModal, + markCompletedUsernameLinkOnboarding, + deleteUsername, + } = useUsernameActions(); + const { showToast } = useToastActions(); + const { setEmojiSkinToneDefault } = useItemsActions(); + const { changeLocation } = useNavActions(); + + let errorDialog: JSX.Element | undefined; + if (hasError) { + errorDialog = ( + setProfileUpdateError(false)} + > + {i18n('icu:ProfileEditorModal--error')} + + ); + } + + if ( + selectedLocation.tab !== NavTab.Settings || + selectedLocation.details.page !== Page.Profile + ) { + return null; + } + + const editState = selectedLocation.details.state; + const setEditState = (newState: EditState) => { + changeLocation({ + tab: NavTab.Settings, + details: { + page: Page.Profile, + state: newState, + }, + }); + }; + + return ( + <> + {errorDialog} + + + ); +}); diff --git a/ts/state/smart/ProfileEditorModal.tsx b/ts/state/smart/ProfileEditorModal.tsx deleted file mode 100644 index e3eeb2dec4..0000000000 --- a/ts/state/smart/ProfileEditorModal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -import React, { memo } from 'react'; -import { useSelector } from 'react-redux'; -import { ProfileEditorModal } from '../../components/ProfileEditorModal'; -import { useConversationsActions } from '../ducks/conversations'; -import { useGlobalModalActions } from '../ducks/globalModals'; -import { useItemsActions } from '../ducks/items'; -import { useToastActions } from '../ducks/toast'; -import { useUsernameActions } from '../ducks/username'; -import { getMe } from '../selectors/conversations'; -import { selectRecentEmojis } from '../selectors/emojis'; -import { - getProfileEditorHasError, - getProfileEditorInitialEditState, -} from '../selectors/globalModals'; -import { - getEmojiSkinToneDefault, - getHasCompletedUsernameLinkOnboarding, - getUsernameCorrupted, - getUsernameLink, - getUsernameLinkColor, - getUsernameLinkCorrupted, -} from '../selectors/items'; -import { getIntl } from '../selectors/user'; -import { - getUsernameEditState, - getUsernameLinkState, -} from '../selectors/username'; -import type { SmartEditUsernameModalBodyProps } from './EditUsernameModalBody'; -import { SmartEditUsernameModalBody } from './EditUsernameModalBody'; - -function renderEditUsernameModalBody( - props: SmartEditUsernameModalBodyProps -): JSX.Element { - return ; -} - -export const SmartProfileEditorModal = memo(function SmartProfileEditorModal() { - const i18n = useSelector(getIntl); - const { - aboutEmoji, - aboutText, - avatars: userAvatarData = [], - color, - familyName, - firstName, - id: conversationId, - profileAvatarUrl, - username, - } = useSelector(getMe); - const hasCompletedUsernameLinkOnboarding = useSelector( - getHasCompletedUsernameLinkOnboarding - ); - const hasError = useSelector(getProfileEditorHasError); - const initialEditState = useSelector(getProfileEditorInitialEditState); - const recentEmojis = useSelector(selectRecentEmojis); - const emojiSkinToneDefault = useSelector(getEmojiSkinToneDefault); - const usernameCorrupted = useSelector(getUsernameCorrupted); - const usernameEditState = useSelector(getUsernameEditState); - const usernameLink = useSelector(getUsernameLink); - const usernameLinkColor = useSelector(getUsernameLinkColor); - const usernameLinkCorrupted = useSelector(getUsernameLinkCorrupted); - const usernameLinkState = useSelector(getUsernameLinkState); - - const { - replaceAvatar, - saveAvatarToDisk, - saveAttachment, - deleteAvatarFromDisk, - myProfileChanged, - } = useConversationsActions(); - const { - resetUsernameLink, - setUsernameLinkColor, - setUsernameEditState, - openUsernameReservationModal, - markCompletedUsernameLinkOnboarding, - deleteUsername, - } = useUsernameActions(); - const { toggleProfileEditor, toggleProfileEditorHasError } = - useGlobalModalActions(); - const { showToast } = useToastActions(); - const { setEmojiSkinToneDefault } = useItemsActions(); - - return ( - - ); -}); diff --git a/ts/state/smart/UsernameEditor.tsx b/ts/state/smart/UsernameEditor.tsx new file mode 100644 index 0000000000..e2cbf31ce3 --- /dev/null +++ b/ts/state/smart/UsernameEditor.tsx @@ -0,0 +1,62 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { memo } from 'react'; +import { useSelector } from 'react-redux'; +import { UsernameEditor } from '../../components/UsernameEditor'; +import { getMinNickname, getMaxNickname } from '../../util/Username'; +import { getIntl } from '../selectors/user'; +import { + getUsernameReservationState, + getUsernameReservationObject, + getUsernameReservationError, + getRecoveredUsername, +} from '../selectors/username'; +import { getUsernameCorrupted } from '../selectors/items'; +import { getMe } from '../selectors/conversations'; +import { useUsernameActions } from '../ducks/username'; +import { useToastActions } from '../ducks/toast'; + +export type SmartUsernameEditorProps = Readonly<{ + onClose(): void; +}>; + +export const SmartUsernameEditor = memo(function SmartUsernameEditor({ + onClose, +}: SmartUsernameEditorProps) { + const i18n = useSelector(getIntl); + const { username } = useSelector(getMe); + const usernameCorrupted = useSelector(getUsernameCorrupted); + const currentUsername = usernameCorrupted ? undefined : username; + const minNickname = getMinNickname(); + const maxNickname = getMaxNickname(); + const state = useSelector(getUsernameReservationState); + const recoveredUsername = useSelector(getRecoveredUsername); + const reservation = useSelector(getUsernameReservationObject); + const error = useSelector(getUsernameReservationError); + const { + setUsernameReservationError, + clearUsernameReservation, + reserveUsername, + confirmUsername, + } = useUsernameActions(); + const { showToast } = useToastActions(); + return ( + + ); +}); diff --git a/ts/state/smart/UsernameOnboardingModal.tsx b/ts/state/smart/UsernameOnboardingModal.tsx index f6ee202de0..a0b8868b3c 100644 --- a/ts/state/smart/UsernameOnboardingModal.tsx +++ b/ts/state/smart/UsernameOnboardingModal.tsx @@ -5,27 +5,35 @@ import React, { memo, useCallback } from 'react'; import { useSelector } from 'react-redux'; import { UsernameOnboardingModal } from '../../components/UsernameOnboardingModal'; -import { EditState } from '../../components/ProfileEditor'; import { getIntl } from '../selectors/user'; import { useGlobalModalActions } from '../ducks/globalModals'; import { useUsernameActions } from '../ducks/username'; +import { NavTab, useNavActions } from '../ducks/nav'; +import { Page } from '../../components/Preferences'; +import { EditState } from '../../components/ProfileEditor'; export const SmartUsernameOnboardingModal = memo( function SmartUsernameOnboardingModal(): JSX.Element { const i18n = useSelector(getIntl); - const { toggleProfileEditor, toggleUsernameOnboarding } = - useGlobalModalActions(); + const { toggleUsernameOnboarding } = useGlobalModalActions(); const { openUsernameReservationModal } = useUsernameActions(); + const { changeLocation } = useNavActions(); const onNext = useCallback(async () => { await window.storage.put('hasCompletedUsernameOnboarding', true); openUsernameReservationModal(); - toggleProfileEditor(EditState.Username); + changeLocation({ + tab: NavTab.Settings, + details: { + page: Page.Profile, + state: EditState.Username, + }, + }); toggleUsernameOnboarding(); }, [ - toggleProfileEditor, - toggleUsernameOnboarding, + changeLocation, openUsernameReservationModal, + toggleUsernameOnboarding, ]); const onSkip = useCallback(async () => { diff --git a/ts/test-both/state/ducks/globalModals_test.ts b/ts/test-both/state/ducks/globalModals_test.ts index 4da6eb68f5..1d5dde93fd 100644 --- a/ts/test-both/state/ducks/globalModals_test.ts +++ b/ts/test-both/state/ducks/globalModals_test.ts @@ -10,21 +10,6 @@ import { } from '../../../state/ducks/globalModals'; describe('both/state/ducks/globalModals', () => { - describe('toggleProfileEditor', () => { - const { toggleProfileEditor } = actions; - - it('toggles isProfileEditorVisible', () => { - const state = getEmptyState(); - const nextState = reducer(state, toggleProfileEditor()); - - assert.isTrue(nextState.isProfileEditorVisible); - - const nextNextState = reducer(nextState, toggleProfileEditor()); - - assert.isFalse(nextNextState.isProfileEditorVisible); - }); - }); - describe('showWhatsNewModal/hideWhatsNewModal', () => { const { showWhatsNewModal, hideWhatsNewModal } = actions; diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 1b167ab834..cefae14d49 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -185,8 +185,8 @@ describe('pnp/username', function (this: Mocha.Suite) { const window = await app.getWindow(); - debug('opening avatar context menu'); - await window.getByRole('button', { name: 'Profile' }).click(); + debug('opening settings tab context menu'); + await window.locator('[data-key="Settings"]').click(); debug('opening username editor'); const profileEditor = window.locator('.ProfileEditor'); @@ -198,7 +198,7 @@ describe('pnp/username', function (this: Mocha.Suite) { debug('waiting for generated discriminator'); const discriminator = profileEditor.locator( - '.EditUsernameModalBody__discriminator__input[value]' + '.UsernameEditor__discriminator__input[value]' ); await discriminator.waitFor(); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 390741cff9..35f28c61d6 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -856,17 +856,18 @@ export type GroupLogResponseType = { } ); -export type ProfileRequestDataType = { - about: string | null; - aboutEmoji: string | null; - avatar: boolean; - sameAvatar: boolean; - commitment: string; - name: string; - paymentAddress: string | null; - phoneNumberSharing: string | null; - version: string; -}; +const uploadProfileZod = z.object({ + about: z.string().nullish(), + aboutEmoji: z.string().nullish(), + avatar: z.boolean(), + sameAvatar: z.boolean(), + commitment: z.string(), + name: z.string(), + paymentAddress: z.string().nullish(), + phoneNumberSharing: z.string().nullish(), + version: z.string(), +}); +export type ProfileRequestDataType = z.infer; const uploadAvatarHeadersZod = z.object({ acl: z.string(), @@ -878,6 +879,14 @@ const uploadAvatarHeadersZod = z.object({ signature: z.string(), }); export type UploadAvatarHeadersType = z.infer; +const uploadAvatarOrOther = z.union([ + uploadAvatarHeadersZod, + z.string(), + z.undefined(), +]); +export type UploadAvatarHeadersOrOtherType = z.infer< + typeof uploadAvatarOrOther +>; const remoteConfigResponseZod = z.object({ config: z @@ -1544,7 +1553,7 @@ export type WebAPIType = { ) => Promise; putProfile: ( jsonData: ProfileRequestDataType - ) => Promise; + ) => Promise; putStickers: ( encryptedManifest: Uint8Array, encryptedStickers: ReadonlyArray, @@ -2644,13 +2653,13 @@ export function initialize({ async function putProfile( jsonData: ProfileRequestDataType - ): Promise { + ): Promise { return _ajax({ call: 'profile', httpType: 'PUT', responseType: 'json', jsonData, - zodSchema: uploadAvatarHeadersZod, + zodSchema: uploadAvatarOrOther, }); } diff --git a/ts/util/getConversation.ts b/ts/util/getConversation.ts index 841e59a632..cca6575811 100644 --- a/ts/util/getConversation.ts +++ b/ts/util/getConversation.ts @@ -34,6 +34,7 @@ import { getTitle, getTitleNoDefault, canHaveUsername, + renderNumber, } from './getTitle'; import { hasDraft } from './hasDraft'; import { isAciString } from './isAciString'; @@ -135,6 +136,8 @@ export function getConversation(model: ConversationModel): ConversationType { const { customColor, customColorId } = getCustomColorData(attributes); + const isItMe = isMe(attributes); + // TODO: DESKTOP-720 return { id: attributes.id, @@ -193,7 +196,7 @@ export function getConversation(model: ConversationModel): ConversationType { isBlocked: isBlocked(attributes), reportingToken: attributes.reportingToken, removalStage: attributes.removalStage, - isMe: isMe(attributes), + isMe: isItMe, isGroupV1AndDisabled: isGroupV1(attributes), isPinned: attributes.isPinned, isUntrusted: model.isUntrusted(), @@ -228,7 +231,10 @@ export function getConversation(model: ConversationModel): ConversationType { systemGivenName: attributes.systemGivenName, systemFamilyName: attributes.systemFamilyName, systemNickname: attributes.systemNickname, - phoneNumber: getNumber(attributes), + phoneNumber: + isItMe && attributes.e164 + ? renderNumber(attributes.e164) + : getNumber(attributes), profileName: getProfileName(attributes), profileSharing: attributes.profileSharing, profileLastUpdatedAt: attributes.profileLastUpdatedAt, diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 4fc499522c..5798baeb35 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -771,6 +771,14 @@ "reasonCategory": "usageTrusted", "updated": "2024-03-26T17:14:14.370Z" }, + { + "rule": "React-useRef", + "path": "ts/components/AvatarEditor.tsx", + "line": " const tryClose = useRef<() => void | undefined>();", + "reasonCategory": "usageTrusted", + "updated": "2025-05-24T03:40:20.019Z", + "reasonDetail": "Holding on to a close function" + }, { "rule": "React-useRef", "path": "ts/components/AvatarTextEditor.tsx", @@ -1414,6 +1422,14 @@ "reasonCategory": "usageTrusted", "updated": "2021-07-30T16:57:33.618Z" }, + { + "rule": "React-useRef", + "path": "ts/components/ProfileEditor.tsx", + "line": " const tryClose = useRef<() => void | undefined>();", + "reasonCategory": "usageTrusted", + "updated": "2025-05-24T03:23:25.769Z", + "reasonDetail": "Holding on to a close function" + }, { "rule": "React-useRef", "path": "ts/components/QrCode.tsx", @@ -1572,6 +1588,22 @@ "reasonCategory": "usageTrusted", "updated": "2023-08-10T00:23:35.320Z" }, + { + "rule": "React-useRef", + "path": "ts/components/UsernameEditor.tsx", + "line": " const tryClose = useRef<() => void | undefined>();", + "reasonCategory": "usageTrusted", + "updated": "2025-05-28T00:57:39.376Z", + "reasonDetail": "Holding on to a close function" + }, + { + "rule": "React-useRef", + "path": "ts/components/UsernameLinkEditor.tsx", + "line": " const tryClose = useRef<() => void | undefined>();", + "reasonCategory": "usageTrusted", + "updated": "2025-05-28T00:57:39.376Z", + "reasonDetail": "Holding on to a close function" + }, { "rule": "React-useRef", "path": "ts/components/conversation/AttachmentStatusIcon.tsx",