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", "message": "Messages",
"description": "Shown to separate the types of search results" "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": { "welcomeToSignal": {
"message": "Welcome to Signal" "message": "Welcome to Signal"
}, },
@ -2238,6 +2252,20 @@
"message": "No conversations found", "message": "No conversations found",
"description": "Label shown when there are no conversations to compose to" "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": { "chooseGroupMembers__title": {
"message": "Choose members", "message": "Choose members",
"description": "The title for the 'choose group members' left pane screen" "description": "The title for the 'choose group members' left pane screen"
@ -3285,7 +3313,7 @@
"message": "Unblock", "message": "Unblock",
"description": "Shown as a button to let the user unblock a message request" "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$?", "message": "Unblock $name$?",
"description": "Shown as a button to let the user unblock a message request", "description": "Shown as a button to let the user unblock a message request",
"placeholders": { "placeholders": {
@ -6185,7 +6213,7 @@
"description": "Placeholder for the username field" "description": "Placeholder for the username field"
}, },
"ProfileEditor--username--helper": { "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" "description": "Shown on the edit username screen"
}, },
"ProfileEditor--username--check-characters": { "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-05: rgba($color-black, 0.05);
$color-black-alpha-06: rgba($color-black, 0.06); $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-12: rgba($color-black, 0.12);
$color-black-alpha-20: rgba($color-black, 0.2); $color-black-alpha-20: rgba($color-black, 0.2);
$color-black-alpha-30: rgba($color-black, 0.3); $color-black-alpha-30: rgba($color-black, 0.3);

View File

@ -100,6 +100,11 @@
&--note-to-self { &--note-to-self {
-webkit-mask-image: url('../images/icons/v2/note-24.svg'); -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 { &__spinner-container {

View File

@ -98,5 +98,11 @@
a { a {
text-decoration: none; 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 font-body-2;
@include light-theme { @include light-theme {
background-color: $color-gray-02; background-color: $color-black-alpha-08;
border: solid 1px $color-gray-02; border: solid 1px $color-gray-02;
color: $color-gray-90; color: $color-gray-90;
} }
@ -40,7 +40,7 @@
} }
&__input { &__input {
background: inherit; background: transparent;
border: none; border: none;
padding-left: 16px; padding-left: 16px;
width: 100%; width: 100%;

View File

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

View File

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

View File

@ -65,6 +65,7 @@ export type Props = {
theme?: ThemeType; theme?: ThemeType;
title: string; title: string;
unblurredAvatarPath?: string; unblurredAvatarPath?: string;
searchResult?: boolean;
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown; onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
@ -108,6 +109,7 @@ export const Avatar: FunctionComponent<Props> = ({
theme, theme,
title, title,
unblurredAvatarPath, unblurredAvatarPath,
searchResult,
blur = getDefaultBlur({ blur = getDefaultBlur({
acceptedMessageRequest, acceptedMessageRequest,
avatarPath, 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) { } else if (noteToSelf) {
contentsChildren = ( contentsChildren = (
<div <div

View File

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

View File

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

View File

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

View File

@ -2,9 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; 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 type { LocalizerType } from '../types/Util';
import { ButtonVariant } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog';
import { WhatsNewModal } from './WhatsNewModal'; import { WhatsNewModal } from './WhatsNewModal';
type PropsType = { type PropsType = {
@ -18,6 +23,9 @@ type PropsType = {
// SafetyNumberModal // SafetyNumberModal
safetyNumberModalContactId?: string; safetyNumberModalContactId?: string;
renderSafetyNumber: () => JSX.Element; renderSafetyNumber: () => JSX.Element;
// UsernameNotFoundModal
hideUsernameNotFoundModal: () => unknown;
usernameNotFoundModalState?: UsernameNotFoundModalStateType;
// WhatsNewModal // WhatsNewModal
isWhatsNewVisible: boolean; isWhatsNewVisible: boolean;
hideWhatsNewModal: () => unknown; hideWhatsNewModal: () => unknown;
@ -34,6 +42,9 @@ export const GlobalModalContainer = ({
// SafetyNumberModal // SafetyNumberModal
safetyNumberModalContactId, safetyNumberModalContactId,
renderSafetyNumber, renderSafetyNumber,
// UsernameNotFoundModal
hideUsernameNotFoundModal,
usernameNotFoundModalState,
// WhatsNewModal // WhatsNewModal
hideWhatsNewModal, hideWhatsNewModal,
isWhatsNewVisible, isWhatsNewVisible,
@ -42,6 +53,23 @@ export const GlobalModalContainer = ({
return renderSafetyNumber(); 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) { if (contactModalState) {
return renderContactModal(); return renderContactModal();
} }

View File

@ -146,6 +146,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
startNewConversationFromPhoneNumber: action( startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber' 'startNewConversationFromPhoneNumber'
), ),
startNewConversationFromUsername: action('startNewConversationFromUsername'),
startSearch: action('startSearch'), startSearch: action('startSearch'),
startSettingGroupMetadata: action('startSettingGroupMetadata'), startSettingGroupMetadata: action('startSettingGroupMetadata'),
theme: React.useContext(StorybookThemeContext), theme: React.useContext(StorybookThemeContext),
@ -439,13 +440,15 @@ story.add('Archive: searching a conversation', () => (
// Compose stories // Compose stories
story.add('Compose: no contacts or groups', () => ( story.add('Compose: no results', () => (
<LeftPane <LeftPane
{...useProps({ {...useProps({
modeSpecificProps: { modeSpecificProps: {
mode: LeftPaneMode.Compose, mode: LeftPaneMode.Compose,
composeContacts: [], composeContacts: [],
composeGroups: [], composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US', regionCode: 'US',
searchTerm: '', 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 <LeftPane
{...useProps({ {...useProps({
modeSpecificProps: { modeSpecificProps: {
mode: LeftPaneMode.Compose, mode: LeftPaneMode.Compose,
composeContacts: defaultConversations, composeContacts: defaultConversations,
composeGroups: [], composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US', regionCode: 'US',
searchTerm: '', 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 <LeftPane
{...useProps({ {...useProps({
modeSpecificProps: { modeSpecificProps: {
mode: LeftPaneMode.Compose, mode: LeftPaneMode.Compose,
composeContacts: defaultConversations, composeContacts: defaultConversations,
composeGroups: [], composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US', regionCode: 'US',
searchTerm: 'ar', 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 <LeftPane
{...useProps({ {...useProps({
modeSpecificProps: { modeSpecificProps: {
mode: LeftPaneMode.Compose, mode: LeftPaneMode.Compose,
composeContacts: [], composeContacts: [],
composeGroups: defaultGroups, composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US', regionCode: 'US',
searchTerm: '', 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 <LeftPane
{...useProps({ {...useProps({
modeSpecificProps: { modeSpecificProps: {
mode: LeftPaneMode.Compose, mode: LeftPaneMode.Compose,
composeContacts: [], composeContacts: [],
composeGroups: defaultGroups, composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US', regionCode: 'US',
searchTerm: 'ar', 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 <LeftPane
{...useProps({ {...useProps({
modeSpecificProps: { modeSpecificProps: {
mode: LeftPaneMode.Compose, mode: LeftPaneMode.Compose,
composeContacts: defaultConversations, composeContacts: defaultConversations,
composeGroups: defaultGroups, composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US', regionCode: 'US',
searchTerm: '', 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 <LeftPane
{...useProps({ {...useProps({
modeSpecificProps: { modeSpecificProps: {
mode: LeftPaneMode.Compose, mode: LeftPaneMode.Compose,
composeContacts: defaultConversations, composeContacts: defaultConversations,
composeGroups: defaultGroups, composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US', regionCode: 'US',
searchTerm: 'ar', searchTerm: 'someone',
}, },
})} })}
/> />

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import { isConversationUnread } from '../../util/isConversationUnread';
import { cleanId } from '../_util'; import { cleanId } from '../_util';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations'; import type { ConversationType } from '../../state/ducks/conversations';
import { Spinner } from '../Spinner';
const BASE_CLASS_NAME = const BASE_CLASS_NAME =
'module-conversation-list__item--contact-or-conversation'; 'module-conversation-list__item--contact-or-conversation';
@ -38,12 +39,14 @@ type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
isNoteToSelf?: boolean; isNoteToSelf?: boolean;
isSelected: boolean; isSelected: boolean;
isUsernameSearchResult?: boolean;
markedUnread?: boolean; markedUnread?: boolean;
messageId?: string; messageId?: string;
messageStatusIcon?: ReactNode; messageStatusIcon?: ReactNode;
messageText?: ReactNode; messageText?: ReactNode;
messageTextIsAlwaysFullSize?: boolean; messageTextIsAlwaysFullSize?: boolean;
onClick?: () => void; onClick?: () => void;
shouldShowSpinner?: boolean;
theme?: ThemeType; theme?: ThemeType;
unreadCount?: number; unreadCount?: number;
} & Pick< } & Pick<
@ -76,6 +79,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
id, id,
isMe, isMe,
isNoteToSelf, isNoteToSelf,
isUsernameSearchResult,
isSelected, isSelected,
markedUnread, markedUnread,
messageStatusIcon, messageStatusIcon,
@ -86,6 +90,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
phoneNumber, phoneNumber,
profileName, profileName,
sharedGroupNames, sharedGroupNames,
shouldShowSpinner,
theme, theme,
title, title,
unblurredAvatarPath, unblurredAvatarPath,
@ -101,8 +106,12 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
const isCheckbox = isBoolean(checked); const isCheckbox = isBoolean(checked);
let checkboxNode: ReactNode; let actionNode: ReactNode;
if (isCheckbox) { if (shouldShowSpinner) {
actionNode = (
<Spinner size="20px" svgSize="small" direction="on-progress-dialog" />
);
} else if (isCheckbox) {
let ariaLabel: string; let ariaLabel: string;
if (disabled) { if (disabled) {
ariaLabel = i18n('cannotSelectContact', [title]); ariaLabel = i18n('cannotSelectContact', [title]);
@ -111,7 +120,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
} else { } else {
ariaLabel = i18n('selectContact', [title]); ariaLabel = i18n('selectContact', [title]);
} }
checkboxNode = ( actionNode = (
<input <input
aria-label={ariaLabel} aria-label={ariaLabel}
checked={checked} checked={checked}
@ -138,6 +147,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
color={color} color={color}
conversationType={conversationType} conversationType={conversationType}
noteToSelf={isAvatarNoteToSelf} noteToSelf={isAvatarNoteToSelf}
searchResult={isUsernameSearchResult}
i18n={i18n} i18n={i18n}
isMe={isMe} isMe={isMe}
name={name} name={name}
@ -187,7 +197,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
</div> </div>
) : null} ) : null}
</div> </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'; } from '../../util/libphonenumberInstance';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { getUsernameFromSearch } from '../../types/Username';
export type LeftPaneComposePropsType = { export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray<ContactListItemPropsType>; composeContacts: ReadonlyArray<ContactListItemPropsType>;
composeGroups: ReadonlyArray<ConversationListItemPropsType>; composeGroups: ReadonlyArray<ConversationListItemPropsType>;
regionCode: string; regionCode: string;
searchTerm: string; searchTerm: string;
isFetchingUsername: boolean;
isUsernamesEnabled: boolean;
}; };
enum TopButton { enum TopButton {
@ -37,6 +41,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
private readonly composeGroups: ReadonlyArray<ConversationListItemPropsType>; private readonly composeGroups: ReadonlyArray<ConversationListItemPropsType>;
private readonly isFetchingUsername: boolean;
private readonly isUsernamesEnabled: boolean;
private readonly searchTerm: string; private readonly searchTerm: string;
private readonly phoneNumber: undefined | PhoneNumber; private readonly phoneNumber: undefined | PhoneNumber;
@ -46,13 +54,17 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
composeGroups, composeGroups,
regionCode, regionCode,
searchTerm, searchTerm,
isUsernamesEnabled,
isFetchingUsername,
}: Readonly<LeftPaneComposePropsType>) { }: Readonly<LeftPaneComposePropsType>) {
super(); super();
this.composeContacts = composeContacts;
this.composeGroups = composeGroups;
this.searchTerm = searchTerm; this.searchTerm = searchTerm;
this.phoneNumber = parsePhoneNumber(searchTerm, regionCode); this.phoneNumber = parsePhoneNumber(searchTerm, regionCode);
this.composeGroups = composeGroups; this.isFetchingUsername = isFetchingUsername;
this.composeContacts = composeContacts; this.isUsernamesEnabled = isUsernamesEnabled;
} }
getHeaderContents({ getHeaderContents({
@ -121,6 +133,9 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
if (this.hasGroupsHeader()) { if (this.hasGroupsHeader()) {
result += 1; result += 1;
} }
if (this.getUsernameFromSearch()) {
result += 2;
}
return result; return result;
} }
@ -187,10 +202,36 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
virtualRowIndex -= 1; virtualRowIndex -= 1;
const group = this.composeGroups[virtualRowIndex]; const group = this.composeGroups[virtualRowIndex];
return { if (group) {
type: RowType.Conversation, return {
conversation: group, 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; return undefined;
@ -220,7 +261,8 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
return ( return (
currHeaderIndices.top !== prevHeaderIndices.top || currHeaderIndices.top !== prevHeaderIndices.top ||
currHeaderIndices.contact !== prevHeaderIndices.contact || 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); 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(): { private getHeaderIndices(): {
top?: number; top?: number;
contact?: number; contact?: number;
group?: number; group?: number;
username?: number;
} { } {
let top: number | undefined; let top: number | undefined;
let contact: number | undefined; let contact: number | undefined;
let group: number | undefined; let group: number | undefined;
let username: number | undefined;
let rowCount = 0; let rowCount = 0;
if (this.hasTopButton()) { if (this.hasTopButton()) {
top = 0; top = 0;
rowCount += 1; rowCount += 1;
} }
if (this.composeContacts.length) { if (this.hasContactsHeader()) {
contact = rowCount; contact = rowCount;
rowCount += this.composeContacts.length; rowCount += this.composeContacts.length;
} }
if (this.composeGroups.length) { if (this.hasGroupsHeader()) {
group = rowCount; group = rowCount;
rowCount += this.composeContacts.length;
}
if (this.getUsernameFromSearch()) {
username = rowCount;
} }
return { return {
top, top,
contact, contact,
group, group,
username,
}; };
} }
} }

View File

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

View File

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

View File

@ -23,8 +23,14 @@ import { getOwn } from '../../util/getOwn';
import { assert, strictAssert } from '../../util/assert'; import { assert, strictAssert } from '../../util/assert';
import * as universalExpireTimer from '../../util/universalExpireTimer'; import * as universalExpireTimer from '../../util/universalExpireTimer';
import { trigger } from '../../shims/events'; import { trigger } from '../../shims/events';
import type { ToggleProfileEditorErrorActionType } from './globalModals'; import type {
import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals'; ShowUsernameNotFoundModalActionType,
ToggleProfileEditorErrorActionType,
} from './globalModals';
import {
TOGGLE_PROFILE_EDITOR_ERROR,
actions as globalModalActions,
} from './globalModals';
import { isRecord } from '../../util/isRecord'; import { isRecord } from '../../util/isRecord';
import type { import type {
@ -41,6 +47,7 @@ import type { BodyRangeType } from '../../types/Util';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import type { MediaItemType } from '../../types/MediaItem'; import type { MediaItemType } from '../../types/MediaItem';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import { UUID } from '../../types/UUID';
import { import {
getGroupSizeRecommendedLimit, getGroupSizeRecommendedLimit,
getGroupSizeHardLimit, getGroupSizeHardLimit,
@ -53,6 +60,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing';
import { writeProfile } from '../../services/writeProfile'; import { writeProfile } from '../../services/writeProfile';
import { writeUsername } from '../../services/writeUsername'; import { writeUsername } from '../../services/writeUsername';
import { import {
getConversationsByUsername,
getMe, getMe,
getMessageIdsPendingBecauseOfVerification, getMessageIdsPendingBecauseOfVerification,
getUsernameSaveState, getUsernameSaveState,
@ -69,6 +77,8 @@ import {
} from './conversationsEnums'; } from './conversationsEnums';
import { showToast } from '../../util/showToast'; import { showToast } from '../../util/showToast';
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername'; import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername';
import { isValidUsername } from '../../types/Username';
import type { NoopActionType } from './noop'; import type { NoopActionType } from './noop';
@ -278,10 +288,16 @@ type ComposerGroupCreationState = {
userAvatarData: Array<AvatarDataType>; userAvatarData: Array<AvatarDataType>;
}; };
export type FoundUsernameType = {
uuid: UUIDStringType;
username: string;
};
type ComposerStateType = type ComposerStateType =
| { | {
step: ComposerStep.StartDirectConversation; step: ComposerStep.StartDirectConversation;
searchTerm: string; searchTerm: string;
isFetchingUsername: boolean;
} }
| ({ | ({
step: ComposerStep.ChooseGroupMembers; step: ComposerStep.ChooseGroupMembers;
@ -314,6 +330,7 @@ export type ConversationsStateType = {
conversationsByE164: ConversationLookupType; conversationsByE164: ConversationLookupType;
conversationsByUuid: ConversationLookupType; conversationsByUuid: ConversationLookupType;
conversationsByGroupId: ConversationLookupType; conversationsByGroupId: ConversationLookupType;
conversationsByUsername: ConversationLookupType;
selectedConversationId?: string; selectedConversationId?: string;
selectedMessage?: string; selectedMessage?: string;
selectedMessageCounter: number; selectedMessageCounter: number;
@ -676,6 +693,12 @@ type SetComposeSearchTermActionType = {
type: 'SET_COMPOSE_SEARCH_TERM'; type: 'SET_COMPOSE_SEARCH_TERM';
payload: { searchTerm: string }; payload: { searchTerm: string };
}; };
type SetIsFetchingUsernameActionType = {
type: 'SET_IS_FETCHING_USERNAME';
payload: {
isFetchingUsername: boolean;
};
};
type SetRecentMediaItemsActionType = { type SetRecentMediaItemsActionType = {
type: 'SET_RECENT_MEDIA_ITEMS'; type: 'SET_RECENT_MEDIA_ITEMS';
payload: { payload: {
@ -768,6 +791,7 @@ export type ConversationActionType =
| SetComposeGroupNameActionType | SetComposeGroupNameActionType
| SetComposeSearchTermActionType | SetComposeSearchTermActionType
| SetConversationHeaderTitleActionType | SetConversationHeaderTitleActionType
| SetIsFetchingUsernameActionType
| SetIsNearBottomActionType | SetIsNearBottomActionType
| SetLoadCountdownStartActionType | SetLoadCountdownStartActionType
| SetMessagesLoadingActionType | SetMessagesLoadingActionType
@ -850,6 +874,7 @@ export const actions = {
showInbox, showInbox,
startComposing, startComposing,
startNewConversationFromPhoneNumber, startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
startSettingGroupMetadata, startSettingGroupMetadata,
toggleAdmin, toggleAdmin,
toggleConversationInChooseMembers, 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 { function startSettingGroupMetadata(): StartSettingGroupMetadataActionType {
return { type: 'START_SETTING_GROUP_METADATA' }; return { type: 'START_SETTING_GROUP_METADATA' };
} }
@ -1951,6 +2081,7 @@ export function getEmptyState(): ConversationsStateType {
conversationsByE164: {}, conversationsByE164: {},
conversationsByUuid: {}, conversationsByUuid: {},
conversationsByGroupId: {}, conversationsByGroupId: {},
conversationsByUsername: {},
outboundMessagesPendingConversationVerification: {}, outboundMessagesPendingConversationVerification: {},
messagesByConversation: {}, messagesByConversation: {},
messagesLookup: {}, messagesLookup: {},
@ -2033,12 +2164,16 @@ export function updateConversationLookups(
state: ConversationsStateType state: ConversationsStateType
): Pick< ): Pick<
ConversationsStateType, ConversationsStateType,
'conversationsByE164' | 'conversationsByUuid' | 'conversationsByGroupId' | 'conversationsByE164'
| 'conversationsByUuid'
| 'conversationsByGroupId'
| 'conversationsByUsername'
> { > {
const result = { const result = {
conversationsByE164: state.conversationsByE164, conversationsByE164: state.conversationsByE164,
conversationsByUuid: state.conversationsByUuid, conversationsByUuid: state.conversationsByUuid,
conversationsByGroupId: state.conversationsByGroupId, conversationsByGroupId: state.conversationsByGroupId,
conversationsByUsername: state.conversationsByUsername,
}; };
if (removed && removed.e164) { if (removed && removed.e164) {
@ -2053,6 +2188,12 @@ export function updateConversationLookups(
removed.groupId removed.groupId
); );
} }
if (removed && removed.username) {
result.conversationsByUsername = omit(
result.conversationsByUsername,
removed.username
);
}
if (added && added.e164) { if (added && added.e164) {
result.conversationsByE164 = { result.conversationsByE164 = {
@ -2072,6 +2213,12 @@ export function updateConversationLookups(
[added.groupId]: added, [added.groupId]: added,
}; };
} }
if (added && added.username) {
result.conversationsByUsername = {
...result.conversationsByUsername,
[added.username]: added,
};
}
return result; return result;
} }
@ -3045,6 +3192,7 @@ export function reducer(
composer: { composer: {
step: ComposerStep.StartDirectConversation, step: ComposerStep.StartDirectConversation,
searchTerm: '', searchTerm: '',
isFetchingUsername: false,
}, },
}; };
} }
@ -3200,8 +3348,14 @@ export function reducer(
); );
return state; return state;
} }
if (composer?.step === ComposerStep.SetGroupMetadata) { if (
assert(false, 'Setting compose search term at this step is a no-op'); composer.step !== ComposerStep.StartDirectConversation &&
composer.step !== ComposerStep.ChooseGroupMembers
) {
assert(
false,
`Setting compose search term at step ${composer.step} is a no-op`
);
return state; 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) { if (action.type === COMPOSE_TOGGLE_EDITING_AVATAR) {
const { composer } = state; const { composer } = state;

View File

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

View File

@ -107,6 +107,12 @@ export const getConversationsByGroupId = createSelector(
return state.conversationsByGroupId; return state.conversationsByGroupId;
} }
); );
export const getConversationsByUsername = createSelector(
getConversations,
(state: ConversationsStateType): ConversationLookupType => {
return state.conversationsByUsername;
}
);
const getAllConversations = createSelector( const getAllConversations = createSelector(
getConversationLookup, 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 { function isTrusted(conversation: ConversationType): boolean {
if (conversation.type === 'group') { if (conversation.type === 'group') {
return true; return true;

View File

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

View File

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

View File

@ -21,6 +21,7 @@ describe('filterAndSortConversationsByTitle', () => {
name: 'Carlos Santana', name: 'Carlos Santana',
title: 'Carlos Santana', title: 'Carlos Santana',
e164: '+16505559876', e164: '+16505559876',
username: 'thisismyusername',
}), }),
getDefaultConversation({ getDefaultConversation({
name: 'Aaron Aardvark', name: 'Aaron Aardvark',
@ -64,6 +65,14 @@ describe('filterAndSortConversationsByTitle', () => {
).map(contact => contact.title); ).map(contact => contact.title);
assert.sameMembers(titles, ['Carlos Santana', '+16505551234']); 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', () => { describe('filterAndSortConversationsByRecent', () => {

View File

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

View File

@ -28,6 +28,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.strictEqual(helper.getBackAction({ showInbox }), showInbox); assert.strictEqual(helper.getBackAction({ showInbox }), showInbox);
@ -42,6 +44,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(), }).getRowCount(),
1 1
); );
@ -54,6 +58,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(), }).getRowCount(),
4 4
); );
@ -66,11 +72,41 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation(), getDefaultConversation()], composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(), }).getRowCount(),
7 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', () => { it('returns the number of conversations + the headers, but not for a phone number', () => {
assert.strictEqual( assert.strictEqual(
new LeftPaneComposeHelper({ new LeftPaneComposeHelper({
@ -78,8 +114,10 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(), }).getRowCount(),
0 2
); );
assert.strictEqual( assert.strictEqual(
new LeftPaneComposeHelper({ new LeftPaneComposeHelper({
@ -87,8 +125,10 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(), }).getRowCount(),
3 5
); );
assert.strictEqual( assert.strictEqual(
new LeftPaneComposeHelper({ new LeftPaneComposeHelper({
@ -96,8 +136,10 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation()], composeGroups: [getDefaultConversation()],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(), }).getRowCount(),
5 7
); );
}); });
@ -108,18 +150,36 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(), }).getRowCount(),
1 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( assert.strictEqual(
new LeftPaneComposeHelper({ new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()], composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
}).getRowCount(), }).getRowCount(),
4 4
); );
@ -133,6 +193,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
@ -151,6 +213,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
@ -184,6 +248,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups, composeGroups,
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.deepEqual(helper.getRow(0), { 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({ const helper = new LeftPaneComposeHelper({
composeContacts: [], composeContacts: [],
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: false,
isFetchingUsername: false,
}); });
assert.isUndefined(helper.getRow(0)); assert.isUndefined(helper.getRow(0));
@ -237,6 +305,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.deepEqual(helper.getRow(1), { assert.deepEqual(helper.getRow(1), {
@ -255,6 +325,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
@ -264,6 +336,31 @@ describe('LeftPaneComposeHelper', () => {
assert.isUndefined(helper.getRow(1)); 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', () => { it('returns a "start new conversation" row, a header, and contacts if searching for a phone number', () => {
const composeContacts = [ const composeContacts = [
getDefaultConversation(), getDefaultConversation(),
@ -274,6 +371,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+16505551234',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
@ -302,6 +401,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isUndefined(helper.getConversationAndMessageAtIndex(0)); assert.isUndefined(helper.getConversationAndMessageAtIndex(0));
@ -315,6 +416,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isUndefined( assert.isUndefined(
@ -328,42 +431,46 @@ describe('LeftPaneComposeHelper', () => {
}); });
describe('shouldRecomputeRowHeights', () => { 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({ const helper = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()], composeContacts: [],
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isFalse( assert.isFalse(
helper.shouldRecomputeRowHeights({ helper.shouldRecomputeRowHeights({
composeContacts: [getDefaultConversation()], composeContacts: [],
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'different search',
isUsernamesEnabled: true,
isFetchingUsername: false,
}) })
); );
assert.isFalse( assert.isFalse(
helper.shouldRecomputeRowHeights({ helper.shouldRecomputeRowHeights({
composeContacts: [ composeContacts: [],
getDefaultConversation(),
getDefaultConversation(),
getDefaultConversation(),
],
composeGroups: [], composeGroups: [],
regionCode: 'US', 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({ const helper = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()], composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isFalse( assert.isFalse(
@ -372,6 +479,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}) })
); );
assert.isFalse( assert.isFalse(
@ -380,16 +489,20 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505559876', 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({ const helper = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()], composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isTrue( assert.isTrue(
@ -398,6 +511,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}) })
); );
assert.isTrue( assert.isTrue(
@ -406,16 +521,20 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', 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({ const helper = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()], composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isTrue( assert.isTrue(
@ -424,6 +543,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}) })
); );
}); });
@ -434,6 +555,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isTrue( assert.isTrue(
@ -442,6 +565,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation(), getDefaultConversation()], composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}) })
); );
@ -450,6 +575,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation(), getDefaultConversation()], composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isTrue( assert.isTrue(
@ -458,6 +585,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true,
isFetchingUsername: false,
}) })
); );
}); });
@ -468,6 +597,8 @@ describe('LeftPaneComposeHelper', () => {
composeGroups: [getDefaultConversation()], composeGroups: [getDefaultConversation()],
regionCode: 'US', regionCode: 'US',
searchTerm: 'soup', searchTerm: 'soup',
isUsernamesEnabled: true,
isFetchingUsername: false,
}); });
assert.isTrue( assert.isTrue(
@ -475,7 +606,9 @@ describe('LeftPaneComposeHelper', () => {
composeContacts: [getDefaultConversation()], composeContacts: [getDefaultConversation()],
composeGroups: [getDefaultConversation(), getDefaultConversation()], composeGroups: [getDefaultConversation(), getDefaultConversation()],
regionCode: 'US', 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; profileKeyCredentialRequest?: string;
userLanguages: ReadonlyArray<string>; userLanguages: ReadonlyArray<string>;
}> }>
): Promise<ReturnType<WebAPIType['getProfile']>> { ): ReturnType<WebAPIType['getProfile']> {
const { accessKey } = options; const { accessKey } = options;
if (accessKey) { if (accessKey) {
@ -2057,6 +2057,12 @@ export default class MessageSender {
return this.server.getProfile(number, options); return this.server.getProfile(number, options);
} }
async getProfileForUsername(
username: string
): ReturnType<WebAPIType['getProfileForUsername']> {
return this.server.getProfileForUsername(username);
}
async getUuidsForE164s( async getUuidsForE164s(
numbers: ReadonlyArray<string> numbers: ReadonlyArray<string>
): Promise<Dictionary<UUIDStringType | null>> { ): Promise<Dictionary<UUIDStringType | null>> {

View File

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

View File

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