Move Profile Editor into the new Settings Tab

This commit is contained in:
Scott Nonnenberg 2025-06-03 09:46:52 +10:00 committed by GitHub
parent 829b84a54e
commit 799a0dcc54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1480 additions and 960 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void> {
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', () => {

View file

@ -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({
<AvatarModalButtons
hasChanges={hasChanges}
i18n={i18n}
onCancel={onCancel}
onCancel={() => {
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 <div className="AvatarEditor">{content}</div>;
return (
<div className="AvatarEditor">
{confirmDiscardModal}
{content}
</div>
);
}

View file

@ -31,6 +31,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): 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,
})}
/>
);

View file

@ -26,6 +26,7 @@ export type PropsType = {
onAvatarLoaded?: (avatarBuffer: Uint8Array) => unknown;
onClear?: () => unknown;
onClick?: () => unknown;
showUploadButton?: boolean;
style?: CSSProperties;
} & Pick<ConversationType, 'avatarPlaceholderGradient' | 'hasAvatar'>;
@ -50,6 +51,7 @@ export function AvatarPreview({
onAvatarLoaded,
onClear,
onClick,
showUploadButton,
style = {},
}: PropsType): JSX.Element {
const [avatarPreview, setAvatarPreview] = useState<Uint8Array | undefined>();
@ -184,7 +186,7 @@ export function AvatarPreview({
style={componentStyle}
>
{content}
{isEditable && <div className="AvatarPreview__upload" />}
{showUploadButton && <div className="AvatarPreview__upload" />}
</div>
</div>
);
@ -232,7 +234,7 @@ export function AvatarPreview({
type="button"
/>
)}
{isEditable && <div className="AvatarPreview__upload" />}
{showUploadButton && <div className="AvatarPreview__upload" />}
</div>
</div>
);

View file

@ -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();
}

View file

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

View file

@ -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 = (
<LeftPaneBanner
actionText={i18n('icu:LeftPane--corrupted-username-link--action-text')}
onClick={() => toggleProfileEditor(ProfileEditorEditState.UsernameLink)}
onClick={() => {
changeLocation({
tab: NavTab.Settings,
details: {
page: Page.Profile,
state: EditState.UsernameLink,
},
});
}}
>
{i18n('icu:LeftPane--corrupted-username-link--text')}
</LeftPaneBanner>

View file

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

View file

@ -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}
/>
</TabList>
<div className="NavTabs__Misc">
<button
type="button"
className="NavTabs__Item NavTabs__Item--Profile"
onClick={() => {
onToggleProfileEditor();
}}
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
>
<Tooltip
content={i18n('icu:NavTabs__ItemLabel--Profile')}
theme={Theme.Dark}
direction={isRTL ? TooltipPlacement.Left : TooltipPlacement.Right}
delay={600}
{isInternalUser && (
<div className="NavTabs__Misc">
<button
type="button"
className="NavTabs__Item NavTabs__Item--Profile"
onClick={() => {
handleSelectionChange(NavTab.Settings);
}}
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
>
<span className="NavTabs__ItemButton">
<span className="NavTabs__ItemContent">
<Avatar
avatarUrl={me.avatarUrl}
badge={badge}
className="module-main-header__avatar"
color={me.color}
conversationType="direct"
i18n={i18n}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
theme={theme}
title={me.title}
// `sharedGroupNames` makes no sense for yourself, but
// `<Avatar>` needs it to determine blurring.
sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT}
/>
<Tooltip
content={i18n('icu:NavTabs__ItemLabel--Profile')}
theme={Theme.Dark}
direction={
isRTL ? TooltipPlacement.Left : TooltipPlacement.Right
}
delay={600}
>
<span className="NavTabs__ItemButton">
<span className="NavTabs__ItemContent">
<Avatar
avatarUrl={me.avatarUrl}
badge={badge}
className="module-main-header__avatar"
color={me.color}
conversationType="direct"
i18n={i18n}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
theme={theme}
title={me.title}
// `sharedGroupNames` makes no sense for yourself, but
// `<Avatar>` needs it to determine blurring.
sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT}
/>
</span>
</span>
</span>
</Tooltip>
</button>
</div>
</Tooltip>
</button>
</div>
)}
</nav>
<TabPanel id={NavTab.Chats} className="NavTabs__TabPanel">
{renderChatsTab}

View file

@ -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<HTMLDivElement | null>(null);
return (
<ProfileEditor
aboutEmoji={undefined}
aboutText={undefined}
color={undefined}
contentsRef={contentsRef}
conversationId="something"
deleteAvatarFromDisk={action('deleteAvatarFromDisk')}
deleteUsername={action('deleteUsername')}
familyName={me.familyName}
firstName={me.firstName ?? ''}
hasCompletedUsernameLinkOnboarding={false}
i18n={i18n}
editState={EditState.None}
markCompletedUsernameLinkOnboarding={action(
'markCompletedUsernameLinkOnboarding'
)}
onProfileChanged={action('onProfileChanged')}
onEmojiSkinToneDefaultChange={action('onEmojiSkinToneDefaultChange')}
openUsernameReservationModal={action('openUsernameReservationModal')}
profileAvatarUrl={undefined}
recentEmojis={[]}
renderUsernameEditor={() => <div />}
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 <div />;
}
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<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = args => <Preferences {...args} />;
const Template: StoryFn<PropsType> = args => {
const [page, setPage] = useState(args.page);
return <Preferences {...args} page={page} setPage={setPage} />;
};
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,

View file

@ -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<HTMLDivElement | null>;
}) => 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<BackupValidationResultType>;
// Change handlers
@ -245,6 +261,7 @@ export type PropsPreloadType = Omit<PropsType, 'i18n'>;
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<Page>(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<AnyToast | undefined>();
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 = (
<>
<SettingsRow>
<Control
@ -719,6 +743,13 @@ export function Preferences({
)}
</>
);
content = (
<PreferencesContent
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--general')}
/>
);
} 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 = (
<SettingsRow>
<Control
icon="Preferences__LanguageIcon"
@ -933,6 +963,13 @@ export function Preferences({
/>
</SettingsRow>
);
content = (
<PreferencesContent
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--appearance')}
/>
);
} 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 = (
<>
<SettingsRow title={i18n('icu:Preferences__button--chats')}>
<Checkbox
@ -1058,9 +1094,15 @@ export function Preferences({
)}
</>
);
content = (
<PreferencesContent
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--chats')}
/>
);
} else if (page === Page.Calls) {
pageTitle = i18n('icu:Preferences__button--calls');
pageContents = (
const pageContents = (
<>
<SettingsRow title={i18n('icu:calling')}>
<Checkbox
@ -1201,9 +1243,15 @@ export function Preferences({
</SettingsRow>
</>
);
content = (
<PreferencesContent
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--calls')}
/>
);
} else if (page === Page.Notifications) {
pageTitle = i18n('icu:Preferences__button--notifications');
pageContents = (
const pageContents = (
<>
<SettingsRow>
<Checkbox
@ -1283,12 +1331,17 @@ export function Preferences({
</SettingsRow>
</>
);
content = (
<PreferencesContent
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--notifications')}
/>
);
} else if (page === Page.Privacy) {
const isCustomDisappearingMessageValue =
!DEFAULT_DURATIONS_SET.has(universalExpireTimer);
pageTitle = i18n('icu:Preferences__button--privacy');
pageContents = (
const pageContents = (
<>
<SettingsRow>
<Control
@ -1527,9 +1580,15 @@ export function Preferences({
) : null}
</>
);
content = (
<PreferencesContent
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--privacy')}
/>
);
} else if (page === Page.DataUsage) {
pageTitle = i18n('icu:Preferences__button--data-usage');
pageContents = (
const pageContents = (
<>
<SettingsRow title={i18n('icu:Preferences__media-auto-download')}>
<Checkbox
@ -1628,9 +1687,15 @@ export function Preferences({
</SettingsRow>
</>
);
content = (
<PreferencesContent
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--data-usage')}
/>
);
} else if (page === Page.ChatColor) {
pageTitle = i18n('icu:ChatColorPicker__menu-title');
pageBackButton = (
const backButton = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
@ -1638,7 +1703,7 @@ export function Preferences({
type="button"
/>
);
pageContents = (
const pageContents = (
<ChatColorPicker
customColors={customColors}
getConversationsWithCustomColor={getConversationsWithCustomColor}
@ -1657,6 +1722,14 @@ export function Preferences({
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
/>
);
content = (
<PreferencesContent
backButton={backButton}
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:ChatColorPicker__menu-title')}
/>
);
} else if (page === Page.PNP) {
let sharingDescription: string;
@ -1674,8 +1747,7 @@ export function Preferences({
);
}
pageTitle = i18n('icu:Preferences__pnp--page-title');
pageBackButton = (
const backButton = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
@ -1683,7 +1755,7 @@ export function Preferences({
type="button"
/>
);
pageContents = (
const pageContents = (
<>
<SettingsRow
title={i18n('icu:Preferences__pnp__sharing--title')}
@ -1734,7 +1806,7 @@ export function Preferences({
onClick:
whoCanSeeMe === PhoneNumberSharingMode.Everybody
? () =>
setToast({ toastType: ToastType.WhoCanFindMeReadOnly })
showToast({ toastType: ToastType.WhoCanFindMeReadOnly })
: noop,
},
]}
@ -1791,25 +1863,43 @@ export function Preferences({
)}
</>
);
content = (
<PreferencesContent
backButton={backButton}
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__pnp--page-title')}
/>
);
} else if (page === Page.Backups) {
pageTitle = i18n('icu:Preferences__button--backups');
pageContents = (
<PreferencesBackups
i18n={i18n}
cloudBackupStatus={cloudBackupStatus}
backupSubscriptionStatus={backupSubscriptionStatus}
locale={resolvedLocale}
content = (
<PreferencesContent
contents={
<PreferencesBackups
i18n={i18n}
cloudBackupStatus={cloudBackupStatus}
backupSubscriptionStatus={backupSubscriptionStatus}
locale={resolvedLocale}
/>
}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--backups')}
/>
);
} else if (page === Page.Internal) {
pageTitle = i18n('icu:Preferences__button--internal');
pageContents = (
<PreferencesInternal
i18n={i18n}
exportLocalBackup={exportLocalBackup}
validateBackup={validateBackup}
getMessageCountBySchemaVersion={getMessageCountBySchemaVersion}
getMessageSampleForSchemaVersion={getMessageSampleForSchemaVersion}
content = (
<PreferencesContent
contents={
<PreferencesInternal
i18n={i18n}
exportLocalBackup={exportLocalBackup}
validateBackup={validateBackup}
getMessageCountBySchemaVersion={getMessageCountBySchemaVersion}
getMessageSampleForSchemaVersion={getMessageSampleForSchemaVersion}
/>
}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--internal')}
/>
);
}
@ -1844,6 +1934,49 @@ export function Preferences({
</div>
) : null}
<div className="Preferences__scroll-area">
<button
type="button"
className={classNames({
'Preferences__profile-chip': true,
'Preferences__profile-chip--selected': page === Page.Profile,
})}
onClick={() => setPage(Page.Profile)}
>
<div className="Preferences__profile-chip__avatar">
<Avatar
avatarUrl={me.avatarUrl}
badge={badge}
className="module-main-header__avatar"
color={me.color}
conversationType="direct"
i18n={i18n}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
theme={theme}
title={me.title}
// `sharedGroupNames` makes no sense for yourself, but
// `<Avatar>` needs it to determine blurring.
sharedGroupNames={[]}
size={AvatarSize.FORTY_EIGHT}
/>
</div>
<div className="Preferences__profile-chip__text-container">
<div className="Preferences__profile-chip__name">
{me.title}
</div>
<div className="Preferences__profile-chip__number">
{me.phoneNumber}
</div>
{me.username && (
<div className="Preferences__profile-chip__username">
{me.username}
</div>
)}
</div>
<div className="Preferences__profile-chip__qr-icon-container">
<div className="Preferences__profile-chip__qr-icon" />
</div>
</button>
<button
type="button"
className={classNames({
@ -1951,32 +2084,11 @@ export function Preferences({
) : null}
</div>
</div>
<div className="Preferences__content">
<div className="Preferences__title">
{pageBackButton}
<div className="Preferences__title--header">{pageTitle}</div>
</div>
<div className="Preferences__page">
<div className="Preferences__settings-pane-spacer" />
<div className="Preferences__settings-pane" ref={settingsPaneRef}>
{pageContents}
</div>
<div className="Preferences__settings-pane-spacer" />
</div>
</div>
{content}
</div>
<ToastManager
OS="unused"
hideToast={() => setToast(undefined)}
i18n={i18n}
onShowDebugLog={shouldNeverBeCalled}
onUndoArchive={shouldNeverBeCalled}
openFileInFolder={shouldNeverBeCalled}
showAttachmentNotAvailableModal={shouldNeverBeCalled}
toast={toast}
containerWidthBreakpoint={WidthBreakpoint.Narrow}
isInFullScreenCall={false}
/>
{renderToastManager({
containerWidthBreakpoint: WidthBreakpoint.Wide,
})}
</FunEmojiLocalizationProvider>
);
}
@ -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<HTMLDivElement | null>;
title: string | undefined;
}): JSX.Element {
return (
<div className="Preferences__content">
<div className="Preferences__title">
{backButton}
<div className="Preferences__title--header">{title}</div>
</div>
<div className="Preferences__page">
<div className="Preferences__settings-pane-spacer" />
<div className="Preferences__settings-pane" ref={contentsRef}>
{contents}
</div>
<div className="Preferences__settings-pane-spacer" />
</div>
</div>
);
}

View file

@ -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<PropsType>;
function renderEditUsernameModalBody(props: {
isRootModal: boolean;
onClose: () => void;
}): JSX.Element {
function renderUsernameEditor(props: { onClose: () => void }): JSX.Element {
return (
<EditUsernameModalBody
<UsernameEditor
i18n={i18n}
minNickname={3}
maxNickname={20}
@ -111,13 +108,16 @@ const Template: StoryFn<PropsType> = args => {
const [emojiSkinToneDefault, setEmojiSkinToneDefault] = useState(
EmojiSkinTone.None
);
const [editState, setEditState] = useState(args.editState);
return (
<ProfileEditor
{...args}
editState={editState}
emojiSkinToneDefault={emojiSkinToneDefault}
onEmojiSkinToneDefaultChange={setEmojiSkinToneDefault}
renderEditUsernameModalBody={renderEditUsernameModalBody}
renderUsernameEditor={renderUsernameEditor}
setEditState={setEditState}
/>
);
};

View file

@ -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<HTMLDivElement | null>;
conversationId: string;
familyName?: string;
firstName: string;
hasCompletedUsernameLinkOnboarding: boolean;
i18n: LocalizerType;
editState: EditState;
profileAvatarUrl?: string;
userAvatarData: ReadonlyArray<AvatarDataType>;
username?: string;
initialEditState?: EditState;
usernameCorrupted: boolean;
usernameEditState: UsernameEditState;
usernameLinkState: UsernameLinkState;
usernameLinkColor?: number;
usernameLink?: string;
usernameLinkColor?: number;
usernameLinkCorrupted: boolean;
usernameLinkState: UsernameLinkState;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'emojiSkinToneDefault'>;
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<HTMLInputElement | null>(null);
const [editState, setEditState] = useState<EditState>(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, string | undefined> = {
[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}
/>
<Modal.ButtonFooter>
<Button
onClick={() => {
const handleCancel = () => {
handleBack();
setStagedProfile(profileData => ({
...profileData,
familyName,
firstName,
}));
};
const hasChanges =
stagedProfile.familyName !== fullName.familyName ||
stagedProfile.firstName !== fullName.firstName;
if (hasChanges) {
setConfirmDiscardAction(() => handleCancel);
} else {
handleCancel();
}
}}
variant={ButtonVariant.Secondary}
>
<div className="ProfileEditor__button-footer">
<Button onClick={handleBack} variant={ButtonVariant.Secondary}>
{i18n('icu:cancel')}
</Button>
<Button
@ -451,12 +448,14 @@ export function ProfileEditor({
});
onProfileChanged(stagedProfile, { keepAvatar: true });
handleBack();
// Delay navigation until setFullName resolves and we are no longer dirty
setTimeout(() => handleBack(), 500);
}}
>
{i18n('icu:save')}
</Button>
</Modal.ButtonFooter>
</div>
</>
);
} else if (editState === EditState.Bio) {
@ -565,28 +564,8 @@ export function ProfileEditor({
);
})}
<Modal.ButtonFooter>
<Button
onClick={() => {
const handleCancel = () => {
handleBack();
setStagedProfile(profileData => ({
...profileData,
...fullBio,
}));
};
const hasChanges =
stagedProfile.aboutText !== fullBio.aboutText ||
stagedProfile.aboutEmoji !== fullBio.aboutEmoji;
if (hasChanges) {
setConfirmDiscardAction(() => handleCancel);
} else {
handleCancel();
}
}}
variant={ButtonVariant.Secondary}
>
<div className="ProfileEditor__button-footer">
<Button onClick={handleBack} variant={ButtonVariant.Secondary}>
{i18n('icu:cancel')}
</Button>
<Button
@ -598,22 +577,23 @@ export function ProfileEditor({
});
onProfileChanged(stagedProfile, { keepAvatar: true });
handleBack();
// Delay navigation until setFullBio resolves and we are no longer dirty
setTimeout(() => handleBack(), 500);
}}
>
{i18n('icu:save')}
</Button>
</Modal.ButtonFooter>
</div>
</>
);
} else if (editState === EditState.Username) {
content = renderEditUsernameModalBody({
isRootModal: initialEditState === editState,
onClose: () => setEditState(EditState.None),
content = renderUsernameEditor({
onClose: handleBack,
});
} else if (editState === EditState.UsernameLink) {
content = (
<UsernameLinkModalBody
<UsernameLinkEditor
i18n={i18n}
link={usernameLink}
username={username ?? ''}
@ -838,6 +818,16 @@ export function ProfileEditor({
throw missingCaseError(editState);
}
const backButton =
editState !== EditState.None ? (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={handleBack}
type="button"
/>
) : undefined;
return (
<>
{usernameEditState === UsernameEditState.ConfirmingDelete && (
@ -859,18 +849,12 @@ export function ProfileEditor({
</ConfirmationDialog>
)}
{confirmDiscardAction && (
<ConfirmDiscardDialog
i18n={i18n}
onDiscard={confirmDiscardAction}
onClose={() => setConfirmDiscardAction(undefined)}
/>
)}
{confirmDiscardModal}
{isResettingUsernameLink && (
<ConfirmationDialog
i18n={i18n}
dialogName="UsernameLinkModal__error"
dialogName="ProfileEditor__resettingUsername"
onClose={() => setIsResettingUsernameLink(false)}
cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:cancel')}
@ -910,7 +894,12 @@ export function ProfileEditor({
</ConfirmationDialog>
)}
<div className="ProfileEditor">{content}</div>
<PreferencesContent
backButton={backButton}
contents={<div className="ProfileEditor">{content}</div>}
contentsRef={contentsRef}
title={TITLES_BY_EDIT_STATE[editState]}
/>
</>
);
}

View file

@ -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<ProfileEditorPropsType, 'renderEditUsernameModalBody'>;
type PropsType = {
myProfileChanged: (
profileData: ProfileDataType,
avatarUpdateOptions: AvatarUpdateOptionsType
) => unknown;
toggleProfileEditor: () => unknown;
toggleProfileEditorHasError: () => unknown;
} & PropsDataType &
Omit<ProfileEditorPropsType, 'onEditStateChanged' | 'onProfileChanged'>;
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, string | undefined> = {
[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 (
<ConfirmationDialog
dialogName="ProfileEditorModal.error"
cancelText={i18n('icu:Confirmation--confirm')}
i18n={i18n}
onClose={toggleProfileEditorHasError}
>
{i18n('icu:ProfileEditorModal--error')}
</ConfirmationDialog>
);
}
return (
<Modal
modalName="ProfileEditorModal"
hasXButton
i18n={i18n}
onClose={toggleProfileEditor}
title={modalTitle}
>
<ProfileEditor
aboutEmoji={aboutEmoji}
aboutText={aboutText}
color={color}
conversationId={conversationId}
deleteAvatarFromDisk={deleteAvatarFromDisk}
deleteUsername={deleteUsername}
familyName={familyName}
firstName={firstName}
hasCompletedUsernameLinkOnboarding={hasCompletedUsernameLinkOnboarding}
i18n={i18n}
initialEditState={initialEditState}
markCompletedUsernameLinkOnboarding={
markCompletedUsernameLinkOnboarding
}
onEditStateChanged={editState => {
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}
/>
</Modal>
);
}

View file

@ -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(() => {

View file

@ -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<ArgsType> = args => {
hash: new Uint8Array(),
};
}
return <EditUsernameModalBody {...args} reservation={reservation} />;
return <UsernameEditor {...args} reservation={reservation} />;
};
export const WithoutUsername = Template.bind({});

View file

@ -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 = (
<>
<i className="EditUsernameModalBody__learn-more__hashtag" />
<i className="UsernameEditor__learn-more__hashtag" />
{i18n('icu:EditUsernameModalBody__learn-more__title')}
</>
);
return (
<>
<div className="EditUsernameModalBody__header">
<div className="EditUsernameModalBody__header__large-at" />
<div className="UsernameEditor__header">
<div className="UsernameEditor__header__large-at" />
<div className="EditUsernameModalBody__header__preview">{title}</div>
<div className="UsernameEditor__header__preview">{title}</div>
</div>
<Input
moduleClassName="EditUsernameModalBody__input"
moduleClassName="UsernameEditor__input"
i18n={i18n}
disableSpellcheck
disabled={isConfirming}
@ -317,9 +343,9 @@ export function EditUsernameModalBody({
{isReserving && <Spinner size="16px" svgSize="small" />}
{isDiscriminatorVisible ? (
<>
<div className="EditUsernameModalBody__divider" />
<div className="UsernameEditor__divider" />
<AutoSizeInput
moduleClassName="EditUsernameModalBody__discriminator"
moduleClassName="UsernameEditor__discriminator"
disableSpellcheck
disabled={isConfirming}
value={discriminator}
@ -330,28 +356,26 @@ export function EditUsernameModalBody({
</>
) : null}
</Input>
{errorString && (
<div className="EditUsernameModalBody__error">{errorString}</div>
<div className="UsernameEditor__error">{errorString}</div>
)}
<div
className={classNames(
'EditUsernameModalBody__info',
!errorString ? 'EditUsernameModalBody__info--no-error' : undefined
'UsernameEditor__info',
!errorString ? 'UsernameEditor__info--no-error' : undefined
)}
>
{i18n('icu:EditUsernameModalBody__username-helper')}
&nbsp;
<button
type="button"
className="EditUsernameModalBody__learn-more-button"
className="UsernameEditor__learn-more-button"
onClick={onLearnMore}
>
{i18n('icu:EditUsernameModalBody__learn-more')}
</button>
</div>
<Modal.ButtonFooter>
<div className="UsernameEditor__button-footer">
<Button
disabled={isConfirming}
onClick={onCancel}
@ -366,32 +390,33 @@ export function EditUsernameModalBody({
i18n('icu:save')
)}
</Button>
</Modal.ButtonFooter>
</div>
{confirmDiscardModal}
{isLearnMoreVisible && (
<Modal
modalName="EditUsernamModalBody.LearnMore"
moduleClassName="EditUsernameModalBody__learn-more"
modalName="UsernameEditor.LearnMore"
moduleClassName="UsernameEditor__learn-more"
i18n={i18n}
onClose={() => setIsLearnMoreVisible(false)}
title={learnMoreTitle}
>
{i18n('icu:EditUsernameModalBody__learn-more__body')}
<Modal.ButtonFooter>
<div className="UsernameEditor__button-footer">
<Button
onClick={() => setIsLearnMoreVisible(false)}
variant={ButtonVariant.Secondary}
>
{i18n('icu:ok')}
</Button>
</Modal.ButtonFooter>
</div>
</Modal>
)}
{error === UsernameReservationError.General && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.generalError"
dialogName="UsernameEditor.generalError"
cancelText={i18n('icu:ok')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
@ -400,10 +425,9 @@ export function EditUsernameModalBody({
{i18n('icu:ProfileEditor--username--general-error')}
</ConfirmationDialog>
)}
{error === UsernameReservationError.ConflictOrGone && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.conflictOrGone"
dialogName="UsernameEditor.conflictOrGone"
cancelText={i18n('icu:ok')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
@ -418,10 +442,9 @@ export function EditUsernameModalBody({
})}
</ConfirmationDialog>
)}
{isConfirmingSave && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.confirmChange"
dialogName="UsernameEditor.confirmChange"
cancelText={i18n('icu:cancel')}
actions={[
{
@ -438,10 +461,9 @@ export function EditUsernameModalBody({
{i18n('icu:EditUsernameModalBody__change-confirmation')}
</ConfirmationDialog>
)}
{isConfirmingReset && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.confirmReset"
dialogName="UsernameEditor.confirmReset"
cancelText={i18n('icu:cancel')}
actions={[
{

View file

@ -8,12 +8,12 @@ import { action } from '@storybook/addon-actions';
import { UsernameLinkState } from '../state/ducks/usernameEnums';
import { SignalService as Proto } from '../protobuf';
import type { PropsType } from './UsernameLinkModalBody';
import type { PropsType } from './UsernameLinkEditor';
import {
UsernameLinkModalBody,
UsernameLinkEditor,
PRINT_WIDTH,
PRINT_HEIGHT,
} from './UsernameLinkModalBody';
} from './UsernameLinkEditor';
import { Modal } from './Modal';
const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
@ -21,8 +21,8 @@ const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
const { i18n } = window.SignalContext;
export default {
component: UsernameLinkModalBody,
title: 'Components/UsernameLinkModalBody',
component: UsernameLinkEditor,
title: 'Components/UsernameLinkEditor',
argTypes: {
link: {
control: { type: 'text' },
@ -92,7 +92,7 @@ const Template: StoryFn<PropsType> = args => {
return (
<>
<Modal modalName="story" i18n={i18n} hasXButton>
<UsernameLinkModalBody {...args} saveAttachment={saveAttachment} />
<UsernameLinkEditor {...args} saveAttachment={saveAttachment} />
</Modal>
{attachment && (
<img

View file

@ -1,10 +1,11 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import classnames from 'classnames';
import { changeDpiBlob } from 'changedpi';
import { noop } from 'lodash';
import { SignalService as Proto } from '../protobuf';
import type { SaveAttachmentActionCreatorType } from '../state/ducks/conversations';
@ -18,10 +19,10 @@ import { drop } from '../util/drop';
import { splitText } from '../util/splitText';
import { loadImage } from '../util/loadImage';
import { Button, ButtonVariant } from './Button';
import { Modal } from './Modal';
import { ConfirmationDialog } from './ConfirmationDialog';
import { Spinner } from './Spinner';
import { BrandedQRCode } from './BrandedQRCode';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
export type PropsType = Readonly<{
i18n: LocalizerType;
@ -63,7 +64,7 @@ export const COLOR_MAP: ReadonlyMap<number, ColorMapEntryType> = 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({
);
})}
</div>
<Modal.ButtonFooter>
<div className="UsernameLinkEditor__button-footer">
<Button variant={ButtonVariant.Secondary} onClick={onCancel}>
{i18n('icu:cancel')}
</Button>
<Button variant={ButtonVariant.Primary} onClick={onSave}>
{i18n('icu:save')}
</Button>
</Modal.ButtonFooter>
</div>
</div>
);
}
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<Uint8Array | undefined>();
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 = (
<>
<div className={classnames(`${CLASS}__actions`)}>
@ -652,14 +675,6 @@ export function UsernameLinkModalBody({
>
{i18n('icu:UsernameLinkModalBody__reset')}
</button>
<Button
className={classnames(`${CLASS}__done`)}
variant={ButtonVariant.Primary}
onClick={onBack}
>
{i18n('icu:UsernameLinkModalBody__done')}
</Button>
</>
);
@ -751,11 +766,11 @@ export function UsernameLinkModalBody({
</ConfirmationDialog>
)}
{resetModalVisibility === ResetModalVisibility.Open && (
{recoveryModalVisibility === RecoveryModalVisibility.Open && (
<ConfirmationDialog
i18n={i18n}
dialogName="UsernameLinkModal__error"
onClose={onResetModalClose}
onClose={onRecoveryModalClose}
cancelButtonVariant={ButtonVariant.Secondary}
cancelText={i18n('icu:ok')}
>
@ -774,6 +789,8 @@ export function UsernameLinkModalBody({
) : (
info
)}
{confirmDiscardModal}
</div>
</div>
);

View file

@ -195,6 +195,7 @@ export function EditConversationAttributesModal({
onClick={() => {
setEditingAvatar(true);
}}
showUploadButton
style={{
height: 96,
width: 96,

View file

@ -177,6 +177,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
isEditable
isGroup
onClick={toggleComposeEditingAvatar}
showUploadButton
style={{
height: 96,
margin: 0,

View file

@ -3,7 +3,7 @@
import * as log from '../logging/log';
import type { NavTab } from '../state/ducks/nav';
import type { Location } from '../state/ducks/nav';
import { SECOND } from '../util/durations';
import { sleep } from '../util/sleep';
@ -14,9 +14,10 @@ export enum BeforeNavigateResponse {
CancelNavigation = 'CancelNavigation',
TimedOut = 'TimedOut',
}
export type BeforeNavigateCallback = (
newTab: NavTab
) => Promise<BeforeNavigateResponse>;
export type BeforeNavigateCallback = (options: {
existingLocation?: Location;
newLocation: Location;
}) => Promise<BeforeNavigateResponse>;
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<boolean> {
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;

View file

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

View file

@ -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<string, boolean>;
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<void, RootStateType, unknown, SetProfileUpdateErrorActionType> {
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) {

View file

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

View file

@ -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<Page, Page.Profile> };
}
| { tab: Exclude<NavTab, NavTab.Settings> }
>;
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<ChangeNavTabActionType>;
export type NavActionType = ReadonlyDeep<ChangeLocationAction>;
// Action Creators
function changeNavTab(selectedNavTab: NavTab): NavActionType {
return {
type: CHANGE_NAV_TAB,
payload: { selectedNavTab },
export function changeLocation(
newLocation: Location
): ThunkAction<void, RootStateType, unknown, NavActionType> {
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<typeof actions> =>
@ -51,7 +100,9 @@ export const useNavActions = (): BoundActionCreatorsMapObject<typeof actions> =>
export function getEmptyState(): NavStateType {
return {
selectedNavTab: NavTab.Chats,
selectedLocation: {
tab: NavTab.Chats,
},
};
}
@ -59,10 +110,10 @@ export function reducer(
state: Readonly<NavStateType> = getEmptyState(),
action: Readonly<NavActionType>
): NavStateType {
if (action.type === CHANGE_NAV_TAB) {
if (action.type === CHANGE_LOCATION) {
return {
...state,
selectedNavTab: action.payload.selectedNavTab,
selectedLocation: action.payload.selectedLocation,
};
}

View file

@ -43,10 +43,10 @@ export type UsernameStateType = ReadonlyDeep<{
// ProfileEditor
editState: UsernameEditState;
// UsernameLinkModalBody
// UsernameLinkEditor
linkState: UsernameLinkState;
// EditUsernameModalBody
// UsernameEditor
usernameReservation: UsernameReservationStateType;
}>;

View file

@ -12,7 +12,7 @@ export enum UsernameEditState {
}
//
// UsernameLinkModalBody
// UsernameLinkEditor
//
export enum UsernameLinkState {
@ -22,7 +22,7 @@ export enum UsernameLinkState {
}
//
// EditUsernameModalBody
// UsernameEditor
//
export enum UsernameReservationState {

View file

@ -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) => {

View file

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

View file

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

View file

@ -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 (
<EditUsernameModalBody
i18n={i18n}
usernameCorrupted={usernameCorrupted}
currentUsername={currentUsername}
minNickname={minNickname}
maxNickname={maxNickname}
state={state}
recoveredUsername={recoveredUsername}
reservation={reservation}
error={error}
setUsernameReservationError={setUsernameReservationError}
clearUsernameReservation={clearUsernameReservation}
reserveUsername={reserveUsername}
confirmUsername={confirmUsername}
showToast={showToast}
isRootModal={isRootModal}
onClose={onClose}
/>
);
}
);

View file

@ -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 <SmartEditNicknameAndNoteModal />;
}
function renderProfileEditor(): JSX.Element {
return <SmartProfileEditorModal />;
}
function renderProfileNameWarningModal(): JSX.Element {
return <SmartProfileNameWarningModal />;
}
@ -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}

View file

@ -106,6 +106,7 @@ import {
pauseBackupMediaDownload,
resumeBackupMediaDownload,
} from '../../util/backupMediaDownload';
import { useNavActions } from '../ducks/nav';
function renderMessageSearchResult(id: string): JSX.Element {
return <SmartMessageSearchResult id={id} />;
@ -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}

View file

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

View file

@ -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 <SmartUpdateDialog {...props} disableDismiss />;
}
function renderProfileEditor(options: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
}): JSX.Element {
return <SmartProfileEditor contentsRef={options.contentsRef} />;
}
function renderToastManager(props: {
containerWidthBreakpoint: WidthBreakpoint;
}): JSX.Element {
return <SmartToastManager disableMegaphone {...props} />;
}
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 (
<StrictMode>
<Preferences
@ -594,6 +652,7 @@ export function SmartPreferences(): JSX.Element {
availableSpeakers={availableSpeakers}
backupFeatureEnabled={backupFeatureEnabled}
backupSubscriptionStatus={backupSubscriptionStatus}
badge={badge}
blockedCount={blockedCount}
cloudBackupStatus={cloudBackupStatus}
customColors={customColors}
@ -655,6 +714,7 @@ export function SmartPreferences(): JSX.Element {
lastSyncTime={lastSyncTime}
localeOverride={localeOverride}
makeSyncRequest={makeSyncRequest}
me={me}
navTabsCollapsed={navTabsCollapsed}
notificationContent={notificationContent}
onAudioNotificationsChange={onAudioNotificationsChange}
@ -697,11 +757,14 @@ export function SmartPreferences(): JSX.Element {
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
onZoomFactorChange={onZoomFactorChange}
otherTabsUnreadStats={otherTabsUnreadStats}
page={page}
preferredSystemLocales={preferredSystemLocales}
refreshCloudBackupStatus={refreshCloudBackupStatus}
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
removeCustomColorOnConversations={removeCustomColorOnConversations}
removeCustomColor={removeCustomColor}
renderProfileEditor={renderProfileEditor}
renderToastManager={renderToastManager}
renderUpdateDialog={renderUpdateDialog}
resetAllChatColors={resetAllChatColors}
resetDefaultChatColor={resetDefaultChatColor}
@ -711,6 +774,9 @@ export function SmartPreferences(): JSX.Element {
selectedSpeaker={selectedSpeaker}
sentMediaQualitySetting={sentMediaQualitySetting}
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
setPage={setPage}
showToast={showToast}
theme={theme}
themeSetting={themeSetting}
universalExpireTimer={universalExpireTimer}
validateBackup={validateBackup}

View file

@ -0,0 +1,167 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import type { MutableRefObject } from 'react';
import { ProfileEditor } from '../../components/ProfileEditor';
import { useConversationsActions } from '../ducks/conversations';
import { useItemsActions } from '../ducks/items';
import { useToastActions } from '../ducks/toast';
import { useUsernameActions } from '../ducks/username';
import { getMe, getProfileUpdateError } from '../selectors/conversations';
import { selectRecentEmojis } from '../selectors/emojis';
import {
getEmojiSkinToneDefault,
getHasCompletedUsernameLinkOnboarding,
getUsernameCorrupted,
getUsernameLink,
getUsernameLinkColor,
getUsernameLinkCorrupted,
} from '../selectors/items';
import { getIntl } from '../selectors/user';
import {
getUsernameEditState,
getUsernameLinkState,
} from '../selectors/username';
import { SmartUsernameEditor } from './UsernameEditor';
import { getSelectedLocation } from '../selectors/nav';
import { NavTab, useNavActions } from '../ducks/nav';
import { Page } from '../../components/Preferences';
import type { EditState } from '../../components/ProfileEditor';
import type { SmartUsernameEditorProps } from './UsernameEditor';
import { ConfirmationDialog } from '../../components/ConfirmationDialog';
function renderUsernameEditor(props: SmartUsernameEditorProps): JSX.Element {
return <SmartUsernameEditor {...props} />;
}
export const SmartProfileEditor = memo(function SmartProfileEditor(props: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
}) {
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 = (
<ConfirmationDialog
dialogName="ProfileEditorModal.error"
cancelText={i18n('icu:Confirmation--confirm')}
i18n={i18n}
onClose={() => setProfileUpdateError(false)}
>
{i18n('icu:ProfileEditorModal--error')}
</ConfirmationDialog>
);
}
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}
<ProfileEditor
aboutEmoji={aboutEmoji}
aboutText={aboutText}
color={color}
contentsRef={props.contentsRef}
conversationId={conversationId}
deleteAvatarFromDisk={deleteAvatarFromDisk}
deleteUsername={deleteUsername}
familyName={familyName}
firstName={firstName ?? ''}
hasCompletedUsernameLinkOnboarding={hasCompletedUsernameLinkOnboarding}
i18n={i18n}
editState={editState}
markCompletedUsernameLinkOnboarding={
markCompletedUsernameLinkOnboarding
}
onProfileChanged={myProfileChanged}
onEmojiSkinToneDefaultChange={setEmojiSkinToneDefault}
openUsernameReservationModal={openUsernameReservationModal}
profileAvatarUrl={profileAvatarUrl}
recentEmojis={recentEmojis}
renderUsernameEditor={renderUsernameEditor}
replaceAvatar={replaceAvatar}
resetUsernameLink={resetUsernameLink}
saveAttachment={saveAttachment}
saveAvatarToDisk={saveAvatarToDisk}
setEditState={setEditState}
setUsernameEditState={setUsernameEditState}
setUsernameLinkColor={setUsernameLinkColor}
showToast={showToast}
emojiSkinToneDefault={emojiSkinToneDefault}
userAvatarData={userAvatarData}
username={username}
usernameCorrupted={usernameCorrupted}
usernameEditState={usernameEditState}
usernameLink={usernameLink}
usernameLinkColor={usernameLinkColor}
usernameLinkCorrupted={usernameLinkCorrupted}
usernameLinkState={usernameLinkState}
/>
</>
);
});

View file

@ -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 <SmartEditUsernameModalBody {...props} />;
}
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 (
<ProfileEditorModal
aboutEmoji={aboutEmoji}
aboutText={aboutText}
color={color}
conversationId={conversationId}
deleteAvatarFromDisk={deleteAvatarFromDisk}
deleteUsername={deleteUsername}
familyName={familyName}
firstName={firstName ?? ''}
hasCompletedUsernameLinkOnboarding={hasCompletedUsernameLinkOnboarding}
hasError={hasError}
i18n={i18n}
initialEditState={initialEditState}
markCompletedUsernameLinkOnboarding={markCompletedUsernameLinkOnboarding}
myProfileChanged={myProfileChanged}
onEmojiSkinToneDefaultChange={setEmojiSkinToneDefault}
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}
toggleProfileEditorHasError={toggleProfileEditorHasError}
userAvatarData={userAvatarData}
username={username}
usernameCorrupted={usernameCorrupted}
usernameEditState={usernameEditState}
usernameLink={usernameLink}
usernameLinkColor={usernameLinkColor}
usernameLinkCorrupted={usernameLinkCorrupted}
usernameLinkState={usernameLinkState}
/>
);
});

View file

@ -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 (
<UsernameEditor
i18n={i18n}
usernameCorrupted={usernameCorrupted}
currentUsername={currentUsername}
minNickname={minNickname}
maxNickname={maxNickname}
state={state}
recoveredUsername={recoveredUsername}
reservation={reservation}
error={error}
setUsernameReservationError={setUsernameReservationError}
clearUsernameReservation={clearUsernameReservation}
reserveUsername={reserveUsername}
confirmUsername={confirmUsername}
showToast={showToast}
onClose={onClose}
/>
);
});

View file

@ -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 () => {

View file

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

View file

@ -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();

View file

@ -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<typeof uploadProfileZod>;
const uploadAvatarHeadersZod = z.object({
acl: z.string(),
@ -878,6 +879,14 @@ const uploadAvatarHeadersZod = z.object({
signature: z.string(),
});
export type UploadAvatarHeadersType = z.infer<typeof uploadAvatarHeadersZod>;
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<void>;
putProfile: (
jsonData: ProfileRequestDataType
) => Promise<UploadAvatarHeadersType | undefined>;
) => Promise<UploadAvatarHeadersOrOtherType>;
putStickers: (
encryptedManifest: Uint8Array,
encryptedStickers: ReadonlyArray<Uint8Array>,
@ -2644,13 +2653,13 @@ export function initialize({
async function putProfile(
jsonData: ProfileRequestDataType
): Promise<UploadAvatarHeadersType | undefined> {
): Promise<UploadAvatarHeadersOrOtherType> {
return _ajax({
call: 'profile',
httpType: 'PUT',
responseType: 'json',
jsonData,
zodSchema: uploadAvatarHeadersZod,
zodSchema: uploadAvatarOrOther,
});
}

View file

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

View file

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