Find by username: Don't automatically add .01 discriminator

This commit is contained in:
Scott Nonnenberg
2025-10-28 06:27:39 +10:00
committed by GitHub
parent 2ab224b0eb
commit 540bb99632
4 changed files with 103 additions and 30 deletions

View File

@@ -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.",

View File

@@ -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,

View File

@@ -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');

View File

@@ -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)) {