diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 75ff1cdbb91..62f1faad79d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -824,8 +824,12 @@ "description": "Shown to separate the types of search results" }, "findByUsernameHeader": { - "message": "Find by Username", - "description": "Shown to separate the types of search results" + "message": "Find by username", + "description": "Shown when search could be a valid username, with one sub-item that will kick off the search" + }, + "findByPhoneNumberHeader": { + "message": "Find by phone number", + "description": "Shown when search could be a valid phone number, with one sub-item that will kick off the search" }, "at-username": { "message": "@$username$", @@ -2352,6 +2356,10 @@ "message": "Failed to fetch username. Check your connection and try again.", "description": "Shown if request to Signal servers to find username fails" }, + "Toast--failed-to-fetch-phone-number": { + "message": "Failed to fetch phone number. Check your connection and try again.", + "description": "Shown if request to Signal servers to find phone number 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", @@ -2362,6 +2370,26 @@ } } }, + "startConversation--phone-number-not-found": { + "message": "User not found. \"$phoneNumber$\" is not a Signal user.", + "description": "Shown in dialog if phone number is not found.", + "placeholders": { + "phoneNumber": { + "content": "$1", + "example": "+1 203-123-4567" + } + } + }, + "startConversation--phone-number-not-valid": { + "message": "User not found. \"$phoneNumber$\" is not a valid phone number.", + "description": "Shown in dialog if phone number is not valid.", + "placeholders": { + "phoneNumber": { + "content": "$1", + "example": "+1 203-123-4567" + } + } + }, "chooseGroupMembers__title": { "message": "Choose members", "description": "The title for the 'choose group members' left pane screen" diff --git a/package.json b/package.json index 42dac035130..37608dbb8e1 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "focus-trap-react": "8.8.1", "form-data": "4.0.0", "fs-extra": "5.0.0", - "fuse.js": "3.4.4", + "fuse.js": "6.5.3", "glob": "7.1.6", "google-libphonenumber": "3.2.27", "got": "11.8.2", diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 6b0df62088d..bb12cf24f08 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4765,6 +4765,11 @@ button.module-image__border-overlay:focus { } } } + + &__spinner__container { + margin-left: 16px; + margin-right: 16px; + } } &--header { diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 76cdf55cbd6..c77ec5c0f8f 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -373,7 +373,7 @@ export class ConversationController { if (normalizedUuid) { newConvo.updateUuid(normalizedUuid); } - if (highTrust && e164 && normalizedUuid) { + if ((highTrust && e164) || normalizedUuid) { updateConversation(newConvo.attributes); } diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index 3687b64508b..e3e7460c08d 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -20,6 +20,7 @@ import enMessages from '../../_locales/en/messages.json'; import { ThemeType } from '../types/Util'; import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; import { UUID } from '../types/UUID'; +import { makeFakeLookupConversationWithoutUuid } from '../test-both/helpers/fakeLookupConversationWithoutUuid'; const i18n = setupI18n('en', enMessages); @@ -85,13 +86,11 @@ const Wrapper = ({ /> )} scrollable={scrollable} + lookupConversationWithoutUuid={makeFakeLookupConversationWithoutUuid()} showChooseGroupMembers={action('showChooseGroupMembers')} - startNewConversationFromPhoneNumber={action( - 'startNewConversationFromPhoneNumber' - )} - startNewConversationFromUsername={action( - 'startNewConversationFromUsername' - )} + showUserNotFoundModal={action('showUserNotFoundModal')} + setIsFetchingUUID={action('setIsFetchingUUID')} + showConversation={action('showConversation')} theme={theme} /> ); @@ -495,16 +494,47 @@ story.add('Headers', () => ( type: RowType.Header, i18nKey: 'findByUsernameHeader', }, + { + type: RowType.Header, + i18nKey: 'findByPhoneNumberHeader', + }, ]} /> )); -story.add('Start new conversation', () => ( +story.add('Find by phone number', () => ( @@ -548,7 +578,30 @@ story.add('Kitchen sink', () => ( rows={[ { type: RowType.StartNewConversation, - phoneNumber: '+12345559876', + phoneNumber: { + isValid: true, + userInput: '+1(234)555 98 76', + e164: '+12345559876', + }, + isFetching: false, + }, + { + type: RowType.StartNewConversation, + phoneNumber: { + isValid: true, + userInput: '+1(234)555 98 76', + e164: '+12345559876', + }, + isFetching: true, + }, + { + type: RowType.StartNewConversation, + phoneNumber: { + isValid: false, + userInput: '+1(234)555 98', + e164: '+123455598', + }, + isFetching: true, }, { type: RowType.Header, diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index f98aa29faef..64dd8ffb3cb 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -10,10 +10,12 @@ import { get, pick } from 'lodash'; import { missingCaseError } from '../util/missingCaseError'; import { assert } from '../util/assert'; +import type { ParsedE164Type } from '../util/libphonenumberInstance'; import type { LocalizerType, ThemeType } from '../types/Util'; import { ScrollBehavior } from '../types/Util'; import { getConversationListWidthBreakpoint } from './_util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; +import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid'; import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem'; import { ConversationListItem } from './conversationList/ConversationListItem'; @@ -21,6 +23,7 @@ import type { ContactListItemConversationType as ContactListItemPropsType } from import { ContactListItem } from './conversationList/ContactListItem'; import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; import { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox'; +import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox'; import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton'; import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation'; import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader'; @@ -32,6 +35,7 @@ export enum RowType { Blank, Contact, ContactCheckbox, + PhoneNumberCheckbox, Conversation, CreateNewGroup, Header, @@ -62,6 +66,13 @@ type ContactCheckboxRowType = { disabledReason?: ContactCheckboxDisabledReason; }; +type PhoneNumberCheckboxRowType = { + type: RowType.PhoneNumberCheckbox; + phoneNumber: ParsedE164Type; + isChecked: boolean; + isFetching: boolean; +}; + type ConversationRowType = { type: RowType.Conversation; conversation: ConversationListItemPropsType; @@ -91,7 +102,8 @@ type SearchResultsLoadingFakeRowType = { type StartNewConversationRowType = { type: RowType.StartNewConversation; - phoneNumber: string; + phoneNumber: ParsedE164Type; + isFetching: boolean; }; type UsernameRowType = { @@ -105,6 +117,7 @@ export type Row = | BlankRowType | ContactRowType | ContactCheckboxRowType + | PhoneNumberCheckboxRowType | ConversationRowType | CreateNewGroupRowType | MessageRowType @@ -141,9 +154,8 @@ export type PropsType = { onSelectConversation: (conversationId: string, messageId?: string) => void; renderMessageSearchResult: (id: string) => JSX.Element; showChooseGroupMembers: () => void; - startNewConversationFromPhoneNumber: (e164: string) => void; - startNewConversationFromUsername: (username: string) => void; -}; + showConversation: (conversationId: string) => void; +} & LookupConversationWithoutUuidActionsType; const NORMAL_ROW_HEIGHT = 76; const HEADER_ROW_HEIGHT = 40; @@ -163,8 +175,10 @@ export const ConversationList: React.FC = ({ scrollable = true, shouldRecomputeRowHeights, showChooseGroupMembers, - startNewConversationFromPhoneNumber, - startNewConversationFromUsername, + lookupConversationWithoutUuid, + showUserNotFoundModal, + setIsFetchingUUID, + showConversation, theme, }) => { const listRef = useRef(null); @@ -251,6 +265,23 @@ export const ConversationList: React.FC = ({ /> ); break; + case RowType.PhoneNumberCheckbox: + result = ( + + onClickContactCheckbox(conversationId, undefined) + } + isChecked={row.isChecked} + isFetching={row.isFetching} + i18n={i18n} + theme={theme} + /> + ); + break; case RowType.Conversation: { const itemProps = pick(row.conversation, [ 'acceptedMessageRequest', @@ -332,7 +363,11 @@ export const ConversationList: React.FC = ({ ); break; @@ -342,7 +377,10 @@ export const ConversationList: React.FC = ({ i18n={i18n} username={row.username} isFetchingUsername={row.isFetchingUsername} - onClick={startNewConversationFromUsername} + lookupConversationWithoutUuid={lookupConversationWithoutUuid} + showUserNotFoundModal={showUserNotFoundModal} + setIsFetchingUUID={setIsFetchingUUID} + showConversation={showConversation} /> ); break; @@ -365,10 +403,12 @@ export const ConversationList: React.FC = ({ onClickArchiveButton, onClickContactCheckbox, onSelectConversation, + lookupConversationWithoutUuid, + showUserNotFoundModal, + setIsFetchingUUID, renderMessageSearchResult, showChooseGroupMembers, - startNewConversationFromPhoneNumber, - startNewConversationFromUsername, + showConversation, theme, ] ); diff --git a/ts/components/ForwardMessageModal.stories.tsx b/ts/components/ForwardMessageModal.stories.tsx index 050003a226e..48d165fc8ff 100644 --- a/ts/components/ForwardMessageModal.stories.tsx +++ b/ts/components/ForwardMessageModal.stories.tsx @@ -60,6 +60,7 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ removeLinkPreview: action('removeLinkPreview'), skinTone: 0, theme: React.useContext(StorybookThemeContext), + regionCode: 'US', }); story.add('Modal', () => { diff --git a/ts/components/ForwardMessageModal.tsx b/ts/components/ForwardMessageModal.tsx index 96872531fc1..94fbd5a233a 100644 --- a/ts/components/ForwardMessageModal.tsx +++ b/ts/components/ForwardMessageModal.tsx @@ -60,6 +60,7 @@ export type DataPropsType = { ) => unknown; onTextTooLong: () => void; theme: ThemeType; + regionCode: string | undefined; } & Pick; type ActionPropsType = Pick< @@ -91,6 +92,7 @@ export const ForwardMessageModal: FunctionComponent = ({ removeLinkPreview, skinTone, theme, + regionCode, }) => { const inputRef = useRef(null); const inputApiRef = React.useRef(); @@ -99,7 +101,7 @@ export const ForwardMessageModal: FunctionComponent = ({ >([]); const [searchTerm, setSearchTerm] = useState(''); const [filteredConversations, setFilteredConversations] = useState( - filterAndSortConversationsByRecent(candidateConversations, '') + filterAndSortConversationsByRecent(candidateConversations, '', regionCode) ); const [attachmentsToForward, setAttachmentsToForward] = useState< Array @@ -168,14 +170,20 @@ export const ForwardMessageModal: FunctionComponent = ({ setFilteredConversations( filterAndSortConversationsByRecent( candidateConversations, - normalizedSearchTerm + normalizedSearchTerm, + regionCode ) ); }, 200); return () => { clearTimeout(timeout); }; - }, [candidateConversations, normalizedSearchTerm, setFilteredConversations]); + }, [ + candidateConversations, + normalizedSearchTerm, + setFilteredConversations, + regionCode, + ]); const contactLookup = useMemo(() => { const map = new Map(); @@ -412,6 +420,12 @@ export const ForwardMessageModal: FunctionComponent = ({ toggleSelectedConversation(conversationId); } }} + lookupConversationWithoutUuid={ + asyncShouldNeverBeCalled + } + showConversation={shouldNeverBeCalled} + showUserNotFoundModal={shouldNeverBeCalled} + setIsFetchingUUID={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled} renderMessageSearchResult={() => { shouldNeverBeCalled(); @@ -420,10 +434,6 @@ export const ForwardMessageModal: FunctionComponent = ({ rowCount={rowCount} shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} - startNewConversationFromPhoneNumber={ - shouldNeverBeCalled - } - startNewConversationFromUsername={shouldNeverBeCalled} theme={theme} /> @@ -470,3 +480,11 @@ export const ForwardMessageModal: FunctionComponent = ({ function shouldNeverBeCalled(..._args: ReadonlyArray): void { assert(false, 'This should never be called. Doing nothing'); } + +async function asyncShouldNeverBeCalled( + ..._args: ReadonlyArray +): Promise { + shouldNeverBeCalled(); + + return undefined; +} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index e9a881fd90e..4c0d31878f8 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -4,9 +4,10 @@ import React from 'react'; import type { ContactModalStateType, - UsernameNotFoundModalStateType, + UserNotFoundModalStateType, } from '../state/ducks/globalModals'; import type { LocalizerType } from '../types/Util'; +import { missingCaseError } from '../util/missingCaseError'; import { ButtonVariant } from './Button'; import { ConfirmationDialog } from './ConfirmationDialog'; @@ -23,9 +24,9 @@ type PropsType = { // SafetyNumberModal safetyNumberModalContactId?: string; renderSafetyNumber: () => JSX.Element; - // UsernameNotFoundModal - hideUsernameNotFoundModal: () => unknown; - usernameNotFoundModalState?: UsernameNotFoundModalStateType; + // UserNotFoundModal + hideUserNotFoundModal: () => unknown; + userNotFoundModalState?: UserNotFoundModalStateType; // WhatsNewModal isWhatsNewVisible: boolean; hideWhatsNewModal: () => unknown; @@ -42,9 +43,9 @@ export const GlobalModalContainer = ({ // SafetyNumberModal safetyNumberModalContactId, renderSafetyNumber, - // UsernameNotFoundModal - hideUsernameNotFoundModal, - usernameNotFoundModalState, + // UserNotFoundModal + hideUserNotFoundModal, + userNotFoundModalState, // WhatsNewModal hideWhatsNewModal, isWhatsNewVisible, @@ -53,19 +54,30 @@ export const GlobalModalContainer = ({ return renderSafetyNumber(); } - if (usernameNotFoundModalState) { + if (userNotFoundModalState) { + let content: string; + if (userNotFoundModalState.type === 'phoneNumber') { + content = i18n('startConversation--phone-number-not-found', { + phoneNumber: userNotFoundModalState.phoneNumber, + }); + } else if (userNotFoundModalState.type === 'username') { + content = i18n('startConversation--username-not-found', { + atUsername: i18n('at-username', { + username: userNotFoundModalState.username, + }), + }); + } else { + throw missingCaseError(userNotFoundModalState); + } + return ( - {i18n('startConversation--username-not-found', { - atUsername: i18n('at-username', { - username: usernameNotFoundModalState.username, - }), - })} + {content} ); } diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index bea95ce4637..62dd90c2ecd 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -18,6 +18,10 @@ import enMessages from '../../_locales/en/messages.json'; import { ThemeType } from '../types/Util'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; +import { + makeFakeLookupConversationWithoutUuid, + useUuidFetchState, +} from '../test-both/helpers/fakeLookupConversationWithoutUuid'; const i18n = setupI18n('en', enMessages); @@ -89,93 +93,112 @@ const defaultModeSpecificProps = { const emptySearchResultsGroup = { isLoading: false, results: [] }; -const useProps = (overrideProps: Partial = {}): PropsType => ({ - clearConversationSearch: action('clearConversationSearch'), - clearGroupCreationError: action('clearGroupCreationError'), - clearSearch: action('clearSearch'), - closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'), - closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'), - composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'), - composeReplaceAvatar: action('composeReplaceAvatar'), - composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'), - createGroup: action('createGroup'), - getPreferredBadge: () => undefined, - i18n, - modeSpecificProps: defaultModeSpecificProps, - preferredWidthFromStorage: 320, - openConversationInternal: action('openConversationInternal'), - regionCode: 'US', - challengeStatus: select( - 'challengeStatus', - ['idle', 'required', 'pending'], - 'idle' - ), - crashReportCount: select('challengeReportCount', [0, 1], 0), - setChallengeStatus: action('setChallengeStatus'), - renderExpiredBuildDialog: () =>
, - renderMainHeader: () =>
, - renderMessageSearchResult: (id: string) => ( - undefined} - i18n={i18n} - id={id} - openConversationInternal={action('openConversationInternal')} - sentAt={1587358800000} - snippet="Lorem <>ipsum<> wow" - theme={ThemeType.light} - to={defaultConversations[1]} - /> - ), - renderNetworkStatus: () =>
, - renderRelinkDialog: () =>
, - renderUpdateDialog: () =>
, - renderCaptchaDialog: () => ( - - ), - renderCrashReportDialog: () => ( - - ), - selectedConversationId: undefined, - selectedMessageId: undefined, - savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'), - searchInConversation: action('searchInConversation'), - setComposeSearchTerm: action('setComposeSearchTerm'), - setComposeGroupAvatar: action('setComposeGroupAvatar'), - setComposeGroupName: action('setComposeGroupName'), - setComposeGroupExpireTimer: action('setComposeGroupExpireTimer'), - showArchivedConversations: action('showArchivedConversations'), - showInbox: action('showInbox'), - startComposing: action('startComposing'), - showChooseGroupMembers: action('showChooseGroupMembers'), - startNewConversationFromPhoneNumber: action( - 'startNewConversationFromPhoneNumber' - ), - startNewConversationFromUsername: action('startNewConversationFromUsername'), - startSearch: action('startSearch'), - startSettingGroupMetadata: action('startSettingGroupMetadata'), - theme: React.useContext(StorybookThemeContext), - toggleComposeEditingAvatar: action('toggleComposeEditingAvatar'), - toggleConversationInChooseMembers: action( - 'toggleConversationInChooseMembers' - ), - updateSearchTerm: action('updateSearchTerm'), +const useProps = (overrideProps: Partial = {}): PropsType => { + let modeSpecificProps = + overrideProps.modeSpecificProps ?? defaultModeSpecificProps; - ...overrideProps, -}); + const [uuidFetchState, setIsFetchingUUID] = useUuidFetchState( + 'uuidFetchState' in modeSpecificProps + ? modeSpecificProps.uuidFetchState + : {} + ); + + if ('uuidFetchState' in modeSpecificProps) { + modeSpecificProps = { + ...modeSpecificProps, + uuidFetchState, + }; + } + + return { + clearConversationSearch: action('clearConversationSearch'), + clearGroupCreationError: action('clearGroupCreationError'), + clearSearch: action('clearSearch'), + closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'), + closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'), + composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'), + composeReplaceAvatar: action('composeReplaceAvatar'), + composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'), + createGroup: action('createGroup'), + getPreferredBadge: () => undefined, + i18n, + preferredWidthFromStorage: 320, + openConversationInternal: action('openConversationInternal'), + regionCode: 'US', + challengeStatus: select( + 'challengeStatus', + ['idle', 'required', 'pending'], + 'idle' + ), + crashReportCount: select('challengeReportCount', [0, 1], 0), + setChallengeStatus: action('setChallengeStatus'), + lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(), + showUserNotFoundModal: action('showUserNotFoundModal'), + setIsFetchingUUID, + showConversation: action('showConversation'), + renderExpiredBuildDialog: () =>
, + renderMainHeader: () =>
, + renderMessageSearchResult: (id: string) => ( + undefined} + i18n={i18n} + id={id} + openConversationInternal={action('openConversationInternal')} + sentAt={1587358800000} + snippet="Lorem <>ipsum<> wow" + theme={ThemeType.light} + to={defaultConversations[1]} + /> + ), + renderNetworkStatus: () =>
, + renderRelinkDialog: () =>
, + renderUpdateDialog: () =>
, + renderCaptchaDialog: () => ( + + ), + renderCrashReportDialog: () => ( + + ), + selectedConversationId: undefined, + selectedMessageId: undefined, + savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'), + searchInConversation: action('searchInConversation'), + setComposeSearchTerm: action('setComposeSearchTerm'), + setComposeGroupAvatar: action('setComposeGroupAvatar'), + setComposeGroupName: action('setComposeGroupName'), + setComposeGroupExpireTimer: action('setComposeGroupExpireTimer'), + showArchivedConversations: action('showArchivedConversations'), + showInbox: action('showInbox'), + startComposing: action('startComposing'), + showChooseGroupMembers: action('showChooseGroupMembers'), + startSearch: action('startSearch'), + startSettingGroupMetadata: action('startSettingGroupMetadata'), + theme: React.useContext(StorybookThemeContext), + toggleComposeEditingAvatar: action('toggleComposeEditingAvatar'), + toggleConversationInChooseMembers: action( + 'toggleConversationInChooseMembers' + ), + updateSearchTerm: action('updateSearchTerm'), + + ...overrideProps, + + modeSpecificProps, + }; +}; // Inbox stories @@ -465,7 +488,7 @@ story.add('Compose: no results', () => ( composeContacts: [], composeGroups: [], isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: '', }, @@ -481,7 +504,7 @@ story.add('Compose: some contacts, no search term', () => ( composeContacts: defaultConversations, composeGroups: [], isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: '', }, @@ -497,7 +520,7 @@ story.add('Compose: some contacts, with a search term', () => ( composeContacts: defaultConversations, composeGroups: [], isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: 'ar', }, @@ -513,7 +536,7 @@ story.add('Compose: some groups, no search term', () => ( composeContacts: [], composeGroups: defaultGroups, isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: '', }, @@ -529,7 +552,7 @@ story.add('Compose: some groups, with search term', () => ( composeContacts: [], composeGroups: defaultGroups, isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: 'ar', }, @@ -545,7 +568,7 @@ story.add('Compose: search is valid username', () => ( composeContacts: [], composeGroups: [], isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: 'someone', }, @@ -561,7 +584,9 @@ story.add('Compose: search is valid username, fetching username', () => ( composeContacts: [], composeGroups: [], isUsernamesEnabled: true, - isFetchingUsername: true, + uuidFetchState: { + 'username:someone': true, + }, regionCode: 'US', searchTerm: 'someone', }, @@ -577,7 +602,7 @@ story.add('Compose: search is valid username, but flag is not enabled', () => ( composeContacts: [], composeGroups: [], isUsernamesEnabled: false, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: 'someone', }, @@ -585,6 +610,59 @@ story.add('Compose: search is valid username, but flag is not enabled', () => ( /> )); +story.add('Compose: search is partial phone number', () => ( + +)); + +story.add('Compose: search is valid phone number', () => ( + +)); + +story.add( + 'Compose: search is valid phone number, fetching phone number', + () => ( + + ) +); + story.add('Compose: all kinds of results, no search term', () => ( ( composeContacts: defaultConversations, composeGroups: defaultGroups, isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: '', }, @@ -609,7 +687,7 @@ story.add('Compose: all kinds of results, with a search term', () => ( composeContacts: defaultConversations, composeGroups: defaultGroups, isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, regionCode: 'US', searchTerm: 'someone', }, @@ -672,6 +750,42 @@ story.add('Crash report dialog', () => ( /> )); +// Choose Group Members + +story.add('Choose Group Members: Partial phone number', () => ( + +)); + +story.add('Choose Group Members: Valid phone number', () => ( + +)); + // Set group metadata story.add('Group Metadata: No Timer', () => ( diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 093e0589683..09918502da3 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -37,6 +37,7 @@ import { MAX_WIDTH, getWidthFromPreferredWidth, } from '../util/leftPaneWidth'; +import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid'; import { ConversationList } from './ConversationList'; import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; @@ -97,8 +98,6 @@ export type PropsType = { closeMaximumGroupSizeModal: () => void; closeRecommendedGroupSizeModal: () => void; createGroup: () => void; - startNewConversationFromPhoneNumber: (e164: string) => void; - startNewConversationFromUsername: (username: string) => void; openConversationInternal: (_: { conversationId: string; messageId?: string; @@ -140,7 +139,9 @@ export type PropsType = { ) => JSX.Element; renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element; renderCrashReportDialog: () => JSX.Element; -}; + + showConversation: (conversationId: string) => void; +} & LookupConversationWithoutUuidActionsType; export const LeftPane: React.FC = ({ challengeStatus, @@ -181,12 +182,14 @@ export const LeftPane: React.FC = ({ showInbox, startComposing, startSearch, - startNewConversationFromPhoneNumber, - startNewConversationFromUsername, + showUserNotFoundModal, + setIsFetchingUUID, + lookupConversationWithoutUuid, + toggleConversationInChooseMembers, + showConversation, startSettingGroupMetadata, theme, toggleComposeEditingAvatar, - toggleConversationInChooseMembers, updateSearchTerm, }) => { const [preferredWidth, setPreferredWidth] = useState( @@ -599,6 +602,10 @@ export const LeftPane: React.FC = ({ throw missingCaseError(disabledReason); } }} + showUserNotFoundModal={showUserNotFoundModal} + setIsFetchingUUID={setIsFetchingUUID} + lookupConversationWithoutUuid={lookupConversationWithoutUuid} + showConversation={showConversation} onSelectConversation={onSelectConversation} renderMessageSearchResult={renderMessageSearchResult} rowCount={helper.getRowCount()} @@ -607,12 +614,6 @@ export const LeftPane: React.FC = ({ scrollable={isScrollable} shouldRecomputeRowHeights={shouldRecomputeRowHeights} showChooseGroupMembers={showChooseGroupMembers} - startNewConversationFromPhoneNumber={ - startNewConversationFromPhoneNumber - } - startNewConversationFromUsername={ - startNewConversationFromUsername - } theme={theme} />
diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index b7293db462a..9ef59635fc8 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -1,25 +1,27 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { FuseOptions } from 'fuse.js'; import Fuse from 'fuse.js'; import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; +import { isNotNil } from '../util/isNotNil'; import type { ConversationStoryType, StoryViewType } from './StoryListItem'; import type { LocalizerType } from '../types/Util'; import { SearchInput } from './SearchInput'; import { StoryListItem } from './StoryListItem'; -const FUSE_OPTIONS: FuseOptions = { +const FUSE_OPTIONS: Fuse.IFuseOptions = { getFn: (obj, path) => { if (path === 'searchNames') { - return obj.stories.flatMap((story: StoryViewType) => [ - story.sender.title, - story.sender.name, - ]); + return obj.stories + .flatMap((story: StoryViewType) => [ + story.sender.title, + story.sender.name, + ]) + .filter(isNotNil); } - return obj.group?.title; + return obj.group?.title ?? ''; }, keys: [ { @@ -32,16 +34,15 @@ const FUSE_OPTIONS: FuseOptions = { }, ], threshold: 0.1, - tokenize: true, }; function search( stories: ReadonlyArray, searchTerm: string ): Array { - return new Fuse(stories, FUSE_OPTIONS).search( - searchTerm - ); + return new Fuse(stories, FUSE_OPTIONS) + .search(searchTerm) + .map(result => result.item); } function getNewestStory(story: ConversationStoryType): StoryViewType { diff --git a/ts/components/ToastFailedToFetchPhoneNumber.tsx b/ts/components/ToastFailedToFetchPhoneNumber.tsx new file mode 100644 index 00000000000..6ad3ad7c432 --- /dev/null +++ b/ts/components/ToastFailedToFetchPhoneNumber.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 ToastFailedToFetchPhoneNumber = ({ + i18n, + onClose, +}: PropsType): JSX.Element => { + return ( + + {i18n('Toast--failed-to-fetch-phone-number')} + + ); +}; diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal.stories.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal.stories.tsx index 3077b4d86d8..c9fdf255054 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal.stories.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal.stories.tsx @@ -9,12 +9,18 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { sleep } from '../../../util/sleep'; +import { makeLookup } from '../../../util/makeLookup'; +import { deconstructLookup } from '../../../util/deconstructLookup'; import { setupI18n } from '../../../util/setupI18n'; +import type { ConversationType } from '../../../state/ducks/conversations'; import enMessages from '../../../../_locales/en/messages.json'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import { AddGroupMembersModal } from './AddGroupMembersModal'; +import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal'; +import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal'; import { RequestState } from './util'; import { ThemeType } from '../../../types/Util'; +import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid'; const i18n = setupI18n('en', enMessages); @@ -24,14 +30,23 @@ const story = storiesOf( ); const allCandidateContacts = times(50, () => getDefaultConversation()); +let allCandidateContactsLookup = makeLookup(allCandidateContacts, 'id'); + +const lookupConversationWithoutUuid = makeFakeLookupConversationWithoutUuid( + convo => { + allCandidateContacts.push(convo); + allCandidateContactsLookup = makeLookup(allCandidateContacts, 'id'); + } +); type PropsType = ComponentProps; -const createProps = (overrideProps: Partial = {}): PropsType => ({ - candidateContacts: allCandidateContacts, +const createProps = ( + overrideProps: Partial = {}, + candidateContacts: Array = [] +): PropsType => ({ clearRequestError: action('clearRequestError'), conversationIdsAlreadyInGroup: new Set(), - getPreferredBadge: () => undefined, groupTitle: 'Tahoe Trip', i18n, onClose: action('onClose'), @@ -39,7 +54,38 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ action('onMakeRequest')(conversationIds); }, requestState: RequestState.Inactive, - theme: ThemeType.light, + renderChooseGroupMembersModal: props => { + const { selectedConversationIds } = props; + return ( + undefined} + theme={ThemeType.light} + i18n={i18n} + lookupConversationWithoutUuid={lookupConversationWithoutUuid} + showUserNotFoundModal={action('showUserNotFoundModal')} + /> + ); + }, + renderConfirmAdditionsModal: props => { + const { selectedConversationIds } = props; + return ( + + ); + }, ...overrideProps, }); @@ -47,18 +93,12 @@ story.add('Default', () => ); story.add('Only 3 contacts', () => ( )); story.add('No candidate contacts', () => ( - + )); story.add('Everyone already added', () => ( diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx index c7da8390ce5..f83cfb96028 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx @@ -2,16 +2,16 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { FunctionComponent } from 'react'; -import React, { useMemo, useReducer } from 'react'; +import React, { useReducer } from 'react'; import { without } from 'lodash'; -import type { LocalizerType, ThemeType } from '../../../types/Util'; +import type { LocalizerType } from '../../../types/Util'; import { AddGroupMemberErrorDialog, AddGroupMemberErrorDialogMode, } from '../../AddGroupMemberErrorDialog'; -import type { ConversationType } from '../../../state/ducks/conversations'; -import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; +import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal'; +import type { SmartConfirmAdditionsModalPropsType } from '../../../state/smart/ConfirmAdditionsModal'; import { getGroupSizeRecommendedLimit, getGroupSizeHardLimit, @@ -20,24 +20,24 @@ import { toggleSelectedContactForGroupAddition, OneTimeModalState, } from '../../../groups/toggleSelectedContactForGroupAddition'; -import { makeLookup } from '../../../util/makeLookup'; -import { deconstructLookup } from '../../../util/deconstructLookup'; import { missingCaseError } from '../../../util/missingCaseError'; import type { RequestState } from './util'; -import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal'; -import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal'; type PropsType = { - candidateContacts: ReadonlyArray; clearRequestError: () => void; conversationIdsAlreadyInGroup: Set; - getPreferredBadge: PreferredBadgeSelectorType; groupTitle: string; i18n: LocalizerType; makeRequest: (conversationIds: ReadonlyArray) => Promise; onClose: () => void; requestState: RequestState; - theme: ThemeType; + + renderChooseGroupMembersModal: ( + props: SmartChooseGroupMembersModalPropsType + ) => JSX.Element; + renderConfirmAdditionsModal: ( + props: SmartConfirmAdditionsModalPropsType + ) => JSX.Element; }; enum Stage { @@ -135,16 +135,15 @@ function reducer( } export const AddGroupMembersModal: FunctionComponent = ({ - candidateContacts, clearRequestError, conversationIdsAlreadyInGroup, - getPreferredBadge, groupTitle, i18n, onClose, makeRequest, requestState, - theme, + renderChooseGroupMembersModal, + renderConfirmAdditionsModal, }) => { const maxGroupSize = getMaximumNumberOfContacts(); const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts(); @@ -175,16 +174,6 @@ export const AddGroupMembersModal: FunctionComponent = ({ stage: Stage.ChoosingContacts, }); - const contactLookup = useMemo( - () => makeLookup(candidateContacts, 'id'), - [candidateContacts] - ); - - const selectedContacts = deconstructLookup( - contactLookup, - selectedConversationIds - ); - if (maximumGroupSizeModalState === OneTimeModalState.Showing) { return ( = ({ }); }; - return ( - - ); + return renderChooseGroupMembersModal({ + confirmAdds, + selectedConversationIds, + conversationIdsAlreadyInGroup, + maxGroupSize, + onClose, + removeSelectedContact, + searchTerm, + setSearchTerm, + toggleSelectedContact, + }); } case Stage.ConfirmingAdds: { const onCloseConfirmationDialog = () => { @@ -263,18 +246,15 @@ export const AddGroupMembersModal: FunctionComponent = ({ clearRequestError(); }; - return ( - { - makeRequest(selectedConversationIds); - }} - onClose={onCloseConfirmationDialog} - requestState={requestState} - selectedContacts={selectedContacts} - /> - ); + return renderConfirmAdditionsModal({ + groupTitle, + makeRequest: () => { + makeRequest(selectedConversationIds); + }, + onClose: onCloseConfirmationDialog, + requestState, + selectedConversationIds, + }); } default: throw missingCaseError(stage); diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx index c985939e829..8dd4949e03b 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -2,7 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { FunctionComponent } from 'react'; -import React, { useEffect, useMemo, useState, useRef } from 'react'; +import React, { + useEffect, + useMemo, + useState, + useRef, + useCallback, +} from 'react'; +import { omit } from 'lodash'; import type { MeasuredComponentProps } from 'react-measure'; import Measure from 'react-measure'; @@ -11,9 +18,16 @@ import { assert } from '../../../../util/assert'; import { refMerger } from '../../../../util/refMerger'; import { useRestoreFocus } from '../../../../hooks/useRestoreFocus'; import { missingCaseError } from '../../../../util/missingCaseError'; +import type { LookupConversationWithoutUuidActionsType } from '../../../../util/lookupConversationWithoutUuid'; +import { parseAndFormatPhoneNumber } from '../../../../util/libphonenumberInstance'; import { filterAndSortConversationsByTitle } from '../../../../util/filterAndSortConversations'; import type { ConversationType } from '../../../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../../../state/selectors/badges'; +import type { + UUIDFetchStateKeyType, + UUIDFetchStateType, +} from '../../../../util/uuidFetchState'; +import { isFetchingByE164 } from '../../../../util/uuidFetchState'; import { ModalHost } from '../../../ModalHost'; import { ContactPills } from '../../../ContactPills'; import { ContactPill } from '../../../ContactPill'; @@ -23,24 +37,37 @@ import { ContactCheckboxDisabledReason } from '../../../conversationList/Contact import { Button, ButtonVariant } from '../../../Button'; import { SearchInput } from '../../../SearchInput'; -type PropsType = { +export type StatePropsType = { + regionCode: string | undefined; candidateContacts: ReadonlyArray; - confirmAdds: () => void; conversationIdsAlreadyInGroup: Set; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; + theme: ThemeType; maxGroupSize: number; - onClose: () => void; - removeSelectedContact: (_: string) => void; searchTerm: string; selectedContacts: ReadonlyArray; + + confirmAdds: () => void; + onClose: () => void; + removeSelectedContact: (_: string) => void; setSearchTerm: (_: string) => void; - theme: ThemeType; toggleSelectedContact: (conversationId: string) => void; -}; +} & Pick< + LookupConversationWithoutUuidActionsType, + 'lookupConversationWithoutUuid' +>; + +type ActionPropsType = Omit< + LookupConversationWithoutUuidActionsType, + 'setIsFetchingUUID' | 'lookupConversationWithoutUuid' +>; + +type PropsType = StatePropsType & ActionPropsType; // TODO: This should use . See DESKTOP-1038. export const ChooseGroupMembersModal: FunctionComponent = ({ + regionCode, candidateContacts, confirmAdds, conversationIdsAlreadyInGroup, @@ -54,9 +81,24 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ setSearchTerm, theme, toggleSelectedContact, + lookupConversationWithoutUuid, + showUserNotFoundModal, }) => { const [focusRef] = useRestoreFocus(); + const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode); + + let isPhoneNumberChecked = false; + if (phoneNumber) { + isPhoneNumberChecked = + phoneNumber.isValid && + selectedContacts.some(contact => contact.e164 === phoneNumber.e164); + } + + const isPhoneNumberVisible = + phoneNumber && + candidateContacts.every(contact => contact.e164 !== phoneNumber.e164); + const inputRef = useRef(null); const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size; @@ -72,7 +114,7 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ const canContinue = Boolean(selectedContacts.length); const [filteredContacts, setFilteredContacts] = useState( - filterAndSortConversationsByTitle(candidateContacts, '') + filterAndSortConversationsByTitle(candidateContacts, '', regionCode) ); const normalizedSearchTerm = searchTerm.trim(); useEffect(() => { @@ -80,38 +122,106 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ setFilteredContacts( filterAndSortConversationsByTitle( candidateContacts, - normalizedSearchTerm + normalizedSearchTerm, + regionCode ) ); }, 200); return () => { clearTimeout(timeout); }; - }, [candidateContacts, normalizedSearchTerm, setFilteredContacts]); + }, [ + candidateContacts, + normalizedSearchTerm, + setFilteredContacts, + regionCode, + ]); - const rowCount = filteredContacts.length; + const [uuidFetchState, setUuidFetchState] = useState({}); + + const setIsFetchingUUID = useCallback( + (identifier: UUIDFetchStateKeyType, isFetching: boolean) => { + setUuidFetchState(prevState => { + return isFetching + ? { + ...prevState, + [identifier]: isFetching, + } + : omit(prevState, identifier); + }); + }, + [setUuidFetchState] + ); + + let rowCount = 0; + if (filteredContacts.length) { + rowCount += filteredContacts.length; + } + if (isPhoneNumberVisible) { + // "Contacts" header + if (filteredContacts.length) { + rowCount += 1; + } + + // "Find by phone number" + phone number + rowCount += 2; + } const getRow = (index: number): undefined | Row => { - const contact = filteredContacts[index]; - if (!contact) { - return undefined; + let virtualIndex = index; + + if (isPhoneNumberVisible && filteredContacts.length) { + if (virtualIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'contactsHeader', + }; + } + + virtualIndex -= 1; } - const isSelected = selectedConversationIdsSet.has(contact.id); - const isAlreadyInGroup = conversationIdsAlreadyInGroup.has(contact.id); + if (virtualIndex < filteredContacts.length) { + const contact = filteredContacts[virtualIndex]; - let disabledReason: undefined | ContactCheckboxDisabledReason; - if (isAlreadyInGroup) { - disabledReason = ContactCheckboxDisabledReason.AlreadyAdded; - } else if (hasSelectedMaximumNumberOfContacts && !isSelected) { - disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected; + const isSelected = selectedConversationIdsSet.has(contact.id); + const isAlreadyInGroup = conversationIdsAlreadyInGroup.has(contact.id); + + let disabledReason: undefined | ContactCheckboxDisabledReason; + if (isAlreadyInGroup) { + disabledReason = ContactCheckboxDisabledReason.AlreadyAdded; + } else if (hasSelectedMaximumNumberOfContacts && !isSelected) { + disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected; + } + + return { + type: RowType.ContactCheckbox, + contact, + isChecked: isSelected || isAlreadyInGroup, + disabledReason, + }; } - return { - type: RowType.ContactCheckbox, - contact, - isChecked: isSelected || isAlreadyInGroup, - disabledReason, - }; + virtualIndex -= filteredContacts.length; + + if (isPhoneNumberVisible) { + if (virtualIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'findByPhoneNumberHeader', + }; + } + if (virtualIndex === 1) { + return { + type: RowType.PhoneNumberCheckbox, + isChecked: isPhoneNumberChecked, + isFetching: isFetchingByE164(uuidFetchState, phoneNumber.e164), + phoneNumber, + }; + } + virtualIndex -= 2; + } + + return undefined; }; return ( @@ -207,6 +317,12 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ throw missingCaseError(disabledReason); } }} + lookupConversationWithoutUuid={ + lookupConversationWithoutUuid + } + showUserNotFoundModal={showUserNotFoundModal} + setIsFetchingUUID={setIsFetchingUUID} + showConversation={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled} renderMessageSearchResult={() => { shouldNeverBeCalled(); @@ -215,8 +331,6 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ rowCount={rowCount} shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} - startNewConversationFromPhoneNumber={shouldNeverBeCalled} - startNewConversationFromUsername={shouldNeverBeCalled} theme={theme} />
diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx index fbb1e83f5dc..ed918125c50 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal.tsx @@ -15,7 +15,7 @@ import { Intl } from '../../../Intl'; import { Emojify } from '../../Emojify'; import { ContactName } from '../../ContactName'; -type PropsType = { +export type StatePropsType = { groupTitle: string; i18n: LocalizerType; makeRequest: () => void; @@ -24,6 +24,8 @@ type PropsType = { selectedContacts: ReadonlyArray; }; +type PropsType = StatePropsType; + export const ConfirmAdditionsModal: FunctionComponent = ({ groupTitle, i18n, diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index c63bbf140f8..4cd8466fe3d 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -12,8 +12,11 @@ import { CapabilityError } from '../../../types/errors'; import enMessages from '../../../../_locales/en/messages.json'; import type { Props } from './ConversationDetails'; import { ConversationDetails } from './ConversationDetails'; +import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal'; +import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal'; import type { ConversationType } from '../../../state/ducks/conversations'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid'; import { ThemeType } from '../../../types/Util'; const i18n = setupI18n('en', enMessages); @@ -33,13 +36,14 @@ const conversation: ConversationType = getDefaultConversation({ conversationColor: 'ultramarine' as const, }); +const allCandidateContacts = times(10, () => getDefaultConversation()); + const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ addMembers: async () => { action('addMembers'); }, areWeASubscriber: false, canEditGroupInfo: false, - candidateContactsToAdd: times(10, () => getDefaultConversation()), conversation: expireTimer ? { ...conversation, @@ -97,6 +101,26 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ ), searchInConversation: action('searchInConversation'), theme: ThemeType.light, + renderChooseGroupMembersModal: props => { + return ( + undefined} + theme={ThemeType.light} + i18n={i18n} + lookupConversationWithoutUuid={makeFakeLookupConversationWithoutUuid()} + showUserNotFoundModal={action('showUserNotFoundModal')} + /> + ); + }, + renderConfirmAdditionsModal: props => { + return ( + + ); + }, }); story.add('Basic', () => { diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index ccd6242ebe3..4c8978f7773 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -8,6 +8,8 @@ import { Button, ButtonIconType, ButtonVariant } from '../../Button'; import { Tooltip } from '../../Tooltip'; import type { ConversationType } from '../../../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; +import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal'; +import type { SmartConfirmAdditionsModalPropsType } from '../../../state/smart/ConfirmAdditionsModal'; import { assert } from '../../../util/assert'; import { getMutedUntilText } from '../../../util/getMutedUntilText'; @@ -59,7 +61,6 @@ export type StateProps = { areWeASubscriber: boolean; badges?: ReadonlyArray; canEditGroupInfo: boolean; - candidateContactsToAdd: Array; conversation?: ConversationType; hasGroupLink: boolean; getPreferredBadge: PreferredBadgeSelectorType; @@ -97,6 +98,12 @@ export type StateProps = { setMuteExpiration: (muteExpiresAt: undefined | number) => unknown; onOutgoingAudioCallInConversation: () => unknown; onOutgoingVideoCallInConversation: () => unknown; + renderChooseGroupMembersModal: ( + props: SmartChooseGroupMembersModalPropsType + ) => JSX.Element; + renderConfirmAdditionsModal: ( + props: SmartConfirmAdditionsModalPropsType + ) => JSX.Element; }; type ActionProps = { @@ -115,7 +122,6 @@ export const ConversationDetails: React.ComponentType = ({ areWeASubscriber, badges, canEditGroupInfo, - candidateContactsToAdd, conversation, deleteAvatarFromDisk, hasGroupLink, @@ -133,6 +139,8 @@ export const ConversationDetails: React.ComponentType = ({ onUnblock, pendingApprovalMemberships, pendingMemberships, + renderChooseGroupMembersModal, + renderConfirmAdditionsModal, replaceAvatar, saveAvatarToDisk, searchInConversation, @@ -228,7 +236,8 @@ export const ConversationDetails: React.ComponentType = ({ case ModalState.AddingGroupMembers: modalNode = ( { setAddGroupMembersRequestState(oldRequestState => { assert( @@ -241,7 +250,6 @@ export const ConversationDetails: React.ComponentType = ({ conversationIdsAlreadyInGroup={ new Set(memberships.map(membership => membership.member.id)) } - getPreferredBadge={getPreferredBadge} groupTitle={conversation.title} i18n={i18n} makeRequest={async conversationIds => { @@ -265,7 +273,6 @@ export const ConversationDetails: React.ComponentType = ({ setEditGroupAttributesRequestState(RequestState.Inactive); }} requestState={addGroupMembersRequestState} - theme={theme} /> ); break; diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index c31ddfda780..904ce7947a0 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -29,6 +29,7 @@ export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`; const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`; export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`; const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`; +const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`; type PropsType = { checked?: boolean; @@ -113,7 +114,12 @@ export const BaseConversationListItem: FunctionComponent = let actionNode: ReactNode; if (shouldShowSpinner) { actionNode = ( - + ); } else if (isCheckbox) { let ariaLabel: string; diff --git a/ts/components/conversationList/ContactListItem.tsx b/ts/components/conversationList/ContactListItem.tsx index 4d5226098f5..6937ae86b4a 100644 --- a/ts/components/conversationList/ContactListItem.tsx +++ b/ts/components/conversationList/ContactListItem.tsx @@ -30,6 +30,7 @@ export type ContactListItemConversationType = Pick< | 'title' | 'type' | 'unblurredAvatarPath' + | 'e164' >; type PropsDataType = ContactListItemConversationType & { diff --git a/ts/components/conversationList/PhoneNumberCheckbox.tsx b/ts/components/conversationList/PhoneNumberCheckbox.tsx new file mode 100644 index 00000000000..5c5c0239eef --- /dev/null +++ b/ts/components/conversationList/PhoneNumberCheckbox.tsx @@ -0,0 +1,112 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; + +import { ButtonVariant } from '../Button'; +import { ConfirmationDialog } from '../ConfirmationDialog'; +import { BaseConversationListItem } from './BaseConversationListItem'; +import type { ParsedE164Type } from '../../util/libphonenumberInstance'; +import type { LocalizerType, ThemeType } from '../../types/Util'; +import { AvatarColors } from '../../types/Colors'; +import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid'; + +export type PropsDataType = { + phoneNumber: ParsedE164Type; + isChecked: boolean; + isFetching: boolean; +}; + +type PropsHousekeepingType = { + i18n: LocalizerType; + theme: ThemeType; + toggleConversationInChooseMembers: (conversationId: string) => void; +} & LookupConversationWithoutUuidActionsType; + +type PropsType = PropsDataType & PropsHousekeepingType; + +export const PhoneNumberCheckbox: FunctionComponent = React.memo( + function PhoneNumberCheckbox({ + phoneNumber, + isChecked, + isFetching, + theme, + i18n, + lookupConversationWithoutUuid, + showUserNotFoundModal, + setIsFetchingUUID, + toggleConversationInChooseMembers, + }) { + const [isModalVisible, setIsModalVisible] = useState(false); + + const onClickItem = React.useCallback(async () => { + if (!phoneNumber.isValid) { + setIsModalVisible(true); + return; + } + if (isFetching) { + return; + } + + const conversationId = await lookupConversationWithoutUuid({ + showUserNotFoundModal, + setIsFetchingUUID, + + type: 'e164', + e164: phoneNumber.e164, + phoneNumber: phoneNumber.userInput, + }); + + if (conversationId !== undefined) { + toggleConversationInChooseMembers(conversationId); + } + }, [ + isFetching, + toggleConversationInChooseMembers, + lookupConversationWithoutUuid, + showUserNotFoundModal, + setIsFetchingUUID, + setIsModalVisible, + phoneNumber, + ]); + + let modal: JSX.Element | undefined; + if (isModalVisible) { + modal = ( + setIsModalVisible(false)} + > + {i18n('startConversation--phone-number-not-valid', { + phoneNumber: phoneNumber.userInput, + })} + + ); + } + + return ( + <> + + {modal} + + ); + } +); diff --git a/ts/components/conversationList/StartNewConversation.tsx b/ts/components/conversationList/StartNewConversation.tsx index 89b78c48f52..12b1ed9211a 100644 --- a/ts/components/conversationList/StartNewConversation.tsx +++ b/ts/components/conversationList/StartNewConversation.tsx @@ -2,54 +2,105 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { FunctionComponent } from 'react'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; -import { - BaseConversationListItem, - MESSAGE_TEXT_CLASS_NAME, -} from './BaseConversationListItem'; +import { ButtonVariant } from '../Button'; +import { ConfirmationDialog } from '../ConfirmationDialog'; +import { BaseConversationListItem } from './BaseConversationListItem'; +import type { ParsedE164Type } from '../../util/libphonenumberInstance'; +import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid'; import type { LocalizerType } from '../../types/Util'; import { AvatarColors } from '../../types/Colors'; -const TEXT_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__start-new-conversation`; - type PropsData = { - phoneNumber: string; + phoneNumber: ParsedE164Type; + isFetching: boolean; }; type PropsHousekeeping = { i18n: LocalizerType; - onClick: (phoneNumber: string) => void; -}; + showConversation: (conversationId: string) => void; +} & LookupConversationWithoutUuidActionsType; export type Props = PropsData & PropsHousekeeping; export const StartNewConversation: FunctionComponent = React.memo( - function StartNewConversation({ i18n, onClick, phoneNumber }) { - const messageText = ( -
{i18n('startConversation')}
- ); + function StartNewConversation({ + i18n, + phoneNumber, + isFetching, + lookupConversationWithoutUuid, + showUserNotFoundModal, + setIsFetchingUUID, + showConversation, + }) { + const [isModalVisible, setIsModalVisible] = useState(false); - const boundOnClick = useCallback(() => { - onClick(phoneNumber); - }, [onClick, phoneNumber]); + const boundOnClick = useCallback(async () => { + if (!phoneNumber.isValid) { + setIsModalVisible(true); + return; + } + if (isFetching) { + return; + } + const conversationId = await lookupConversationWithoutUuid({ + showUserNotFoundModal, + setIsFetchingUUID, + + type: 'e164', + e164: phoneNumber.e164, + phoneNumber: phoneNumber.userInput, + }); + + if (conversationId !== undefined) { + showConversation(conversationId); + } + }, [ + showConversation, + lookupConversationWithoutUuid, + showUserNotFoundModal, + setIsFetchingUUID, + setIsModalVisible, + phoneNumber, + isFetching, + ]); + + let modal: JSX.Element | undefined; + if (isModalVisible) { + modal = ( + setIsModalVisible(false)} + > + {i18n('startConversation--phone-number-not-valid', { + phoneNumber: phoneNumber.userInput, + })} + + ); + } return ( - + <> + + {modal} + ); } ); diff --git a/ts/components/conversationList/UsernameSearchResultListItem.tsx b/ts/components/conversationList/UsernameSearchResultListItem.tsx index ebbacff65d4..645cfb62366 100644 --- a/ts/components/conversationList/UsernameSearchResultListItem.tsx +++ b/ts/components/conversationList/UsernameSearchResultListItem.tsx @@ -2,12 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { FunctionComponent } from 'react'; -import React from 'react'; -import { noop } from 'lodash'; +import React, { useCallback } from 'react'; import { BaseConversationListItem } from './BaseConversationListItem'; import type { LocalizerType } from '../../types/Util'; +import { lookupConversationWithoutUuid } from '../../util/lookupConversationWithoutUuid'; +import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid'; type PropsData = { username: string; @@ -16,23 +17,42 @@ type PropsData = { type PropsHousekeeping = { i18n: LocalizerType; - onClick: (username: string) => void; -}; + showConversation: (conversationId: string) => void; +} & LookupConversationWithoutUuidActionsType; export type Props = PropsData & PropsHousekeeping; export const UsernameSearchResultListItem: FunctionComponent = ({ i18n, isFetchingUsername, - onClick, username, + showUserNotFoundModal, + setIsFetchingUUID, + showConversation, }) => { const usernameText = i18n('at-username', { username }); - const boundOnClick = isFetchingUsername - ? noop - : () => { - onClick(username); - }; + const boundOnClick = useCallback(async () => { + if (isFetchingUsername) { + return; + } + const conversationId = await lookupConversationWithoutUuid({ + showUserNotFoundModal, + setIsFetchingUUID, + + type: 'username', + username, + }); + + if (conversationId !== undefined) { + showConversation(conversationId); + } + }, [ + username, + showUserNotFoundModal, + setIsFetchingUUID, + showConversation, + isFetchingUsername, + ]); return ( { - const results = fuse.search(query.substr(0, 32)); + const results = fuse.search(query.substr(0, 32)).map(result => result.item); if (count) { return take(results, count); diff --git a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx index ca9fa016e1a..4dd4b9ee29c 100644 --- a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx +++ b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx @@ -18,46 +18,78 @@ import { } from '../AddGroupMemberErrorDialog'; import { Button } from '../Button'; import type { LocalizerType } from '../../types/Util'; +import type { ParsedE164Type } from '../../util/libphonenumberInstance'; +import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance'; +import type { UUIDFetchStateType } from '../../util/uuidFetchState'; +import { isFetchingByE164 } from '../../util/uuidFetchState'; import { getGroupSizeRecommendedLimit, getGroupSizeHardLimit, } from '../../groups/limits'; export type LeftPaneChooseGroupMembersPropsType = { + uuidFetchState: UUIDFetchStateType; candidateContacts: ReadonlyArray; isShowingRecommendedGroupSizeModal: boolean; isShowingMaximumGroupSizeModal: boolean; searchTerm: string; + regionCode: string | undefined; selectedContacts: Array; }; export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper { private readonly candidateContacts: ReadonlyArray; + private readonly isPhoneNumberChecked: boolean; + private readonly isShowingMaximumGroupSizeModal: boolean; private readonly isShowingRecommendedGroupSizeModal: boolean; private readonly searchTerm: string; + private readonly phoneNumber: ParsedE164Type | undefined; + private readonly selectedContacts: Array; private readonly selectedConversationIdsSet: Set; + private readonly uuidFetchState: UUIDFetchStateType; + constructor({ candidateContacts, isShowingMaximumGroupSizeModal, isShowingRecommendedGroupSizeModal, searchTerm, + regionCode, selectedContacts, + uuidFetchState, }: Readonly) { super(); + this.uuidFetchState = uuidFetchState; + this.candidateContacts = candidateContacts; this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal; this.isShowingRecommendedGroupSizeModal = isShowingRecommendedGroupSizeModal; this.searchTerm = searchTerm; + + const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode); + if (phoneNumber) { + this.isPhoneNumberChecked = + phoneNumber.isValid && + selectedContacts.some(contact => contact.e164 === phoneNumber.e164); + + const isVisible = this.candidateContacts.every( + contact => contact.e164 !== phoneNumber.e164 + ); + if (isVisible) { + this.phoneNumber = phoneNumber; + } + } else { + this.isPhoneNumberChecked = false; + } this.selectedContacts = selectedContacts; this.selectedConversationIdsSet = new Set( @@ -207,46 +239,90 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper 0) { + rowCount += 1; + } + + return rowCount; } - getRow(rowIndex: number): undefined | Row { - if (!this.candidateContacts.length) { + getRow(actualRowIndex: number): undefined | Row { + if (!this.candidateContacts.length && !this.phoneNumber) { return undefined; } - if (rowIndex === 0) { - return { - type: RowType.Header, - i18nKey: 'contactsHeader', - }; - } + const rowCount = this.getRowCount(); // This puts a blank row for the footer. - if (rowIndex === this.candidateContacts.length + 1) { + if (actualRowIndex === rowCount - 1) { return { type: RowType.Blank }; } - const contact = this.candidateContacts[rowIndex - 1]; - if (!contact) { - return undefined; + let virtualRowIndex = actualRowIndex; + + if (this.candidateContacts.length) { + if (virtualRowIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'contactsHeader', + }; + } + + if (virtualRowIndex <= this.candidateContacts.length) { + const contact = this.candidateContacts[virtualRowIndex - 1]; + + const isChecked = this.selectedConversationIdsSet.has(contact.id); + const disabledReason = + !isChecked && this.hasSelectedMaximumNumberOfContacts() + ? ContactCheckboxDisabledReason.MaximumContactsSelected + : undefined; + + return { + type: RowType.ContactCheckbox, + contact, + isChecked, + disabledReason, + }; + } + + virtualRowIndex -= 1 + this.candidateContacts.length; } - const isChecked = this.selectedConversationIdsSet.has(contact.id); - const disabledReason = - !isChecked && this.hasSelectedMaximumNumberOfContacts() - ? ContactCheckboxDisabledReason.MaximumContactsSelected - : undefined; + if (this.phoneNumber) { + if (virtualRowIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'findByPhoneNumberHeader', + }; + } + if (virtualRowIndex === 1) { + return { + type: RowType.PhoneNumberCheckbox, + isChecked: this.isPhoneNumberChecked, + isFetching: isFetchingByE164( + this.uuidFetchState, + this.phoneNumber.e164 + ), + phoneNumber: this.phoneNumber, + }; + } + virtualRowIndex -= 2; + } - return { - type: RowType.ContactCheckbox, - contact, - isChecked, - disabledReason, - }; + return undefined; } // This is deliberately unimplemented because these keyboard shortcuts shouldn't work in diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx index c7ee99f0829..49f47a103bb 100644 --- a/ts/components/leftPane/LeftPaneComposeHelper.tsx +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -3,7 +3,6 @@ import type { ReactChild, ChangeEvent } from 'react'; import React from 'react'; -import type { PhoneNumber } from 'google-libphonenumber'; import { LeftPaneHelper } from './LeftPaneHelper'; import type { Row } from '../ConversationList'; @@ -12,13 +11,15 @@ import type { ContactListItemConversationType } from '../conversationList/Contac import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem'; import { SearchInput } from '../SearchInput'; import type { LocalizerType } from '../../types/Util'; -import { - instance as phoneNumberInstance, - PhoneNumberFormat, -} from '../../util/libphonenumberInstance'; -import { assert } from '../../util/assert'; +import type { ParsedE164Type } from '../../util/libphonenumberInstance'; +import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance'; import { missingCaseError } from '../../util/missingCaseError'; import { getUsernameFromSearch } from '../../types/Username'; +import type { UUIDFetchStateType } from '../../util/uuidFetchState'; +import { + isFetchingByUsername, + isFetchingByE164, +} from '../../util/uuidFetchState'; export type LeftPaneComposePropsType = { composeContacts: ReadonlyArray; @@ -26,14 +27,13 @@ export type LeftPaneComposePropsType = { regionCode: string | undefined; searchTerm: string; - isFetchingUsername: boolean; + uuidFetchState: UUIDFetchStateType; isUsernamesEnabled: boolean; }; enum TopButton { None, CreateNewGroup, - StartNewConversation, } export class LeftPaneComposeHelper extends LeftPaneHelper { @@ -41,13 +41,15 @@ export class LeftPaneComposeHelper extends LeftPaneHelper; - private readonly isFetchingUsername: boolean; + private readonly uuidFetchState: UUIDFetchStateType; private readonly isUsernamesEnabled: boolean; private readonly searchTerm: string; - private readonly phoneNumber: undefined | PhoneNumber; + private readonly phoneNumber: ParsedE164Type | undefined; + + private readonly isPhoneNumberVisible: boolean; constructor({ composeContacts, @@ -55,15 +57,23 @@ export class LeftPaneComposeHelper extends LeftPaneHelper) { super(); this.composeContacts = composeContacts; this.composeGroups = composeGroups; this.searchTerm = searchTerm; - this.phoneNumber = parsePhoneNumber(searchTerm, regionCode); - this.isFetchingUsername = isFetchingUsername; + this.phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode); + if (this.phoneNumber) { + const { phoneNumber } = this; + this.isPhoneNumberVisible = this.composeContacts.every( + contact => contact.e164 !== phoneNumber.e164 + ); + } else { + this.isPhoneNumberVisible = false; + } + this.uuidFetchState = uuidFetchState; this.isUsernamesEnabled = isUsernamesEnabled; } @@ -141,6 +151,9 @@ export class LeftPaneComposeHelper extends LeftPaneHelper, - path: string + path: string | Array ): ReadonlyArray | string { // It'd be nice to avoid this cast, but Fuse's types don't allow it. - const rawValue = getOwn(conversation as Record, path); + const rawValue = get(conversation as Record, path); if (typeof rawValue !== 'string') { // It might make more sense to return `undefined` here, but [Fuse's types don't @@ -78,7 +78,7 @@ export class MemberRepository { this.isFuseReady = true; } - const results = this.fuse.search(`${pattern}`); + const results = this.fuse.search(pattern).map(result => result.item); if (omit) { return results.filter(({ id }) => id !== omit.id); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 0b5bea9c0b8..a8e4cd86a2e 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -21,15 +21,13 @@ import { getOwn } from '../../util/getOwn'; import { assert, strictAssert } from '../../util/assert'; import * as universalExpireTimer from '../../util/universalExpireTimer'; import { trigger } from '../../shims/events'; -import type { - ShowUsernameNotFoundModalActionType, - ToggleProfileEditorErrorActionType, -} from './globalModals'; -import { - TOGGLE_PROFILE_EDITOR_ERROR, - actions as globalModalActions, -} from './globalModals'; +import type { ToggleProfileEditorErrorActionType } from './globalModals'; +import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals'; import { isRecord } from '../../util/isRecord'; +import type { + UUIDFetchStateKeyType, + UUIDFetchStateType, +} from '../../util/uuidFetchState'; import type { AvatarColorType, @@ -45,7 +43,6 @@ 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, @@ -57,7 +54,6 @@ import { ContactSpoofingType } from '../../util/contactSpoofing'; import { writeProfile } from '../../services/writeProfile'; import { writeUsername } from '../../services/writeUsername'; import { - getConversationsByUsername, getConversationIdsStoppingSend, getConversationIdsStoppedForVerification, getMe, @@ -76,8 +72,6 @@ import { } from './conversationsEnums'; import { showToast } from '../../util/showToast'; import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername'; -import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername'; -import { isValidUsername } from '../../types/Username'; import { useBoundActions } from '../../hooks/useBoundActions'; import type { NoopActionType } from './noop'; @@ -288,20 +282,16 @@ export type ConversationVerificationData = canceledAt: number; }; -export type FoundUsernameType = { - uuid: UUIDStringType; - username: string; -}; - type ComposerStateType = | { step: ComposerStep.StartDirectConversation; searchTerm: string; - isFetchingUsername: boolean; + uuidFetchState: UUIDFetchStateType; } | ({ step: ComposerStep.ChooseGroupMembers; searchTerm: string; + uuidFetchState: UUIDFetchStateType; } & ComposerGroupCreationState) | ({ step: ComposerStep.SetGroupMetadata; @@ -677,10 +667,11 @@ type SetComposeSearchTermActionType = { type: 'SET_COMPOSE_SEARCH_TERM'; payload: { searchTerm: string }; }; -type SetIsFetchingUsernameActionType = { - type: 'SET_IS_FETCHING_USERNAME'; +type SetIsFetchingUUIDActionType = { + type: 'SET_IS_FETCHING_UUID'; payload: { - isFetchingUsername: boolean; + identifier: UUIDFetchStateKeyType; + isFetching: boolean; }; }; type SetRecentMediaItemsActionType = { @@ -773,7 +764,7 @@ export type ConversationActionType = | SetComposeGroupNameActionType | SetComposeSearchTermActionType | SetConversationHeaderTitleActionType - | SetIsFetchingUsernameActionType + | SetIsFetchingUUIDActionType | SetIsNearBottomActionType | SetMessageLoadingStateActionType | SetPreJoinConversationActionType @@ -840,6 +831,7 @@ export const actions = { setComposeGroupExpireTimer, setComposeGroupName, setComposeSearchTerm, + setIsFetchingUUID, setIsNearBottom, setMessageLoadingState, setPreJoinConversation, @@ -849,9 +841,8 @@ export const actions = { showArchivedConversations, showChooseGroupMembers, showInbox, + showConversation, startComposing, - startNewConversationFromPhoneNumber, - startNewConversationFromUsername, startSettingGroupMetadata, toggleAdmin, toggleConversationInChooseMembers, @@ -1661,6 +1652,18 @@ function setIsNearBottom( }, }; } +function setIsFetchingUUID( + identifier: UUIDFetchStateKeyType, + isFetching: boolean +): SetIsFetchingUUIDActionType { + return { + type: 'SET_IS_FETCHING_UUID', + payload: { + identifier, + isFetching, + }, + }; +} function setSelectedConversationHeaderTitle( title?: string ): SetConversationHeaderTitleActionType { @@ -1772,117 +1775,6 @@ function showChooseGroupMembers(): ShowChooseGroupMembersActionType { return { type: 'SHOW_CHOOSE_GROUP_MEMBERS' }; } -function startNewConversationFromPhoneNumber( - e164: string -): ThunkAction { - return dispatch => { - trigger('showConversation', e164); - - dispatch(showInbox()); - }; -} - -async function checkForUsername( - username: string -): Promise { - if (!isValidUsername(username)) { - return undefined; - } - - try { - const profile = await window.textsecure.messaging.getProfileForUsername( - username - ); - - if (!profile.uuid) { - log.error("checkForUsername: Returned profile didn't include a uuid"); - return; - } - - return { - uuid: UUID.cast(profile.uuid), - 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' }; } @@ -2029,6 +1921,14 @@ function showInbox(): ShowInboxActionType { payload: null, }; } +function showConversation( + conversationId: string +): ThunkAction { + return dispatch => { + trigger('showConversation', conversationId); + dispatch(showInbox()); + }; +} function showArchivedConversations(): ShowArchivedConversationsActionType { return { type: 'SHOW_ARCHIVED_CONVERSATIONS', @@ -3060,7 +2960,7 @@ export function reducer( composer: { step: ComposerStep.StartDirectConversation, searchTerm: '', - isFetchingUsername: false, + uuidFetchState: {}, }, }; } @@ -3103,6 +3003,7 @@ export function reducer( composer: { step: ComposerStep.ChooseGroupMembers, searchTerm: '', + uuidFetchState: {}, selectedConversationIds, recommendedGroupSizeModalState, maximumGroupSizeModalState, @@ -3235,26 +3136,36 @@ export function reducer( }; } - if (action.type === 'SET_IS_FETCHING_USERNAME') { + if (action.type === 'SET_IS_FETCHING_UUID') { const { composer } = state; if (!composer) { assert( false, - 'Setting compose username with the composer closed is a no-op' + 'Setting compose uuid fetch state 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'); + if ( + composer.step !== ComposerStep.StartDirectConversation && + composer.step !== ComposerStep.ChooseGroupMembers + ) { + assert(false, 'Setting compose uuid fetch state at this step is a no-op'); return state; } - const { isFetchingUsername } = action.payload; + const { identifier, isFetching } = action.payload; + + const { uuidFetchState } = composer; return { ...state, composer: { ...composer, - isFetchingUsername, + uuidFetchState: isFetching + ? { + ...composer.uuidFetchState, + [identifier]: isFetching, + } + : omit(uuidFetchState, identifier), }, }; } diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 0bf3bcd7fd1..bedefce1ce5 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -9,7 +9,7 @@ export type GlobalModalsStateType = { readonly isWhatsNewVisible: boolean; readonly profileEditorHasError: boolean; readonly safetyNumberModalContactId?: string; - readonly usernameNotFoundModalState?: UsernameNotFoundModalStateType; + readonly userNotFoundModalState?: UserNotFoundModalStateType; }; // Actions @@ -17,10 +17,8 @@ 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 SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL'; +const HIDE_UUID_NOT_FOUND_MODAL = 'globalModals/HIDE_UUID_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 = @@ -32,9 +30,15 @@ export type ContactModalStateType = { conversationId?: string; }; -export type UsernameNotFoundModalStateType = { - username: string; -}; +export type UserNotFoundModalStateType = + | { + type: 'phoneNumber'; + phoneNumber: string; + } + | { + type: 'username'; + username: string; + }; type HideContactModalActionType = { type: typeof HIDE_CONTACT_MODAL; @@ -53,15 +57,13 @@ type ShowWhatsNewModalActionType = { type: typeof SHOW_WHATS_NEW_MODAL; }; -type HideUsernameNotFoundModalActionType = { - type: typeof HIDE_USERNAME_NOT_FOUND_MODAL; +type HideUserNotFoundModalActionType = { + type: typeof HIDE_UUID_NOT_FOUND_MODAL; }; -export type ShowUsernameNotFoundModalActionType = { - type: typeof SHOW_USERNAME_NOT_FOUND_MODAL; - payload: { - username: string; - }; +export type ShowUserNotFoundModalActionType = { + type: typeof SHOW_UUID_NOT_FOUND_MODAL; + payload: UserNotFoundModalStateType; }; type ToggleProfileEditorActionType = { @@ -82,8 +84,8 @@ export type GlobalModalsActionType = | ShowContactModalActionType | HideWhatsNewModalActionType | ShowWhatsNewModalActionType - | HideUsernameNotFoundModalActionType - | ShowUsernameNotFoundModalActionType + | HideUserNotFoundModalActionType + | ShowUserNotFoundModalActionType | ToggleProfileEditorActionType | ToggleProfileEditorErrorActionType | ToggleSafetyNumberModalActionType; @@ -95,8 +97,8 @@ export const actions = { showContactModal, hideWhatsNewModal, showWhatsNewModal, - hideUsernameNotFoundModal, - showUsernameNotFoundModal, + hideUserNotFoundModal, + showUserNotFoundModal, toggleProfileEditor, toggleProfileEditorHasError, toggleSafetyNumberModal, @@ -133,20 +135,18 @@ function showWhatsNewModal(): ShowWhatsNewModalActionType { }; } -function hideUsernameNotFoundModal(): HideUsernameNotFoundModalActionType { +function hideUserNotFoundModal(): HideUserNotFoundModalActionType { return { - type: HIDE_USERNAME_NOT_FOUND_MODAL, + type: HIDE_UUID_NOT_FOUND_MODAL, }; } -function showUsernameNotFoundModal( - username: string -): ShowUsernameNotFoundModalActionType { +function showUserNotFoundModal( + payload: UserNotFoundModalStateType +): ShowUserNotFoundModalActionType { return { - type: SHOW_USERNAME_NOT_FOUND_MODAL, - payload: { - username, - }, + type: SHOW_UUID_NOT_FOUND_MODAL, + payload, }; } @@ -209,20 +209,18 @@ export function reducer( }; } - if (action.type === HIDE_USERNAME_NOT_FOUND_MODAL) { + if (action.type === HIDE_UUID_NOT_FOUND_MODAL) { return { ...state, - usernameNotFoundModalState: undefined, + userNotFoundModalState: undefined, }; } - if (action.type === SHOW_USERNAME_NOT_FOUND_MODAL) { - const { username } = action.payload; - + if (action.type === SHOW_UUID_NOT_FOUND_MODAL) { return { ...state, - usernameNotFoundModalState: { - username, + userNotFoundModalState: { + ...action.payload, }, }; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 384a41272f0..ea23ef73be8 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -25,6 +25,7 @@ import { } from '../ducks/conversationsEnums'; import { getOwn } from '../../util/getOwn'; import { isNotNil } from '../../util/isNotNil'; +import type { UUIDFetchStateType } from '../../util/uuidFetchState'; import { deconstructLookup } from '../../util/deconstructLookup'; import type { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; import type { TimelineItemType } from '../../components/conversation/TimelineItem'; @@ -394,21 +395,25 @@ export const getComposerConversationSearchTerm = createSelector( } ); -export const getIsFetchingUsername = createSelector( +export const getComposerUUIDFetchState = createSelector( getComposerState, - (composer): boolean => { + (composer): UUIDFetchStateType => { if (!composer) { assert(false, 'getIsFetchingUsername: composer is not open'); - return false; + return {}; } - if (composer.step !== ComposerStep.StartDirectConversation) { + if ( + composer.step !== ComposerStep.StartDirectConversation && + composer.step !== ComposerStep.ChooseGroupMembers + ) { assert( false, - `getIsFetchingUsername: step ${composer.step} has no isFetchingUsername key` + `getComposerUUIDFetchState: step ${composer.step} ` + + 'has no uuidFetchState key' ); - return false; + return {}; } - return composer.isFetchingUsername; + return composer.uuidFetchState; } ); @@ -512,28 +517,33 @@ const getNormalizedComposerConversationSearchTerm = createSelector( export const getFilteredComposeContacts = createSelector( getNormalizedComposerConversationSearchTerm, getComposableContacts, + getRegionCode, ( searchTerm: string, - contacts: Array + contacts: Array, + regionCode: string | undefined ): Array => { - return filterAndSortConversationsByTitle(contacts, searchTerm); + return filterAndSortConversationsByTitle(contacts, searchTerm, regionCode); } ); export const getFilteredComposeGroups = createSelector( getNormalizedComposerConversationSearchTerm, getComposableGroups, + getRegionCode, ( searchTerm: string, - groups: Array + groups: Array, + regionCode: string | undefined ): Array => { - return filterAndSortConversationsByTitle(groups, searchTerm); + return filterAndSortConversationsByTitle(groups, searchTerm, regionCode); } ); export const getFilteredCandidateContactsForNewGroup = createSelector( getCandidateContactsForNewGroup, getNormalizedComposerConversationSearchTerm, + getRegionCode, filterAndSortConversationsByTitle ); diff --git a/ts/state/smart/ChooseGroupMembersModal.tsx b/ts/state/smart/ChooseGroupMembersModal.tsx new file mode 100644 index 00000000000..765e2a0aeff --- /dev/null +++ b/ts/state/smart/ChooseGroupMembersModal.tsx @@ -0,0 +1,63 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; + +import type { StateType } from '../reducer'; +import { mapDispatchToProps } from '../actions'; +import { strictAssert } from '../../util/assert'; +import { lookupConversationWithoutUuid } from '../../util/lookupConversationWithoutUuid'; + +import type { StatePropsType } from '../../components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal'; +import { ChooseGroupMembersModal } from '../../components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal'; + +import { getIntl, getTheme, getRegionCode } from '../selectors/user'; +import { + getCandidateContactsForNewGroup, + getConversationByIdSelector, +} from '../selectors/conversations'; +import { getPreferredBadgeSelector } from '../selectors/badges'; + +export type SmartChooseGroupMembersModalPropsType = { + conversationIdsAlreadyInGroup: Set; + maxGroupSize: number; + confirmAdds: () => void; + onClose: () => void; + removeSelectedContact: (_: string) => void; + searchTerm: string; + selectedConversationIds: ReadonlyArray; + setSearchTerm: (_: string) => void; + toggleSelectedContact: (conversationId: string) => void; +}; + +const mapStateToProps = ( + state: StateType, + props: SmartChooseGroupMembersModalPropsType +): StatePropsType => { + const conversationSelector = getConversationByIdSelector(state); + + const candidateContacts = getCandidateContactsForNewGroup(state); + const selectedContacts = props.selectedConversationIds.map(conversationId => { + const convo = conversationSelector(conversationId); + strictAssert( + convo, + ' selected conversation not found' + ); + return convo; + }); + + return { + ...props, + regionCode: getRegionCode(state), + candidateContacts, + getPreferredBadge: getPreferredBadgeSelector(state), + i18n: getIntl(state), + theme: getTheme(state), + selectedContacts, + lookupConversationWithoutUuid, + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartChooseGroupMembersModal = smart(ChooseGroupMembersModal); diff --git a/ts/state/smart/ConfirmAdditionsModal.tsx b/ts/state/smart/ConfirmAdditionsModal.tsx new file mode 100644 index 00000000000..846985f8bb4 --- /dev/null +++ b/ts/state/smart/ConfirmAdditionsModal.tsx @@ -0,0 +1,49 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; + +import type { StateType } from '../reducer'; +import { mapDispatchToProps } from '../actions'; +import { strictAssert } from '../../util/assert'; + +import type { StatePropsType } from '../../components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal'; +import { ConfirmAdditionsModal } from '../../components/conversation/conversation-details/AddGroupMembersModal/ConfirmAdditionsModal'; +import type { RequestState } from '../../components/conversation/conversation-details/util'; + +import { getIntl } from '../selectors/user'; +import { getConversationByIdSelector } from '../selectors/conversations'; + +export type SmartConfirmAdditionsModalPropsType = { + selectedConversationIds: ReadonlyArray; + groupTitle: string; + makeRequest: () => void; + onClose: () => void; + requestState: RequestState; +}; + +const mapStateToProps = ( + state: StateType, + props: SmartConfirmAdditionsModalPropsType +): StatePropsType => { + const conversationSelector = getConversationByIdSelector(state); + + const selectedContacts = props.selectedConversationIds.map(conversationId => { + const convo = conversationSelector(conversationId); + strictAssert( + convo, + ' selected conversation not found' + ); + return convo; + }); + + return { + ...props, + selectedContacts, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartConfirmAdditionsModal = smart(ConfirmAdditionsModal); diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 3150160d193..80b3cfdbec1 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -1,6 +1,7 @@ // Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import React from 'react'; import { connect } from 'react-redux'; import type { StateType } from '../reducer'; @@ -8,7 +9,6 @@ import { mapDispatchToProps } from '../actions'; import type { StateProps } from '../../components/conversation/conversation-details/ConversationDetails'; import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails'; import { - getCandidateContactsForNewGroup, getConversationByIdSelector, getConversationByUuidSelector, } from '../selectors/conversations'; @@ -24,6 +24,10 @@ import { import { assert } from '../../util/assert'; import { SignalService as Proto } from '../../protobuf'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; +import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembersModal'; +import { SmartChooseGroupMembersModal } from './ChooseGroupMembersModal'; +import type { SmartConfirmAdditionsModalPropsType } from './ConfirmAdditionsModal'; +import { SmartConfirmAdditionsModal } from './ConfirmAdditionsModal'; export type SmartConversationDetailsProps = { addMembers: (conversationIds: ReadonlyArray) => Promise; @@ -56,6 +60,18 @@ export type SmartConversationDetailsProps = { const ACCESS_ENUM = Proto.AccessControl.AccessRequired; +const renderChooseGroupMembersModal = ( + props: SmartChooseGroupMembersModalPropsType +) => { + return ; +}; + +const renderConfirmAdditionsModal = ( + props: SmartConfirmAdditionsModalPropsType +) => { + return ; +}; + const mapStateToProps = ( state: StateType, props: SmartConversationDetailsProps @@ -69,7 +85,6 @@ const mapStateToProps = ( const canEditGroupInfo = Boolean(conversation.canEditGroupInfo); const isAdmin = Boolean(conversation.areWeAdmin); - const candidateContactsToAdd = getCandidateContactsForNewGroup(state); const hasGroupLink = Boolean(conversation.groupLink) && @@ -88,7 +103,6 @@ const mapStateToProps = ( areWeASubscriber: getAreWeASubscriber(state), badges, canEditGroupInfo, - candidateContactsToAdd, conversation: { ...conversation, ...getConversationColorAttributes(conversation), @@ -102,6 +116,8 @@ const mapStateToProps = ( hasGroupLink, isGroup: conversation.type === 'group', theme: getTheme(state), + renderChooseGroupMembersModal, + renderConfirmAdditionsModal, }; }; diff --git a/ts/state/smart/ForwardMessageModal.tsx b/ts/state/smart/ForwardMessageModal.tsx index 62558b5208f..de2fd2abb57 100644 --- a/ts/state/smart/ForwardMessageModal.tsx +++ b/ts/state/smart/ForwardMessageModal.tsx @@ -11,7 +11,7 @@ import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { getAllComposableConversations } from '../selectors/conversations'; import { getLinkPreview } from '../selectors/linkPreviews'; -import { getIntl, getTheme } from '../selectors/user'; +import { getIntl, getTheme, getRegionCode } from '../selectors/user'; import { getEmojiSkinTone } from '../selectors/items'; import { selectRecentEmojis } from '../selectors/emojis'; import type { AttachmentType } from '../../types/Attachment'; @@ -69,6 +69,7 @@ const mapStateToProps = ( skinTone, onTextTooLong, theme: getTheme(state), + regionCode: getRegionCode(state), }; }; diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index b84b55ebdf9..1e4c2682972 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -9,6 +9,7 @@ import type { PropsType as LeftPanePropsType } from '../../components/LeftPane'; import { LeftPane, LeftPaneMode } from '../../components/LeftPane'; import type { StateType } from '../reducer'; import { missingCaseError } from '../../util/missingCaseError'; +import { lookupConversationWithoutUuid } from '../../util/lookupConversationWithoutUuid'; import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums'; import { @@ -32,11 +33,11 @@ import { getComposeGroupName, getComposerConversationSearchTerm, getComposerStep, + getComposerUUIDFetchState, getComposeSelectedContacts, getFilteredCandidateContactsForNewGroup, getFilteredComposeContacts, getFilteredComposeGroups, - getIsFetchingUsername, getLeftPaneLists, getMaximumGroupSizeModalState, getRecommendedGroupSizeModalState, @@ -141,7 +142,7 @@ const getModeSpecificProps = ( regionCode: getRegionCode(state), searchTerm: getComposerConversationSearchTerm(state), isUsernamesEnabled: getUsernamesEnabled(state), - isFetchingUsername: getIsFetchingUsername(state), + uuidFetchState: getComposerUUIDFetchState(state), }; case ComposerStep.ChooseGroupMembers: return { @@ -152,8 +153,10 @@ const getModeSpecificProps = ( OneTimeModalState.Showing, isShowingMaximumGroupSizeModal: getMaximumGroupSizeModalState(state) === OneTimeModalState.Showing, + regionCode: getRegionCode(state), searchTerm: getComposerConversationSearchTerm(state), selectedContacts: getComposeSelectedContacts(state), + uuidFetchState: getComposerUUIDFetchState(state), }; case ComposerStep.SetGroupMetadata: return { @@ -192,6 +195,7 @@ const mapStateToProps = (state: StateType) => { renderUpdateDialog, renderCaptchaDialog, renderCrashReportDialog, + lookupConversationWithoutUuid, theme: getTheme(state), }; }; diff --git a/ts/test-both/helpers/defaultComposerStates.ts b/ts/test-both/helpers/defaultComposerStates.ts index d4483fd0e33..7fa639ac691 100644 --- a/ts/test-both/helpers/defaultComposerStates.ts +++ b/ts/test-both/helpers/defaultComposerStates.ts @@ -7,12 +7,13 @@ import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAdd export const defaultStartDirectConversationComposerState = { step: ComposerStep.StartDirectConversation as const, searchTerm: '', - isFetchingUsername: false, + uuidFetchState: {}, }; export const defaultChooseGroupMembersComposerState = { step: ComposerStep.ChooseGroupMembers as const, searchTerm: '', + uuidFetchState: {}, groupAvatar: undefined, groupName: '', groupExpireTimer: 0, diff --git a/ts/test-both/helpers/fakeLookupConversationWithoutUuid.ts b/ts/test-both/helpers/fakeLookupConversationWithoutUuid.ts new file mode 100644 index 00000000000..bace33ef9cb --- /dev/null +++ b/ts/test-both/helpers/fakeLookupConversationWithoutUuid.ts @@ -0,0 +1,103 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useState } from 'react'; + +import type { + UUIDFetchStateType, + UUIDFetchStateKeyType, +} from '../../util/uuidFetchState'; +import type { lookupConversationWithoutUuid } from '../../util/lookupConversationWithoutUuid'; +import { sleep } from '../../util/sleep'; +import * as durations from '../../util/durations'; +import type { ConversationType } from '../../state/ducks/conversations'; +import { getDefaultConversation } from './getDefaultConversation'; + +const VALID_IDENTIFIERS = new Set([ + 'e164:+12125551234', + 'username:bobross', +]); + +export function makeFakeLookupConversationWithoutUuid( + saveConversation?: (convo: ConversationType) => void +): typeof lookupConversationWithoutUuid { + const cache = new Map(); + + return async options => { + const identifier: UUIDFetchStateKeyType = + options.type === 'e164' + ? `e164:${options.e164}` + : `username:${options.username}`; + + let result = cache.get(identifier); + if (result) { + return result.id; + } + + if (VALID_IDENTIFIERS.has(identifier) && saveConversation) { + result = getDefaultConversation({ + // We don't really know anything about the contact + firstName: undefined, + avatarPath: undefined, + name: undefined, + profileName: undefined, + + ...(options.type === 'e164' + ? { + title: options.e164, + e164: options.e164, + phoneNumber: options.e164, + } + : { + title: `@${options.username}`, + username: options.username, + }), + }); + cache.set(identifier, result); + + saveConversation(result); + } + + options.setIsFetchingUUID(identifier, true); + + await sleep(durations.SECOND); + + options.setIsFetchingUUID(identifier, false); + + if (!result) { + options.showUserNotFoundModal( + options.type === 'username' + ? options + : { + type: 'phoneNumber', + phoneNumber: options.phoneNumber, + } + ); + return undefined; + } + + return result.id; + }; +} + +type SetIsFetchingUUIDType = ( + identifier: UUIDFetchStateKeyType, + isFetching: boolean +) => void; + +export function useUuidFetchState( + initial: UUIDFetchStateType = {} +): [UUIDFetchStateType, SetIsFetchingUUIDType] { + const [uuidFetchState, setUuidFetchState] = useState(initial); + + const setIsFetchingUUID: SetIsFetchingUUIDType = (key, value) => { + setUuidFetchState(prev => { + return { + ...prev, + [key]: value, + }; + }); + }; + + return [uuidFetchState, setIsFetchingUUID]; +} diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index d5cd035acfa..9fb7b2ec969 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -977,8 +977,7 @@ describe('both/state/selectors/conversations', () => { const result = getFilteredComposeContacts(state); const ids = result.map(contact => contact.id); - // NOTE: convo-6 matches because you can't write "Sharing" without "in" - assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-6']); + assert.deepEqual(ids, ['convo-1', 'convo-5']); }); it('can search for note to self', () => { diff --git a/ts/test-both/util/filterAndSortConversations_test.ts b/ts/test-both/util/filterAndSortConversations_test.ts index cf50eb68d14..845fe4672fa 100644 --- a/ts/test-both/util/filterAndSortConversations_test.ts +++ b/ts/test-both/util/filterAndSortConversations_test.ts @@ -38,9 +38,11 @@ describe('filterAndSortConversationsByTitle', () => { ]; it('without a search term, sorts conversations by title (but puts no-name contacts at the bottom)', () => { - const titles = filterAndSortConversationsByTitle(conversations, '').map( - contact => contact.title - ); + const titles = filterAndSortConversationsByTitle( + conversations, + '', + 'US' + ).map(contact => contact.title); assert.deepEqual(titles, [ 'Aaron Aardvark', 'Belinda Beetle', @@ -53,7 +55,8 @@ describe('filterAndSortConversationsByTitle', () => { it('can search for contacts by title', () => { const titles = filterAndSortConversationsByTitle( conversations, - 'belind' + 'belind', + 'US' ).map(contact => contact.title); assert.sameMembers(titles, ['Belinda Beetle', 'Belinda Zephyr']); }); @@ -61,15 +64,26 @@ describe('filterAndSortConversationsByTitle', () => { it('can search for contacts by phone number (and puts no-name contacts at the bottom)', () => { const titles = filterAndSortConversationsByTitle( conversations, - '650555' + '650555', + 'US' ).map(contact => contact.title); assert.sameMembers(titles, ['Carlos Santana', '+16505551234']); }); + it('can search for contacts by formatted phone number (and puts no-name contacts at the bottom)', () => { + const titles = filterAndSortConversationsByTitle( + conversations, + '(650)555 12-34', + 'US' + ).map(contact => contact.title); + assert.sameMembers(titles, ['+16505551234']); + }); + it('can search for contacts by username', () => { const titles = filterAndSortConversationsByTitle( conversations, - 'thisis' + 'thisis', + 'US' ).map(contact => contact.title); assert.sameMembers(titles, ['Carlos Santana']); }); @@ -100,9 +114,11 @@ describe('filterAndSortConversationsByRecent', () => { ]; it('sorts by recency when no search term is provided', () => { - const titles = filterAndSortConversationsByRecent(conversations, '').map( - contact => contact.title - ); + const titles = filterAndSortConversationsByRecent( + conversations, + '', + 'US' + ).map(contact => contact.title); assert.sameMembers(titles, [ '+16505551234', 'George Washington', diff --git a/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts index 8f4a776d167..316173b1d39 100644 --- a/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts @@ -13,10 +13,12 @@ import { updateRemoteConfig } from '../../../test-both/helpers/RemoteConfigStub' describe('LeftPaneChooseGroupMembersHelper', () => { const defaults = { + uuidFetchState: {}, candidateContacts: [], isShowingRecommendedGroupSizeModal: false, isShowingMaximumGroupSizeModal: false, searchTerm: '', + regionCode: 'US', selectedContacts: [], }; diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts index 375f611f19a..cdac2e02982 100644 --- a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts @@ -29,7 +29,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.strictEqual(helper.getBackAction({ showInbox }), showInbox); @@ -45,7 +45,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 1 ); @@ -59,7 +59,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 4 ); @@ -73,7 +73,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 7 ); @@ -87,7 +87,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'someone', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 8 ); @@ -101,7 +101,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'someone', isUsernamesEnabled: false, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 6 ); @@ -115,7 +115,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 2 ); @@ -126,7 +126,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 5 ); @@ -137,13 +137,13 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 7 ); }); - it('returns 1 (for the "Start new conversation" button) if searching for a phone number with no contacts', () => { + it('returns 2 (for the "Start new conversation" button) if searching for a phone number with no contacts', () => { assert.strictEqual( new LeftPaneComposeHelper({ composeContacts: [], @@ -151,9 +151,9 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '+16505551234', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), - 1 + 2 ); }); @@ -165,13 +165,13 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'someone', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), 2 ); }); - it('returns the number of contacts + 4 (for the "Start new conversation" button and header) if searching for a phone number', () => { + it('returns the number of contacts + 2 (for the "Start new conversation" button and header) if searching for a phone number', () => { assert.strictEqual( new LeftPaneComposeHelper({ composeContacts: [getDefaultConversation(), getDefaultConversation()], @@ -179,9 +179,9 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '+16505551234', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }).getRowCount(), - 4 + 5 ); }); }); @@ -194,7 +194,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.deepEqual(helper.getRow(0), { @@ -214,7 +214,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.deepEqual(helper.getRow(0), { @@ -249,7 +249,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.deepEqual(helper.getRow(0), { @@ -288,7 +288,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: false, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isUndefined(helper.getRow(0)); @@ -306,7 +306,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.deepEqual(helper.getRow(1), { @@ -324,21 +324,29 @@ describe('LeftPaneComposeHelper', () => { composeContacts: [], composeGroups: [], regionCode: 'US', - searchTerm: '+16505551234', + searchTerm: '+1(650) 555 12 34', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.deepEqual(helper.getRow(0), { - type: RowType.StartNewConversation, - phoneNumber: '+16505551234', + type: RowType.Header, + i18nKey: 'findByPhoneNumberHeader', }); - assert.isUndefined(helper.getRow(1)); + assert.deepEqual(helper.getRow(1), { + type: RowType.StartNewConversation, + phoneNumber: { + isValid: true, + userInput: '+1(650) 555 12 34', + e164: '+16505551234', + }, + isFetching: false, + }); + assert.isUndefined(helper.getRow(2)); }); it('returns just a "find by username" header if no results', () => { const username = 'someone'; - const isFetchingUsername = true; const helper = new LeftPaneComposeHelper({ composeContacts: [], @@ -346,7 +354,9 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: username, isUsernamesEnabled: true, - isFetchingUsername, + uuidFetchState: { + [`username:${username}`]: true, + }, }); assert.deepEqual(helper.getRow(0), { @@ -356,7 +366,7 @@ describe('LeftPaneComposeHelper', () => { assert.deepEqual(helper.getRow(1), { type: RowType.UsernameSearchResult, username, - isFetchingUsername, + isFetchingUsername: true, }); assert.isUndefined(helper.getRow(2)); }); @@ -370,27 +380,36 @@ describe('LeftPaneComposeHelper', () => { composeContacts, composeGroups: [], regionCode: 'US', - searchTerm: '+16505551234', + searchTerm: '+1(650) 555 12 34', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.deepEqual(helper.getRow(0), { - type: RowType.StartNewConversation, - phoneNumber: '+16505551234', - }); - assert.deepEqual(helper.getRow(1), { type: RowType.Header, i18nKey: 'contactsHeader', }); - assert.deepEqual(helper.getRow(2), { + assert.deepEqual(helper.getRow(1), { type: RowType.Contact, contact: composeContacts[0], }); - assert.deepEqual(helper.getRow(3), { + assert.deepEqual(helper.getRow(2), { type: RowType.Contact, contact: composeContacts[1], }); + assert.deepEqual(helper.getRow(3), { + type: RowType.Header, + i18nKey: 'findByPhoneNumberHeader', + }); + assert.deepEqual(helper.getRow(4), { + type: RowType.StartNewConversation, + phoneNumber: { + isValid: true, + userInput: '+1(650) 555 12 34', + e164: '+16505551234', + }, + isFetching: false, + }); }); }); @@ -402,7 +421,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isUndefined(helper.getConversationAndMessageAtIndex(0)); @@ -417,7 +436,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isUndefined( @@ -438,7 +457,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isFalse( @@ -448,7 +467,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'different search', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); assert.isFalse( @@ -458,7 +477,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'last search', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); }); @@ -470,7 +489,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isFalse( @@ -480,17 +499,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, - }) - ); - assert.isFalse( - helper.shouldRecomputeRowHeights({ - composeContacts: [getDefaultConversation()], - composeGroups: [], - regionCode: 'US', - searchTerm: '+16505559876', - isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); }); @@ -502,7 +511,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isTrue( @@ -512,7 +521,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); assert.isTrue( @@ -522,7 +531,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '+16505551234', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); }); @@ -534,7 +543,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: '', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isTrue( @@ -544,7 +553,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); }); @@ -556,7 +565,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isTrue( @@ -566,7 +575,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); @@ -576,7 +585,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isTrue( @@ -586,7 +595,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'foo bar', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); }); @@ -598,7 +607,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'soup', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }); assert.isTrue( @@ -608,7 +617,7 @@ describe('LeftPaneComposeHelper', () => { regionCode: 'US', searchTerm: 'soup', isUsernamesEnabled: true, - isFetchingUsername: false, + uuidFetchState: {}, }) ); }); diff --git a/ts/util/filterAndSortConversations.ts b/ts/util/filterAndSortConversations.ts index c72ac33e952..3c1e7e3d2aa 100644 --- a/ts/util/filterAndSortConversations.ts +++ b/ts/util/filterAndSortConversations.ts @@ -1,16 +1,16 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { FuseOptions } from 'fuse.js'; import Fuse from 'fuse.js'; import type { ConversationType } from '../state/ducks/conversations'; +import { parseAndFormatPhoneNumber } from './libphonenumberInstance'; -const FUSE_OPTIONS: FuseOptions = { +const FUSE_OPTIONS: Fuse.IFuseOptions = { // A small-but-nonzero threshold lets us match parts of E164s better, and makes the // search a little more forgiving. - threshold: 0.05, - tokenize: true, + threshold: 0.1, + useExtendedSearch: true, keys: [ { name: 'searchableTitle', @@ -37,21 +37,45 @@ const FUSE_OPTIONS: FuseOptions = { const collator = new Intl.Collator(); +const cachedIndices = new WeakMap< + ReadonlyArray, + Fuse +>(); + +// See https://fusejs.io/examples.html#extended-search for +// extended search documentation. function searchConversations( conversations: ReadonlyArray, - searchTerm: string + searchTerm: string, + regionCode: string | undefined ): Array { - return new Fuse(conversations, FUSE_OPTIONS).search( - searchTerm - ); + const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode); + + // Escape the search term + let extendedSearchTerm = searchTerm; + + // OR phoneNumber + if (phoneNumber) { + extendedSearchTerm += ` | ${phoneNumber.e164}`; + } + + let index = cachedIndices.get(conversations); + if (!index) { + index = new Fuse(conversations, FUSE_OPTIONS); + cachedIndices.set(conversations, index); + } + + const results = index.search(extendedSearchTerm); + return results.map(result => result.item); } export function filterAndSortConversationsByRecent( conversations: ReadonlyArray, - searchTerm: string + searchTerm: string, + regionCode: string | undefined ): Array { if (searchTerm.length) { - return searchConversations(conversations, searchTerm); + return searchConversations(conversations, searchTerm, regionCode); } return conversations.concat().sort((a, b) => { @@ -65,10 +89,11 @@ export function filterAndSortConversationsByRecent( export function filterAndSortConversationsByTitle( conversations: ReadonlyArray, - searchTerm: string + searchTerm: string, + regionCode: string | undefined ): Array { if (searchTerm.length) { - return searchConversations(conversations, searchTerm); + return searchConversations(conversations, searchTerm, regionCode); } return conversations.concat().sort((a, b) => { diff --git a/ts/util/libphonenumberInstance.ts b/ts/util/libphonenumberInstance.ts index 402ecb48a0b..8edc259070f 100644 --- a/ts/util/libphonenumberInstance.ts +++ b/ts/util/libphonenumberInstance.ts @@ -2,8 +2,34 @@ // SPDX-License-Identifier: AGPL-3.0-only import libphonenumber from 'google-libphonenumber'; +import type { PhoneNumber } from 'google-libphonenumber'; const instance = libphonenumber.PhoneNumberUtil.getInstance(); const { PhoneNumberFormat } = libphonenumber; export { instance, PhoneNumberFormat }; + +export type ParsedE164Type = Readonly<{ + isValid: boolean; + userInput: string; + e164: string; +}>; + +export function parseAndFormatPhoneNumber( + str: string, + regionCode: string | undefined, + format = PhoneNumberFormat.E164 +): ParsedE164Type | undefined { + let result: PhoneNumber; + try { + result = instance.parse(str, regionCode); + } catch (err) { + return undefined; + } + + return { + isValid: instance.isValidNumber(result), + userInput: str, + e164: instance.format(result, format), + }; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index f0d50af50b8..23dd6aa36a2 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1636,6 +1636,18 @@ "reasonCategory": "falseMatch", "updated": "2020-09-11T17:24:56.124Z" }, + { + "rule": "jQuery-$(", + "path": "node_modules/fuse.js/dist/fuse.basic.min.js", + "reasonCategory": "falseMatch", + "updated": "2022-03-31T19:50:28.622Z" + }, + { + "rule": "jQuery-$(", + "path": "node_modules/fuse.js/dist/fuse.min.js", + "reasonCategory": "falseMatch", + "updated": "2022-03-31T19:50:28.622Z" + }, { "rule": "jQuery-load(", "path": "node_modules/get-uri/node_modules/debug/src/browser.js", diff --git a/ts/util/lookupConversationWithoutUuid.ts b/ts/util/lookupConversationWithoutUuid.ts new file mode 100644 index 00000000000..069040c111a --- /dev/null +++ b/ts/util/lookupConversationWithoutUuid.ts @@ -0,0 +1,159 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ToastFailedToFetchUsername } from '../components/ToastFailedToFetchUsername'; +import { ToastFailedToFetchPhoneNumber } from '../components/ToastFailedToFetchPhoneNumber'; +import type { UserNotFoundModalStateType } from '../state/ducks/globalModals'; +import * as log from '../logging/log'; +import { UUID } from '../types/UUID'; +import type { UUIDStringType } from '../types/UUID'; +import { isValidUsername } from '../types/Username'; +import * as Errors from '../types/errors'; +import { HTTPError } from '../textsecure/Errors'; +import { showToast } from './showToast'; +import { strictAssert } from './assert'; +import type { UUIDFetchStateKeyType } from './uuidFetchState'; + +export type LookupConversationWithoutUuidActionsType = Readonly<{ + lookupConversationWithoutUuid: typeof lookupConversationWithoutUuid; + showUserNotFoundModal: (state: UserNotFoundModalStateType) => void; + setIsFetchingUUID: ( + identifier: UUIDFetchStateKeyType, + isFetching: boolean + ) => void; +}>; + +export type LookupConversationWithoutUuidOptionsType = Omit< + LookupConversationWithoutUuidActionsType, + 'lookupConversationWithoutUuid' +> & + Readonly< + | { + type: 'e164'; + e164: string; + phoneNumber: string; + } + | { + type: 'username'; + username: string; + } + >; + +type FoundUsernameType = { + uuid: UUIDStringType; + username: string; +}; + +export async function lookupConversationWithoutUuid( + options: LookupConversationWithoutUuidOptionsType +): Promise { + const knownConversation = window.ConversationController.get( + options.type === 'e164' ? options.e164 : options.username + ); + if (knownConversation && knownConversation.get('uuid')) { + return knownConversation.id; + } + + const identifier: UUIDFetchStateKeyType = + options.type === 'e164' + ? `e164:${options.e164}` + : `username:${options.username}`; + + const { showUserNotFoundModal, setIsFetchingUUID } = options; + setIsFetchingUUID(identifier, true); + + try { + let conversationId: string | undefined; + if (options.type === 'e164') { + const serverLookup = await window.textsecure.messaging.getUuidsForE164s([ + options.e164, + ]); + + if (serverLookup[options.e164]) { + conversationId = window.ConversationController.ensureContactIds({ + e164: options.e164, + uuid: serverLookup[options.e164], + highTrust: true, + reason: 'startNewConversationWithoutUuid(e164)', + }); + } + } else { + const foundUsername = await checkForUsername(options.username); + if (foundUsername) { + conversationId = window.ConversationController.ensureContactIds({ + uuid: foundUsername.uuid, + highTrust: true, + reason: 'startNewConversationWithoutUuid(username)', + }); + + const convo = window.ConversationController.get(conversationId); + strictAssert(convo, 'We just ensured conversation existence'); + + convo.set({ username: foundUsername.username }); + } + } + + if (!conversationId) { + showUserNotFoundModal( + options.type === 'username' + ? options + : { + type: 'phoneNumber', + phoneNumber: options.phoneNumber, + } + ); + return undefined; + } + + return conversationId; + } catch (error) { + log.error( + 'startNewConversationWithoutUuid: Something went wrong fetching:', + Errors.toLogFormat(error) + ); + + if (options.type === 'e164') { + showToast(ToastFailedToFetchPhoneNumber); + } else { + showToast(ToastFailedToFetchUsername); + } + + return undefined; + } finally { + setIsFetchingUUID(identifier, false); + } +} + +async function checkForUsername( + username: string +): Promise { + if (!isValidUsername(username)) { + return undefined; + } + + try { + const profile = await window.textsecure.messaging.getProfileForUsername( + username + ); + + if (!profile.uuid) { + log.error("checkForUsername: Returned profile didn't include a uuid"); + return; + } + + return { + uuid: UUID.cast(profile.uuid), + username, + }; + } catch (error) { + if (!(error instanceof HTTPError)) { + throw error; + } + + if (error.code === 404) { + return undefined; + } + + throw error; + } +} diff --git a/ts/util/uuidFetchState.ts b/ts/util/uuidFetchState.ts new file mode 100644 index 00000000000..474bac9568a --- /dev/null +++ b/ts/util/uuidFetchState.ts @@ -0,0 +1,19 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type UUIDFetchStateKeyType = `${'username' | 'e164'}:${string}`; +export type UUIDFetchStateType = Record; + +export const isFetchingByUsername = ( + fetchState: UUIDFetchStateType, + username: string +): boolean => { + return Boolean(fetchState[`username:${username}`]); +}; + +export const isFetchingByE164 = ( + fetchState: UUIDFetchStateType, + e164: string +): boolean => { + return Boolean(fetchState[`e164:${e164}`]); +}; diff --git a/ts/views/inbox_view.ts b/ts/views/inbox_view.ts index baac4ef07b1..b1f6a408246 100644 --- a/ts/views/inbox_view.ts +++ b/ts/views/inbox_view.ts @@ -5,6 +5,7 @@ import * as Backbone from 'backbone'; import * as log from '../logging/log'; import type { ConversationModel } from '../models/conversations'; import { showToast } from '../util/showToast'; +import { strictAssert } from '../util/assert'; import { ToastStickerPackInstallFailed } from '../components/ToastStickerPackInstallFailed'; window.Whisper = window.Whisper || {}; @@ -115,26 +116,19 @@ Whisper.InboxView = Whisper.View.extend({ this.conversation_stack.unload(); }); - window.Whisper.events.on( - 'showConversation', - async (id, messageId, username) => { - const conversation = - await window.ConversationController.getOrCreateAndWait( - id, - 'private', - { username } - ); + window.Whisper.events.on('showConversation', (id, messageId) => { + const conversation = window.ConversationController.get(id); + strictAssert(conversation, 'Conversation must be found'); - conversation.setMarkedUnread(false); + conversation.setMarkedUnread(false); - const { openConversationExternal } = window.reduxActions.conversations; - if (openConversationExternal) { - openConversationExternal(conversation.id, messageId); - } - - this.conversation_stack.open(conversation, messageId); + const { openConversationExternal } = window.reduxActions.conversations; + if (openConversationExternal) { + openConversationExternal(conversation.id, messageId); } - ); + + this.conversation_stack.open(conversation, messageId); + }); window.Whisper.events.on('loadingProgress', count => { const view = this.appLoadingScreen; diff --git a/yarn.lock b/yarn.lock index 2f150f01f23..097ca3c622d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7762,10 +7762,10 @@ functions-have-names@^1.1.1: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.1.1.tgz#79d35927f07b8e7103d819fed475b64ccf7225ea" integrity sha512-U0kNHUoxwPNPWOJaMG7Z00d4a/qZVrFtzWJRaK8V9goaVOCXBSQSJpt3MYGNtkScKEBKovxLjnNdC9MlXwo5Pw== -fuse.js@3.4.4: - version "3.4.4" - resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.4.tgz#f98f55fcb3b595cf6a3e629c5ffaf10982103e95" - integrity sha512-pyLQo/1oR5Ywf+a/tY8z4JygnIglmRxVUOiyFAbd11o9keUDpUJSMGRWJngcnkURj30kDHPmhoKY8ChJiz3EpQ== +fuse.js@6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.5.3.tgz#7446c0acbc4ab0ab36fa602e97499bdb69452b93" + integrity sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg== fuse.js@^3.4.4: version "3.4.5"