From cbae7f8ee92b79eb7d07b03cb9dad65d68204e47 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 11 Nov 2021 17:17:29 -0800 Subject: [PATCH] Search for username in compose mode --- _locales/en/messages.json | 32 ++- stylesheets/_variables.scss | 1 + stylesheets/components/Avatar.scss | 5 + stylesheets/components/ProfileEditor.scss | 6 + stylesheets/components/SearchInput.scss | 4 +- ts/background.ts | 4 + ts/components/Avatar.stories.tsx | 14 ++ ts/components/Avatar.tsx | 11 + ts/components/ConversationList.stories.tsx | 43 +++- ts/components/ConversationList.tsx | 24 ++- ts/components/ForwardMessageModal.tsx | 1 + ts/components/GlobalModalContainer.tsx | 30 ++- ts/components/LeftPane.stories.tsx | 79 +++++++- ts/components/LeftPane.tsx | 5 + ts/components/ProfileEditor.tsx | 12 +- ts/components/ToastFailedToFetchUsername.tsx | 22 ++ .../ChooseGroupMembersModal.tsx | 1 + .../BaseConversationListItem.tsx | 18 +- .../UsernameSearchResultListItem.tsx | 52 +++++ .../leftPane/LeftPaneComposeHelper.tsx | 85 +++++++- ts/models/conversations.ts | 3 + ts/shims/events.ts | 4 +- ts/state/ducks/conversations.ts | 188 +++++++++++++++++- ts/state/ducks/globalModals.ts | 61 +++++- ts/state/selectors/conversations.ts | 24 +++ ts/state/smart/LeftPane.tsx | 10 +- ts/test-both/helpers/defaultComposerStates.ts | 1 + .../util/filterAndSortConversations_test.ts | 9 + .../state/ducks/conversations_test.ts | 4 +- .../leftPane/LeftPaneComposeHelper_test.ts | 171 ++++++++++++++-- ts/test-node/types/Username_test.ts | 78 ++++++++ ts/textsecure/SendMessage.ts | 8 +- ts/textsecure/WebAPI.ts | 8 + ts/types/Username.ts | 20 ++ ts/util/filterAndSortConversations.ts | 4 + ts/views/inbox_view.ts | 27 ++- 36 files changed, 997 insertions(+), 72 deletions(-) create mode 100644 ts/components/ToastFailedToFetchUsername.tsx create mode 100644 ts/components/conversationList/UsernameSearchResultListItem.tsx create mode 100644 ts/test-node/types/Username_test.ts create mode 100644 ts/types/Username.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 37e9e2394..64e6132fe 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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 you’ve entered the complete username.", + "description": "Shown in dialog if username is not found. Note that 'username' will be the output of at-username", + "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": { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 46b726ed1..ac32cc443 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -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); diff --git a/stylesheets/components/Avatar.scss b/stylesheets/components/Avatar.scss index a3fd6bdd4..8fe771b13 100644 --- a/stylesheets/components/Avatar.scss +++ b/stylesheets/components/Avatar.scss @@ -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 { diff --git a/stylesheets/components/ProfileEditor.scss b/stylesheets/components/ProfileEditor.scss index b9d349184..c3df01b48 100644 --- a/stylesheets/components/ProfileEditor.scss +++ b/stylesheets/components/ProfileEditor.scss @@ -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; + } } } diff --git a/stylesheets/components/SearchInput.scss b/stylesheets/components/SearchInput.scss index f9beff1a0..75462c3b7 100644 --- a/stylesheets/components/SearchInput.scss +++ b/stylesheets/components/SearchInput.scss @@ -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%; diff --git a/ts/background.ts b/ts/background.ts index 52064cedf..85d539928 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -952,6 +952,10 @@ export async function startApp(): Promise { conversations, 'groupId' ), + conversationsByUsername: window.Signal.Util.makeLookup( + conversations, + 'username' + ), messagesByConversation: {}, messagesLookup: {}, outboundMessagesPendingConversationVerification: {}, diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index ea6d01055..6a49ef625 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -54,6 +54,12 @@ const createProps = (overrideProps: Partial = {}): 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 => ); }); +story.add('Search Icon', () => { + const props = createProps({ + searchResult: true, + }); + + return sizes.map(size => ); +}); + story.add('Colors', () => { const props = createProps(); diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 0cde40644..2e151f44e 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -65,6 +65,7 @@ export type Props = { theme?: ThemeType; title: string; unblurredAvatarPath?: string; + searchResult?: boolean; onClick?: (event: MouseEvent) => unknown; @@ -108,6 +109,7 @@ export const Avatar: FunctionComponent = ({ theme, title, unblurredAvatarPath, + searchResult, blur = getDefaultBlur({ acceptedMessageRequest, avatarPath, @@ -181,6 +183,15 @@ export const Avatar: FunctionComponent = ({ )} ); + } else if (searchResult) { + contentsChildren = ( +
+ ); } else if (noteToSelf) { contentsChildren = (
); @@ -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', () => ( + +)); + story.add('Search results loading skeleton', () => ( ( }, { 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, diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 77b992ba3..7a48bc751 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -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; @@ -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 = ({ shouldRecomputeRowHeights, showChooseGroupMembers, startNewConversationFromPhoneNumber, + startNewConversationFromUsername, theme, }) => { const listRef = useRef(null); @@ -327,6 +338,16 @@ export const ConversationList: React.FC = ({ /> ); break; + case RowType.UsernameSearchResult: + result = ( + + ); + break; default: throw missingCaseError(row); } @@ -349,6 +370,7 @@ export const ConversationList: React.FC = ({ renderMessageSearchResult, showChooseGroupMembers, startNewConversationFromPhoneNumber, + startNewConversationFromUsername, theme, ] ); diff --git a/ts/components/ForwardMessageModal.tsx b/ts/components/ForwardMessageModal.tsx index 5dfda5474..830a91972 100644 --- a/ts/components/ForwardMessageModal.tsx +++ b/ts/components/ForwardMessageModal.tsx @@ -420,6 +420,7 @@ export const ForwardMessageModal: FunctionComponent = ({ startNewConversationFromPhoneNumber={ shouldNeverBeCalled } + startNewConversationFromUsername={shouldNeverBeCalled} theme={theme} />
diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 006a265d2..e9a881fd9 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -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 ( + + {i18n('startConversation--username-not-found', { + atUsername: i18n('at-username', { + username: usernameNotFoundModalState.username, + }), + })} + + ); + } + if (contactModalState) { return renderContactModal(); } diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index f3f4743f5..fbbdc1bf9 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -146,6 +146,7 @@ const useProps = (overrideProps: Partial = {}): 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', () => ( ( /> )); -story.add('Compose: some contacts, no groups, no search term', () => ( +story.add('Compose: some contacts, no search term', () => ( ( /> )); -story.add('Compose: some contacts, no groups, with a search term', () => ( +story.add('Compose: some contacts, with a search term', () => ( ( /> )); -story.add('Compose: some groups, no contacts, no search term', () => ( +story.add('Compose: some groups, no search term', () => ( ( /> )); -story.add('Compose: some groups, no contacts, with search term', () => ( +story.add('Compose: some groups, with search term', () => ( ( /> )); -story.add('Compose: some contacts, some groups, no search term', () => ( +story.add('Compose: search is valid username', () => ( + +)); + +story.add('Compose: search is valid username, fetching username', () => ( + +)); + +story.add('Compose: search is valid username, but flag is not enabled', () => ( + +)); + +story.add('Compose: all kinds of results, 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', () => ( diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index ab6afed5e..d20aaeda2 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -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 = ({ startComposing, startSearch, startNewConversationFromPhoneNumber, + startNewConversationFromUsername, startSettingGroupMetadata, theme, toggleComposeEditingAvatar, @@ -607,6 +609,9 @@ export const LeftPane: React.FC = ({ startNewConversationFromPhoneNumber={ startNewConversationFromPhoneNumber } + startNewConversationFromUsername={ + startNewConversationFromUsername + } theme={theme} />
diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index dea2d82cd..a903247cc 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -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} /> -
{usernameError}
-
+ {usernameError && ( +
{usernameError}
+ )} +
diff --git a/ts/components/ToastFailedToFetchUsername.tsx b/ts/components/ToastFailedToFetchUsername.tsx new file mode 100644 index 000000000..0ac51004a --- /dev/null +++ b/ts/components/ToastFailedToFetchUsername.tsx @@ -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 ( + + {i18n('Toast--failed-to-fetch-username')} + + ); +}; diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx index 883bb326a..b2930c8da 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -229,6 +229,7 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} startNewConversationFromPhoneNumber={shouldNeverBeCalled} + startNewConversationFromUsername={shouldNeverBeCalled} theme={theme} />
diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index 5d01b1db8..e54297dab 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -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 = id, isMe, isNoteToSelf, + isUsernameSearchResult, isSelected, markedUnread, messageStatusIcon, @@ -86,6 +90,7 @@ export const BaseConversationListItem: FunctionComponent = phoneNumber, profileName, sharedGroupNames, + shouldShowSpinner, theme, title, unblurredAvatarPath, @@ -101,8 +106,12 @@ export const BaseConversationListItem: FunctionComponent = const isCheckbox = isBoolean(checked); - let checkboxNode: ReactNode; - if (isCheckbox) { + let actionNode: ReactNode; + if (shouldShowSpinner) { + actionNode = ( + + ); + } else if (isCheckbox) { let ariaLabel: string; if (disabled) { ariaLabel = i18n('cannotSelectContact', [title]); @@ -111,7 +120,7 @@ export const BaseConversationListItem: FunctionComponent = } else { ariaLabel = i18n('selectContact', [title]); } - checkboxNode = ( + actionNode = ( = color={color} conversationType={conversationType} noteToSelf={isAvatarNoteToSelf} + searchResult={isUsernameSearchResult} i18n={i18n} isMe={isMe} name={name} @@ -187,7 +197,7 @@ export const BaseConversationListItem: FunctionComponent = ) : null} - {checkboxNode} + {actionNode} ); diff --git a/ts/components/conversationList/UsernameSearchResultListItem.tsx b/ts/components/conversationList/UsernameSearchResultListItem.tsx new file mode 100644 index 000000000..ebbacff65 --- /dev/null +++ b/ts/components/conversationList/UsernameSearchResultListItem.tsx @@ -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 = ({ + i18n, + isFetchingUsername, + onClick, + username, +}) => { + const usernameText = i18n('at-username', { username }); + const boundOnClick = isFetchingUsername + ? noop + : () => { + onClick(username); + }; + + return ( + + ); +}; diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx index 24bbae454..f78be8eab 100644 --- a/ts/components/leftPane/LeftPaneComposeHelper.tsx +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -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; composeGroups: ReadonlyArray; + regionCode: string; searchTerm: string; + isFetchingUsername: boolean; + isUsernamesEnabled: boolean; }; enum TopButton { @@ -37,6 +41,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper; + 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) { 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): void { + window.Whisper.events.trigger(name, ...rest); } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 659efc7f5..8afbc16c5 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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; }; +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 { + 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; diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 5e943ab7d..0bf3bcd7f 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -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, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 6943a7b04..28f54ddc0 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -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; diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 04fdb3830..6082b3fb3 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -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 { diff --git a/ts/test-both/helpers/defaultComposerStates.ts b/ts/test-both/helpers/defaultComposerStates.ts index 625c7671e..3d8ca364e 100644 --- a/ts/test-both/helpers/defaultComposerStates.ts +++ b/ts/test-both/helpers/defaultComposerStates.ts @@ -7,6 +7,7 @@ import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAdd export const defaultStartDirectConversationComposerState = { step: ComposerStep.StartDirectConversation as const, searchTerm: '', + isFetchingUsername: false, }; export const defaultChooseGroupMembersComposerState = { diff --git a/ts/test-both/util/filterAndSortConversations_test.ts b/ts/test-both/util/filterAndSortConversations_test.ts index fdb124949..cf50eb68d 100644 --- a/ts/test-both/util/filterAndSortConversations_test.ts +++ b/ts/test-both/util/filterAndSortConversations_test.ts @@ -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', () => { diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index c46e089b1..bda711b41 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -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, diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts index 916382172..375f611f1 100644 --- a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts @@ -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, }) ); }); diff --git a/ts/test-node/types/Username_test.ts b/ts/test-node/types/Username_test.ts new file mode 100644 index 000000000..c097f0911 --- /dev/null +++ b/ts/test-node/types/Username_test.ts @@ -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@')); + }); + }); +}); diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index d86f946ad..a0ae3180d 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -2043,7 +2043,7 @@ export default class MessageSender { profileKeyCredentialRequest?: string; userLanguages: ReadonlyArray; }> - ): Promise> { + ): ReturnType { const { accessKey } = options; if (accessKey) { @@ -2057,6 +2057,12 @@ export default class MessageSender { return this.server.getProfile(number, options); } + async getProfileForUsername( + username: string + ): ReturnType { + return this.server.getProfileForUsername(username); + } + async getUuidsForE164s( numbers: ReadonlyArray ): Promise> { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index febc76a3b..370718192 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -770,6 +770,7 @@ export type WebAPIType = { userLanguages: ReadonlyArray; } ) => Promise; + getProfileForUsername: (username: string) => Promise; 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 { diff --git a/ts/types/Username.ts b/ts/types/Username.ts new file mode 100644 index 000000000..a65696580 --- /dev/null +++ b/ts/types/Username.ts @@ -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; +} diff --git a/ts/util/filterAndSortConversations.ts b/ts/util/filterAndSortConversations.ts index c08a5c779..c72ac33e9 100644 --- a/ts/util/filterAndSortConversations.ts +++ b/ts/util/filterAndSortConversations.ts @@ -24,6 +24,10 @@ const FUSE_OPTIONS: FuseOptions = { name: 'name', weight: 1, }, + { + name: 'username', + weight: 1, + }, { name: 'e164', weight: 0.5, diff --git a/ts/views/inbox_view.ts b/ts/views/inbox_view.ts index 33d532deb..147907123 100644 --- a/ts/views/inbox_view.ts +++ b/ts/views/inbox_view.ts @@ -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;