Usernames: Create/update/delete in profile editor

This commit is contained in:
Scott Nonnenberg 2021-11-01 12:13:35 -07:00 committed by GitHub
parent a9cb621eb6
commit 3190f95fac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 923 additions and 89 deletions

View file

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

View 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,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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