signal-desktop/ts/state/ducks/username.ts
2022-10-18 10:12:02 -07:00

489 lines
12 KiB
TypeScript

// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import type { UsernameReservationType } from '../../types/Username';
import { ReserveUsernameError } from '../../types/Username';
import * as usernameServices from '../../services/username';
import type { ReserveUsernameResultType } from '../../services/username';
import {
isValidNickname,
getMinNickname,
getMaxNickname,
} from '../../util/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 {
UsernameEditState,
UsernameReservationState,
UsernameReservationError,
} from './usernameEnums';
import { showToast, ToastType } from './toast';
import type { ToastActionType } from './toast';
export type UsernameReservationStateType = Readonly<{
state: UsernameReservationState;
reservation?: UsernameReservationType;
error?: UsernameReservationError;
abortController?: AbortController;
}>;
export type UsernameStateType = Readonly<{
// ProfileEditor
editState: UsernameEditState;
// 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 RESERVE_USERNAME = 'username/RESERVE_USERNAME';
const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME';
const DELETE_USERNAME = 'username/DELETE_USERNAME';
type SetUsernameEditStateActionType = {
type: typeof SET_USERNAME_EDIT_STATE;
payload: {
editState: UsernameEditState;
};
};
type OpenUsernameReservationModalActionType = {
type: typeof OPEN_USERNAME_RESERVATION_MODAL;
};
type CloseUsernameReservationModalActionType = {
type: typeof CLOSE_USERNAME_RESERVATION_MODAL;
};
type SetUsernameReservationErrorActionType = {
type: typeof SET_USERNAME_RESERVATION_ERROR;
payload: {
error: UsernameReservationError | undefined;
};
};
type ReserveUsernameActionType = PromiseAction<
typeof RESERVE_USERNAME,
ReserveUsernameResultType | undefined,
{ abortController: AbortController }
>;
type ConfirmUsernameActionType = PromiseAction<typeof CONFIRM_USERNAME, void>;
type DeleteUsernameActionType = PromiseAction<typeof DELETE_USERNAME, void>;
export type UsernameActionType =
| SetUsernameEditStateActionType
| OpenUsernameReservationModalActionType
| CloseUsernameReservationModalActionType
| SetUsernameReservationErrorActionType
| ReserveUsernameActionType
| ConfirmUsernameActionType
| DeleteUsernameActionType;
export const actions = {
setUsernameEditState,
openUsernameReservationModal,
closeUsernameReservationModal,
setUsernameReservationError,
reserveUsername,
confirmUsername,
deleteUsername,
};
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 },
};
}
const INPUT_DELAY_MS = 500;
export type ReserveUsernameOptionsType = Readonly<{
doReserveUsername?: typeof usernameServices.reserveUsername;
delay?: number;
}>;
export function reserveUsername(
nickname: string,
{
doReserveUsername = usernameServices.reserveUsername,
delay = INPUT_DELAY_MS,
}: ReserveUsernameOptionsType = {}
): ThunkAction<
void,
RootStateType,
unknown,
ReserveUsernameActionType | SetUsernameReservationErrorActionType
> {
return (dispatch, getState) => {
if (!nickname) {
return;
}
if (!isValidNickname(nickname)) {
const error = getNicknameInvalidError(nickname);
if (error) {
dispatch(setUsernameReservationError(error));
} else {
assertDev(false, 'This should not happen');
dispatch(setUsernameReservationError(UsernameReservationError.General));
}
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,
abortSignal,
});
};
dispatch({
type: RESERVE_USERNAME,
payload: run(),
meta: { abortController },
});
};
}
export type ConfirmUsernameOptionsType = Readonly<{
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 = Readonly<{
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 username = me.username ?? defaultUsername;
if (!username) {
return;
}
const run = async () => {
try {
await doDeleteUsername(username);
} catch {
dispatch(showToast(ToastType.FailedToDeleteUsername));
}
};
dispatch({
type: DELETE_USERNAME,
payload: run(),
});
};
}
// Reducers
export function getEmptyState(): UsernameStateType {
return {
editState: UsernameEditState.Editing,
usernameReservation: {
state: UsernameReservationState.Closed,
},
};
}
export function reducer(
state: Readonly<UsernameStateType> = getEmptyState(),
action: Readonly<UsernameActionType>
): UsernameStateType {
const { usernameReservation } = state;
if (action.type === SET_USERNAME_EDIT_STATE) {
const { editState } = action.payload;
return {
...state,
editState,
};
}
if (action.type === OPEN_USERNAME_RESERVATION_MODAL) {
return {
...state,
usernameReservation: {
state: UsernameReservationState.Open,
},
};
}
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 === 'username/RESERVE_USERNAME_PENDING') {
usernameReservation.abortController?.abort();
const { meta } = action;
return {
...state,
usernameReservation: {
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 {
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'
);
return {
...state,
usernameReservation: {
state: UsernameReservationState.Closed,
},
};
}
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;
}
return state;
}
// Helpers
function getNicknameInvalidError(
nickname: string | undefined
): UsernameReservationError | undefined {
if (!nickname) {
return undefined;
}
if (nickname.length < getMinNickname()) {
return UsernameReservationError.NotEnoughCharacters;
}
if (!/^[0-9a-z_]+$/.test(nickname)) {
return UsernameReservationError.CheckCharacters;
}
if (!/^[a-z_]/.test(nickname)) {
return UsernameReservationError.CheckStartingCharacter;
}
if (nickname.length > getMaxNickname()) {
return UsernameReservationError.TooManyCharacters;
}
return undefined;
}