diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index a314d5e55..3661b5a42 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -25,6 +25,7 @@ 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 { UsernameCheckbox as UsernameCheckboxComponent } from './conversationList/UsernameCheckbox'; import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton'; import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation'; import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader'; @@ -32,19 +33,20 @@ import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } f import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem'; export enum RowType { - ArchiveButton, - Blank, - Contact, - ContactCheckbox, - PhoneNumberCheckbox, - Conversation, - CreateNewGroup, - Header, - MessageSearchResult, - SearchResultsLoadingFakeHeader, - SearchResultsLoadingFakeRow, - StartNewConversation, - UsernameSearchResult, + ArchiveButton = 'ArchiveButton', + Blank = 'Blank', + Contact = 'Contact', + ContactCheckbox = 'ContactCheckbox', + PhoneNumberCheckbox = 'PhoneNumberCheckbox', + UsernameCheckbox = 'UsernameCheckbox', + Conversation = 'Conversation', + CreateNewGroup = 'CreateNewGroup', + Header = 'Header', + MessageSearchResult = 'MessageSearchResult', + SearchResultsLoadingFakeHeader = 'SearchResultsLoadingFakeHeader', + SearchResultsLoadingFakeRow = 'SearchResultsLoadingFakeRow', + StartNewConversation = 'StartNewConversation', + UsernameSearchResult = 'UsernameSearchResult', } type ArchiveButtonRowType = { @@ -74,6 +76,13 @@ type PhoneNumberCheckboxRowType = { isFetching: boolean; }; +type UsernameCheckboxRowType = { + type: RowType.UsernameCheckbox; + username: string; + isChecked: boolean; + isFetching: boolean; +}; + type ConversationRowType = { type: RowType.Conversation; conversation: ConversationListItemPropsType; @@ -119,6 +128,7 @@ export type Row = | ContactRowType | ContactCheckboxRowType | PhoneNumberCheckboxRowType + | UsernameCheckboxRowType | ConversationRowType | CreateNewGroupRowType | MessageRowType @@ -283,6 +293,23 @@ export const ConversationList: React.FC = ({ /> ); break; + case RowType.UsernameCheckbox: + result = ( + + onClickContactCheckbox(conversationId, undefined) + } + isChecked={row.isChecked} + isFetching={row.isFetching} + i18n={i18n} + theme={theme} + /> + ); + break; case RowType.Conversation: { const itemProps = pick(row.conversation, [ 'acceptedMessageRequest', diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index c34c5e027..a19b7b502 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -883,6 +883,7 @@ export const ChooseGroupMembersPartialPhoneNumber = (): JSX.Element => ( candidateContacts: [], isShowingRecommendedGroupSizeModal: false, isShowingMaximumGroupSizeModal: false, + isUsernamesEnabled: true, searchTerm: '+1(212) 555', regionCode: 'US', selectedContacts: [], @@ -904,6 +905,7 @@ export const ChooseGroupMembersValidPhoneNumber = (): JSX.Element => ( candidateContacts: [], isShowingRecommendedGroupSizeModal: false, isShowingMaximumGroupSizeModal: false, + isUsernamesEnabled: true, searchTerm: '+1(212) 555 5454', regionCode: 'US', selectedContacts: [], @@ -916,6 +918,28 @@ ChooseGroupMembersValidPhoneNumber.story = { name: 'Choose Group Members: Valid phone number', }; +export const ChooseGroupMembersUsername = (): JSX.Element => ( + +); + +ChooseGroupMembersUsername.story = { + name: 'Choose Group Members: username', +}; + export const GroupMetadataNoTimer = (): JSX.Element => ( ); }, diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx index c567440ac..ffe594737 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -14,6 +14,7 @@ import type { MeasuredComponentProps } from 'react-measure'; import Measure from 'react-measure'; import type { LocalizerType, ThemeType } from '../../../../types/Util'; +import { getUsernameFromSearch } from '../../../../types/Username'; import { assert } from '../../../../util/assert'; import { refMerger } from '../../../../util/refMerger'; import { useRestoreFocus } from '../../../../hooks/useRestoreFocus'; @@ -27,7 +28,10 @@ import type { UUIDFetchStateKeyType, UUIDFetchStateType, } from '../../../../util/uuidFetchState'; -import { isFetchingByE164 } from '../../../../util/uuidFetchState'; +import { + isFetchingByE164, + isFetchingByUsername, +} from '../../../../util/uuidFetchState'; import { ModalHost } from '../../../ModalHost'; import { ContactPills } from '../../../ContactPills'; import { ContactPill } from '../../../ContactPill'; @@ -53,6 +57,7 @@ export type StatePropsType = { removeSelectedContact: (_: string) => void; setSearchTerm: (_: string) => void; toggleSelectedContact: (conversationId: string) => void; + isUsernamesEnabled: boolean; } & Pick< LookupConversationWithoutUuidActionsType, 'lookupConversationWithoutUuid' @@ -83,6 +88,7 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ toggleSelectedContact, lookupConversationWithoutUuid, showUserNotFoundModal, + isUsernamesEnabled, }) => { const [focusRef] = useRestoreFocus(); @@ -99,6 +105,21 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ phoneNumber && candidateContacts.every(contact => contact.e164 !== phoneNumber.e164); + let username: string | undefined; + let isUsernameChecked = false; + let isUsernameVisible = false; + if (!phoneNumber && isUsernamesEnabled) { + username = getUsernameFromSearch(searchTerm); + + isUsernameChecked = selectedContacts.some( + contact => contact.username === username + ); + + isUsernameVisible = candidateContacts.every( + contact => contact.username !== username + ); + } + const inputRef = useRef(null); const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size; @@ -157,19 +178,24 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ if (filteredContacts.length) { rowCount += filteredContacts.length; } - if (isPhoneNumberVisible) { + if (isPhoneNumberVisible || isUsernameVisible) { // "Contacts" header if (filteredContacts.length) { rowCount += 1; } // "Find by phone number" + phone number + // or + // "Find by username" + username rowCount += 2; } const getRow = (index: number): undefined | Row => { let virtualIndex = index; - if (isPhoneNumberVisible && filteredContacts.length) { + if ( + (isPhoneNumberVisible || isUsernameVisible) && + filteredContacts.length + ) { if (virtualIndex === 0) { return { type: RowType.Header, @@ -221,6 +247,24 @@ export const ChooseGroupMembersModal: FunctionComponent = ({ virtualIndex -= 2; } + if (username) { + if (virtualIndex === 0) { + return { + type: RowType.Header, + i18nKey: 'findByUsernameHeader', + }; + } + if (virtualIndex === 1) { + return { + type: RowType.UsernameCheckbox, + isChecked: isUsernameChecked, + isFetching: isFetchingByUsername(uuidFetchState, username), + username, + }; + } + virtualIndex -= 2; + } + return undefined; }; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index f1d600721..18133f2f9 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -110,6 +110,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ i18n={i18n} lookupConversationWithoutUuid={makeFakeLookupConversationWithoutUuid()} showUserNotFoundModal={action('showUserNotFoundModal')} + isUsernamesEnabled /> ); }, diff --git a/ts/components/conversationList/ContactListItem.tsx b/ts/components/conversationList/ContactListItem.tsx index 6937ae86b..6863e5f40 100644 --- a/ts/components/conversationList/ContactListItem.tsx +++ b/ts/components/conversationList/ContactListItem.tsx @@ -30,6 +30,7 @@ export type ContactListItemConversationType = Pick< | 'title' | 'type' | 'unblurredAvatarPath' + | 'username' | 'e164' >; diff --git a/ts/components/conversationList/UsernameCheckbox.tsx b/ts/components/conversationList/UsernameCheckbox.tsx new file mode 100644 index 000000000..28c954703 --- /dev/null +++ b/ts/components/conversationList/UsernameCheckbox.tsx @@ -0,0 +1,84 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { BaseConversationListItem } from './BaseConversationListItem'; +import type { LocalizerType, ThemeType } from '../../types/Util'; +import { AvatarColors } from '../../types/Colors'; +import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid'; + +export type PropsDataType = { + username: string; + isChecked: boolean; + isFetching: boolean; +}; + +type PropsHousekeepingType = { + i18n: LocalizerType; + theme: ThemeType; + toggleConversationInChooseMembers: (conversationId: string) => void; +} & LookupConversationWithoutUuidActionsType; + +type PropsType = PropsDataType & PropsHousekeepingType; + +export const UsernameCheckbox: FunctionComponent = React.memo( + function UsernameCheckbox({ + username, + isChecked, + isFetching, + theme, + i18n, + lookupConversationWithoutUuid, + showUserNotFoundModal, + setIsFetchingUUID, + toggleConversationInChooseMembers, + }) { + const onClickItem = React.useCallback(async () => { + if (isFetching) { + return; + } + + const conversationId = await lookupConversationWithoutUuid({ + showUserNotFoundModal, + setIsFetchingUUID, + + type: 'username', + username, + }); + + if (conversationId !== undefined) { + toggleConversationInChooseMembers(conversationId); + } + }, [ + isFetching, + toggleConversationInChooseMembers, + lookupConversationWithoutUuid, + showUserNotFoundModal, + setIsFetchingUUID, + username, + ]); + + const title = i18n('at-username', { username }); + + return ( + + ); + } +); diff --git a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx index 4dd4b9ee2..e6611d0e4 100644 --- a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx +++ b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx @@ -18,10 +18,14 @@ import { } from '../AddGroupMemberErrorDialog'; import { Button } from '../Button'; import type { LocalizerType } from '../../types/Util'; +import { getUsernameFromSearch } from '../../types/Username'; import type { ParsedE164Type } from '../../util/libphonenumberInstance'; import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance'; import type { UUIDFetchStateType } from '../../util/uuidFetchState'; -import { isFetchingByE164 } from '../../util/uuidFetchState'; +import { + isFetchingByUsername, + isFetchingByE164, +} from '../../util/uuidFetchState'; import { getGroupSizeRecommendedLimit, getGroupSizeHardLimit, @@ -32,6 +36,7 @@ export type LeftPaneChooseGroupMembersPropsType = { candidateContacts: ReadonlyArray; isShowingRecommendedGroupSizeModal: boolean; isShowingMaximumGroupSizeModal: boolean; + isUsernamesEnabled: boolean; searchTerm: string; regionCode: string | undefined; selectedContacts: Array; @@ -42,6 +47,8 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper; private readonly selectedConversationIdsSet: Set; @@ -60,6 +69,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper contact.username !== username + ); + + if (isVisible) { + this.username = username; + } + + this.isUsernameChecked = selectedContacts.some( + contact => contact.username === this.username + ); + } else { + this.isUsernameChecked = false; + } this.selectedContacts = selectedContacts; this.selectedConversationIdsSet = new Set( @@ -246,6 +272,11 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper contact.username !== this.username + ); + } else { + this.isUsernameVisible = false; + } } override getHeaderContents({ @@ -148,7 +161,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper { candidateContacts: [], isShowingRecommendedGroupSizeModal: false, isShowingMaximumGroupSizeModal: false, + isUsernamesEnabled: true, searchTerm: '', regionCode: 'US', selectedContacts: [], @@ -62,6 +63,7 @@ describe('LeftPaneChooseGroupMembersHelper', () => { candidateContacts: [], searchTerm: 'foo bar', selectedContacts: [getDefaultConversation()], + isUsernamesEnabled: false, }).getRowCount(), 0 ); @@ -107,6 +109,7 @@ describe('LeftPaneChooseGroupMembersHelper', () => { candidateContacts: [], searchTerm: 'foo bar', selectedContacts: [getDefaultConversation()], + isUsernamesEnabled: false, }).getRow(0) ); }); @@ -120,6 +123,7 @@ describe('LeftPaneChooseGroupMembersHelper', () => { ...defaults, candidateContacts, searchTerm: 'foo bar', + isUsernamesEnabled: false, selectedContacts: [candidateContacts[1]], }); @@ -164,5 +168,51 @@ describe('LeftPaneChooseGroupMembersHelper', () => { disabledReason: undefined, }); }); + + it('returns a header, then the phone number, then a blank space if there are contacts', () => { + const helper = new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts: [], + searchTerm: '212 555', + selectedContacts: [], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'findByPhoneNumberHeader', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.PhoneNumberCheckbox, + phoneNumber: { + isValid: false, + userInput: '212 555', + e164: '+1212555', + }, + isChecked: false, + isFetching: false, + }); + assert.deepEqual(helper.getRow(2), { type: RowType.Blank }); + }); + + it('returns a header, then the username, then a blank space if there are contacts', () => { + const helper = new LeftPaneChooseGroupMembersHelper({ + ...defaults, + candidateContacts: [], + searchTerm: 'signal', + selectedContacts: [], + }); + + assert.deepEqual(helper.getRow(0), { + type: RowType.Header, + i18nKey: 'findByUsernameHeader', + }); + assert.deepEqual(helper.getRow(1), { + type: RowType.UsernameCheckbox, + username: 'signal', + isChecked: false, + isFetching: false, + }); + assert.deepEqual(helper.getRow(2), { type: RowType.Blank }); + }); }); }); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index db800d99f..a966d143e 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1626,7 +1626,7 @@ export function initialize({ return (await _ajax({ call: 'profile', httpType: 'GET', - urlParameters: `username/${usernameToFetch}`, + urlParameters: `/username/${usernameToFetch}`, responseType: 'json', redactUrl: _createRedactor(usernameToFetch), })) as ProfileType;