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