diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9a6ec4db4e..b5646cdd72 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2866,7 +2866,11 @@ }, "icu:startConversation--username-not-found": { "messageformat": "{atUsername} is not a Signal user. Make sure you've entered the complete username.", - "description": "Shown in dialog if username is not found. Note that 'username' will be the output of at-username" + "description": "Shown in dialog if username is not found. Note that 'username' will be the output of at-username, no @ symbol" + }, + "icu:startConversation--username-not-valid": { + "messageformat": "{atUsername} is not a valid username. Make sure you've entered the complete username followed by its set of digits.", + "description": "Shown in dialog if username is invalid. Note that 'username' will be the output of at-username, no @ symbol" }, "icu:startConversation--phone-number-not-found": { "messageformat": "User not found. \"{phoneNumber}\" is not a Signal user.", diff --git a/ts/components/GlobalModalContainer.dom.tsx b/ts/components/GlobalModalContainer.dom.tsx index 565dce8146..aecbdf4234 100644 --- a/ts/components/GlobalModalContainer.dom.tsx +++ b/ts/components/GlobalModalContainer.dom.tsx @@ -33,6 +33,7 @@ import { import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGifMessageSendModal.preload.js'; import { CriticalIdlePrimaryDeviceModal } from './CriticalIdlePrimaryDeviceModal.dom.js'; import { LowDiskSpaceBackupImportModal } from './LowDiskSpaceBackupImportModal.dom.js'; +import { isUsernameValid } from '../util/Username.dom.js'; // NOTE: All types should be required for this component so that the smart // component gives you type errors when adding/removing props. @@ -392,6 +393,13 @@ export function GlobalModalContainer({ content = i18n('icu:startConversation--phone-number-not-found', { phoneNumber: userNotFoundModalState.phoneNumber, }); + } else if ( + userNotFoundModalState.type === 'username' && + !isUsernameValid(userNotFoundModalState.username) + ) { + content = i18n('icu:startConversation--username-not-valid', { + atUsername: userNotFoundModalState.username, + }); } else if (userNotFoundModalState.type === 'username') { content = i18n('icu:startConversation--username-not-found', { atUsername: userNotFoundModalState.username, diff --git a/ts/test-electron/util/Username_test.dom.ts b/ts/test-electron/util/Username_test.dom.ts index 4dde16bdc7..07005fb0e0 100644 --- a/ts/test-electron/util/Username_test.dom.ts +++ b/ts/test-electron/util/Username_test.dom.ts @@ -6,27 +6,68 @@ import { assert } from 'chai'; import * as Username from '../../util/Username.dom.js'; describe('Username', () => { + describe('isUsernameValid', () => { + const { isUsernameValid } = Username; + + it('returns false for missing discriminator', () => { + assert.isFalse(isUsernameValid('use')); + assert.isFalse(isUsernameValid('user')); + assert.isFalse(isUsernameValid('usern')); + assert.isFalse(isUsernameValid('usern.')); + }); + + it('matches valid username searches', () => { + assert.isTrue(isUsernameValid('username.01')); + assert.isTrue(isUsernameValid('username.12')); + assert.isTrue(isUsernameValid('xyz.568')); + assert.isTrue(isUsernameValid('numbered9.34')); + assert.isTrue(isUsernameValid('u12.34')); + assert.isTrue(isUsernameValid('with_underscore.56')); + assert.isTrue(isUsernameValid('username_with_32_characters_1234.45')); + }); + + it('does not match when then username starts with a number', () => { + assert.isFalse(isUsernameValid('1user.12')); + assert.isFalse(isUsernameValid('9user_name.12')); + }); + + it('does not match usernames shorter than 3 characters or longer than 32', () => { + assert.isFalse(isUsernameValid('us.12')); + assert.isFalse(isUsernameValid('username_with_33_characters_12345.67')); + }); + + it('does not match something that looks like a phone number', () => { + assert.isFalse(isUsernameValid('+')); + assert.isFalse(isUsernameValid('2223')); + assert.isFalse(isUsernameValid('+3')); + assert.isFalse(isUsernameValid('+234234234233')); + }); + + it('does not match invalid discriminators', () => { + assert.isFalse(isUsernameValid('username.0')); + assert.isFalse(isUsernameValid('username.00')); + assert.isFalse(isUsernameValid('username.000')); + assert.isFalse(isUsernameValid('username.001')); + assert.isFalse(isUsernameValid('username.012')); + }); + }); + describe('getUsernameFromSearch', () => { const { getUsernameFromSearch } = Username; it('matches partial username searches without discriminator', () => { - assert.strictEqual(getUsernameFromSearch('use'), 'use.01'); - assert.strictEqual(getUsernameFromSearch('user'), 'user.01'); - assert.strictEqual(getUsernameFromSearch('usern'), 'usern.01'); - assert.strictEqual(getUsernameFromSearch('usern.'), 'usern.01'); + assert.strictEqual(getUsernameFromSearch('use'), 'use'); + assert.strictEqual(getUsernameFromSearch('user'), 'user'); + assert.strictEqual(getUsernameFromSearch('usern'), 'usern'); + assert.strictEqual(getUsernameFromSearch('usern.'), 'usern.'); }); it('matches and strips leading @', () => { - assert.strictEqual(getUsernameFromSearch('@user'), 'user.01'); - assert.strictEqual(getUsernameFromSearch('@user.'), 'user.01'); + assert.strictEqual(getUsernameFromSearch('@user'), 'user'); + assert.strictEqual(getUsernameFromSearch('@user.'), 'user.'); assert.strictEqual(getUsernameFromSearch('@user.01'), 'user.01'); }); - it('adds a 1 if discriminator is one digit', () => { - assert.strictEqual(getUsernameFromSearch('@user.0'), 'user.01'); - assert.strictEqual(getUsernameFromSearch('@user.2'), 'user.21'); - }); - it('matches valid username searches', () => { assert.strictEqual(getUsernameFromSearch('username.12'), 'username.12'); assert.strictEqual(getUsernameFromSearch('xyz.568'), 'xyz.568'); diff --git a/ts/util/Username.dom.ts b/ts/util/Username.dom.ts index f989bc5bd2..373d928690 100644 --- a/ts/util/Username.dom.ts +++ b/ts/util/Username.dom.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as RemoteConfig from '../RemoteConfig.dom.js'; +import { getDiscriminator, getNickname } from '../types/Username.std.js'; import { parseIntWithFallback } from './parseIntWithFallback.std.js'; export function getMaxNickname(): number { @@ -15,9 +16,42 @@ export function getMinNickname(): number { } // Usernames have a minimum length of 3 and maximum of 32 -const USERNAME_CHARS = /^@?[a-zA-Z_][a-zA-Z0-9_]{2,31}(.\d+)?$/; +const USERNAME_LIKE = /^@?[a-zA-Z_][a-zA-Z0-9_]{2,31}(.\d*?)?$/; +const NICKNAME_CHARS = /^[a-zA-Z_][a-zA-Z0-9_]+$/; const ALL_DIGITS = /^\d+$/; +export function isUsernameValid(username: string): boolean { + const nickname = getNickname(username); + const discriminator = getDiscriminator(username); + + if (!nickname) { + return false; + } + + if ( + nickname.length < getMinNickname() || + nickname.length > getMaxNickname() + ) { + return false; + } + + if (!NICKNAME_CHARS.test(nickname)) { + return false; + } + + if (!discriminator || discriminator.length === 0) { + return false; + } + if (discriminator[0] === '0' && discriminator[1] === '0') { + return false; + } + if (discriminator[0] === '0' && discriminator.length !== 2) { + return false; + } + + return true; +} + export function getUsernameFromSearch(searchTerm: string): string | undefined { let modifiedTerm = searchTerm.trim(); @@ -28,26 +62,12 @@ export function getUsernameFromSearch(searchTerm: string): string | undefined { if (modifiedTerm.startsWith('@')) { modifiedTerm = modifiedTerm.slice(1); } - if (modifiedTerm.endsWith('.')) { - // Allow nicknames without full discriminator - modifiedTerm = `${modifiedTerm}01`; - } else if (/\.\d$/.test(modifiedTerm)) { - // Add one more digit if they only have one - modifiedTerm = `${modifiedTerm}1`; - } else if (!/\.\d*$/.test(modifiedTerm)) { - // Allow nicknames without discriminator - modifiedTerm = `${modifiedTerm}.01`; - } - if (!USERNAME_CHARS.test(modifiedTerm)) { + if (!USERNAME_LIKE.test(modifiedTerm)) { return undefined; } - try { - return modifiedTerm; - } catch { - return undefined; - } + return modifiedTerm; } export function isProbablyAUsername(text: string): boolean { @@ -57,7 +77,7 @@ export function isProbablyAUsername(text: string): boolean { return true; } - if (!USERNAME_CHARS.test(searchTerm)) { + if (!USERNAME_LIKE.test(searchTerm)) { return false; } if (ALL_DIGITS.test(searchTerm)) {