signal-desktop/ts/state/ducks/username.ts

638 lines
17 KiB
TypeScript
Raw Normal View History

2022-10-18 17:12:02 +00:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
2022-10-18 17:12:02 +00:00
import type { UsernameReservationType } from '../../types/Username';
import {
ReserveUsernameError,
ConfirmUsernameResult,
} from '../../types/Username';
2022-10-18 17:12:02 +00:00
import * as usernameServices from '../../services/username';
2023-07-20 03:14:08 +00:00
import { storageServiceUploadJob } from '../../services/storage';
2022-10-18 17:12:02 +00:00
import type { ReserveUsernameResultType } from '../../services/username';
import { missingCaseError } from '../../util/missingCaseError';
import { sleep } from '../../util/sleep';
import { assertDev } from '../../util/assert';
import type { StateType as RootStateType } from '../reducer';
import type { PromiseAction } from '../util';
import { getMe } from '../selectors/conversations';
import { getUsernameCorrupted } from '../selectors/items';
2022-10-18 17:12:02 +00:00
import {
UsernameEditState,
2023-07-20 03:14:08 +00:00
UsernameLinkState,
2022-10-18 17:12:02 +00:00
UsernameReservationState,
UsernameReservationError,
} from './usernameEnums';
import { showToast } from './toast';
import { ToastType } from '../../types/Toast';
2022-10-18 17:12:02 +00:00
import type { ToastActionType } from './toast';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
2022-10-18 17:12:02 +00:00
export type UsernameReservationStateType = ReadonlyDeep<{
2022-10-18 17:12:02 +00:00
state: UsernameReservationState;
2024-02-06 18:35:59 +00:00
recoveredUsername?: string;
2022-10-18 17:12:02 +00:00
reservation?: UsernameReservationType;
error?: UsernameReservationError;
abortController?: AbortController;
}>;
export type UsernameStateType = ReadonlyDeep<{
2022-10-18 17:12:02 +00:00
// ProfileEditor
editState: UsernameEditState;
2023-07-20 03:14:08 +00:00
// UsernameLinkModalBody
linkState: UsernameLinkState;
2022-10-18 17:12:02 +00:00
// EditUsernameModalBody
usernameReservation: UsernameReservationStateType;
}>;
// Actions
const SET_USERNAME_EDIT_STATE = 'username/SET_USERNAME_EDIT_STATE';
const OPEN_USERNAME_RESERVATION_MODAL = 'username/OPEN_RESERVATION_MODAL';
const CLOSE_USERNAME_RESERVATION_MODAL = 'username/CLOSE_RESERVATION_MODAL';
const SET_USERNAME_RESERVATION_ERROR = 'username/SET_RESERVATION_ERROR';
const CLEAR_USERNAME_RESERVATION = 'username/CLEAR_RESERVATION';
2022-10-18 17:12:02 +00:00
const RESERVE_USERNAME = 'username/RESERVE_USERNAME';
const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME';
const DELETE_USERNAME = 'username/DELETE_USERNAME';
2023-07-20 03:14:08 +00:00
const RESET_USERNAME_LINK = 'username/RESET_USERNAME_LINK';
2022-10-18 17:12:02 +00:00
type SetUsernameEditStateActionType = ReadonlyDeep<{
2022-10-18 17:12:02 +00:00
type: typeof SET_USERNAME_EDIT_STATE;
payload: {
editState: UsernameEditState;
};
}>;
2022-10-18 17:12:02 +00:00
type OpenUsernameReservationModalActionType = ReadonlyDeep<{
2022-10-18 17:12:02 +00:00
type: typeof OPEN_USERNAME_RESERVATION_MODAL;
}>;
2022-10-18 17:12:02 +00:00
type CloseUsernameReservationModalActionType = ReadonlyDeep<{
2022-10-18 17:12:02 +00:00
type: typeof CLOSE_USERNAME_RESERVATION_MODAL;
}>;
2022-10-18 17:12:02 +00:00
type SetUsernameReservationErrorActionType = ReadonlyDeep<{
2022-10-18 17:12:02 +00:00
type: typeof SET_USERNAME_RESERVATION_ERROR;
payload: {
error: UsernameReservationError | undefined;
};
}>;
2022-10-18 17:12:02 +00:00
2024-02-06 18:35:59 +00:00
type ClearUsernameReservationActionType = ReadonlyDeep<{
type: typeof CLEAR_USERNAME_RESERVATION;
}>;
type ReserveUsernameActionType = ReadonlyDeep<
PromiseAction<
typeof RESERVE_USERNAME,
ReserveUsernameResultType | undefined,
{ abortController: AbortController }
>
>;
type ConfirmUsernameActionType = ReadonlyDeep<
PromiseAction<typeof CONFIRM_USERNAME, ConfirmUsernameResult>
>;
type DeleteUsernameActionType = ReadonlyDeep<
PromiseAction<typeof DELETE_USERNAME, void>
2022-10-18 17:12:02 +00:00
>;
2023-07-20 03:14:08 +00:00
type ResetUsernameLinkActionType = ReadonlyDeep<
2024-02-09 17:58:12 +00:00
PromiseAction<typeof RESET_USERNAME_LINK, void>
2023-07-20 03:14:08 +00:00
>;
2022-10-18 17:12:02 +00:00
export type UsernameActionType = ReadonlyDeep<
2022-10-18 17:12:02 +00:00
| SetUsernameEditStateActionType
| OpenUsernameReservationModalActionType
| CloseUsernameReservationModalActionType
| SetUsernameReservationErrorActionType
2024-02-06 18:35:59 +00:00
| ClearUsernameReservationActionType
2022-10-18 17:12:02 +00:00
| ReserveUsernameActionType
| ConfirmUsernameActionType
| DeleteUsernameActionType
2023-07-20 03:14:08 +00:00
| ResetUsernameLinkActionType
>;
2022-10-18 17:12:02 +00:00
export const actions = {
setUsernameEditState,
openUsernameReservationModal,
closeUsernameReservationModal,
setUsernameReservationError,
clearUsernameReservation,
2022-10-18 17:12:02 +00:00
reserveUsername,
confirmUsername,
deleteUsername,
2023-07-20 03:14:08 +00:00
markCompletedUsernameOnboarding,
resetUsernameLink,
setUsernameLinkColor,
markCompletedUsernameLinkOnboarding,
2022-10-18 17:12:02 +00:00
};
export const useUsernameActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
2022-10-18 17:12:02 +00:00
export function setUsernameEditState(
editState: UsernameEditState
): SetUsernameEditStateActionType {
return {
type: SET_USERNAME_EDIT_STATE,
payload: { editState },
};
}
export function openUsernameReservationModal(): OpenUsernameReservationModalActionType {
return {
type: OPEN_USERNAME_RESERVATION_MODAL,
};
}
export function closeUsernameReservationModal(): CloseUsernameReservationModalActionType {
return {
type: CLOSE_USERNAME_RESERVATION_MODAL,
};
}
export function setUsernameReservationError(
error: UsernameReservationError | undefined
): SetUsernameReservationErrorActionType {
return {
type: SET_USERNAME_RESERVATION_ERROR,
payload: { error },
};
}
2024-02-06 18:35:59 +00:00
export function clearUsernameReservation(): ClearUsernameReservationActionType {
return {
type: CLEAR_USERNAME_RESERVATION,
};
}
2022-10-18 17:12:02 +00:00
const INPUT_DELAY_MS = 500;
export type ReserveUsernameOptionsType = ReadonlyDeep<{
nickname: string;
customDiscriminator?: string;
2022-10-18 17:12:02 +00:00
doReserveUsername?: typeof usernameServices.reserveUsername;
delay?: number;
}>;
export function reserveUsername({
nickname,
customDiscriminator,
doReserveUsername = usernameServices.reserveUsername,
delay = INPUT_DELAY_MS,
}: ReserveUsernameOptionsType): ThunkAction<
2022-10-18 17:12:02 +00:00
void,
RootStateType,
unknown,
ReserveUsernameActionType | SetUsernameReservationErrorActionType
> {
return (dispatch, getState) => {
if (!nickname) {
return;
}
const { username } = getMe(getState());
const abortController = new AbortController();
const { signal: abortSignal } = abortController;
const run = async () => {
try {
await sleep(delay, abortSignal);
} catch {
// Aborted
return;
}
return doReserveUsername({
previousUsername: username,
nickname,
customDiscriminator,
2022-10-18 17:12:02 +00:00
abortSignal,
});
};
dispatch({
type: RESERVE_USERNAME,
payload: run(),
meta: { abortController },
});
};
}
export type ConfirmUsernameOptionsType = ReadonlyDeep<{
2022-10-18 17:12:02 +00:00
doConfirmUsername?: typeof usernameServices.confirmUsername;
}>;
export function confirmUsername({
doConfirmUsername = usernameServices.confirmUsername,
}: ConfirmUsernameOptionsType = {}): ThunkAction<
void,
RootStateType,
unknown,
ConfirmUsernameActionType | SetUsernameReservationErrorActionType
> {
return (dispatch, getState) => {
const { reservation } = getState().username.usernameReservation;
if (reservation === undefined) {
assertDev(false, 'This should not happen');
dispatch(setUsernameReservationError(UsernameReservationError.General));
return;
}
dispatch({
type: CONFIRM_USERNAME,
payload: doConfirmUsername(reservation),
});
};
}
export type DeleteUsernameOptionsType = ReadonlyDeep<{
2022-10-18 17:12:02 +00:00
doDeleteUsername?: typeof usernameServices.deleteUsername;
// Only for testing
username?: string;
}>;
export function deleteUsername({
doDeleteUsername = usernameServices.deleteUsername,
username: defaultUsername,
}: DeleteUsernameOptionsType = {}): ThunkAction<
void,
RootStateType,
unknown,
DeleteUsernameActionType | ToastActionType
> {
return (dispatch, getState) => {
const me = getMe(getState());
const isUsernameCorrupted = getUsernameCorrupted(getState());
2022-10-18 17:12:02 +00:00
const username = me.username ?? defaultUsername;
if (!username && !isUsernameCorrupted) {
2022-10-18 17:12:02 +00:00
return;
}
const run = async () => {
try {
await doDeleteUsername(username);
} catch {
dispatch(showToast({ toastType: ToastType.FailedToDeleteUsername }));
2022-10-18 17:12:02 +00:00
}
};
dispatch({
type: DELETE_USERNAME,
payload: run(),
});
};
}
2023-07-20 03:14:08 +00:00
export type ResetUsernameLinkOptionsType = ReadonlyDeep<{
doResetLink?: typeof usernameServices.resetLink;
}>;
export function resetUsernameLink({
doResetLink = usernameServices.resetLink,
}: ResetUsernameLinkOptionsType = {}): ThunkAction<
void,
RootStateType,
unknown,
ResetUsernameLinkActionType
> {
return dispatch => {
const me = window.ConversationController.getOurConversationOrThrow();
const username = me.get('username');
assertDev(username, 'Username is required for resetting link');
dispatch({
type: RESET_USERNAME_LINK,
payload: doResetLink(username),
});
};
}
function markCompletedUsernameOnboarding(): ThunkAction<
void,
RootStateType,
unknown,
never
> {
return async () => {
await window.storage.put('hasCompletedUsernameOnboarding', true);
const me = window.ConversationController.getOurConversationOrThrow();
me.captureChange('usernameOnboarding');
storageServiceUploadJob();
};
}
function markCompletedUsernameLinkOnboarding(): ThunkAction<
void,
RootStateType,
unknown,
never
> {
return async () => {
await window.storage.put('hasCompletedUsernameLinkOnboarding', true);
};
}
function setUsernameLinkColor(
color: number
): ThunkAction<void, RootStateType, unknown, never> {
return async () => {
await window.storage.put('usernameLinkColor', color);
const me = window.ConversationController.getOurConversationOrThrow();
me.captureChange('usernameLinkColor');
storageServiceUploadJob();
};
}
2022-10-18 17:12:02 +00:00
// Reducers
export function getEmptyState(): UsernameStateType {
return {
editState: UsernameEditState.Editing,
2023-07-20 03:14:08 +00:00
linkState: UsernameLinkState.Ready,
2022-10-18 17:12:02 +00:00
usernameReservation: {
state: UsernameReservationState.Closed,
},
};
}
export function reducer(
state: Readonly<UsernameStateType> = getEmptyState(),
action: Readonly<UsernameActionType>
): UsernameStateType {
const { usernameReservation } = state;
2024-02-06 18:35:59 +00:00
if (action.type === OPEN_USERNAME_RESERVATION_MODAL) {
2022-10-18 17:12:02 +00:00
return {
...state,
2024-02-06 18:35:59 +00:00
editState: UsernameEditState.Editing,
linkState: UsernameLinkState.Ready,
usernameReservation: {
state: UsernameReservationState.Open,
},
2022-10-18 17:12:02 +00:00
};
}
2024-02-06 18:35:59 +00:00
if (action.type === SET_USERNAME_EDIT_STATE) {
const { editState } = action.payload;
2022-10-18 17:12:02 +00:00
return {
...state,
2024-02-06 18:35:59 +00:00
editState,
2022-10-18 17:12:02 +00:00
};
}
if (action.type === CLOSE_USERNAME_RESERVATION_MODAL) {
return {
...state,
usernameReservation: {
state: UsernameReservationState.Closed,
},
};
}
if (action.type === SET_USERNAME_RESERVATION_ERROR) {
const { error } = action.payload;
return {
...state,
usernameReservation: {
...usernameReservation,
error,
reservation: undefined,
},
};
}
if (action.type === CLEAR_USERNAME_RESERVATION) {
return {
...state,
usernameReservation: {
...usernameReservation,
error: undefined,
reservation: undefined,
},
};
}
2022-10-18 17:12:02 +00:00
if (action.type === 'username/RESERVE_USERNAME_PENDING') {
usernameReservation.abortController?.abort();
const { meta } = action;
return {
...state,
usernameReservation: {
...usernameReservation,
error: undefined,
2022-10-18 17:12:02 +00:00
state: UsernameReservationState.Reserving,
abortController: meta.abortController,
},
};
}
if (action.type === 'username/RESERVE_USERNAME_FULFILLED') {
const { meta } = action;
// New reservation is pending
if (meta.abortController !== usernameReservation.abortController) {
return state;
}
assertDev(
usernameReservation.state === UsernameReservationState.Reserving,
'Must be reserving before resolving reservation'
);
const { payload } = action;
assertDev(
payload !== undefined,
'Payload can be undefined only when aborted'
);
if (!payload.ok) {
const { error } = payload;
let stateError: UsernameReservationError;
if (error === ReserveUsernameError.Unprocessable) {
stateError = UsernameReservationError.CheckCharacters;
} else if (error === ReserveUsernameError.Conflict) {
stateError = UsernameReservationError.UsernameNotAvailable;
} else if (error === ReserveUsernameError.NotEnoughCharacters) {
stateError = UsernameReservationError.NotEnoughCharacters;
} else if (error === ReserveUsernameError.TooManyCharacters) {
stateError = UsernameReservationError.TooManyCharacters;
} else if (error === ReserveUsernameError.CheckStartingCharacter) {
stateError = UsernameReservationError.CheckStartingCharacter;
} else if (error === ReserveUsernameError.CheckCharacters) {
stateError = UsernameReservationError.CheckCharacters;
} else if (error === ReserveUsernameError.NotEnoughDiscriminator) {
stateError = UsernameReservationError.NotEnoughDiscriminator;
} else if (error === ReserveUsernameError.AllZeroDiscriminator) {
stateError = UsernameReservationError.AllZeroDiscriminator;
} else if (error === ReserveUsernameError.LeadingZeroDiscriminator) {
stateError = UsernameReservationError.LeadingZeroDiscriminator;
} else if (error === ReserveUsernameError.TooManyAttempts) {
stateError = UsernameReservationError.TooManyAttempts;
2022-10-18 17:12:02 +00:00
} else {
throw missingCaseError(error);
}
return {
...state,
usernameReservation: {
state: UsernameReservationState.Open,
error: stateError,
},
};
}
const { reservation } = payload;
return {
...state,
usernameReservation: {
state: UsernameReservationState.Open,
reservation,
},
};
}
if (action.type === 'username/RESERVE_USERNAME_REJECTED') {
const { meta } = action;
// New reservation is pending
if (meta.abortController !== usernameReservation.abortController) {
return state;
}
assertDev(
usernameReservation.state === UsernameReservationState.Reserving,
'Must be reserving before rejecting reservation'
);
return {
...state,
usernameReservation: {
state: UsernameReservationState.Open,
error: UsernameReservationError.General,
},
};
}
if (action.type === 'username/CONFIRM_USERNAME_PENDING') {
assertDev(
usernameReservation.state === UsernameReservationState.Open,
'Must be open before confirmation'
);
return {
...state,
usernameReservation: {
reservation: usernameReservation.reservation,
state: UsernameReservationState.Confirming,
},
};
}
if (action.type === 'username/CONFIRM_USERNAME_FULFILLED') {
assertDev(
usernameReservation.state === UsernameReservationState.Confirming,
'Must be reserving before resolving confirmation'
);
const { payload } = action;
if (payload === ConfirmUsernameResult.Ok) {
return {
...state,
usernameReservation: {
state: UsernameReservationState.Closed,
},
};
}
2024-02-06 18:35:59 +00:00
if (payload === ConfirmUsernameResult.OkRecovered) {
const { reservation } = state.usernameReservation;
assertDev(
reservation !== undefined,
'Must be reserving before resolving confirmation'
);
return {
...state,
usernameReservation: {
state: UsernameReservationState.Closed,
recoveredUsername: reservation.username,
},
};
}
if (payload === ConfirmUsernameResult.ConflictOrGone) {
return {
...state,
usernameReservation: {
2023-03-09 01:38:52 +00:00
reservation: state.usernameReservation.reservation,
state: UsernameReservationState.Open,
error: UsernameReservationError.ConflictOrGone,
},
};
}
throw missingCaseError(payload);
2022-10-18 17:12:02 +00:00
}
if (action.type === 'username/CONFIRM_USERNAME_REJECTED') {
assertDev(
usernameReservation.state === UsernameReservationState.Confirming,
'Must be reserving before rejecting reservation'
);
return {
...state,
usernameReservation: {
state: UsernameReservationState.Open,
error: UsernameReservationError.General,
},
};
}
if (action.type === 'username/DELETE_USERNAME_PENDING') {
return {
...state,
editState: UsernameEditState.Deleting,
};
}
if (action.type === 'username/DELETE_USERNAME_FULFILLED') {
return {
...state,
editState: UsernameEditState.Editing,
};
}
if (action.type === 'username/DELETE_USERNAME_REJECTED') {
assertDev(false, 'Should never reject');
return state;
}
2023-07-20 03:14:08 +00:00
if (action.type === 'username/RESET_USERNAME_LINK_PENDING') {
return {
...state,
linkState: UsernameLinkState.Updating,
};
}
if (action.type === 'username/RESET_USERNAME_LINK_FULFILLED') {
return {
...state,
linkState: UsernameLinkState.Ready,
};
}
if (action.type === 'username/RESET_USERNAME_LINK_REJECTED') {
return {
...state,
linkState: UsernameLinkState.Error,
2023-07-20 03:14:08 +00:00
};
}
2022-10-18 17:12:02 +00:00
return state;
}