From 486ada8b6cd0be64cd0dee628e43be3fc5266c96 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Tue, 14 Feb 2023 09:39:47 -0800 Subject: [PATCH] Handle 409/410 when confirming username --- _locales/en/messages.json | 4 ++ ts/components/EditUsernameModalBody.tsx | 23 +++++++++- ts/services/username.ts | 10 ++++- ts/state/ducks/username.ts | 32 ++++++++++---- ts/state/ducks/usernameEnums.ts | 1 + ts/test-electron/state/ducks/username_test.ts | 42 +++++++++++++++++-- ts/test-mock/pnp/send_gv2_invite_test.ts | 4 +- ts/types/Username.ts | 5 +++ 8 files changed, 106 insertions(+), 15 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e6ce8bfdfe..d2c132ed13 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5223,6 +5223,10 @@ "message": "Your username couldn’t be saved. Check your connection and try again.", "description": "Shown if something unknown has gone wrong with username save." }, + "icu:ProfileEditor--username--reservation-gone": { + "messageformat": "{username} is no longer available. A new set of digits will be paired with your username, please try saving it again.", + "description": "Shown if username reservation has expired and new one needs to be generated." + }, "ProfileEditor--username--delete-general-error": { "message": "Your username couldn’t be removed. Check your connection and try again.", "description": "Shown if something unknown has gone wrong with username delete." diff --git a/ts/components/EditUsernameModalBody.tsx b/ts/components/EditUsernameModalBody.tsx index 431c0f7cbf..508073e8db 100644 --- a/ts/components/EditUsernameModalBody.tsx +++ b/ts/components/EditUsernameModalBody.tsx @@ -120,7 +120,10 @@ export function EditUsernameModalBody({ return i18n('ProfileEditor--username--unavailable'); } // Displayed through confirmation modal below - if (error === UsernameReservationError.General) { + if ( + error === UsernameReservationError.General || + error === UsernameReservationError.ConflictOrGone + ) { return; } throw missingCaseError(error); @@ -264,6 +267,24 @@ export function EditUsernameModalBody({ {i18n('ProfileEditor--username--general-error')} )} + + {error === UsernameReservationError.ConflictOrGone && ( + { + if (nickname) { + reserveUsername(nickname); + } + }} + > + {i18n('icu:ProfileEditor--username--reservation-gone', { + username: currentUsername, + })} + + )} ); } diff --git a/ts/services/username.ts b/ts/services/username.ts index d985e183f4..2a3fb92cc4 100644 --- a/ts/services/username.ts +++ b/ts/services/username.ts @@ -8,7 +8,7 @@ import { strictAssert } from '../util/assert'; import { sleep } from '../util/sleep'; import { getMinNickname, getMaxNickname } from '../util/Username'; import type { UsernameReservationType } from '../types/Username'; -import { ReserveUsernameError } from '../types/Username'; +import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username'; import * as Errors from '../types/errors'; import * as log from '../logging/log'; import MessageSender from '../textsecure/SendMessage'; @@ -129,7 +129,7 @@ async function updateUsernameAndSyncProfile( export async function confirmUsername( reservation: UsernameReservationType, abortSignal?: AbortSignal -): Promise { +): Promise { const { server } = window.textsecure; if (!server) { throw new Error('server interface is not available!'); @@ -162,9 +162,15 @@ export async function confirmUsername( return confirmUsername(reservation, abortSignal); } + + if (error.code === 409 || error.code === 410) { + return ConfirmUsernameResult.ConflictOrGone; + } } throw error; } + + return ConfirmUsernameResult.Ok; } export async function deleteUsername( diff --git a/ts/state/ducks/username.ts b/ts/state/ducks/username.ts index c4b58dba6e..1a6f78e925 100644 --- a/ts/state/ducks/username.ts +++ b/ts/state/ducks/username.ts @@ -5,7 +5,10 @@ import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; import type { UsernameReservationType } from '../../types/Username'; -import { ReserveUsernameError } from '../../types/Username'; +import { + ReserveUsernameError, + ConfirmUsernameResult, +} from '../../types/Username'; import * as usernameServices from '../../services/username'; import type { ReserveUsernameResultType } from '../../services/username'; import { @@ -83,7 +86,7 @@ type ReserveUsernameActionType = ReadonlyDeep< > >; type ConfirmUsernameActionType = ReadonlyDeep< - PromiseAction + PromiseAction >; type DeleteUsernameActionType = ReadonlyDeep< PromiseAction @@ -425,12 +428,25 @@ export function reducer( 'Must be reserving before resolving confirmation' ); - return { - ...state, - usernameReservation: { - state: UsernameReservationState.Closed, - }, - }; + const { payload } = action; + if (payload === ConfirmUsernameResult.Ok) { + return { + ...state, + usernameReservation: { + state: UsernameReservationState.Closed, + }, + }; + } + if (payload === ConfirmUsernameResult.ConflictOrGone) { + return { + ...state, + usernameReservation: { + state: UsernameReservationState.Open, + error: UsernameReservationError.ConflictOrGone, + }, + }; + } + throw missingCaseError(payload); } if (action.type === 'username/CONFIRM_USERNAME_REJECTED') { diff --git a/ts/state/ducks/usernameEnums.ts b/ts/state/ducks/usernameEnums.ts index b6bc1e8211..bc86ac3185 100644 --- a/ts/state/ducks/usernameEnums.ts +++ b/ts/state/ducks/usernameEnums.ts @@ -29,4 +29,5 @@ export enum UsernameReservationError { CheckCharacters = 'CheckCharacters', UsernameNotAvailable = 'UsernameNotAvailable', General = 'General', + ConflictOrGone = 'ConflictOrGone', } diff --git a/ts/test-electron/state/ducks/username_test.ts b/ts/test-electron/state/ducks/username_test.ts index ac8ea1f74a..04cc6cee86 100644 --- a/ts/test-electron/state/ducks/username_test.ts +++ b/ts/test-electron/state/ducks/username_test.ts @@ -20,7 +20,10 @@ import { actions } from '../../../state/ducks/username'; import { ToastType } from '../../../types/Toast'; import { noopAction } from '../../../state/ducks/noop'; import { reducer } from '../../../state/reducer'; -import { ReserveUsernameError } from '../../../types/Username'; +import { + ReserveUsernameError, + ConfirmUsernameResult, +} from '../../../types/Username'; const DEFAULT_RESERVATION = { username: 'abc.12', @@ -312,7 +315,7 @@ describe('electron/state/ducks/username', () => { describe('confirmUsername', () => { it('should dispatch promise when reservation is present', () => { - const doConfirmUsername = sinon.stub().resolves(); + const doConfirmUsername = sinon.stub().resolves(ConfirmUsernameResult.Ok); const dispatch = sinon.spy(); actions.confirmUsername({ @@ -344,7 +347,7 @@ describe('electron/state/ducks/username', () => { state = reducer(state, { type: 'username/CONFIRM_USERNAME_FULFILLED', - payload: undefined, + payload: ConfirmUsernameResult.Ok, meta: undefined, }); @@ -389,6 +392,39 @@ describe('electron/state/ducks/username', () => { UsernameReservationError.General ); }); + + it('should not close modal on "conflict or gone"', () => { + let state = stateWithReservation; + + state = reducer(state, { + type: 'username/CONFIRM_USERNAME_PENDING', + meta: undefined, + }); + assert.strictEqual( + getUsernameReservationState(state), + UsernameReservationState.Confirming + ); + assert.strictEqual( + getUsernameReservationObject(state), + DEFAULT_RESERVATION + ); + + state = reducer(state, { + type: 'username/CONFIRM_USERNAME_FULFILLED', + payload: ConfirmUsernameResult.ConflictOrGone, + meta: undefined, + }); + + assert.strictEqual( + getUsernameReservationState(state), + UsernameReservationState.Open + ); + assert.strictEqual(getUsernameReservationObject(state), undefined); + assert.strictEqual( + getUsernameReservationError(state), + UsernameReservationError.ConflictOrGone + ); + }); }); describe('deleteUsername', () => { diff --git a/ts/test-mock/pnp/send_gv2_invite_test.ts b/ts/test-mock/pnp/send_gv2_invite_test.ts index 2ae03f9c85..ee6f606a18 100644 --- a/ts/test-mock/pnp/send_gv2_invite_test.ts +++ b/ts/test-mock/pnp/send_gv2_invite_test.ts @@ -25,7 +25,9 @@ describe('pnp/send gv2 invite', function needsName() { let pniContact: PrimaryDevice; beforeEach(async () => { - bootstrap = new Bootstrap(); + bootstrap = new Bootstrap({ + contactCount: 0, + }); await bootstrap.init(); const { phone, server } = bootstrap; diff --git a/ts/types/Username.ts b/ts/types/Username.ts index 1ba745ea95..a5f59495e1 100644 --- a/ts/types/Username.ts +++ b/ts/types/Username.ts @@ -12,6 +12,11 @@ export enum ReserveUsernameError { Conflict = 'Conflict', } +export enum ConfirmUsernameResult { + Ok = 'Ok', + ConflictOrGone = 'ConflictOrGone', +} + export function getUsernameFromSearch(searchTerm: string): string | undefined { // Search term contains username if it: // - Is a valid username with or without a discriminator