Search for username in compose mode

This commit is contained in:
Scott Nonnenberg 2021-11-11 17:17:29 -08:00 committed by GitHub
parent 6731cc6629
commit cbae7f8ee9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 997 additions and 72 deletions

View File

@ -823,6 +823,20 @@
"message": "Messages",
"description": "Shown to separate the types of search results"
},
"findByUsernameHeader": {
"message": "Find by Username",
"description": "Shown to separate the types of search results"
},
"at-username": {
"message": "@$username$",
"description": "@ added to username to signify it as a username. Should it be on the right in your language?",
"placeholders": {
"username": {
"content": "$1",
"example": "sammy45"
}
}
},
"welcomeToSignal": {
"message": "Welcome to Signal"
},
@ -2238,6 +2252,20 @@
"message": "No conversations found",
"description": "Label shown when there are no conversations to compose to"
},
"Toast--failed-to-fetch-username": {
"message": "Failed to fetch username. Check your connection and try again.",
"description": "Shown if request to Signal servers to find username fails"
},
"startConversation--username-not-found": {
"message": "User not found. $atUsername$ is not a Signal user; make sure youve entered the complete username.",
"description": "Shown in dialog if username is not found. Note that 'username' will be the output of at-username",
"placeholders": {
"atUsername": {
"content": "$1",
"example": "@alex"
}
}
},
"chooseGroupMembers__title": {
"message": "Choose members",
"description": "The title for the 'choose group members' left pane screen"
@ -3285,7 +3313,7 @@
"message": "Unblock",
"description": "Shown as a button to let the user unblock a message request"
},
"MessageRequests--unblock-confirm-title": {
"MessageRequests--unblock-direct-confirm-title": {
"message": "Unblock $name$?",
"description": "Shown as a button to let the user unblock a message request",
"placeholders": {
@ -6185,7 +6213,7 @@
"description": "Placeholder for the username field"
},
"ProfileEditor--username--helper": {
"message": "Usernames on Signal are optional. If you choose to create a username and make it searchable, other Signal users will be able to find you by this username and contact you without knowing your phone number.",
"message": "Usernames on Signal are optional. If you choose to create a username other Signal users will be able to find you by this username and contact you without knowing your phone number.",
"description": "Shown on the edit username screen"
},
"ProfileEditor--username--check-characters": {

View File

@ -38,6 +38,7 @@ $color-white-alpha-90: rgba($color-white, 0.9);
$color-black-alpha-05: rgba($color-black, 0.05);
$color-black-alpha-06: rgba($color-black, 0.06);
$color-black-alpha-08: rgba($color-black, 0.08);
$color-black-alpha-12: rgba($color-black, 0.12);
$color-black-alpha-20: rgba($color-black, 0.2);
$color-black-alpha-30: rgba($color-black, 0.3);

View File

@ -100,6 +100,11 @@
&--note-to-self {
-webkit-mask-image: url('../images/icons/v2/note-24.svg');
}
&--search-result {
-webkit-mask-image: url('../images/icons/v2/search-24.svg');
-webkit-mask-size: 50%;
}
}
&__spinner-container {

View File

@ -98,5 +98,11 @@
a {
text-decoration: none;
}
// To account for missing error section - 16px previous margin, 34px for
// 16px margin of error plus 18px line height.
&--no-error {
margin-bottom: 50px;
}
}
}

View File

@ -12,7 +12,7 @@
@include font-body-2;
@include light-theme {
background-color: $color-gray-02;
background-color: $color-black-alpha-08;
border: solid 1px $color-gray-02;
color: $color-gray-90;
}
@ -40,7 +40,7 @@
}
&__input {
background: inherit;
background: transparent;
border: none;
padding-left: 16px;
width: 100%;

View File

@ -952,6 +952,10 @@ export async function startApp(): Promise<void> {
conversations,
'groupId'
),
conversationsByUsername: window.Signal.Util.makeLookup(
conversations,
'username'
),
messagesByConversation: {},
messagesLookup: {},
outboundMessagesPendingConversationVerification: {},

View File

@ -54,6 +54,12 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
onClick: action('onClick'),
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
searchResult: boolean(
'searchResult',
typeof overrideProps.searchResult === 'boolean'
? overrideProps.searchResult
: false
),
sharedGroupNames: [],
size: 80,
title: overrideProps.title || '',
@ -153,6 +159,14 @@ story.add('Group Icon', () => {
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Search Icon', () => {
const props = createProps({
searchResult: true,
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Colors', () => {
const props = createProps();

View File

@ -65,6 +65,7 @@ export type Props = {
theme?: ThemeType;
title: string;
unblurredAvatarPath?: string;
searchResult?: boolean;
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
@ -108,6 +109,7 @@ export const Avatar: FunctionComponent<Props> = ({
theme,
title,
unblurredAvatarPath,
searchResult,
blur = getDefaultBlur({
acceptedMessageRequest,
avatarPath,
@ -181,6 +183,15 @@ export const Avatar: FunctionComponent<Props> = ({
)}
</>
);
} else if (searchResult) {
contentsChildren = (
<div
className={classNames(
'module-Avatar__icon',
'module-Avatar__icon--search-result'
)}
/>
);
} else if (noteToSelf) {
contentsChildren = (
<div

View File

@ -84,6 +84,9 @@ const Wrapper = ({
startNewConversationFromPhoneNumber={action(
'startNewConversationFromPhoneNumber'
)}
startNewConversationFromUsername={action(
'startNewConversationFromUsername'
)}
theme={theme}
/>
);
@ -492,6 +495,10 @@ story.add('Headers', () => (
type: RowType.Header,
i18nKey: 'messagesHeader',
},
{
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
},
]}
/>
));
@ -507,6 +514,27 @@ story.add('Start new conversation', () => (
/>
));
story.add('Find by username', () => (
<Wrapper
rows={[
{
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
},
{
type: RowType.UsernameSearchResult,
username: 'jowerty',
isFetchingUsername: false,
},
{
type: RowType.UsernameSearchResult,
username: 'jowerty',
isFetchingUsername: true,
},
]}
/>
));
story.add('Search results loading skeleton', () => (
<Wrapper
scrollable={false}
@ -528,12 +556,16 @@ story.add('Kitchen sink', () => (
},
{
type: RowType.Header,
i18nKey: 'messagesHeader',
i18nKey: 'contactsHeader',
},
{
type: RowType.Contact,
contact: defaultConversations[0],
},
{
type: RowType.Header,
i18nKey: 'messagesHeader',
},
{
type: RowType.Conversation,
conversation: defaultConversations[1],
@ -542,6 +574,15 @@ story.add('Kitchen sink', () => (
type: RowType.MessageSearchResult,
messageId: '123',
},
{
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
},
{
type: RowType.UsernameSearchResult,
username: 'jowerty',
isFetchingUsername: false,
},
{
type: RowType.ArchiveButton,
archivedConversationsCount: 123,

View File

@ -26,6 +26,7 @@ import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
export enum RowType {
ArchiveButton,
@ -39,6 +40,7 @@ export enum RowType {
SearchResultsLoadingFakeHeader,
SearchResultsLoadingFakeRow,
StartNewConversation,
UsernameSearchResult,
}
type ArchiveButtonRowType = {
@ -93,6 +95,12 @@ type StartNewConversationRowType = {
phoneNumber: string;
};
type UsernameRowType = {
type: RowType.UsernameSearchResult;
username: string;
isFetchingUsername: boolean;
};
export type Row =
| ArchiveButtonRowType
| BlankRowType
@ -104,7 +112,8 @@ export type Row =
| HeaderRowType
| SearchResultsLoadingFakeHeaderType
| SearchResultsLoadingFakeRowType
| StartNewConversationRowType;
| StartNewConversationRowType
| UsernameRowType;
export type PropsType = {
badgesById?: Record<string, BadgeType>;
@ -134,6 +143,7 @@ export type PropsType = {
renderMessageSearchResult: (id: string) => JSX.Element;
showChooseGroupMembers: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
startNewConversationFromUsername: (username: string) => void;
};
const NORMAL_ROW_HEIGHT = 76;
@ -155,6 +165,7 @@ export const ConversationList: React.FC<PropsType> = ({
shouldRecomputeRowHeights,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
theme,
}) => {
const listRef = useRef<null | List>(null);
@ -327,6 +338,16 @@ export const ConversationList: React.FC<PropsType> = ({
/>
);
break;
case RowType.UsernameSearchResult:
result = (
<UsernameSearchResultListItem
i18n={i18n}
username={row.username}
isFetchingUsername={row.isFetchingUsername}
onClick={startNewConversationFromUsername}
/>
);
break;
default:
throw missingCaseError(row);
}
@ -349,6 +370,7 @@ export const ConversationList: React.FC<PropsType> = ({
renderMessageSearchResult,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
theme,
]
);

View File

@ -420,6 +420,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
}
startNewConversationFromUsername={shouldNeverBeCalled}
theme={theme}
/>
</div>

View File

@ -2,9 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ContactModalStateType } from '../state/ducks/globalModals';
import type {
ContactModalStateType,
UsernameNotFoundModalStateType,
} from '../state/ducks/globalModals';
import type { LocalizerType } from '../types/Util';
import { ButtonVariant } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog';
import { WhatsNewModal } from './WhatsNewModal';
type PropsType = {
@ -18,6 +23,9 @@ type PropsType = {
// SafetyNumberModal
safetyNumberModalContactId?: string;
renderSafetyNumber: () => JSX.Element;
// UsernameNotFoundModal
hideUsernameNotFoundModal: () => unknown;
usernameNotFoundModalState?: UsernameNotFoundModalStateType;
// WhatsNewModal
isWhatsNewVisible: boolean;
hideWhatsNewModal: () => unknown;
@ -34,6 +42,9 @@ export const GlobalModalContainer = ({
// SafetyNumberModal
safetyNumberModalContactId,
renderSafetyNumber,
// UsernameNotFoundModal
hideUsernameNotFoundModal,
usernameNotFoundModalState,
// WhatsNewModal
hideWhatsNewModal,
isWhatsNewVisible,
@ -42,6 +53,23 @@ export const GlobalModalContainer = ({
return renderSafetyNumber();
}
if (usernameNotFoundModalState) {
return (
<ConfirmationDialog
cancelText={i18n('ok')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
onClose={hideUsernameNotFoundModal}
>
{i18n('startConversation--username-not-found', {
atUsername: i18n('at-username', {
username: usernameNotFoundModalState.username,
}),
})}
</ConfirmationDialog>
);
}
if (contactModalState) {
return renderContactModal();
}

View File

@ -146,6 +146,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
startNewConversationFromUsername: action('startNewConversationFromUsername'),
startSearch: action('startSearch'),
startSettingGroupMetadata: action('startSettingGroupMetadata'),
theme: React.useContext(StorybookThemeContext),
@ -439,13 +440,15 @@ story.add('Archive: searching a conversation', () => (
// Compose stories
story.add('Compose: no contacts or groups', () => (
story.add('Compose: no results', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: '',
},
@ -453,13 +456,15 @@ story.add('Compose: no contacts or groups', () => (
/>
));
story.add('Compose: some contacts, no groups, no search term', () => (
story.add('Compose: some contacts, no search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: '',
},
@ -467,13 +472,15 @@ story.add('Compose: some contacts, no groups, no search term', () => (
/>
));
story.add('Compose: some contacts, no groups, with a search term', () => (
story.add('Compose: some contacts, with a search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'ar',
},
@ -481,13 +488,15 @@ story.add('Compose: some contacts, no groups, with a search term', () => (
/>
));
story.add('Compose: some groups, no contacts, no search term', () => (
story.add('Compose: some groups, no search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: '',
},
@ -495,13 +504,15 @@ story.add('Compose: some groups, no contacts, no search term', () => (
/>
));
story.add('Compose: some groups, no contacts, with search term', () => (
story.add('Compose: some groups, with search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'ar',
},
@ -509,13 +520,63 @@ story.add('Compose: some groups, no contacts, with search term', () => (
/>
));
story.add('Compose: some contacts, some groups, no search term', () => (
story.add('Compose: search is valid username', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'someone',
},
})}
/>
));
story.add('Compose: search is valid username, fetching username', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: true,
regionCode: 'US',
searchTerm: 'someone',
},
})}
/>
));
story.add('Compose: search is valid username, but flag is not enabled', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: false,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'someone',
},
})}
/>
));
story.add('Compose: all kinds of results, no search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: '',
},
@ -523,15 +584,17 @@ story.add('Compose: some contacts, some groups, no search term', () => (
/>
));
story.add('Compose: some contacts, some groups, with a search term', () => (
story.add('Compose: all kinds of results, with a search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'ar',
searchTerm: 'someone',
},
})}
/>

View File

@ -103,6 +103,7 @@ export type PropsType = {
closeRecommendedGroupSizeModal: () => void;
createGroup: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
startNewConversationFromUsername: (username: string) => void;
openConversationInternal: (_: {
conversationId: string;
messageId?: string;
@ -185,6 +186,7 @@ export const LeftPane: React.FC<PropsType> = ({
startComposing,
startSearch,
startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
startSettingGroupMetadata,
theme,
toggleComposeEditingAvatar,
@ -607,6 +609,9 @@ export const LeftPane: React.FC<PropsType> = ({
startNewConversationFromPhoneNumber={
startNewConversationFromPhoneNumber
}
startNewConversationFromUsername={
startNewConversationFromUsername
}
theme={theme}
/>
</div>

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import * as log from '../logging/log';
import type { AvatarColorType } from '../types/Colors';
@ -627,8 +628,15 @@ export const ProfileEditor = ({
value={newUsername}
/>
<div className="ProfileEditor__error">{usernameError}</div>
<div className="ProfileEditor__info">
{usernameError && (
<div className="ProfileEditor__error">{usernameError}</div>
)}
<div
className={classNames(
'ProfileEditor__info',
!usernameError ? 'ProfileEditor__info--no-error' : undefined
)}
>
<Intl i18n={i18n} id="ProfileEditor--username--helper" />
</div>

View File

@ -0,0 +1,22 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export const ToastFailedToFetchUsername = ({
i18n,
onClose,
}: PropsType): JSX.Element => {
return (
<Toast onClose={onClose} style={{ maxWidth: '280px' }}>
{i18n('Toast--failed-to-fetch-username')}
</Toast>
);
};

View File

@ -229,6 +229,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
startNewConversationFromUsername={shouldNeverBeCalled}
theme={theme}
/>
</div>

View File

@ -14,6 +14,7 @@ import { isConversationUnread } from '../../util/isConversationUnread';
import { cleanId } from '../_util';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
import { Spinner } from '../Spinner';
const BASE_CLASS_NAME =
'module-conversation-list__item--contact-or-conversation';
@ -38,12 +39,14 @@ type PropsType = {
i18n: LocalizerType;
isNoteToSelf?: boolean;
isSelected: boolean;
isUsernameSearchResult?: boolean;
markedUnread?: boolean;
messageId?: string;
messageStatusIcon?: ReactNode;
messageText?: ReactNode;
messageTextIsAlwaysFullSize?: boolean;
onClick?: () => void;
shouldShowSpinner?: boolean;
theme?: ThemeType;
unreadCount?: number;
} & Pick<
@ -76,6 +79,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
id,
isMe,
isNoteToSelf,
isUsernameSearchResult,
isSelected,
markedUnread,
messageStatusIcon,
@ -86,6 +90,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
phoneNumber,
profileName,
sharedGroupNames,
shouldShowSpinner,
theme,
title,
unblurredAvatarPath,
@ -101,8 +106,12 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
const isCheckbox = isBoolean(checked);
let checkboxNode: ReactNode;
if (isCheckbox) {
let actionNode: ReactNode;
if (shouldShowSpinner) {
actionNode = (
<Spinner size="20px" svgSize="small" direction="on-progress-dialog" />
);
} else if (isCheckbox) {
let ariaLabel: string;
if (disabled) {
ariaLabel = i18n('cannotSelectContact', [title]);
@ -111,7 +120,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
} else {
ariaLabel = i18n('selectContact', [title]);
}
checkboxNode = (
actionNode = (
<input
aria-label={ariaLabel}
checked={checked}
@ -138,6 +147,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
color={color}
conversationType={conversationType}
noteToSelf={isAvatarNoteToSelf}
searchResult={isUsernameSearchResult}
i18n={i18n}
isMe={isMe}
name={name}
@ -187,7 +197,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
</div>
) : null}
</div>
{checkboxNode}
{actionNode}
</>
);

View File

@ -0,0 +1,52 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React from 'react';
import { noop } from 'lodash';
import { BaseConversationListItem } from './BaseConversationListItem';
import type { LocalizerType } from '../../types/Util';
type PropsData = {
username: string;
isFetchingUsername: boolean;
};
type PropsHousekeeping = {
i18n: LocalizerType;
onClick: (username: string) => void;
};
export type Props = PropsData & PropsHousekeeping;
export const UsernameSearchResultListItem: FunctionComponent<Props> = ({
i18n,
isFetchingUsername,
onClick,
username,
}) => {
const usernameText = i18n('at-username', { username });
const boundOnClick = isFetchingUsername
? noop
: () => {
onClick(username);
};
return (
<BaseConversationListItem
acceptedMessageRequest={false}
conversationType="direct"
headerName={usernameText}
i18n={i18n}
isMe={false}
isSelected={false}
isUsernameSearchResult
shouldShowSpinner={isFetchingUsername}
onClick={boundOnClick}
sharedGroupNames={[]}
title={usernameText}
/>
);
};

View File

@ -18,12 +18,16 @@ import {
} from '../../util/libphonenumberInstance';
import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { getUsernameFromSearch } from '../../types/Username';
export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray<ContactListItemPropsType>;
composeGroups: ReadonlyArray<ConversationListItemPropsType>;
regionCode: string;
searchTerm: string;
isFetchingUsername: boolean;
isUsernamesEnabled: boolean;
};
enum TopButton {
@ -37,6 +41,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
private readonly composeGroups: ReadonlyArray<ConversationListItemPropsType>;
private readonly isFetchingUsername: boolean;
private readonly isUsernamesEnabled: boolean;
private readonly searchTerm: string;
private readonly phoneNumber: undefined | PhoneNumber;
@ -46,13 +54,17 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
composeGroups,
regionCode,
searchTerm,
isUsernamesEnabled,
isFetchingUsername,
}: Readonly<LeftPaneComposePropsType>) {
super();
this.composeContacts = composeContacts;
this.composeGroups = composeGroups;
this.searchTerm = searchTerm;
this.phoneNumber = parsePhoneNumber(searchTerm, regionCode);
this.composeGroups = composeGroups;
this.composeContacts = composeContacts;
this.isFetchingUsername = isFetchingUsername;
this.isUsernamesEnabled = isUsernamesEnabled;
}
getHeaderContents({
@ -121,6 +133,9 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
if (this.hasGroupsHeader()) {
result += 1;
}
if (this.getUsernameFromSearch()) {
result += 2;
}
return result;
}
@ -187,10 +202,36 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
virtualRowIndex -= 1;
const group = this.composeGroups[virtualRowIndex];
return {
type: RowType.Conversation,
conversation: group,
};
if (group) {
return {
type: RowType.Conversation,
conversation: group,
};
}
virtualRowIndex -= this.composeGroups.length;
}
const username = this.getUsernameFromSearch();
if (username) {
if (virtualRowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
};
}
virtualRowIndex -= 1;
if (virtualRowIndex === 0) {
return {
type: RowType.UsernameSearchResult,
username,
isFetchingUsername: this.isFetchingUsername,
};
virtualRowIndex -= 1;
}
}
return undefined;
@ -220,7 +261,8 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
return (
currHeaderIndices.top !== prevHeaderIndices.top ||
currHeaderIndices.contact !== prevHeaderIndices.contact ||
currHeaderIndices.group !== prevHeaderIndices.group
currHeaderIndices.group !== prevHeaderIndices.group ||
currHeaderIndices.username !== prevHeaderIndices.username
);
}
@ -246,31 +288,56 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
return Boolean(this.composeGroups.length);
}
private getUsernameFromSearch(): string | undefined {
if (!this.isUsernamesEnabled) {
return undefined;
}
if (this.phoneNumber) {
return undefined;
}
if (this.searchTerm) {
return getUsernameFromSearch(this.searchTerm);
}
return undefined;
}
private getHeaderIndices(): {
top?: number;
contact?: number;
group?: number;
username?: number;
} {
let top: number | undefined;
let contact: number | undefined;
let group: number | undefined;
let username: number | undefined;
let rowCount = 0;
if (this.hasTopButton()) {
top = 0;
rowCount += 1;
}
if (this.composeContacts.length) {
if (this.hasContactsHeader()) {
contact = rowCount;
rowCount += this.composeContacts.length;
}
if (this.composeGroups.length) {
if (this.hasGroupsHeader()) {
group = rowCount;
rowCount += this.composeContacts.length;
}
if (this.getUsernameFromSearch()) {
username = rowCount;
}
return {
top,
contact,
group,
username,
};
}
}

View File

@ -4478,10 +4478,13 @@ export class ConversationModel extends window.Backbone
getTitle(): string {
if (isDirectConversation(this.attributes)) {
const username = this.get('username');
return (
this.get('name') ||
this.getProfileName() ||
this.getNumber() ||
(username && window.i18n('at-username', { username })) ||
window.i18n('unknownContact')
);
}

View File

@ -4,6 +4,6 @@
// Matching Whisper.events.trigger API
// eslint-disable-next-line max-len
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
export function trigger(name: string, param1?: any, param2?: any): void {
window.Whisper.events.trigger(name, param1, param2);
export function trigger(name: string, ...rest: Array<any>): void {
window.Whisper.events.trigger(name, ...rest);
}

View File

@ -23,8 +23,14 @@ import { getOwn } from '../../util/getOwn';
import { assert, strictAssert } from '../../util/assert';
import * as universalExpireTimer from '../../util/universalExpireTimer';
import { trigger } from '../../shims/events';
import type { ToggleProfileEditorErrorActionType } from './globalModals';
import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals';
import type {
ShowUsernameNotFoundModalActionType,
ToggleProfileEditorErrorActionType,
} from './globalModals';
import {
TOGGLE_PROFILE_EDITOR_ERROR,
actions as globalModalActions,
} from './globalModals';
import { isRecord } from '../../util/isRecord';
import type {
@ -41,6 +47,7 @@ import type { BodyRangeType } from '../../types/Util';
import { CallMode } from '../../types/Calling';
import type { MediaItemType } from '../../types/MediaItem';
import type { UUIDStringType } from '../../types/UUID';
import { UUID } from '../../types/UUID';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
@ -53,6 +60,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing';
import { writeProfile } from '../../services/writeProfile';
import { writeUsername } from '../../services/writeUsername';
import {
getConversationsByUsername,
getMe,
getMessageIdsPendingBecauseOfVerification,
getUsernameSaveState,
@ -69,6 +77,8 @@ import {
} from './conversationsEnums';
import { showToast } from '../../util/showToast';
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername';
import { isValidUsername } from '../../types/Username';
import type { NoopActionType } from './noop';
@ -278,10 +288,16 @@ type ComposerGroupCreationState = {
userAvatarData: Array<AvatarDataType>;
};
export type FoundUsernameType = {
uuid: UUIDStringType;
username: string;
};
type ComposerStateType =
| {
step: ComposerStep.StartDirectConversation;
searchTerm: string;
isFetchingUsername: boolean;
}
| ({
step: ComposerStep.ChooseGroupMembers;
@ -314,6 +330,7 @@ export type ConversationsStateType = {
conversationsByE164: ConversationLookupType;
conversationsByUuid: ConversationLookupType;
conversationsByGroupId: ConversationLookupType;
conversationsByUsername: ConversationLookupType;
selectedConversationId?: string;
selectedMessage?: string;
selectedMessageCounter: number;
@ -676,6 +693,12 @@ type SetComposeSearchTermActionType = {
type: 'SET_COMPOSE_SEARCH_TERM';
payload: { searchTerm: string };
};
type SetIsFetchingUsernameActionType = {
type: 'SET_IS_FETCHING_USERNAME';
payload: {
isFetchingUsername: boolean;
};
};
type SetRecentMediaItemsActionType = {
type: 'SET_RECENT_MEDIA_ITEMS';
payload: {
@ -768,6 +791,7 @@ export type ConversationActionType =
| SetComposeGroupNameActionType
| SetComposeSearchTermActionType
| SetConversationHeaderTitleActionType
| SetIsFetchingUsernameActionType
| SetIsNearBottomActionType
| SetLoadCountdownStartActionType
| SetMessagesLoadingActionType
@ -850,6 +874,7 @@ export const actions = {
showInbox,
startComposing,
startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
startSettingGroupMetadata,
toggleAdmin,
toggleConversationInChooseMembers,
@ -1793,6 +1818,111 @@ function startNewConversationFromPhoneNumber(
};
}
async function checkForUsername(
username: string
): Promise<FoundUsernameType | undefined> {
if (!isValidUsername(username)) {
return undefined;
}
try {
const profile = await window.textsecure.messaging.getProfileForUsername(
username
);
if (!profile.username || profile.username !== username) {
log.error("checkForUsername: Returned username didn't match searched");
return;
}
if (!profile.uuid) {
log.error("checkForUsername: Returned profile didn't include a uuid");
return;
}
return {
uuid: UUID.cast(profile.uuid),
username: profile.username,
};
} catch (error: unknown) {
if (!isRecord(error)) {
throw error;
}
if (error.code === 404) {
return undefined;
}
throw error;
}
}
function startNewConversationFromUsername(
username: string
): ThunkAction<
void,
RootStateType,
unknown,
| ShowInboxActionType
| SetIsFetchingUsernameActionType
| ShowUsernameNotFoundModalActionType
> {
return async (dispatch, getState) => {
const state = getState();
const byUsername = getConversationsByUsername(state);
const knownConversation = getOwn(byUsername, username);
if (knownConversation && knownConversation.uuid) {
trigger('showConversation', knownConversation.uuid, username);
dispatch(showInbox());
return;
}
dispatch({
type: 'SET_IS_FETCHING_USERNAME',
payload: {
isFetchingUsername: true,
},
});
try {
const foundUsername = await checkForUsername(username);
dispatch({
type: 'SET_IS_FETCHING_USERNAME',
payload: {
isFetchingUsername: false,
},
});
if (!foundUsername) {
dispatch(globalModalActions.showUsernameNotFoundModal(username));
return;
}
trigger(
'showConversation',
foundUsername.uuid,
undefined,
foundUsername.username
);
dispatch(showInbox());
} catch (error) {
log.error(
'startNewConversationFromUsername: Something went wrong fetching username:',
error.stack
);
dispatch({
type: 'SET_IS_FETCHING_USERNAME',
payload: {
isFetchingUsername: false,
},
});
showToast(ToastFailedToFetchUsername);
}
};
}
function startSettingGroupMetadata(): StartSettingGroupMetadataActionType {
return { type: 'START_SETTING_GROUP_METADATA' };
}
@ -1951,6 +2081,7 @@ export function getEmptyState(): ConversationsStateType {
conversationsByE164: {},
conversationsByUuid: {},
conversationsByGroupId: {},
conversationsByUsername: {},
outboundMessagesPendingConversationVerification: {},
messagesByConversation: {},
messagesLookup: {},
@ -2033,12 +2164,16 @@ export function updateConversationLookups(
state: ConversationsStateType
): Pick<
ConversationsStateType,
'conversationsByE164' | 'conversationsByUuid' | 'conversationsByGroupId'
| 'conversationsByE164'
| 'conversationsByUuid'
| 'conversationsByGroupId'
| 'conversationsByUsername'
> {
const result = {
conversationsByE164: state.conversationsByE164,
conversationsByUuid: state.conversationsByUuid,
conversationsByGroupId: state.conversationsByGroupId,
conversationsByUsername: state.conversationsByUsername,
};
if (removed && removed.e164) {
@ -2053,6 +2188,12 @@ export function updateConversationLookups(
removed.groupId
);
}
if (removed && removed.username) {
result.conversationsByUsername = omit(
result.conversationsByUsername,
removed.username
);
}
if (added && added.e164) {
result.conversationsByE164 = {
@ -2072,6 +2213,12 @@ export function updateConversationLookups(
[added.groupId]: added,
};
}
if (added && added.username) {
result.conversationsByUsername = {
...result.conversationsByUsername,
[added.username]: added,
};
}
return result;
}
@ -3045,6 +3192,7 @@ export function reducer(
composer: {
step: ComposerStep.StartDirectConversation,
searchTerm: '',
isFetchingUsername: false,
},
};
}
@ -3200,8 +3348,14 @@ export function reducer(
);
return state;
}
if (composer?.step === ComposerStep.SetGroupMetadata) {
assert(false, 'Setting compose search term at this step is a no-op');
if (
composer.step !== ComposerStep.StartDirectConversation &&
composer.step !== ComposerStep.ChooseGroupMembers
) {
assert(
false,
`Setting compose search term at step ${composer.step} is a no-op`
);
return state;
}
@ -3214,6 +3368,30 @@ export function reducer(
};
}
if (action.type === 'SET_IS_FETCHING_USERNAME') {
const { composer } = state;
if (!composer) {
assert(
false,
'Setting compose username with the composer closed is a no-op'
);
return state;
}
if (composer.step !== ComposerStep.StartDirectConversation) {
assert(false, 'Setting compose username at this step is a no-op');
return state;
}
const { isFetchingUsername } = action.payload;
return {
...state,
composer: {
...composer,
isFetchingUsername,
},
};
}
if (action.type === COMPOSE_TOGGLE_EDITING_AVATAR) {
const { composer } = state;

View File

@ -6,9 +6,10 @@
export type GlobalModalsStateType = {
readonly contactModalState?: ContactModalStateType;
readonly isProfileEditorVisible: boolean;
readonly isWhatsNewVisible: boolean;
readonly profileEditorHasError: boolean;
readonly safetyNumberModalContactId?: string;
readonly isWhatsNewVisible: boolean;
readonly usernameNotFoundModalState?: UsernameNotFoundModalStateType;
};
// Actions
@ -16,6 +17,10 @@ export type GlobalModalsStateType = {
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
const SHOW_WHATS_NEW_MODAL = 'globalModals/SHOW_WHATS_NEW_MODAL_MODAL';
const SHOW_USERNAME_NOT_FOUND_MODAL =
'globalModals/SHOW_USERNAME_NOT_FOUND_MODAL';
const HIDE_USERNAME_NOT_FOUND_MODAL =
'globalModals/HIDE_USERNAME_NOT_FOUND_MODAL';
const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR =
@ -27,6 +32,10 @@ export type ContactModalStateType = {
conversationId?: string;
};
export type UsernameNotFoundModalStateType = {
username: string;
};
type HideContactModalActionType = {
type: typeof HIDE_CONTACT_MODAL;
};
@ -44,6 +53,17 @@ type ShowWhatsNewModalActionType = {
type: typeof SHOW_WHATS_NEW_MODAL;
};
type HideUsernameNotFoundModalActionType = {
type: typeof HIDE_USERNAME_NOT_FOUND_MODAL;
};
export type ShowUsernameNotFoundModalActionType = {
type: typeof SHOW_USERNAME_NOT_FOUND_MODAL;
payload: {
username: string;
};
};
type ToggleProfileEditorActionType = {
type: typeof TOGGLE_PROFILE_EDITOR;
};
@ -62,6 +82,8 @@ export type GlobalModalsActionType =
| ShowContactModalActionType
| HideWhatsNewModalActionType
| ShowWhatsNewModalActionType
| HideUsernameNotFoundModalActionType
| ShowUsernameNotFoundModalActionType
| ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType
| ToggleSafetyNumberModalActionType;
@ -73,6 +95,8 @@ export const actions = {
showContactModal,
hideWhatsNewModal,
showWhatsNewModal,
hideUsernameNotFoundModal,
showUsernameNotFoundModal,
toggleProfileEditor,
toggleProfileEditorHasError,
toggleSafetyNumberModal,
@ -109,6 +133,23 @@ function showWhatsNewModal(): ShowWhatsNewModalActionType {
};
}
function hideUsernameNotFoundModal(): HideUsernameNotFoundModalActionType {
return {
type: HIDE_USERNAME_NOT_FOUND_MODAL,
};
}
function showUsernameNotFoundModal(
username: string
): ShowUsernameNotFoundModalActionType {
return {
type: SHOW_USERNAME_NOT_FOUND_MODAL,
payload: {
username,
},
};
}
function toggleProfileEditor(): ToggleProfileEditorActionType {
return { type: TOGGLE_PROFILE_EDITOR };
}
@ -168,6 +209,24 @@ export function reducer(
};
}
if (action.type === HIDE_USERNAME_NOT_FOUND_MODAL) {
return {
...state,
usernameNotFoundModalState: undefined,
};
}
if (action.type === SHOW_USERNAME_NOT_FOUND_MODAL) {
const { username } = action.payload;
return {
...state,
usernameNotFoundModalState: {
username,
},
};
}
if (action.type === SHOW_CONTACT_MODAL) {
return {
...state,

View File

@ -107,6 +107,12 @@ export const getConversationsByGroupId = createSelector(
return state.conversationsByGroupId;
}
);
export const getConversationsByUsername = createSelector(
getConversations,
(state: ConversationsStateType): ConversationLookupType => {
return state.conversationsByUsername;
}
);
const getAllConversations = createSelector(
getConversationLookup,
@ -397,6 +403,24 @@ export const getComposerConversationSearchTerm = createSelector(
}
);
export const getIsFetchingUsername = createSelector(
getComposerState,
(composer): boolean => {
if (!composer) {
assert(false, 'getIsFetchingUsername: composer is not open');
return false;
}
if (composer.step !== ComposerStep.StartDirectConversation) {
assert(
false,
`getIsFetchingUsername: step ${composer.step} has no isFetchingUsername key`
);
return false;
}
return composer.isFetchingUsername;
}
);
function isTrusted(conversation: ConversationType): boolean {
if (conversation.type === 'group') {
return true;

View File

@ -21,19 +21,23 @@ import {
} from '../selectors/search';
import { getIntl, getRegionCode, getTheme } from '../selectors/user';
import { getBadgesById } from '../selectors/badges';
import { getPreferredLeftPaneWidth } from '../selectors/items';
import {
getPreferredLeftPaneWidth,
getUsernamesEnabled,
} from '../selectors/items';
import {
getCantAddContactForModal,
getComposeAvatarData,
getComposeGroupAvatar,
getComposeGroupExpireTimer,
getComposeGroupName,
getComposeSelectedContacts,
getComposerConversationSearchTerm,
getComposerStep,
getComposeSelectedContacts,
getFilteredCandidateContactsForNewGroup,
getFilteredComposeContacts,
getFilteredComposeGroups,
getIsFetchingUsername,
getLeftPaneLists,
getMaximumGroupSizeModalState,
getRecommendedGroupSizeModalState,
@ -126,6 +130,8 @@ const getModeSpecificProps = (
composeGroups: getFilteredComposeGroups(state),
regionCode: getRegionCode(state),
searchTerm: getComposerConversationSearchTerm(state),
isUsernamesEnabled: getUsernamesEnabled(state),
isFetchingUsername: getIsFetchingUsername(state),
};
case ComposerStep.ChooseGroupMembers:
return {

View File

@ -7,6 +7,7 @@ import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAdd
export const defaultStartDirectConversationComposerState = {
step: ComposerStep.StartDirectConversation as const,
searchTerm: '',
isFetchingUsername: false,
};
export const defaultChooseGroupMembersComposerState = {

View File

@ -21,6 +21,7 @@ describe('filterAndSortConversationsByTitle', () => {
name: 'Carlos Santana',
title: 'Carlos Santana',
e164: '+16505559876',
username: 'thisismyusername',
}),
getDefaultConversation({
name: 'Aaron Aardvark',
@ -64,6 +65,14 @@ describe('filterAndSortConversationsByTitle', () => {
).map(contact => contact.title);
assert.sameMembers(titles, ['Carlos Santana', '+16505551234']);
});
it('can search for contacts by username', () => {
const titles = filterAndSortConversationsByTitle(
conversations,
'thisis'
).map(contact => contact.title);
assert.sameMembers(titles, ['Carlos Santana']);
});
});
describe('filterAndSortConversationsByRecent', () => {

View File

@ -1234,8 +1234,8 @@ describe('both/state/ducks/conversations', () => {
...getEmptyState(),
composer: defaultStartDirectConversationComposerState,
};
const action = setComposeSearchTerm('foo bar');
const result = reducer(state, action);
const result = reducer(state, setComposeSearchTerm('foo bar'));
assert.deepEqual(result.composer, {
...defaultStartDirectConversationComposerState,

View File

@ -28,6 +28,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.strictEqual(helper.getBackAction({ showInbox }), showInbox);
@ -42,6 +44,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
1
);
@ -54,6 +58,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
4
);
@ -66,11 +72,41 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
7
);
});
it('returns the number of contacts, number groups + 4 (for headers and username)', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US',
searchTerm: 'someone',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
8
);
});
it('if usernames are disabled, two less rows are shown', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US',
searchTerm: 'someone',
isUsernamesEnabled: false,
isFetchingUsername: false,
}).getRowCount(),
6
);
});
it('returns the number of conversations + the headers, but not for a phone number', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
@ -78,8 +114,10 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
0
2
);
assert.strictEqual(
new LeftPaneComposeHelper({
@ -87,8 +125,10 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
3
5
);
assert.strictEqual(
new LeftPaneComposeHelper({
@ -96,8 +136,10 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation()],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
5
7
);
});
@ -108,18 +150,36 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
1
);
});
it('returns the number of contacts + 2 (for the "Start new conversation" button and header) if searching for a phone number', () => {
it('returns 2 if just username in results', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [],
composeGroups: [],
regionCode: 'US',
searchTerm: 'someone',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
2
);
});
it('returns the number of contacts + 4 (for the "Start new conversation" button and header) if searching for a phone number', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [],
regionCode: 'US',
searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(),
4
);
@ -133,6 +193,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.deepEqual(helper.getRow(0), {
@ -151,6 +213,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.deepEqual(helper.getRow(0), {
@ -184,6 +248,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups,
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.deepEqual(helper.getRow(0), {
@ -215,12 +281,14 @@ describe('LeftPaneComposeHelper', () => {
});
});
it('returns no rows if searching and there are no results', () => {
it('returns no rows if searching, no results, and usernames are disabled', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [],
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: false,
isFetchingUsername: false,
});
assert.isUndefined(helper.getRow(0));
@ -237,6 +305,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.deepEqual(helper.getRow(1), {
@ -255,6 +325,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.deepEqual(helper.getRow(0), {
@ -264,6 +336,31 @@ describe('LeftPaneComposeHelper', () => {
assert.isUndefined(helper.getRow(1));
});
it('returns just a "find by username" header if no results', () => {
const username = 'someone';
const isFetchingUsername = true;
const helper = new LeftPaneComposeHelper({
composeContacts: [],
composeGroups: [],
regionCode: 'US',
searchTerm: username,
isUsernamesEnabled: true,
isFetchingUsername,
});
assert.deepEqual(helper.getRow(0), {
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
});
assert.deepEqual(helper.getRow(1), {
type: RowType.UsernameSearchResult,
username,
isFetchingUsername,
});
assert.isUndefined(helper.getRow(2));
});
it('returns a "start new conversation" row, a header, and contacts if searching for a phone number', () => {
const composeContacts = [
getDefaultConversation(),
@ -274,6 +371,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.deepEqual(helper.getRow(0), {
@ -302,6 +401,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isUndefined(helper.getConversationAndMessageAtIndex(0));
@ -315,6 +416,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isUndefined(
@ -328,42 +431,46 @@ describe('LeftPaneComposeHelper', () => {
});
describe('shouldRecomputeRowHeights', () => {
it('returns false if going from "no header" to "no header"', () => {
it('returns false if just search changes, so "Find by username" header is in same position', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeContacts: [],
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [getDefaultConversation()],
composeContacts: [],
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
searchTerm: 'different search',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [
getDefaultConversation(),
getDefaultConversation(),
getDefaultConversation(),
],
composeContacts: [],
composeGroups: [],
regionCode: 'US',
searchTerm: 'bing bong',
searchTerm: 'last search',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
});
it('returns false if going from "has header" to "has header"', () => {
it('returns true if "Find by usernames" header changes location or goes away', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isFalse(
@ -372,6 +479,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
assert.isFalse(
@ -380,16 +489,20 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '+16505559876',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
});
it('returns true if going from "no header" to "has header"', () => {
it('returns true if search changes or becomes an e164', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isTrue(
@ -398,6 +511,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
assert.isTrue(
@ -406,16 +521,20 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
});
it('returns true if going from "has header" to "no header"', () => {
it('returns true if going from no search to some search (showing "Find by username" section)', () => {
const helper = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isTrue(
@ -424,6 +543,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
});
@ -434,6 +555,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isTrue(
@ -442,6 +565,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
@ -450,6 +575,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isTrue(
@ -458,6 +585,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
});
@ -468,6 +597,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation()],
regionCode: 'US',
searchTerm: 'soup',
isUsernamesEnabled: true,
isFetchingUsername: false,
});
assert.isTrue(
@ -475,7 +606,9 @@ describe('LeftPaneComposeHelper', () => {
composeContacts: [getDefaultConversation()],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US',
searchTerm: 'sandwich',
searchTerm: 'soup',
isUsernamesEnabled: true,
isFetchingUsername: false,
})
);
});

View File

@ -0,0 +1,78 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as Username from '../../types/Username';
describe('Username', () => {
describe('getUsernameFromSearch', () => {
const { getUsernameFromSearch } = Username;
it('matches invalid username searches', () => {
assert.strictEqual(getUsernameFromSearch('username!'), 'username!');
assert.strictEqual(getUsernameFromSearch('1username'), '1username');
assert.strictEqual(getUsernameFromSearch('us'), 'us');
assert.strictEqual(
getUsernameFromSearch('username901234567890123456'),
'username901234567890123456'
);
});
it('matches valid username searches', () => {
assert.strictEqual(getUsernameFromSearch('username_34'), 'username_34');
assert.strictEqual(getUsernameFromSearch('u5ername'), 'u5ername');
assert.strictEqual(getUsernameFromSearch('use'), 'use');
assert.strictEqual(
getUsernameFromSearch('username90123456789012345'),
'username90123456789012345'
);
});
it('matches valid and invalid usernames with @ prefix', () => {
assert.strictEqual(getUsernameFromSearch('@username!'), 'username!');
assert.strictEqual(getUsernameFromSearch('@1username'), '1username');
assert.strictEqual(getUsernameFromSearch('@username_34'), 'username_34');
assert.strictEqual(getUsernameFromSearch('@u5ername'), 'u5ername');
});
it('matches valid and invalid usernames with @ suffix', () => {
assert.strictEqual(getUsernameFromSearch('username!@'), 'username!');
assert.strictEqual(getUsernameFromSearch('1username@'), '1username');
assert.strictEqual(getUsernameFromSearch('username_34@'), 'username_34');
assert.strictEqual(getUsernameFromSearch('u5ername@'), 'u5ername');
});
it('does not match something that looks like a phone number', () => {
assert.isUndefined(getUsernameFromSearch('+'));
assert.isUndefined(getUsernameFromSearch('2223'));
assert.isUndefined(getUsernameFromSearch('+3'));
assert.isUndefined(getUsernameFromSearch('+234234234233'));
});
});
describe('isValidUsername', () => {
const { isValidUsername } = Username;
it('does not match invalid username searches', () => {
assert.isFalse(isValidUsername('username!'));
assert.isFalse(isValidUsername('1username'));
assert.isFalse(isValidUsername('us'));
assert.isFalse(isValidUsername('username901234567890123456'));
});
it('matches valid usernames', () => {
assert.isTrue(isValidUsername('username_34'));
assert.isTrue(isValidUsername('u5ername'));
assert.isTrue(isValidUsername('use'));
assert.isTrue(isValidUsername('username90123456789012345'));
});
it('does not match valid and invalid usernames with @ prefix or suffix', () => {
assert.isFalse(isValidUsername('@username_34'));
assert.isFalse(isValidUsername('@1username'));
assert.isFalse(isValidUsername('username_34@'));
assert.isFalse(isValidUsername('1username@'));
});
});
});

View File

@ -2043,7 +2043,7 @@ export default class MessageSender {
profileKeyCredentialRequest?: string;
userLanguages: ReadonlyArray<string>;
}>
): Promise<ReturnType<WebAPIType['getProfile']>> {
): ReturnType<WebAPIType['getProfile']> {
const { accessKey } = options;
if (accessKey) {
@ -2057,6 +2057,12 @@ export default class MessageSender {
return this.server.getProfile(number, options);
}
async getProfileForUsername(
username: string
): ReturnType<WebAPIType['getProfileForUsername']> {
return this.server.getProfileForUsername(username);
}
async getUuidsForE164s(
numbers: ReadonlyArray<string>
): Promise<Dictionary<UUIDStringType | null>> {

View File

@ -770,6 +770,7 @@ export type WebAPIType = {
userLanguages: ReadonlyArray<string>;
}
) => Promise<ProfileType>;
getProfileForUsername: (username: string) => Promise<ProfileType>;
getProfileUnauth: (
identifier: string,
options: {
@ -1077,6 +1078,7 @@ export function initialize({
getKeysForIdentifierUnauth,
getMyKeys,
getProfile,
getProfileForUsername,
getProfileUnauth,
getBadgeImageFile,
getProvisioningResource,
@ -1385,6 +1387,12 @@ export function initialize({
})) as ProfileType;
}
async function getProfileForUsername(usernameToFetch: string) {
return getProfile(`username/${usernameToFetch}`, {
userLanguages: [],
});
}
async function putProfile(
jsonData: ProfileRequestDataType
): Promise<UploadAvatarHeadersType | undefined> {

20
ts/types/Username.ts Normal file
View File

@ -0,0 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function isValidUsername(searchTerm: string): boolean {
return /^[a-z][0-9a-z_]{2,24}$/.test(searchTerm);
}
export function getUsernameFromSearch(searchTerm: string): string | undefined {
if (/^[+0-9]+$/.test(searchTerm)) {
return undefined;
}
const match = /^@?(.*?)@?$/.exec(searchTerm);
if (match && match[1]) {
return match[1];
}
return undefined;
}

View File

@ -24,6 +24,10 @@ const FUSE_OPTIONS: FuseOptions<ConversationType> = {
name: 'name',
weight: 1,
},
{
name: 'username',
weight: 1,
},
{
name: 'e164',
weight: 0.5,

View File

@ -115,19 +115,26 @@ Whisper.InboxView = Whisper.View.extend({
this.conversation_stack.unload();
});
window.Whisper.events.on('showConversation', async (id, messageId) => {
const conversation =
await window.ConversationController.getOrCreateAndWait(id, 'private');
window.Whisper.events.on(
'showConversation',
async (id, messageId, username) => {
const conversation =
await window.ConversationController.getOrCreateAndWait(
id,
'private',
{ username }
);
conversation.setMarkedUnread(false);
conversation.setMarkedUnread(false);
const { openConversationExternal } = window.reduxActions.conversations;
if (openConversationExternal) {
openConversationExternal(conversation.id, messageId);
const { openConversationExternal } = window.reduxActions.conversations;
if (openConversationExternal) {
openConversationExternal(conversation.id, messageId);
}
this.conversation_stack.open(conversation, messageId);
}
this.conversation_stack.open(conversation, messageId);
});
);
window.Whisper.events.on('loadingProgress', count => {
const view = this.appLoadingScreen;