Usernames: Create/update/delete in profile editor
This commit is contained in:
parent
a9cb621eb6
commit
3190f95fac
38 changed files with 923 additions and 89 deletions
|
@ -25,6 +25,7 @@ import * as universalExpireTimer from '../../util/universalExpireTimer';
|
|||
import { trigger } from '../../shims/events';
|
||||
import type { ToggleProfileEditorErrorActionType } from './globalModals';
|
||||
import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals';
|
||||
import { isRecord } from '../../util/isRecord';
|
||||
|
||||
import type {
|
||||
AvatarColorType,
|
||||
|
@ -50,15 +51,24 @@ import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelect
|
|||
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||
import { writeProfile } from '../../services/writeProfile';
|
||||
import { writeUsername } from '../../services/writeUsername';
|
||||
import {
|
||||
getMe,
|
||||
getMessageIdsPendingBecauseOfVerification,
|
||||
getUsernameSaveState,
|
||||
} from '../selectors/conversations';
|
||||
import type { AvatarDataType } from '../../types/Avatar';
|
||||
import { getDefaultAvatars } from '../../types/Avatar';
|
||||
import { getAvatarData } from '../../util/getAvatarData';
|
||||
import { isSameAvatarData } from '../../util/isSameAvatarData';
|
||||
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
||||
import {
|
||||
UsernameSaveState,
|
||||
ComposerStep,
|
||||
OneTimeModalState,
|
||||
} from './conversationsEnums';
|
||||
import { showToast } from '../../util/showToast';
|
||||
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
|
||||
|
||||
import type { NoopActionType } from './noop';
|
||||
|
||||
|
@ -89,6 +99,7 @@ export type ConversationType = {
|
|||
familyName?: string;
|
||||
firstName?: string;
|
||||
profileName?: string;
|
||||
username?: string;
|
||||
about?: string;
|
||||
aboutText?: string;
|
||||
aboutEmoji?: string;
|
||||
|
@ -242,18 +253,6 @@ export type PreJoinConversationType = {
|
|||
approvalRequired: boolean;
|
||||
};
|
||||
|
||||
export enum ComposerStep {
|
||||
StartDirectConversation = 'StartDirectConversation',
|
||||
ChooseGroupMembers = 'ChooseGroupMembers',
|
||||
SetGroupMetadata = 'SetGroupMetadata',
|
||||
}
|
||||
|
||||
export enum OneTimeModalState {
|
||||
NeverShown,
|
||||
Showing,
|
||||
Shown,
|
||||
}
|
||||
|
||||
type ComposerGroupCreationState = {
|
||||
groupAvatar: undefined | Uint8Array;
|
||||
groupName: string;
|
||||
|
@ -308,6 +307,7 @@ export type ConversationsStateType = {
|
|||
showArchived: boolean;
|
||||
composer?: ComposerStateType;
|
||||
contactSpoofingReview?: ContactSpoofingReviewStateType;
|
||||
usernameSaveState: UsernameSaveState;
|
||||
|
||||
/**
|
||||
* Each key is a conversation ID. Each value is an array of message IDs stopped by that
|
||||
|
@ -363,6 +363,7 @@ const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
|
|||
const MESSAGE_STOPPED_BY_MISSING_VERIFICATION =
|
||||
'conversations/MESSAGE_STOPPED_BY_MISSING_VERIFICATION';
|
||||
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
||||
const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE';
|
||||
|
||||
type CancelMessagesPendingConversationVerificationActionType = {
|
||||
type: typeof CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION;
|
||||
|
@ -677,6 +678,12 @@ export type ToggleConversationInChooseMembersActionType = {
|
|||
maxGroupSize: number;
|
||||
};
|
||||
};
|
||||
type UpdateUsernameSaveStateActionType = {
|
||||
type: typeof UPDATE_USERNAME_SAVE_STATE;
|
||||
payload: {
|
||||
newSaveState: UsernameSaveState;
|
||||
};
|
||||
};
|
||||
|
||||
type ReplaceAvatarsActionType = {
|
||||
type: typeof REPLACE_AVATARS;
|
||||
|
@ -743,7 +750,8 @@ export type ConversationActionType =
|
|||
| StartSettingGroupMetadataActionType
|
||||
| SwitchToAssociatedViewActionType
|
||||
| ToggleConversationInChooseMembersActionType
|
||||
| ToggleComposeEditingAvatarActionType;
|
||||
| ToggleComposeEditingAvatarActionType
|
||||
| UpdateUsernameSaveStateActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
|
@ -755,6 +763,7 @@ export const actions = {
|
|||
clearInvitedUuidsForNewlyCreatedGroup,
|
||||
clearSelectedMessage,
|
||||
clearUnreadMetrics,
|
||||
clearUsernameSave,
|
||||
closeCantAddContactToGroupModal,
|
||||
closeContactSpoofingReview,
|
||||
closeMaximumGroupSizeModal,
|
||||
|
@ -789,6 +798,7 @@ export const actions = {
|
|||
reviewGroupMemberNameCollision,
|
||||
reviewMessageRequestNameCollision,
|
||||
saveAvatarToDisk,
|
||||
saveUsername,
|
||||
scrollToMessage,
|
||||
selectMessage,
|
||||
setComposeGroupAvatar,
|
||||
|
@ -963,6 +973,82 @@ function saveAvatarToDisk(
|
|||
};
|
||||
}
|
||||
|
||||
function makeUsernameSaveType(
|
||||
newSaveState: UsernameSaveState
|
||||
): UpdateUsernameSaveStateActionType {
|
||||
return {
|
||||
type: UPDATE_USERNAME_SAVE_STATE,
|
||||
payload: {
|
||||
newSaveState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearUsernameSave(): UpdateUsernameSaveStateActionType {
|
||||
return makeUsernameSaveType(UsernameSaveState.None);
|
||||
}
|
||||
|
||||
function saveUsername({
|
||||
username,
|
||||
previousUsername,
|
||||
}: {
|
||||
username: string | undefined;
|
||||
previousUsername: string | undefined;
|
||||
}): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
UpdateUsernameSaveStateActionType
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
const previousState = getUsernameSaveState(state);
|
||||
if (previousState !== UsernameSaveState.None) {
|
||||
log.error(
|
||||
`saveUsername: Save requested, but previous state was ${previousState}`
|
||||
);
|
||||
dispatch(makeUsernameSaveType(UsernameSaveState.GeneralError));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(makeUsernameSaveType(UsernameSaveState.Saving));
|
||||
await writeUsername({ username, previousUsername });
|
||||
|
||||
// writeUsername 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.
|
||||
dispatch(makeUsernameSaveType(UsernameSaveState.Success));
|
||||
} catch (error: unknown) {
|
||||
// Check to see if we were deleting
|
||||
if (!username) {
|
||||
dispatch(makeUsernameSaveType(UsernameSaveState.DeleteFailed));
|
||||
showToast(ToastFailedToDeleteUsername);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRecord(error)) {
|
||||
dispatch(makeUsernameSaveType(UsernameSaveState.GeneralError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === 409) {
|
||||
dispatch(makeUsernameSaveType(UsernameSaveState.UsernameTakenError));
|
||||
return;
|
||||
}
|
||||
if (error.code === 400) {
|
||||
dispatch(
|
||||
makeUsernameSaveType(UsernameSaveState.UsernameMalformedError)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(makeUsernameSaveType(UsernameSaveState.GeneralError));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function myProfileChanged(
|
||||
profileData: ProfileDataType,
|
||||
avatarBuffer?: Uint8Array
|
||||
|
@ -1816,6 +1902,7 @@ export function getEmptyState(): ConversationsStateType {
|
|||
showArchived: false,
|
||||
selectedConversationTitle: '',
|
||||
selectedConversationPanelDepth: 0,
|
||||
usernameSaveState: UsernameSaveState.None,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -3287,5 +3374,14 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === UPDATE_USERNAME_SAVE_STATE) {
|
||||
const { newSaveState } = action.payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
usernameSaveState: newSaveState,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
30
ts/state/ducks/conversationsEnums.ts
Normal file
30
ts/state/ducks/conversationsEnums.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// We prevent circular loops between ducks and selectors/components with `import type`.
|
||||
// For example, Selectors are used in action creators using thunk/getState, but those
|
||||
// Selectors need types from the ducks. Selectors shouldn't use code from ducks.
|
||||
//
|
||||
// But enums can be used as types but also as code. So we keep them out of the ducks.
|
||||
|
||||
export enum UsernameSaveState {
|
||||
None = 'None',
|
||||
Saving = 'Saving',
|
||||
UsernameTakenError = 'UsernameTakenError',
|
||||
UsernameMalformedError = 'UsernameMalformedError',
|
||||
GeneralError = 'GeneralError',
|
||||
DeleteFailed = 'DeleteFailed',
|
||||
Success = 'Success',
|
||||
}
|
||||
|
||||
export enum ComposerStep {
|
||||
StartDirectConversation = 'StartDirectConversation',
|
||||
ChooseGroupMembers = 'ChooseGroupMembers',
|
||||
SetGroupMetadata = 'SetGroupMetadata',
|
||||
}
|
||||
|
||||
export enum OneTimeModalState {
|
||||
NeverShown,
|
||||
Showing,
|
||||
Shown,
|
||||
}
|
|
@ -17,6 +17,7 @@ import { ConversationColors } from '../../types/Colors';
|
|||
import { reloadSelectedConversation } from '../../shims/reloadSelectedConversation';
|
||||
import type { StorageAccessType } from '../../types/Storage.d';
|
||||
import { actions as conversationActions } from './conversations';
|
||||
import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -25,6 +26,8 @@ export type ItemsStateType = {
|
|||
|
||||
readonly [key: string]: unknown;
|
||||
|
||||
readonly remoteConfig?: RemoteConfigType;
|
||||
|
||||
// This property should always be set and this is ensured in background.ts
|
||||
readonly defaultConversationColor?: DefaultConversationColorType;
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import { fromPairs, isNumber } from 'lodash';
|
|||
import { createSelector } from 'reselect';
|
||||
|
||||
import type { StateType } from '../reducer';
|
||||
|
||||
import type {
|
||||
ConversationLookupType,
|
||||
ConversationMessageType,
|
||||
|
@ -15,7 +16,8 @@ import type {
|
|||
MessagesByConversationType,
|
||||
PreJoinConversationType,
|
||||
} from '../ducks/conversations';
|
||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
|
||||
import type { UsernameSaveState } from '../ducks/conversationsEnums';
|
||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { isNotNil } from '../../util/isNotNil';
|
||||
import { deconstructLookup } from '../../util/deconstructLookup';
|
||||
|
@ -161,6 +163,13 @@ export const getSelectedMessage = createSelector(
|
|||
}
|
||||
);
|
||||
|
||||
export const getUsernameSaveState = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): UsernameSaveState => {
|
||||
return state.usernameSaveState;
|
||||
}
|
||||
);
|
||||
|
||||
export const getShowArchived = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): boolean => {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { createSelector } from 'reselect';
|
|||
import { isInteger } from 'lodash';
|
||||
|
||||
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
|
||||
import type { ConfigMapType } from '../../RemoteConfig';
|
||||
|
||||
import type { StateType } from '../reducer';
|
||||
import type { ItemsStateType } from '../ducks/items';
|
||||
|
@ -35,6 +36,17 @@ export const getUniversalExpireTimer = createSelector(
|
|||
(state: ItemsStateType): number => state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0
|
||||
);
|
||||
|
||||
const getRemoteConfig = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): ConfigMapType | undefined => state.remoteConfig
|
||||
);
|
||||
|
||||
export const getUsernamesEnabled = createSelector(
|
||||
getRemoteConfig,
|
||||
(remoteConfig?: ConfigMapType): boolean =>
|
||||
Boolean(remoteConfig?.['desktop.usernames']?.enabled)
|
||||
);
|
||||
|
||||
export const getDefaultConversationColor = createSelector(
|
||||
getItems,
|
||||
(
|
||||
|
|
|
@ -12,10 +12,8 @@ import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
|||
|
||||
import { getIntl } from '../selectors/user';
|
||||
|
||||
const FilteredSmartProfileEditorModal = SmartProfileEditorModal;
|
||||
|
||||
function renderProfileEditor(): JSX.Element {
|
||||
return <FilteredSmartProfileEditorModal />;
|
||||
return <SmartProfileEditorModal />;
|
||||
}
|
||||
|
||||
function renderContactModal(): JSX.Element {
|
||||
|
|
|
@ -10,7 +10,7 @@ import { LeftPane, LeftPaneMode } from '../../components/LeftPane';
|
|||
import type { StateType } from '../reducer';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
|
||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
|
||||
import {
|
||||
getIsSearchingInAConversation,
|
||||
getQuery,
|
||||
|
@ -53,8 +53,6 @@ import { SmartRelinkDialog } from './RelinkDialog';
|
|||
import { SmartUpdateDialog } from './UpdateDialog';
|
||||
import { SmartCaptchaDialog } from './CaptchaDialog';
|
||||
|
||||
const FilteredSmartMessageSearchResult = SmartMessageSearchResult;
|
||||
|
||||
function renderExpiredBuildDialog(
|
||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||
): JSX.Element {
|
||||
|
@ -64,7 +62,7 @@ function renderMainHeader(): JSX.Element {
|
|||
return <SmartMainHeader />;
|
||||
}
|
||||
function renderMessageSearchResult(id: string): JSX.Element {
|
||||
return <FilteredSmartMessageSearchResult id={id} />;
|
||||
return <SmartMessageSearchResult id={id} />;
|
||||
}
|
||||
function renderNetworkStatus(
|
||||
props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||
|
|
|
@ -8,8 +8,8 @@ import { ProfileEditorModal } from '../../components/ProfileEditorModal';
|
|||
import type { PropsDataType } from '../../components/ProfileEditor';
|
||||
import type { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getEmojiSkinTone } from '../selectors/items';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import { getEmojiSkinTone, getUsernamesEnabled } from '../selectors/items';
|
||||
import { getMe, getUsernameSaveState } from '../selectors/conversations';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
|
||||
function mapStateToProps(
|
||||
|
@ -25,9 +25,11 @@ function mapStateToProps(
|
|||
firstName,
|
||||
familyName,
|
||||
id: conversationId,
|
||||
username,
|
||||
} = getMe(state);
|
||||
const recentEmojis = selectRecentEmojis(state);
|
||||
const skinTone = getEmojiSkinTone(state);
|
||||
const isUsernameFlagEnabled = getUsernamesEnabled(state);
|
||||
|
||||
return {
|
||||
aboutEmoji,
|
||||
|
@ -39,9 +41,12 @@ function mapStateToProps(
|
|||
firstName: String(firstName),
|
||||
hasError: state.globalModals.profileEditorHasError,
|
||||
i18n: getIntl(state),
|
||||
isUsernameFlagEnabled,
|
||||
recentEmojis,
|
||||
skinTone,
|
||||
userAvatarData,
|
||||
username,
|
||||
usernameSaveState: getUsernameSaveState(state),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -27,10 +27,8 @@ type ExternalProps = {
|
|||
previousMessageId: undefined | string;
|
||||
};
|
||||
|
||||
const FilteredSmartContactName = SmartContactName;
|
||||
|
||||
function renderContact(conversationId: string): JSX.Element {
|
||||
return <FilteredSmartContactName conversationId={conversationId} />;
|
||||
return <SmartContactName conversationId={conversationId} />;
|
||||
}
|
||||
|
||||
function renderUniversalTimerNotification(): JSX.Element {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue