Allow adding to a group by phone number

This commit is contained in:
Fedor Indutny 2022-04-04 17:38:22 -07:00 committed by GitHub
parent 76a1a805ef
commit 9568d5792e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1842 additions and 693 deletions

View file

@ -824,8 +824,12 @@
"description": "Shown to separate the types of search results" "description": "Shown to separate the types of search results"
}, },
"findByUsernameHeader": { "findByUsernameHeader": {
"message": "Find by Username", "message": "Find by username",
"description": "Shown to separate the types of search results" "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": { "at-username": {
"message": "@$username$", "message": "@$username$",
@ -2352,6 +2356,10 @@
"message": "Failed to fetch username. Check your connection and try again.", "message": "Failed to fetch username. Check your connection and try again.",
"description": "Shown if request to Signal servers to find username fails" "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": { "startConversation--username-not-found": {
"message": "User not found. $atUsername$ is not a Signal user; make sure youve entered the complete username.", "message": "User not found. $atUsername$ is not a Signal user; make sure youve entered the complete username.",
"description": "Shown in dialog if username is not found. Note that 'username' will be the output of at-username", "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": { "chooseGroupMembers__title": {
"message": "Choose members", "message": "Choose members",
"description": "The title for the 'choose group members' left pane screen" "description": "The title for the 'choose group members' left pane screen"

View file

@ -109,7 +109,7 @@
"focus-trap-react": "8.8.1", "focus-trap-react": "8.8.1",
"form-data": "4.0.0", "form-data": "4.0.0",
"fs-extra": "5.0.0", "fs-extra": "5.0.0",
"fuse.js": "3.4.4", "fuse.js": "6.5.3",
"glob": "7.1.6", "glob": "7.1.6",
"google-libphonenumber": "3.2.27", "google-libphonenumber": "3.2.27",
"got": "11.8.2", "got": "11.8.2",

View file

@ -4765,6 +4765,11 @@ button.module-image__border-overlay:focus {
} }
} }
} }
&__spinner__container {
margin-left: 16px;
margin-right: 16px;
}
} }
&--header { &--header {

View file

@ -373,7 +373,7 @@ export class ConversationController {
if (normalizedUuid) { if (normalizedUuid) {
newConvo.updateUuid(normalizedUuid); newConvo.updateUuid(normalizedUuid);
} }
if (highTrust && e164 && normalizedUuid) { if ((highTrust && e164) || normalizedUuid) {
updateConversation(newConvo.attributes); updateConversation(newConvo.attributes);
} }

View file

@ -20,6 +20,7 @@ import enMessages from '../../_locales/en/messages.json';
import { ThemeType } from '../types/Util'; import { ThemeType } from '../types/Util';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import { UUID } from '../types/UUID'; import { UUID } from '../types/UUID';
import { makeFakeLookupConversationWithoutUuid } from '../test-both/helpers/fakeLookupConversationWithoutUuid';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -85,13 +86,11 @@ const Wrapper = ({
/> />
)} )}
scrollable={scrollable} scrollable={scrollable}
lookupConversationWithoutUuid={makeFakeLookupConversationWithoutUuid()}
showChooseGroupMembers={action('showChooseGroupMembers')} showChooseGroupMembers={action('showChooseGroupMembers')}
startNewConversationFromPhoneNumber={action( showUserNotFoundModal={action('showUserNotFoundModal')}
'startNewConversationFromPhoneNumber' setIsFetchingUUID={action('setIsFetchingUUID')}
)} showConversation={action('showConversation')}
startNewConversationFromUsername={action(
'startNewConversationFromUsername'
)}
theme={theme} theme={theme}
/> />
); );
@ -495,16 +494,47 @@ story.add('Headers', () => (
type: RowType.Header, type: RowType.Header,
i18nKey: 'findByUsernameHeader', i18nKey: 'findByUsernameHeader',
}, },
{
type: RowType.Header,
i18nKey: 'findByPhoneNumberHeader',
},
]} ]}
/> />
)); ));
story.add('Start new conversation', () => ( story.add('Find by phone number', () => (
<Wrapper <Wrapper
rows={[ rows={[
{
type: RowType.Header,
i18nKey: 'findByPhoneNumberHeader',
},
{ {
type: RowType.StartNewConversation, 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: true,
userInput: '+1(234)555',
e164: '+1234555',
},
isFetching: false,
}, },
]} ]}
/> />
@ -548,7 +578,30 @@ story.add('Kitchen sink', () => (
rows={[ rows={[
{ {
type: RowType.StartNewConversation, 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, type: RowType.Header,

View file

@ -10,10 +10,12 @@ import { get, pick } from 'lodash';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { assert } from '../util/assert'; import { assert } from '../util/assert';
import type { ParsedE164Type } from '../util/libphonenumberInstance';
import type { LocalizerType, ThemeType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import { ScrollBehavior } from '../types/Util'; import { ScrollBehavior } from '../types/Util';
import { getConversationListWidthBreakpoint } from './_util'; import { getConversationListWidthBreakpoint } from './_util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem'; import type { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
import { ConversationListItem } from './conversationList/ConversationListItem'; import { ConversationListItem } from './conversationList/ConversationListItem';
@ -21,6 +23,7 @@ import type { ContactListItemConversationType as ContactListItemPropsType } from
import { ContactListItem } from './conversationList/ContactListItem'; import { ContactListItem } from './conversationList/ContactListItem';
import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox'; import { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox';
import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox';
import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton'; import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation'; import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader'; import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
@ -32,6 +35,7 @@ export enum RowType {
Blank, Blank,
Contact, Contact,
ContactCheckbox, ContactCheckbox,
PhoneNumberCheckbox,
Conversation, Conversation,
CreateNewGroup, CreateNewGroup,
Header, Header,
@ -62,6 +66,13 @@ type ContactCheckboxRowType = {
disabledReason?: ContactCheckboxDisabledReason; disabledReason?: ContactCheckboxDisabledReason;
}; };
type PhoneNumberCheckboxRowType = {
type: RowType.PhoneNumberCheckbox;
phoneNumber: ParsedE164Type;
isChecked: boolean;
isFetching: boolean;
};
type ConversationRowType = { type ConversationRowType = {
type: RowType.Conversation; type: RowType.Conversation;
conversation: ConversationListItemPropsType; conversation: ConversationListItemPropsType;
@ -91,7 +102,8 @@ type SearchResultsLoadingFakeRowType = {
type StartNewConversationRowType = { type StartNewConversationRowType = {
type: RowType.StartNewConversation; type: RowType.StartNewConversation;
phoneNumber: string; phoneNumber: ParsedE164Type;
isFetching: boolean;
}; };
type UsernameRowType = { type UsernameRowType = {
@ -105,6 +117,7 @@ export type Row =
| BlankRowType | BlankRowType
| ContactRowType | ContactRowType
| ContactCheckboxRowType | ContactCheckboxRowType
| PhoneNumberCheckboxRowType
| ConversationRowType | ConversationRowType
| CreateNewGroupRowType | CreateNewGroupRowType
| MessageRowType | MessageRowType
@ -141,9 +154,8 @@ export type PropsType = {
onSelectConversation: (conversationId: string, messageId?: string) => void; onSelectConversation: (conversationId: string, messageId?: string) => void;
renderMessageSearchResult: (id: string) => JSX.Element; renderMessageSearchResult: (id: string) => JSX.Element;
showChooseGroupMembers: () => void; showChooseGroupMembers: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void; showConversation: (conversationId: string) => void;
startNewConversationFromUsername: (username: string) => void; } & LookupConversationWithoutUuidActionsType;
};
const NORMAL_ROW_HEIGHT = 76; const NORMAL_ROW_HEIGHT = 76;
const HEADER_ROW_HEIGHT = 40; const HEADER_ROW_HEIGHT = 40;
@ -163,8 +175,10 @@ export const ConversationList: React.FC<PropsType> = ({
scrollable = true, scrollable = true,
shouldRecomputeRowHeights, shouldRecomputeRowHeights,
showChooseGroupMembers, showChooseGroupMembers,
startNewConversationFromPhoneNumber, lookupConversationWithoutUuid,
startNewConversationFromUsername, showUserNotFoundModal,
setIsFetchingUUID,
showConversation,
theme, theme,
}) => { }) => {
const listRef = useRef<null | List>(null); const listRef = useRef<null | List>(null);
@ -251,6 +265,23 @@ export const ConversationList: React.FC<PropsType> = ({
/> />
); );
break; break;
case RowType.PhoneNumberCheckbox:
result = (
<PhoneNumberCheckboxComponent
phoneNumber={row.phoneNumber}
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
toggleConversationInChooseMembers={conversationId =>
onClickContactCheckbox(conversationId, undefined)
}
isChecked={row.isChecked}
isFetching={row.isFetching}
i18n={i18n}
theme={theme}
/>
);
break;
case RowType.Conversation: { case RowType.Conversation: {
const itemProps = pick(row.conversation, [ const itemProps = pick(row.conversation, [
'acceptedMessageRequest', 'acceptedMessageRequest',
@ -332,7 +363,11 @@ export const ConversationList: React.FC<PropsType> = ({
<StartNewConversationComponent <StartNewConversationComponent
i18n={i18n} i18n={i18n}
phoneNumber={row.phoneNumber} phoneNumber={row.phoneNumber}
onClick={startNewConversationFromPhoneNumber} isFetching={row.isFetching}
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
showConversation={showConversation}
/> />
); );
break; break;
@ -342,7 +377,10 @@ export const ConversationList: React.FC<PropsType> = ({
i18n={i18n} i18n={i18n}
username={row.username} username={row.username}
isFetchingUsername={row.isFetchingUsername} isFetchingUsername={row.isFetchingUsername}
onClick={startNewConversationFromUsername} lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
showConversation={showConversation}
/> />
); );
break; break;
@ -365,10 +403,12 @@ export const ConversationList: React.FC<PropsType> = ({
onClickArchiveButton, onClickArchiveButton,
onClickContactCheckbox, onClickContactCheckbox,
onSelectConversation, onSelectConversation,
lookupConversationWithoutUuid,
showUserNotFoundModal,
setIsFetchingUUID,
renderMessageSearchResult, renderMessageSearchResult,
showChooseGroupMembers, showChooseGroupMembers,
startNewConversationFromPhoneNumber, showConversation,
startNewConversationFromUsername,
theme, theme,
] ]
); );

View file

@ -60,6 +60,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
removeLinkPreview: action('removeLinkPreview'), removeLinkPreview: action('removeLinkPreview'),
skinTone: 0, skinTone: 0,
theme: React.useContext(StorybookThemeContext), theme: React.useContext(StorybookThemeContext),
regionCode: 'US',
}); });
story.add('Modal', () => { story.add('Modal', () => {

View file

@ -60,6 +60,7 @@ export type DataPropsType = {
) => unknown; ) => unknown;
onTextTooLong: () => void; onTextTooLong: () => void;
theme: ThemeType; theme: ThemeType;
regionCode: string | undefined;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>; } & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type ActionPropsType = Pick< type ActionPropsType = Pick<
@ -91,6 +92,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
removeLinkPreview, removeLinkPreview,
skinTone, skinTone,
theme, theme,
regionCode,
}) => { }) => {
const inputRef = useRef<null | HTMLInputElement>(null); const inputRef = useRef<null | HTMLInputElement>(null);
const inputApiRef = React.useRef<InputApi | undefined>(); const inputApiRef = React.useRef<InputApi | undefined>();
@ -99,7 +101,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
>([]); >([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filteredConversations, setFilteredConversations] = useState( const [filteredConversations, setFilteredConversations] = useState(
filterAndSortConversationsByRecent(candidateConversations, '') filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
); );
const [attachmentsToForward, setAttachmentsToForward] = useState< const [attachmentsToForward, setAttachmentsToForward] = useState<
Array<AttachmentType> Array<AttachmentType>
@ -168,14 +170,20 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
setFilteredConversations( setFilteredConversations(
filterAndSortConversationsByRecent( filterAndSortConversationsByRecent(
candidateConversations, candidateConversations,
normalizedSearchTerm normalizedSearchTerm,
regionCode
) )
); );
}, 200); }, 200);
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
}; };
}, [candidateConversations, normalizedSearchTerm, setFilteredConversations]); }, [
candidateConversations,
normalizedSearchTerm,
setFilteredConversations,
regionCode,
]);
const contactLookup = useMemo(() => { const contactLookup = useMemo(() => {
const map = new Map(); const map = new Map();
@ -412,6 +420,12 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
toggleSelectedConversation(conversationId); toggleSelectedConversation(conversationId);
} }
}} }}
lookupConversationWithoutUuid={
asyncShouldNeverBeCalled
}
showConversation={shouldNeverBeCalled}
showUserNotFoundModal={shouldNeverBeCalled}
setIsFetchingUUID={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => { renderMessageSearchResult={() => {
shouldNeverBeCalled(); shouldNeverBeCalled();
@ -420,10 +434,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
rowCount={rowCount} rowCount={rowCount}
shouldRecomputeRowHeights={false} shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled} showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
}
startNewConversationFromUsername={shouldNeverBeCalled}
theme={theme} theme={theme}
/> />
</div> </div>
@ -470,3 +480,11 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): void { function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): void {
assert(false, 'This should never be called. Doing nothing'); assert(false, 'This should never be called. Doing nothing');
} }
async function asyncShouldNeverBeCalled(
..._args: ReadonlyArray<unknown>
): Promise<undefined> {
shouldNeverBeCalled();
return undefined;
}

View file

@ -4,9 +4,10 @@
import React from 'react'; import React from 'react';
import type { import type {
ContactModalStateType, ContactModalStateType,
UsernameNotFoundModalStateType, UserNotFoundModalStateType,
} from '../state/ducks/globalModals'; } from '../state/ducks/globalModals';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { ButtonVariant } from './Button'; import { ButtonVariant } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
@ -23,9 +24,9 @@ type PropsType = {
// SafetyNumberModal // SafetyNumberModal
safetyNumberModalContactId?: string; safetyNumberModalContactId?: string;
renderSafetyNumber: () => JSX.Element; renderSafetyNumber: () => JSX.Element;
// UsernameNotFoundModal // UserNotFoundModal
hideUsernameNotFoundModal: () => unknown; hideUserNotFoundModal: () => unknown;
usernameNotFoundModalState?: UsernameNotFoundModalStateType; userNotFoundModalState?: UserNotFoundModalStateType;
// WhatsNewModal // WhatsNewModal
isWhatsNewVisible: boolean; isWhatsNewVisible: boolean;
hideWhatsNewModal: () => unknown; hideWhatsNewModal: () => unknown;
@ -42,9 +43,9 @@ export const GlobalModalContainer = ({
// SafetyNumberModal // SafetyNumberModal
safetyNumberModalContactId, safetyNumberModalContactId,
renderSafetyNumber, renderSafetyNumber,
// UsernameNotFoundModal // UserNotFoundModal
hideUsernameNotFoundModal, hideUserNotFoundModal,
usernameNotFoundModalState, userNotFoundModalState,
// WhatsNewModal // WhatsNewModal
hideWhatsNewModal, hideWhatsNewModal,
isWhatsNewVisible, isWhatsNewVisible,
@ -53,19 +54,30 @@ export const GlobalModalContainer = ({
return renderSafetyNumber(); 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 ( return (
<ConfirmationDialog <ConfirmationDialog
cancelText={i18n('ok')} cancelText={i18n('ok')}
cancelButtonVariant={ButtonVariant.Secondary} cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n} i18n={i18n}
onClose={hideUsernameNotFoundModal} onClose={hideUserNotFoundModal}
> >
{i18n('startConversation--username-not-found', { {content}
atUsername: i18n('at-username', {
username: usernameNotFoundModalState.username,
}),
})}
</ConfirmationDialog> </ConfirmationDialog>
); );
} }

View file

@ -18,6 +18,10 @@ import enMessages from '../../_locales/en/messages.json';
import { ThemeType } from '../types/Util'; import { ThemeType } from '../types/Util';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import {
makeFakeLookupConversationWithoutUuid,
useUuidFetchState,
} from '../test-both/helpers/fakeLookupConversationWithoutUuid';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -89,7 +93,24 @@ const defaultModeSpecificProps = {
const emptySearchResultsGroup = { isLoading: false, results: [] }; const emptySearchResultsGroup = { isLoading: false, results: [] };
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
let modeSpecificProps =
overrideProps.modeSpecificProps ?? defaultModeSpecificProps;
const [uuidFetchState, setIsFetchingUUID] = useUuidFetchState(
'uuidFetchState' in modeSpecificProps
? modeSpecificProps.uuidFetchState
: {}
);
if ('uuidFetchState' in modeSpecificProps) {
modeSpecificProps = {
...modeSpecificProps,
uuidFetchState,
};
}
return {
clearConversationSearch: action('clearConversationSearch'), clearConversationSearch: action('clearConversationSearch'),
clearGroupCreationError: action('clearGroupCreationError'), clearGroupCreationError: action('clearGroupCreationError'),
clearSearch: action('clearSearch'), clearSearch: action('clearSearch'),
@ -101,7 +122,6 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
createGroup: action('createGroup'), createGroup: action('createGroup'),
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
i18n, i18n,
modeSpecificProps: defaultModeSpecificProps,
preferredWidthFromStorage: 320, preferredWidthFromStorage: 320,
openConversationInternal: action('openConversationInternal'), openConversationInternal: action('openConversationInternal'),
regionCode: 'US', regionCode: 'US',
@ -112,6 +132,10 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
), ),
crashReportCount: select('challengeReportCount', [0, 1], 0), crashReportCount: select('challengeReportCount', [0, 1], 0),
setChallengeStatus: action('setChallengeStatus'), setChallengeStatus: action('setChallengeStatus'),
lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(),
showUserNotFoundModal: action('showUserNotFoundModal'),
setIsFetchingUUID,
showConversation: action('showConversation'),
renderExpiredBuildDialog: () => <div />, renderExpiredBuildDialog: () => <div />,
renderMainHeader: () => <div />, renderMainHeader: () => <div />,
renderMessageSearchResult: (id: string) => ( renderMessageSearchResult: (id: string) => (
@ -161,10 +185,6 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
showInbox: action('showInbox'), showInbox: action('showInbox'),
startComposing: action('startComposing'), startComposing: action('startComposing'),
showChooseGroupMembers: action('showChooseGroupMembers'), showChooseGroupMembers: action('showChooseGroupMembers'),
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
startNewConversationFromUsername: action('startNewConversationFromUsername'),
startSearch: action('startSearch'), startSearch: action('startSearch'),
startSettingGroupMetadata: action('startSettingGroupMetadata'), startSettingGroupMetadata: action('startSettingGroupMetadata'),
theme: React.useContext(StorybookThemeContext), theme: React.useContext(StorybookThemeContext),
@ -175,7 +195,10 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
updateSearchTerm: action('updateSearchTerm'), updateSearchTerm: action('updateSearchTerm'),
...overrideProps, ...overrideProps,
});
modeSpecificProps,
};
};
// Inbox stories // Inbox stories
@ -465,7 +488,7 @@ story.add('Compose: no results', () => (
composeContacts: [], composeContacts: [],
composeGroups: [], composeGroups: [],
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
}, },
@ -481,7 +504,7 @@ story.add('Compose: some contacts, no search term', () => (
composeContacts: defaultConversations, composeContacts: defaultConversations,
composeGroups: [], composeGroups: [],
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
}, },
@ -497,7 +520,7 @@ story.add('Compose: some contacts, with a search term', () => (
composeContacts: defaultConversations, composeContacts: defaultConversations,
composeGroups: [], composeGroups: [],
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: 'ar', searchTerm: 'ar',
}, },
@ -513,7 +536,7 @@ story.add('Compose: some groups, no search term', () => (
composeContacts: [], composeContacts: [],
composeGroups: defaultGroups, composeGroups: defaultGroups,
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
}, },
@ -529,7 +552,7 @@ story.add('Compose: some groups, with search term', () => (
composeContacts: [], composeContacts: [],
composeGroups: defaultGroups, composeGroups: defaultGroups,
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: 'ar', searchTerm: 'ar',
}, },
@ -545,7 +568,7 @@ story.add('Compose: search is valid username', () => (
composeContacts: [], composeContacts: [],
composeGroups: [], composeGroups: [],
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: 'someone', searchTerm: 'someone',
}, },
@ -561,7 +584,9 @@ story.add('Compose: search is valid username, fetching username', () => (
composeContacts: [], composeContacts: [],
composeGroups: [], composeGroups: [],
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: true, uuidFetchState: {
'username:someone': true,
},
regionCode: 'US', regionCode: 'US',
searchTerm: 'someone', searchTerm: 'someone',
}, },
@ -577,7 +602,7 @@ story.add('Compose: search is valid username, but flag is not enabled', () => (
composeContacts: [], composeContacts: [],
composeGroups: [], composeGroups: [],
isUsernamesEnabled: false, isUsernamesEnabled: false,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: 'someone', 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', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: false,
uuidFetchState: {},
regionCode: 'US',
searchTerm: '+1(212)555',
},
})}
/>
));
story.add('Compose: search is valid phone number', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: true,
uuidFetchState: {},
regionCode: 'US',
searchTerm: '2125555454',
},
})}
/>
));
story.add(
'Compose: search is valid phone number, fetching phone number',
() => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: true,
uuidFetchState: {
'e164:+12125555454': true,
},
regionCode: 'US',
searchTerm: '(212)5555454',
},
})}
/>
)
);
story.add('Compose: all kinds of results, no search term', () => ( story.add('Compose: all kinds of results, no search term', () => (
<LeftPane <LeftPane
{...useProps({ {...useProps({
@ -593,7 +671,7 @@ story.add('Compose: all kinds of results, no search term', () => (
composeContacts: defaultConversations, composeContacts: defaultConversations,
composeGroups: defaultGroups, composeGroups: defaultGroups,
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
}, },
@ -609,7 +687,7 @@ story.add('Compose: all kinds of results, with a search term', () => (
composeContacts: defaultConversations, composeContacts: defaultConversations,
composeGroups: defaultGroups, composeGroups: defaultGroups,
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
regionCode: 'US', regionCode: 'US',
searchTerm: 'someone', searchTerm: 'someone',
}, },
@ -672,6 +750,42 @@ story.add('Crash report dialog', () => (
/> />
)); ));
// Choose Group Members
story.add('Choose Group Members: Partial phone number', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.ChooseGroupMembers,
uuidFetchState: {},
candidateContacts: [],
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
searchTerm: '+1(212) 555',
regionCode: 'US',
selectedContacts: [],
},
})}
/>
));
story.add('Choose Group Members: Valid phone number', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.ChooseGroupMembers,
uuidFetchState: {},
candidateContacts: [],
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
searchTerm: '+1(212) 555 5454',
regionCode: 'US',
selectedContacts: [],
},
})}
/>
));
// Set group metadata // Set group metadata
story.add('Group Metadata: No Timer', () => ( story.add('Group Metadata: No Timer', () => (

View file

@ -37,6 +37,7 @@ import {
MAX_WIDTH, MAX_WIDTH,
getWidthFromPreferredWidth, getWidthFromPreferredWidth,
} from '../util/leftPaneWidth'; } from '../util/leftPaneWidth';
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
import { ConversationList } from './ConversationList'; import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox'; import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
@ -97,8 +98,6 @@ export type PropsType = {
closeMaximumGroupSizeModal: () => void; closeMaximumGroupSizeModal: () => void;
closeRecommendedGroupSizeModal: () => void; closeRecommendedGroupSizeModal: () => void;
createGroup: () => void; createGroup: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
startNewConversationFromUsername: (username: string) => void;
openConversationInternal: (_: { openConversationInternal: (_: {
conversationId: string; conversationId: string;
messageId?: string; messageId?: string;
@ -140,7 +139,9 @@ export type PropsType = {
) => JSX.Element; ) => JSX.Element;
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element; renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
renderCrashReportDialog: () => JSX.Element; renderCrashReportDialog: () => JSX.Element;
};
showConversation: (conversationId: string) => void;
} & LookupConversationWithoutUuidActionsType;
export const LeftPane: React.FC<PropsType> = ({ export const LeftPane: React.FC<PropsType> = ({
challengeStatus, challengeStatus,
@ -181,12 +182,14 @@ export const LeftPane: React.FC<PropsType> = ({
showInbox, showInbox,
startComposing, startComposing,
startSearch, startSearch,
startNewConversationFromPhoneNumber, showUserNotFoundModal,
startNewConversationFromUsername, setIsFetchingUUID,
lookupConversationWithoutUuid,
toggleConversationInChooseMembers,
showConversation,
startSettingGroupMetadata, startSettingGroupMetadata,
theme, theme,
toggleComposeEditingAvatar, toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
updateSearchTerm, updateSearchTerm,
}) => { }) => {
const [preferredWidth, setPreferredWidth] = useState( const [preferredWidth, setPreferredWidth] = useState(
@ -599,6 +602,10 @@ export const LeftPane: React.FC<PropsType> = ({
throw missingCaseError(disabledReason); throw missingCaseError(disabledReason);
} }
}} }}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showConversation={showConversation}
onSelectConversation={onSelectConversation} onSelectConversation={onSelectConversation}
renderMessageSearchResult={renderMessageSearchResult} renderMessageSearchResult={renderMessageSearchResult}
rowCount={helper.getRowCount()} rowCount={helper.getRowCount()}
@ -607,12 +614,6 @@ export const LeftPane: React.FC<PropsType> = ({
scrollable={isScrollable} scrollable={isScrollable}
shouldRecomputeRowHeights={shouldRecomputeRowHeights} shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers} showChooseGroupMembers={showChooseGroupMembers}
startNewConversationFromPhoneNumber={
startNewConversationFromPhoneNumber
}
startNewConversationFromUsername={
startNewConversationFromUsername
}
theme={theme} theme={theme}
/> />
</div> </div>

View file

@ -1,25 +1,27 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { FuseOptions } from 'fuse.js';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { isNotNil } from '../util/isNotNil';
import type { ConversationStoryType, StoryViewType } from './StoryListItem'; import type { ConversationStoryType, StoryViewType } from './StoryListItem';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
import { StoryListItem } from './StoryListItem'; import { StoryListItem } from './StoryListItem';
const FUSE_OPTIONS: FuseOptions<ConversationStoryType> = { const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
getFn: (obj, path) => { getFn: (obj, path) => {
if (path === 'searchNames') { if (path === 'searchNames') {
return obj.stories.flatMap((story: StoryViewType) => [ return obj.stories
.flatMap((story: StoryViewType) => [
story.sender.title, story.sender.title,
story.sender.name, story.sender.name,
]); ])
.filter(isNotNil);
} }
return obj.group?.title; return obj.group?.title ?? '';
}, },
keys: [ keys: [
{ {
@ -32,16 +34,15 @@ const FUSE_OPTIONS: FuseOptions<ConversationStoryType> = {
}, },
], ],
threshold: 0.1, threshold: 0.1,
tokenize: true,
}; };
function search( function search(
stories: ReadonlyArray<ConversationStoryType>, stories: ReadonlyArray<ConversationStoryType>,
searchTerm: string searchTerm: string
): Array<ConversationStoryType> { ): Array<ConversationStoryType> {
return new Fuse<ConversationStoryType>(stories, FUSE_OPTIONS).search( return new Fuse<ConversationStoryType>(stories, FUSE_OPTIONS)
searchTerm .search(searchTerm)
); .map(result => result.item);
} }
function getNewestStory(story: ConversationStoryType): StoryViewType { function getNewestStory(story: ConversationStoryType): StoryViewType {

View file

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

View file

@ -9,12 +9,18 @@ import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { sleep } from '../../../util/sleep'; import { sleep } from '../../../util/sleep';
import { makeLookup } from '../../../util/makeLookup';
import { deconstructLookup } from '../../../util/deconstructLookup';
import { setupI18n } from '../../../util/setupI18n'; import { setupI18n } from '../../../util/setupI18n';
import type { ConversationType } from '../../../state/ducks/conversations';
import enMessages from '../../../../_locales/en/messages.json'; import enMessages from '../../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { AddGroupMembersModal } from './AddGroupMembersModal'; import { AddGroupMembersModal } from './AddGroupMembersModal';
import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal';
import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal';
import { RequestState } from './util'; import { RequestState } from './util';
import { ThemeType } from '../../../types/Util'; import { ThemeType } from '../../../types/Util';
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -24,14 +30,23 @@ const story = storiesOf(
); );
const allCandidateContacts = times(50, () => getDefaultConversation()); const allCandidateContacts = times(50, () => getDefaultConversation());
let allCandidateContactsLookup = makeLookup(allCandidateContacts, 'id');
const lookupConversationWithoutUuid = makeFakeLookupConversationWithoutUuid(
convo => {
allCandidateContacts.push(convo);
allCandidateContactsLookup = makeLookup(allCandidateContacts, 'id');
}
);
type PropsType = ComponentProps<typeof AddGroupMembersModal>; type PropsType = ComponentProps<typeof AddGroupMembersModal>;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (
candidateContacts: allCandidateContacts, overrideProps: Partial<PropsType> = {},
candidateContacts: Array<ConversationType> = []
): PropsType => ({
clearRequestError: action('clearRequestError'), clearRequestError: action('clearRequestError'),
conversationIdsAlreadyInGroup: new Set(), conversationIdsAlreadyInGroup: new Set(),
getPreferredBadge: () => undefined,
groupTitle: 'Tahoe Trip', groupTitle: 'Tahoe Trip',
i18n, i18n,
onClose: action('onClose'), onClose: action('onClose'),
@ -39,7 +54,38 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
action('onMakeRequest')(conversationIds); action('onMakeRequest')(conversationIds);
}, },
requestState: RequestState.Inactive, requestState: RequestState.Inactive,
theme: ThemeType.light, renderChooseGroupMembersModal: props => {
const { selectedConversationIds } = props;
return (
<ChooseGroupMembersModal
{...props}
candidateContacts={candidateContacts}
selectedContacts={deconstructLookup(
allCandidateContactsLookup,
selectedConversationIds
)}
regionCode="US"
getPreferredBadge={() => undefined}
theme={ThemeType.light}
i18n={i18n}
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showUserNotFoundModal={action('showUserNotFoundModal')}
/>
);
},
renderConfirmAdditionsModal: props => {
const { selectedConversationIds } = props;
return (
<ConfirmAdditionsModal
{...props}
i18n={i18n}
selectedContacts={deconstructLookup(
allCandidateContactsLookup,
selectedConversationIds
)}
/>
);
},
...overrideProps, ...overrideProps,
}); });
@ -47,18 +93,12 @@ story.add('Default', () => <AddGroupMembersModal {...createProps()} />);
story.add('Only 3 contacts', () => ( story.add('Only 3 contacts', () => (
<AddGroupMembersModal <AddGroupMembersModal
{...createProps({ {...createProps({}, allCandidateContacts.slice(0, 3))}
candidateContacts: allCandidateContacts.slice(0, 3),
})}
/> />
)); ));
story.add('No candidate contacts', () => ( story.add('No candidate contacts', () => (
<AddGroupMembersModal <AddGroupMembersModal {...createProps({}, [])} />
{...createProps({
candidateContacts: [],
})}
/>
)); ));
story.add('Everyone already added', () => ( story.add('Everyone already added', () => (

View file

@ -2,16 +2,16 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react'; import type { FunctionComponent } from 'react';
import React, { useMemo, useReducer } from 'react'; import React, { useReducer } from 'react';
import { without } from 'lodash'; import { without } from 'lodash';
import type { LocalizerType, ThemeType } from '../../../types/Util'; import type { LocalizerType } from '../../../types/Util';
import { import {
AddGroupMemberErrorDialog, AddGroupMemberErrorDialog,
AddGroupMemberErrorDialogMode, AddGroupMemberErrorDialogMode,
} from '../../AddGroupMemberErrorDialog'; } from '../../AddGroupMemberErrorDialog';
import type { ConversationType } from '../../../state/ducks/conversations'; import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; import type { SmartConfirmAdditionsModalPropsType } from '../../../state/smart/ConfirmAdditionsModal';
import { import {
getGroupSizeRecommendedLimit, getGroupSizeRecommendedLimit,
getGroupSizeHardLimit, getGroupSizeHardLimit,
@ -20,24 +20,24 @@ import {
toggleSelectedContactForGroupAddition, toggleSelectedContactForGroupAddition,
OneTimeModalState, OneTimeModalState,
} from '../../../groups/toggleSelectedContactForGroupAddition'; } from '../../../groups/toggleSelectedContactForGroupAddition';
import { makeLookup } from '../../../util/makeLookup';
import { deconstructLookup } from '../../../util/deconstructLookup';
import { missingCaseError } from '../../../util/missingCaseError'; import { missingCaseError } from '../../../util/missingCaseError';
import type { RequestState } from './util'; import type { RequestState } from './util';
import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal';
import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal';
type PropsType = { type PropsType = {
candidateContacts: ReadonlyArray<ConversationType>;
clearRequestError: () => void; clearRequestError: () => void;
conversationIdsAlreadyInGroup: Set<string>; conversationIdsAlreadyInGroup: Set<string>;
getPreferredBadge: PreferredBadgeSelectorType;
groupTitle: string; groupTitle: string;
i18n: LocalizerType; i18n: LocalizerType;
makeRequest: (conversationIds: ReadonlyArray<string>) => Promise<void>; makeRequest: (conversationIds: ReadonlyArray<string>) => Promise<void>;
onClose: () => void; onClose: () => void;
requestState: RequestState; requestState: RequestState;
theme: ThemeType;
renderChooseGroupMembersModal: (
props: SmartChooseGroupMembersModalPropsType
) => JSX.Element;
renderConfirmAdditionsModal: (
props: SmartConfirmAdditionsModalPropsType
) => JSX.Element;
}; };
enum Stage { enum Stage {
@ -135,16 +135,15 @@ function reducer(
} }
export const AddGroupMembersModal: FunctionComponent<PropsType> = ({ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
candidateContacts,
clearRequestError, clearRequestError,
conversationIdsAlreadyInGroup, conversationIdsAlreadyInGroup,
getPreferredBadge,
groupTitle, groupTitle,
i18n, i18n,
onClose, onClose,
makeRequest, makeRequest,
requestState, requestState,
theme, renderChooseGroupMembersModal,
renderConfirmAdditionsModal,
}) => { }) => {
const maxGroupSize = getMaximumNumberOfContacts(); const maxGroupSize = getMaximumNumberOfContacts();
const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts(); const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts();
@ -175,16 +174,6 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
stage: Stage.ChoosingContacts, stage: Stage.ChoosingContacts,
}); });
const contactLookup = useMemo(
() => makeLookup(candidateContacts, 'id'),
[candidateContacts]
);
const selectedContacts = deconstructLookup(
contactLookup,
selectedConversationIds
);
if (maximumGroupSizeModalState === OneTimeModalState.Showing) { if (maximumGroupSizeModalState === OneTimeModalState.Showing) {
return ( return (
<AddGroupMemberErrorDialog <AddGroupMemberErrorDialog
@ -239,23 +228,17 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
}); });
}; };
return ( return renderChooseGroupMembersModal({
<ChooseGroupMembersModal confirmAdds,
candidateContacts={candidateContacts} selectedConversationIds,
confirmAdds={confirmAdds} conversationIdsAlreadyInGroup,
conversationIdsAlreadyInGroup={conversationIdsAlreadyInGroup} maxGroupSize,
getPreferredBadge={getPreferredBadge} onClose,
i18n={i18n} removeSelectedContact,
maxGroupSize={maxGroupSize} searchTerm,
onClose={onClose} setSearchTerm,
removeSelectedContact={removeSelectedContact} toggleSelectedContact,
searchTerm={searchTerm} });
selectedContacts={selectedContacts}
setSearchTerm={setSearchTerm}
theme={theme}
toggleSelectedContact={toggleSelectedContact}
/>
);
} }
case Stage.ConfirmingAdds: { case Stage.ConfirmingAdds: {
const onCloseConfirmationDialog = () => { const onCloseConfirmationDialog = () => {
@ -263,18 +246,15 @@ export const AddGroupMembersModal: FunctionComponent<PropsType> = ({
clearRequestError(); clearRequestError();
}; };
return ( return renderConfirmAdditionsModal({
<ConfirmAdditionsModal groupTitle,
groupTitle={groupTitle} makeRequest: () => {
i18n={i18n}
makeRequest={() => {
makeRequest(selectedConversationIds); makeRequest(selectedConversationIds);
}} },
onClose={onCloseConfirmationDialog} onClose: onCloseConfirmationDialog,
requestState={requestState} requestState,
selectedContacts={selectedContacts} selectedConversationIds,
/> });
);
} }
default: default:
throw missingCaseError(stage); throw missingCaseError(stage);

View file

@ -2,7 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react'; 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 type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure'; import Measure from 'react-measure';
@ -11,9 +18,16 @@ import { assert } from '../../../../util/assert';
import { refMerger } from '../../../../util/refMerger'; import { refMerger } from '../../../../util/refMerger';
import { useRestoreFocus } from '../../../../hooks/useRestoreFocus'; import { useRestoreFocus } from '../../../../hooks/useRestoreFocus';
import { missingCaseError } from '../../../../util/missingCaseError'; import { missingCaseError } from '../../../../util/missingCaseError';
import type { LookupConversationWithoutUuidActionsType } from '../../../../util/lookupConversationWithoutUuid';
import { parseAndFormatPhoneNumber } from '../../../../util/libphonenumberInstance';
import { filterAndSortConversationsByTitle } from '../../../../util/filterAndSortConversations'; import { filterAndSortConversationsByTitle } from '../../../../util/filterAndSortConversations';
import type { ConversationType } from '../../../../state/ducks/conversations'; import type { ConversationType } from '../../../../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../../../../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../../../../state/selectors/badges';
import type {
UUIDFetchStateKeyType,
UUIDFetchStateType,
} from '../../../../util/uuidFetchState';
import { isFetchingByE164 } from '../../../../util/uuidFetchState';
import { ModalHost } from '../../../ModalHost'; import { ModalHost } from '../../../ModalHost';
import { ContactPills } from '../../../ContactPills'; import { ContactPills } from '../../../ContactPills';
import { ContactPill } from '../../../ContactPill'; import { ContactPill } from '../../../ContactPill';
@ -23,24 +37,37 @@ import { ContactCheckboxDisabledReason } from '../../../conversationList/Contact
import { Button, ButtonVariant } from '../../../Button'; import { Button, ButtonVariant } from '../../../Button';
import { SearchInput } from '../../../SearchInput'; import { SearchInput } from '../../../SearchInput';
type PropsType = { export type StatePropsType = {
regionCode: string | undefined;
candidateContacts: ReadonlyArray<ConversationType>; candidateContacts: ReadonlyArray<ConversationType>;
confirmAdds: () => void;
conversationIdsAlreadyInGroup: Set<string>; conversationIdsAlreadyInGroup: Set<string>;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType; i18n: LocalizerType;
theme: ThemeType;
maxGroupSize: number; maxGroupSize: number;
onClose: () => void;
removeSelectedContact: (_: string) => void;
searchTerm: string; searchTerm: string;
selectedContacts: ReadonlyArray<ConversationType>; selectedContacts: ReadonlyArray<ConversationType>;
confirmAdds: () => void;
onClose: () => void;
removeSelectedContact: (_: string) => void;
setSearchTerm: (_: string) => void; setSearchTerm: (_: string) => void;
theme: ThemeType;
toggleSelectedContact: (conversationId: string) => void; toggleSelectedContact: (conversationId: string) => void;
}; } & Pick<
LookupConversationWithoutUuidActionsType,
'lookupConversationWithoutUuid'
>;
type ActionPropsType = Omit<
LookupConversationWithoutUuidActionsType,
'setIsFetchingUUID' | 'lookupConversationWithoutUuid'
>;
type PropsType = StatePropsType & ActionPropsType;
// TODO: This should use <Modal>. See DESKTOP-1038. // TODO: This should use <Modal>. See DESKTOP-1038.
export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
regionCode,
candidateContacts, candidateContacts,
confirmAdds, confirmAdds,
conversationIdsAlreadyInGroup, conversationIdsAlreadyInGroup,
@ -54,9 +81,24 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
setSearchTerm, setSearchTerm,
theme, theme,
toggleSelectedContact, toggleSelectedContact,
lookupConversationWithoutUuid,
showUserNotFoundModal,
}) => { }) => {
const [focusRef] = useRestoreFocus(); 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 | HTMLInputElement>(null); const inputRef = useRef<null | HTMLInputElement>(null);
const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size; const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size;
@ -72,7 +114,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
const canContinue = Boolean(selectedContacts.length); const canContinue = Boolean(selectedContacts.length);
const [filteredContacts, setFilteredContacts] = useState( const [filteredContacts, setFilteredContacts] = useState(
filterAndSortConversationsByTitle(candidateContacts, '') filterAndSortConversationsByTitle(candidateContacts, '', regionCode)
); );
const normalizedSearchTerm = searchTerm.trim(); const normalizedSearchTerm = searchTerm.trim();
useEffect(() => { useEffect(() => {
@ -80,21 +122,66 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
setFilteredContacts( setFilteredContacts(
filterAndSortConversationsByTitle( filterAndSortConversationsByTitle(
candidateContacts, candidateContacts,
normalizedSearchTerm normalizedSearchTerm,
regionCode
) )
); );
}, 200); }, 200);
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
}; };
}, [candidateContacts, normalizedSearchTerm, setFilteredContacts]); }, [
candidateContacts,
normalizedSearchTerm,
setFilteredContacts,
regionCode,
]);
const rowCount = filteredContacts.length; const [uuidFetchState, setUuidFetchState] = useState<UUIDFetchStateType>({});
const getRow = (index: number): undefined | Row => {
const contact = filteredContacts[index]; const setIsFetchingUUID = useCallback(
if (!contact) { (identifier: UUIDFetchStateKeyType, isFetching: boolean) => {
return undefined; 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 => {
let virtualIndex = index;
if (isPhoneNumberVisible && filteredContacts.length) {
if (virtualIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
virtualIndex -= 1;
}
if (virtualIndex < filteredContacts.length) {
const contact = filteredContacts[virtualIndex];
const isSelected = selectedConversationIdsSet.has(contact.id); const isSelected = selectedConversationIdsSet.has(contact.id);
const isAlreadyInGroup = conversationIdsAlreadyInGroup.has(contact.id); const isAlreadyInGroup = conversationIdsAlreadyInGroup.has(contact.id);
@ -112,6 +199,29 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
isChecked: isSelected || isAlreadyInGroup, isChecked: isSelected || isAlreadyInGroup,
disabledReason, 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 ( return (
@ -207,6 +317,12 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
throw missingCaseError(disabledReason); throw missingCaseError(disabledReason);
} }
}} }}
lookupConversationWithoutUuid={
lookupConversationWithoutUuid
}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
showConversation={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => { renderMessageSearchResult={() => {
shouldNeverBeCalled(); shouldNeverBeCalled();
@ -215,8 +331,6 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
rowCount={rowCount} rowCount={rowCount}
shouldRecomputeRowHeights={false} shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled} showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
startNewConversationFromUsername={shouldNeverBeCalled}
theme={theme} theme={theme}
/> />
</div> </div>

View file

@ -15,7 +15,7 @@ import { Intl } from '../../../Intl';
import { Emojify } from '../../Emojify'; import { Emojify } from '../../Emojify';
import { ContactName } from '../../ContactName'; import { ContactName } from '../../ContactName';
type PropsType = { export type StatePropsType = {
groupTitle: string; groupTitle: string;
i18n: LocalizerType; i18n: LocalizerType;
makeRequest: () => void; makeRequest: () => void;
@ -24,6 +24,8 @@ type PropsType = {
selectedContacts: ReadonlyArray<ConversationType>; selectedContacts: ReadonlyArray<ConversationType>;
}; };
type PropsType = StatePropsType;
export const ConfirmAdditionsModal: FunctionComponent<PropsType> = ({ export const ConfirmAdditionsModal: FunctionComponent<PropsType> = ({
groupTitle, groupTitle,
i18n, i18n,

View file

@ -12,8 +12,11 @@ import { CapabilityError } from '../../../types/errors';
import enMessages from '../../../../_locales/en/messages.json'; import enMessages from '../../../../_locales/en/messages.json';
import type { Props } from './ConversationDetails'; import type { Props } from './ConversationDetails';
import { ConversationDetails } from './ConversationDetails'; import { ConversationDetails } from './ConversationDetails';
import { ChooseGroupMembersModal } from './AddGroupMembersModal/ChooseGroupMembersModal';
import { ConfirmAdditionsModal } from './AddGroupMembersModal/ConfirmAdditionsModal';
import type { ConversationType } from '../../../state/ducks/conversations'; import type { ConversationType } from '../../../state/ducks/conversations';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
import { ThemeType } from '../../../types/Util'; import { ThemeType } from '../../../types/Util';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -33,13 +36,14 @@ const conversation: ConversationType = getDefaultConversation({
conversationColor: 'ultramarine' as const, conversationColor: 'ultramarine' as const,
}); });
const allCandidateContacts = times(10, () => getDefaultConversation());
const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
addMembers: async () => { addMembers: async () => {
action('addMembers'); action('addMembers');
}, },
areWeASubscriber: false, areWeASubscriber: false,
canEditGroupInfo: false, canEditGroupInfo: false,
candidateContactsToAdd: times(10, () => getDefaultConversation()),
conversation: expireTimer conversation: expireTimer
? { ? {
...conversation, ...conversation,
@ -97,6 +101,26 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
), ),
searchInConversation: action('searchInConversation'), searchInConversation: action('searchInConversation'),
theme: ThemeType.light, theme: ThemeType.light,
renderChooseGroupMembersModal: props => {
return (
<ChooseGroupMembersModal
{...props}
candidateContacts={allCandidateContacts}
selectedContacts={[]}
regionCode="US"
getPreferredBadge={() => undefined}
theme={ThemeType.light}
i18n={i18n}
lookupConversationWithoutUuid={makeFakeLookupConversationWithoutUuid()}
showUserNotFoundModal={action('showUserNotFoundModal')}
/>
);
},
renderConfirmAdditionsModal: props => {
return (
<ConfirmAdditionsModal {...props} selectedContacts={[]} i18n={i18n} />
);
},
}); });
story.add('Basic', () => { story.add('Basic', () => {

View file

@ -8,6 +8,8 @@ import { Button, ButtonIconType, ButtonVariant } from '../../Button';
import { Tooltip } from '../../Tooltip'; import { Tooltip } from '../../Tooltip';
import type { ConversationType } from '../../../state/ducks/conversations'; import type { ConversationType } from '../../../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; 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 { assert } from '../../../util/assert';
import { getMutedUntilText } from '../../../util/getMutedUntilText'; import { getMutedUntilText } from '../../../util/getMutedUntilText';
@ -59,7 +61,6 @@ export type StateProps = {
areWeASubscriber: boolean; areWeASubscriber: boolean;
badges?: ReadonlyArray<BadgeType>; badges?: ReadonlyArray<BadgeType>;
canEditGroupInfo: boolean; canEditGroupInfo: boolean;
candidateContactsToAdd: Array<ConversationType>;
conversation?: ConversationType; conversation?: ConversationType;
hasGroupLink: boolean; hasGroupLink: boolean;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
@ -97,6 +98,12 @@ export type StateProps = {
setMuteExpiration: (muteExpiresAt: undefined | number) => unknown; setMuteExpiration: (muteExpiresAt: undefined | number) => unknown;
onOutgoingAudioCallInConversation: () => unknown; onOutgoingAudioCallInConversation: () => unknown;
onOutgoingVideoCallInConversation: () => unknown; onOutgoingVideoCallInConversation: () => unknown;
renderChooseGroupMembersModal: (
props: SmartChooseGroupMembersModalPropsType
) => JSX.Element;
renderConfirmAdditionsModal: (
props: SmartConfirmAdditionsModalPropsType
) => JSX.Element;
}; };
type ActionProps = { type ActionProps = {
@ -115,7 +122,6 @@ export const ConversationDetails: React.ComponentType<Props> = ({
areWeASubscriber, areWeASubscriber,
badges, badges,
canEditGroupInfo, canEditGroupInfo,
candidateContactsToAdd,
conversation, conversation,
deleteAvatarFromDisk, deleteAvatarFromDisk,
hasGroupLink, hasGroupLink,
@ -133,6 +139,8 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onUnblock, onUnblock,
pendingApprovalMemberships, pendingApprovalMemberships,
pendingMemberships, pendingMemberships,
renderChooseGroupMembersModal,
renderConfirmAdditionsModal,
replaceAvatar, replaceAvatar,
saveAvatarToDisk, saveAvatarToDisk,
searchInConversation, searchInConversation,
@ -228,7 +236,8 @@ export const ConversationDetails: React.ComponentType<Props> = ({
case ModalState.AddingGroupMembers: case ModalState.AddingGroupMembers:
modalNode = ( modalNode = (
<AddGroupMembersModal <AddGroupMembersModal
candidateContacts={candidateContactsToAdd} renderChooseGroupMembersModal={renderChooseGroupMembersModal}
renderConfirmAdditionsModal={renderConfirmAdditionsModal}
clearRequestError={() => { clearRequestError={() => {
setAddGroupMembersRequestState(oldRequestState => { setAddGroupMembersRequestState(oldRequestState => {
assert( assert(
@ -241,7 +250,6 @@ export const ConversationDetails: React.ComponentType<Props> = ({
conversationIdsAlreadyInGroup={ conversationIdsAlreadyInGroup={
new Set(memberships.map(membership => membership.member.id)) new Set(memberships.map(membership => membership.member.id))
} }
getPreferredBadge={getPreferredBadge}
groupTitle={conversation.title} groupTitle={conversation.title}
i18n={i18n} i18n={i18n}
makeRequest={async conversationIds => { makeRequest={async conversationIds => {
@ -265,7 +273,6 @@ export const ConversationDetails: React.ComponentType<Props> = ({
setEditGroupAttributesRequestState(RequestState.Inactive); setEditGroupAttributesRequestState(RequestState.Inactive);
}} }}
requestState={addGroupMembersRequestState} requestState={addGroupMembersRequestState}
theme={theme}
/> />
); );
break; break;

View file

@ -29,6 +29,7 @@ export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`;
const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`; const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`; export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`; const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
type PropsType = { type PropsType = {
checked?: boolean; checked?: boolean;
@ -113,7 +114,12 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
let actionNode: ReactNode; let actionNode: ReactNode;
if (shouldShowSpinner) { if (shouldShowSpinner) {
actionNode = ( actionNode = (
<Spinner size="20px" svgSize="small" direction="on-progress-dialog" /> <Spinner
size="20px"
svgSize="small"
moduleClassName={SPINNER_CLASS_NAME}
direction="on-progress-dialog"
/>
); );
} else if (isCheckbox) { } else if (isCheckbox) {
let ariaLabel: string; let ariaLabel: string;

View file

@ -30,6 +30,7 @@ export type ContactListItemConversationType = Pick<
| 'title' | 'title'
| 'type' | 'type'
| 'unblurredAvatarPath' | 'unblurredAvatarPath'
| 'e164'
>; >;
type PropsDataType = ContactListItemConversationType & { type PropsDataType = ContactListItemConversationType & {

View file

@ -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<PropsType> = 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 = (
<ConfirmationDialog
cancelText={i18n('ok')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
onClose={() => setIsModalVisible(false)}
>
{i18n('startConversation--phone-number-not-valid', {
phoneNumber: phoneNumber.userInput,
})}
</ConfirmationDialog>
);
}
return (
<>
<BaseConversationListItem
acceptedMessageRequest={false}
checked={isChecked}
color={AvatarColors[0]}
conversationType="direct"
headerName={phoneNumber.userInput}
i18n={i18n}
isMe={false}
isSelected={false}
onClick={onClickItem}
phoneNumber={phoneNumber.userInput}
shouldShowSpinner={isFetching}
theme={theme}
sharedGroupNames={[]}
title={phoneNumber.userInput}
/>
{modal}
</>
);
}
);

View file

@ -2,54 +2,105 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react'; import type { FunctionComponent } from 'react';
import React, { useCallback } from 'react'; import React, { useCallback, useState } from 'react';
import { import { ButtonVariant } from '../Button';
BaseConversationListItem, import { ConfirmationDialog } from '../ConfirmationDialog';
MESSAGE_TEXT_CLASS_NAME, import { BaseConversationListItem } from './BaseConversationListItem';
} from './BaseConversationListItem';
import type { ParsedE164Type } from '../../util/libphonenumberInstance';
import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { AvatarColors } from '../../types/Colors'; import { AvatarColors } from '../../types/Colors';
const TEXT_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__start-new-conversation`;
type PropsData = { type PropsData = {
phoneNumber: string; phoneNumber: ParsedE164Type;
isFetching: boolean;
}; };
type PropsHousekeeping = { type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
onClick: (phoneNumber: string) => void; showConversation: (conversationId: string) => void;
}; } & LookupConversationWithoutUuidActionsType;
export type Props = PropsData & PropsHousekeeping; export type Props = PropsData & PropsHousekeeping;
export const StartNewConversation: FunctionComponent<Props> = React.memo( export const StartNewConversation: FunctionComponent<Props> = React.memo(
function StartNewConversation({ i18n, onClick, phoneNumber }) { function StartNewConversation({
const messageText = ( i18n,
<div className={TEXT_CLASS_NAME}>{i18n('startConversation')}</div> phoneNumber,
); isFetching,
lookupConversationWithoutUuid,
showUserNotFoundModal,
setIsFetchingUUID,
showConversation,
}) {
const [isModalVisible, setIsModalVisible] = useState(false);
const boundOnClick = useCallback(() => { const boundOnClick = useCallback(async () => {
onClick(phoneNumber); if (!phoneNumber.isValid) {
}, [onClick, phoneNumber]); 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 = (
<ConfirmationDialog
cancelText={i18n('ok')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
onClose={() => setIsModalVisible(false)}
>
{i18n('startConversation--phone-number-not-valid', {
phoneNumber: phoneNumber.userInput,
})}
</ConfirmationDialog>
);
}
return ( return (
<>
<BaseConversationListItem <BaseConversationListItem
acceptedMessageRequest={false} acceptedMessageRequest={false}
color={AvatarColors[0]} color={AvatarColors[0]}
conversationType="direct" conversationType="direct"
headerName={phoneNumber} headerName={phoneNumber.userInput}
i18n={i18n} i18n={i18n}
isMe={false} isMe={false}
isSelected={false} isSelected={false}
messageText={messageText}
onClick={boundOnClick} onClick={boundOnClick}
phoneNumber={phoneNumber} phoneNumber={phoneNumber.userInput}
shouldShowSpinner={isFetching}
sharedGroupNames={[]} sharedGroupNames={[]}
title={phoneNumber} title={phoneNumber.userInput}
/> />
{modal}
</>
); );
} }
); );

View file

@ -2,12 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react'; import type { FunctionComponent } from 'react';
import React from 'react'; import React, { useCallback } from 'react';
import { noop } from 'lodash';
import { BaseConversationListItem } from './BaseConversationListItem'; import { BaseConversationListItem } from './BaseConversationListItem';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { lookupConversationWithoutUuid } from '../../util/lookupConversationWithoutUuid';
import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid';
type PropsData = { type PropsData = {
username: string; username: string;
@ -16,23 +17,42 @@ type PropsData = {
type PropsHousekeeping = { type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
onClick: (username: string) => void; showConversation: (conversationId: string) => void;
}; } & LookupConversationWithoutUuidActionsType;
export type Props = PropsData & PropsHousekeeping; export type Props = PropsData & PropsHousekeeping;
export const UsernameSearchResultListItem: FunctionComponent<Props> = ({ export const UsernameSearchResultListItem: FunctionComponent<Props> = ({
i18n, i18n,
isFetchingUsername, isFetchingUsername,
onClick,
username, username,
showUserNotFoundModal,
setIsFetchingUUID,
showConversation,
}) => { }) => {
const usernameText = i18n('at-username', { username }); const usernameText = i18n('at-username', { username });
const boundOnClick = isFetchingUsername const boundOnClick = useCallback(async () => {
? noop if (isFetchingUsername) {
: () => { return;
onClick(username); }
}; const conversationId = await lookupConversationWithoutUuid({
showUserNotFoundModal,
setIsFetchingUUID,
type: 'username',
username,
});
if (conversationId !== undefined) {
showConversation(conversationId);
}
}, [
username,
showUserNotFoundModal,
setIsFetchingUUID,
showConversation,
isFetchingUsername,
]);
return ( return (
<BaseConversationListItem <BaseConversationListItem

View file

@ -1,4 +1,4 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
// Camelcase disabled due to emoji-datasource using snake_case // Camelcase disabled due to emoji-datasource using snake_case
@ -220,15 +220,12 @@ export function getImagePath(
const fuse = new Fuse(data, { const fuse = new Fuse(data, {
shouldSort: true, shouldSort: true,
threshold: 0.2, threshold: 0.2,
maxPatternLength: 32,
minMatchCharLength: 1, minMatchCharLength: 1,
tokenize: true,
tokenSeparator: /[-_\s]+/,
keys: ['short_name', 'name'], keys: ['short_name', 'name'],
}); });
export function search(query: string, count = 0): Array<EmojiData> { export function search(query: string, count = 0): Array<EmojiData> {
const results = fuse.search(query.substr(0, 32)); const results = fuse.search(query.substr(0, 32)).map(result => result.item);
if (count) { if (count) {
return take(results, count); return take(results, count);

View file

@ -18,46 +18,78 @@ import {
} from '../AddGroupMemberErrorDialog'; } from '../AddGroupMemberErrorDialog';
import { Button } from '../Button'; import { Button } from '../Button';
import type { LocalizerType } from '../../types/Util'; 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 { import {
getGroupSizeRecommendedLimit, getGroupSizeRecommendedLimit,
getGroupSizeHardLimit, getGroupSizeHardLimit,
} from '../../groups/limits'; } from '../../groups/limits';
export type LeftPaneChooseGroupMembersPropsType = { export type LeftPaneChooseGroupMembersPropsType = {
uuidFetchState: UUIDFetchStateType;
candidateContacts: ReadonlyArray<ConversationType>; candidateContacts: ReadonlyArray<ConversationType>;
isShowingRecommendedGroupSizeModal: boolean; isShowingRecommendedGroupSizeModal: boolean;
isShowingMaximumGroupSizeModal: boolean; isShowingMaximumGroupSizeModal: boolean;
searchTerm: string; searchTerm: string;
regionCode: string | undefined;
selectedContacts: Array<ConversationType>; selectedContacts: Array<ConversationType>;
}; };
export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneChooseGroupMembersPropsType> { export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneChooseGroupMembersPropsType> {
private readonly candidateContacts: ReadonlyArray<ConversationType>; private readonly candidateContacts: ReadonlyArray<ConversationType>;
private readonly isPhoneNumberChecked: boolean;
private readonly isShowingMaximumGroupSizeModal: boolean; private readonly isShowingMaximumGroupSizeModal: boolean;
private readonly isShowingRecommendedGroupSizeModal: boolean; private readonly isShowingRecommendedGroupSizeModal: boolean;
private readonly searchTerm: string; private readonly searchTerm: string;
private readonly phoneNumber: ParsedE164Type | undefined;
private readonly selectedContacts: Array<ConversationType>; private readonly selectedContacts: Array<ConversationType>;
private readonly selectedConversationIdsSet: Set<string>; private readonly selectedConversationIdsSet: Set<string>;
private readonly uuidFetchState: UUIDFetchStateType;
constructor({ constructor({
candidateContacts, candidateContacts,
isShowingMaximumGroupSizeModal, isShowingMaximumGroupSizeModal,
isShowingRecommendedGroupSizeModal, isShowingRecommendedGroupSizeModal,
searchTerm, searchTerm,
regionCode,
selectedContacts, selectedContacts,
uuidFetchState,
}: Readonly<LeftPaneChooseGroupMembersPropsType>) { }: Readonly<LeftPaneChooseGroupMembersPropsType>) {
super(); super();
this.uuidFetchState = uuidFetchState;
this.candidateContacts = candidateContacts; this.candidateContacts = candidateContacts;
this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal; this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal;
this.isShowingRecommendedGroupSizeModal = this.isShowingRecommendedGroupSizeModal =
isShowingRecommendedGroupSizeModal; isShowingRecommendedGroupSizeModal;
this.searchTerm = searchTerm; 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.selectedContacts = selectedContacts;
this.selectedConversationIdsSet = new Set( this.selectedConversationIdsSet = new Set(
@ -207,33 +239,50 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
} }
getRowCount(): number { getRowCount(): number {
if (!this.candidateContacts.length) { let rowCount = 0;
return 0;
} // Header + Phone Number
return this.candidateContacts.length + 2; if (this.phoneNumber) {
rowCount += 2;
} }
getRow(rowIndex: number): undefined | Row { // Header + Contacts
if (!this.candidateContacts.length) { if (this.candidateContacts.length) {
rowCount += 1 + this.candidateContacts.length;
}
// Footer
if (rowCount > 0) {
rowCount += 1;
}
return rowCount;
}
getRow(actualRowIndex: number): undefined | Row {
if (!this.candidateContacts.length && !this.phoneNumber) {
return undefined; return undefined;
} }
if (rowIndex === 0) { const rowCount = this.getRowCount();
// This puts a blank row for the footer.
if (actualRowIndex === rowCount - 1) {
return { type: RowType.Blank };
}
let virtualRowIndex = actualRowIndex;
if (this.candidateContacts.length) {
if (virtualRowIndex === 0) {
return { return {
type: RowType.Header, type: RowType.Header,
i18nKey: 'contactsHeader', i18nKey: 'contactsHeader',
}; };
} }
// This puts a blank row for the footer. if (virtualRowIndex <= this.candidateContacts.length) {
if (rowIndex === this.candidateContacts.length + 1) { const contact = this.candidateContacts[virtualRowIndex - 1];
return { type: RowType.Blank };
}
const contact = this.candidateContacts[rowIndex - 1];
if (!contact) {
return undefined;
}
const isChecked = this.selectedConversationIdsSet.has(contact.id); const isChecked = this.selectedConversationIdsSet.has(contact.id);
const disabledReason = const disabledReason =
@ -249,6 +298,33 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
}; };
} }
virtualRowIndex -= 1 + this.candidateContacts.length;
}
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 undefined;
}
// This is deliberately unimplemented because these keyboard shortcuts shouldn't work in // This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
// the composer. The same is true for the "in direction" function below. // the composer. The same is true for the "in direction" function below.
getConversationAndMessageAtIndex( getConversationAndMessageAtIndex(

View file

@ -3,7 +3,6 @@
import type { ReactChild, ChangeEvent } from 'react'; import type { ReactChild, ChangeEvent } from 'react';
import React from 'react'; import React from 'react';
import type { PhoneNumber } from 'google-libphonenumber';
import { LeftPaneHelper } from './LeftPaneHelper'; import { LeftPaneHelper } from './LeftPaneHelper';
import type { Row } from '../ConversationList'; import type { Row } from '../ConversationList';
@ -12,13 +11,15 @@ import type { ContactListItemConversationType } from '../conversationList/Contac
import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem'; import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
import { SearchInput } from '../SearchInput'; import { SearchInput } from '../SearchInput';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { import type { ParsedE164Type } from '../../util/libphonenumberInstance';
instance as phoneNumberInstance, import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance';
PhoneNumberFormat,
} from '../../util/libphonenumberInstance';
import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { getUsernameFromSearch } from '../../types/Username'; import { getUsernameFromSearch } from '../../types/Username';
import type { UUIDFetchStateType } from '../../util/uuidFetchState';
import {
isFetchingByUsername,
isFetchingByE164,
} from '../../util/uuidFetchState';
export type LeftPaneComposePropsType = { export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray<ContactListItemConversationType>; composeContacts: ReadonlyArray<ContactListItemConversationType>;
@ -26,14 +27,13 @@ export type LeftPaneComposePropsType = {
regionCode: string | undefined; regionCode: string | undefined;
searchTerm: string; searchTerm: string;
isFetchingUsername: boolean; uuidFetchState: UUIDFetchStateType;
isUsernamesEnabled: boolean; isUsernamesEnabled: boolean;
}; };
enum TopButton { enum TopButton {
None, None,
CreateNewGroup, CreateNewGroup,
StartNewConversation,
} }
export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsType> { export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsType> {
@ -41,13 +41,15 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
private readonly composeGroups: ReadonlyArray<ConversationListItemPropsType>; private readonly composeGroups: ReadonlyArray<ConversationListItemPropsType>;
private readonly isFetchingUsername: boolean; private readonly uuidFetchState: UUIDFetchStateType;
private readonly isUsernamesEnabled: boolean; private readonly isUsernamesEnabled: boolean;
private readonly searchTerm: string; private readonly searchTerm: string;
private readonly phoneNumber: undefined | PhoneNumber; private readonly phoneNumber: ParsedE164Type | undefined;
private readonly isPhoneNumberVisible: boolean;
constructor({ constructor({
composeContacts, composeContacts,
@ -55,15 +57,23 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
regionCode, regionCode,
searchTerm, searchTerm,
isUsernamesEnabled, isUsernamesEnabled,
isFetchingUsername, uuidFetchState,
}: Readonly<LeftPaneComposePropsType>) { }: Readonly<LeftPaneComposePropsType>) {
super(); super();
this.composeContacts = composeContacts; this.composeContacts = composeContacts;
this.composeGroups = composeGroups; this.composeGroups = composeGroups;
this.searchTerm = searchTerm; this.searchTerm = searchTerm;
this.phoneNumber = parsePhoneNumber(searchTerm, regionCode); this.phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
this.isFetchingUsername = isFetchingUsername; 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; this.isUsernamesEnabled = isUsernamesEnabled;
} }
@ -141,6 +151,9 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
if (this.getUsernameFromSearch()) { if (this.getUsernameFromSearch()) {
result += 2; result += 2;
} }
if (this.isPhoneNumberVisible) {
result += 2;
}
return result; return result;
} }
@ -153,18 +166,6 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
switch (topButton) { switch (topButton) {
case TopButton.None: case TopButton.None:
break; break;
case TopButton.StartNewConversation:
assert(
this.phoneNumber,
'LeftPaneComposeHelper: we should have a phone number if the top button is "Start new conversation"'
);
return {
type: RowType.StartNewConversation,
phoneNumber: phoneNumberInstance.format(
this.phoneNumber,
PhoneNumberFormat.E164
),
};
case TopButton.CreateNewGroup: case TopButton.CreateNewGroup:
return { type: RowType.CreateNewGroup }; return { type: RowType.CreateNewGroup };
default: default:
@ -232,7 +233,34 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
return { return {
type: RowType.UsernameSearchResult, type: RowType.UsernameSearchResult,
username, username,
isFetchingUsername: this.isFetchingUsername, isFetchingUsername: isFetchingByUsername(
this.uuidFetchState,
username
),
};
virtualRowIndex -= 1;
}
}
if (this.phoneNumber && this.isPhoneNumberVisible) {
if (virtualRowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'findByPhoneNumberHeader',
};
}
virtualRowIndex -= 1;
if (virtualRowIndex === 0) {
return {
type: RowType.StartNewConversation,
phoneNumber: this.phoneNumber,
isFetching: isFetchingByE164(
this.uuidFetchState,
this.phoneNumber.e164
),
}; };
virtualRowIndex -= 1; virtualRowIndex -= 1;
@ -272,9 +300,6 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
} }
private getTopButton(): TopButton { private getTopButton(): TopButton {
if (this.phoneNumber) {
return TopButton.StartNewConversation;
}
if (this.searchTerm) { if (this.searchTerm) {
return TopButton.None; return TopButton.None;
} }
@ -352,21 +377,3 @@ function focusRef(el: HTMLElement | null) {
el.focus(); el.focus();
} }
} }
function parsePhoneNumber(
str: string,
regionCode: string | undefined
): undefined | PhoneNumber {
let result: PhoneNumber;
try {
result = phoneNumberInstance.parse(str, regionCode);
} catch (err) {
return undefined;
}
if (!phoneNumberInstance.isValidNumber(result)) {
return undefined;
}
return result;
}

View file

@ -1,10 +1,10 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { get } from 'lodash';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import { getOwn } from '../util/getOwn';
import { filter, map } from '../util/iterables'; import { filter, map } from '../util/iterables';
const FUSE_OPTIONS = { const FUSE_OPTIONS = {
@ -16,10 +16,10 @@ const FUSE_OPTIONS = {
keys: ['name', 'firstName', 'profileName', 'title'], keys: ['name', 'firstName', 'profileName', 'title'],
getFn( getFn(
conversation: Readonly<ConversationType>, conversation: Readonly<ConversationType>,
path: string path: string | Array<string>
): ReadonlyArray<string> | string { ): ReadonlyArray<string> | string {
// It'd be nice to avoid this cast, but Fuse's types don't allow it. // It'd be nice to avoid this cast, but Fuse's types don't allow it.
const rawValue = getOwn(conversation as Record<string, unknown>, path); const rawValue = get(conversation as Record<string, unknown>, path);
if (typeof rawValue !== 'string') { if (typeof rawValue !== 'string') {
// It might make more sense to return `undefined` here, but [Fuse's types don't // 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; this.isFuseReady = true;
} }
const results = this.fuse.search(`${pattern}`); const results = this.fuse.search(pattern).map(result => result.item);
if (omit) { if (omit) {
return results.filter(({ id }) => id !== omit.id); return results.filter(({ id }) => id !== omit.id);

View file

@ -21,15 +21,13 @@ import { getOwn } from '../../util/getOwn';
import { assert, strictAssert } from '../../util/assert'; import { assert, strictAssert } from '../../util/assert';
import * as universalExpireTimer from '../../util/universalExpireTimer'; import * as universalExpireTimer from '../../util/universalExpireTimer';
import { trigger } from '../../shims/events'; import { trigger } from '../../shims/events';
import type { import type { ToggleProfileEditorErrorActionType } from './globalModals';
ShowUsernameNotFoundModalActionType, import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals';
ToggleProfileEditorErrorActionType,
} from './globalModals';
import {
TOGGLE_PROFILE_EDITOR_ERROR,
actions as globalModalActions,
} from './globalModals';
import { isRecord } from '../../util/isRecord'; import { isRecord } from '../../util/isRecord';
import type {
UUIDFetchStateKeyType,
UUIDFetchStateType,
} from '../../util/uuidFetchState';
import type { import type {
AvatarColorType, AvatarColorType,
@ -45,7 +43,6 @@ import type { BodyRangeType } from '../../types/Util';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import type { MediaItemType } from '../../types/MediaItem'; import type { MediaItemType } from '../../types/MediaItem';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import { UUID } from '../../types/UUID';
import { import {
getGroupSizeRecommendedLimit, getGroupSizeRecommendedLimit,
getGroupSizeHardLimit, getGroupSizeHardLimit,
@ -57,7 +54,6 @@ import { ContactSpoofingType } from '../../util/contactSpoofing';
import { writeProfile } from '../../services/writeProfile'; import { writeProfile } from '../../services/writeProfile';
import { writeUsername } from '../../services/writeUsername'; import { writeUsername } from '../../services/writeUsername';
import { import {
getConversationsByUsername,
getConversationIdsStoppingSend, getConversationIdsStoppingSend,
getConversationIdsStoppedForVerification, getConversationIdsStoppedForVerification,
getMe, getMe,
@ -76,8 +72,6 @@ import {
} from './conversationsEnums'; } from './conversationsEnums';
import { showToast } from '../../util/showToast'; import { showToast } from '../../util/showToast';
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername'; import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername';
import { isValidUsername } from '../../types/Username';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import type { NoopActionType } from './noop'; import type { NoopActionType } from './noop';
@ -288,20 +282,16 @@ export type ConversationVerificationData =
canceledAt: number; canceledAt: number;
}; };
export type FoundUsernameType = {
uuid: UUIDStringType;
username: string;
};
type ComposerStateType = type ComposerStateType =
| { | {
step: ComposerStep.StartDirectConversation; step: ComposerStep.StartDirectConversation;
searchTerm: string; searchTerm: string;
isFetchingUsername: boolean; uuidFetchState: UUIDFetchStateType;
} }
| ({ | ({
step: ComposerStep.ChooseGroupMembers; step: ComposerStep.ChooseGroupMembers;
searchTerm: string; searchTerm: string;
uuidFetchState: UUIDFetchStateType;
} & ComposerGroupCreationState) } & ComposerGroupCreationState)
| ({ | ({
step: ComposerStep.SetGroupMetadata; step: ComposerStep.SetGroupMetadata;
@ -677,10 +667,11 @@ type SetComposeSearchTermActionType = {
type: 'SET_COMPOSE_SEARCH_TERM'; type: 'SET_COMPOSE_SEARCH_TERM';
payload: { searchTerm: string }; payload: { searchTerm: string };
}; };
type SetIsFetchingUsernameActionType = { type SetIsFetchingUUIDActionType = {
type: 'SET_IS_FETCHING_USERNAME'; type: 'SET_IS_FETCHING_UUID';
payload: { payload: {
isFetchingUsername: boolean; identifier: UUIDFetchStateKeyType;
isFetching: boolean;
}; };
}; };
type SetRecentMediaItemsActionType = { type SetRecentMediaItemsActionType = {
@ -773,7 +764,7 @@ export type ConversationActionType =
| SetComposeGroupNameActionType | SetComposeGroupNameActionType
| SetComposeSearchTermActionType | SetComposeSearchTermActionType
| SetConversationHeaderTitleActionType | SetConversationHeaderTitleActionType
| SetIsFetchingUsernameActionType | SetIsFetchingUUIDActionType
| SetIsNearBottomActionType | SetIsNearBottomActionType
| SetMessageLoadingStateActionType | SetMessageLoadingStateActionType
| SetPreJoinConversationActionType | SetPreJoinConversationActionType
@ -840,6 +831,7 @@ export const actions = {
setComposeGroupExpireTimer, setComposeGroupExpireTimer,
setComposeGroupName, setComposeGroupName,
setComposeSearchTerm, setComposeSearchTerm,
setIsFetchingUUID,
setIsNearBottom, setIsNearBottom,
setMessageLoadingState, setMessageLoadingState,
setPreJoinConversation, setPreJoinConversation,
@ -849,9 +841,8 @@ export const actions = {
showArchivedConversations, showArchivedConversations,
showChooseGroupMembers, showChooseGroupMembers,
showInbox, showInbox,
showConversation,
startComposing, startComposing,
startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
startSettingGroupMetadata, startSettingGroupMetadata,
toggleAdmin, toggleAdmin,
toggleConversationInChooseMembers, 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( function setSelectedConversationHeaderTitle(
title?: string title?: string
): SetConversationHeaderTitleActionType { ): SetConversationHeaderTitleActionType {
@ -1772,117 +1775,6 @@ function showChooseGroupMembers(): ShowChooseGroupMembersActionType {
return { type: 'SHOW_CHOOSE_GROUP_MEMBERS' }; return { type: 'SHOW_CHOOSE_GROUP_MEMBERS' };
} }
function startNewConversationFromPhoneNumber(
e164: string
): ThunkAction<void, RootStateType, unknown, ShowInboxActionType> {
return dispatch => {
trigger('showConversation', e164);
dispatch(showInbox());
};
}
async function checkForUsername(
username: string
): Promise<FoundUsernameType | undefined> {
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 { function startSettingGroupMetadata(): StartSettingGroupMetadataActionType {
return { type: 'START_SETTING_GROUP_METADATA' }; return { type: 'START_SETTING_GROUP_METADATA' };
} }
@ -2029,6 +1921,14 @@ function showInbox(): ShowInboxActionType {
payload: null, payload: null,
}; };
} }
function showConversation(
conversationId: string
): ThunkAction<void, RootStateType, unknown, ShowInboxActionType> {
return dispatch => {
trigger('showConversation', conversationId);
dispatch(showInbox());
};
}
function showArchivedConversations(): ShowArchivedConversationsActionType { function showArchivedConversations(): ShowArchivedConversationsActionType {
return { return {
type: 'SHOW_ARCHIVED_CONVERSATIONS', type: 'SHOW_ARCHIVED_CONVERSATIONS',
@ -3060,7 +2960,7 @@ export function reducer(
composer: { composer: {
step: ComposerStep.StartDirectConversation, step: ComposerStep.StartDirectConversation,
searchTerm: '', searchTerm: '',
isFetchingUsername: false, uuidFetchState: {},
}, },
}; };
} }
@ -3103,6 +3003,7 @@ export function reducer(
composer: { composer: {
step: ComposerStep.ChooseGroupMembers, step: ComposerStep.ChooseGroupMembers,
searchTerm: '', searchTerm: '',
uuidFetchState: {},
selectedConversationIds, selectedConversationIds,
recommendedGroupSizeModalState, recommendedGroupSizeModalState,
maximumGroupSizeModalState, 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; const { composer } = state;
if (!composer) { if (!composer) {
assert( assert(
false, 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; return state;
} }
if (composer.step !== ComposerStep.StartDirectConversation) { if (
assert(false, 'Setting compose username at this step is a no-op'); composer.step !== ComposerStep.StartDirectConversation &&
composer.step !== ComposerStep.ChooseGroupMembers
) {
assert(false, 'Setting compose uuid fetch state at this step is a no-op');
return state; return state;
} }
const { isFetchingUsername } = action.payload; const { identifier, isFetching } = action.payload;
const { uuidFetchState } = composer;
return { return {
...state, ...state,
composer: { composer: {
...composer, ...composer,
isFetchingUsername, uuidFetchState: isFetching
? {
...composer.uuidFetchState,
[identifier]: isFetching,
}
: omit(uuidFetchState, identifier),
}, },
}; };
} }

View file

@ -9,7 +9,7 @@ export type GlobalModalsStateType = {
readonly isWhatsNewVisible: boolean; readonly isWhatsNewVisible: boolean;
readonly profileEditorHasError: boolean; readonly profileEditorHasError: boolean;
readonly safetyNumberModalContactId?: string; readonly safetyNumberModalContactId?: string;
readonly usernameNotFoundModalState?: UsernameNotFoundModalStateType; readonly userNotFoundModalState?: UserNotFoundModalStateType;
}; };
// Actions // Actions
@ -17,10 +17,8 @@ export type GlobalModalsStateType = {
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL'; const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL'; const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
const SHOW_WHATS_NEW_MODAL = 'globalModals/SHOW_WHATS_NEW_MODAL_MODAL'; const SHOW_WHATS_NEW_MODAL = 'globalModals/SHOW_WHATS_NEW_MODAL_MODAL';
const SHOW_USERNAME_NOT_FOUND_MODAL = const SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL';
'globalModals/SHOW_USERNAME_NOT_FOUND_MODAL'; const HIDE_UUID_NOT_FOUND_MODAL = 'globalModals/HIDE_UUID_NOT_FOUND_MODAL';
const HIDE_USERNAME_NOT_FOUND_MODAL =
'globalModals/HIDE_USERNAME_NOT_FOUND_MODAL';
const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL'; const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR'; const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR = export const TOGGLE_PROFILE_EDITOR_ERROR =
@ -32,7 +30,13 @@ export type ContactModalStateType = {
conversationId?: string; conversationId?: string;
}; };
export type UsernameNotFoundModalStateType = { export type UserNotFoundModalStateType =
| {
type: 'phoneNumber';
phoneNumber: string;
}
| {
type: 'username';
username: string; username: string;
}; };
@ -53,15 +57,13 @@ type ShowWhatsNewModalActionType = {
type: typeof SHOW_WHATS_NEW_MODAL; type: typeof SHOW_WHATS_NEW_MODAL;
}; };
type HideUsernameNotFoundModalActionType = { type HideUserNotFoundModalActionType = {
type: typeof HIDE_USERNAME_NOT_FOUND_MODAL; type: typeof HIDE_UUID_NOT_FOUND_MODAL;
}; };
export type ShowUsernameNotFoundModalActionType = { export type ShowUserNotFoundModalActionType = {
type: typeof SHOW_USERNAME_NOT_FOUND_MODAL; type: typeof SHOW_UUID_NOT_FOUND_MODAL;
payload: { payload: UserNotFoundModalStateType;
username: string;
};
}; };
type ToggleProfileEditorActionType = { type ToggleProfileEditorActionType = {
@ -82,8 +84,8 @@ export type GlobalModalsActionType =
| ShowContactModalActionType | ShowContactModalActionType
| HideWhatsNewModalActionType | HideWhatsNewModalActionType
| ShowWhatsNewModalActionType | ShowWhatsNewModalActionType
| HideUsernameNotFoundModalActionType | HideUserNotFoundModalActionType
| ShowUsernameNotFoundModalActionType | ShowUserNotFoundModalActionType
| ToggleProfileEditorActionType | ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType | ToggleProfileEditorErrorActionType
| ToggleSafetyNumberModalActionType; | ToggleSafetyNumberModalActionType;
@ -95,8 +97,8 @@ export const actions = {
showContactModal, showContactModal,
hideWhatsNewModal, hideWhatsNewModal,
showWhatsNewModal, showWhatsNewModal,
hideUsernameNotFoundModal, hideUserNotFoundModal,
showUsernameNotFoundModal, showUserNotFoundModal,
toggleProfileEditor, toggleProfileEditor,
toggleProfileEditorHasError, toggleProfileEditorHasError,
toggleSafetyNumberModal, toggleSafetyNumberModal,
@ -133,20 +135,18 @@ function showWhatsNewModal(): ShowWhatsNewModalActionType {
}; };
} }
function hideUsernameNotFoundModal(): HideUsernameNotFoundModalActionType { function hideUserNotFoundModal(): HideUserNotFoundModalActionType {
return { return {
type: HIDE_USERNAME_NOT_FOUND_MODAL, type: HIDE_UUID_NOT_FOUND_MODAL,
}; };
} }
function showUsernameNotFoundModal( function showUserNotFoundModal(
username: string payload: UserNotFoundModalStateType
): ShowUsernameNotFoundModalActionType { ): ShowUserNotFoundModalActionType {
return { return {
type: SHOW_USERNAME_NOT_FOUND_MODAL, type: SHOW_UUID_NOT_FOUND_MODAL,
payload: { payload,
username,
},
}; };
} }
@ -209,20 +209,18 @@ export function reducer(
}; };
} }
if (action.type === HIDE_USERNAME_NOT_FOUND_MODAL) { if (action.type === HIDE_UUID_NOT_FOUND_MODAL) {
return { return {
...state, ...state,
usernameNotFoundModalState: undefined, userNotFoundModalState: undefined,
}; };
} }
if (action.type === SHOW_USERNAME_NOT_FOUND_MODAL) { if (action.type === SHOW_UUID_NOT_FOUND_MODAL) {
const { username } = action.payload;
return { return {
...state, ...state,
usernameNotFoundModalState: { userNotFoundModalState: {
username, ...action.payload,
}, },
}; };
} }

View file

@ -25,6 +25,7 @@ import {
} from '../ducks/conversationsEnums'; } from '../ducks/conversationsEnums';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
import { isNotNil } from '../../util/isNotNil'; import { isNotNil } from '../../util/isNotNil';
import type { UUIDFetchStateType } from '../../util/uuidFetchState';
import { deconstructLookup } from '../../util/deconstructLookup'; import { deconstructLookup } from '../../util/deconstructLookup';
import type { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; import type { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import type { TimelineItemType } from '../../components/conversation/TimelineItem'; import type { TimelineItemType } from '../../components/conversation/TimelineItem';
@ -394,21 +395,25 @@ export const getComposerConversationSearchTerm = createSelector(
} }
); );
export const getIsFetchingUsername = createSelector( export const getComposerUUIDFetchState = createSelector(
getComposerState, getComposerState,
(composer): boolean => { (composer): UUIDFetchStateType => {
if (!composer) { if (!composer) {
assert(false, 'getIsFetchingUsername: composer is not open'); 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( assert(
false, 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( export const getFilteredComposeContacts = createSelector(
getNormalizedComposerConversationSearchTerm, getNormalizedComposerConversationSearchTerm,
getComposableContacts, getComposableContacts,
getRegionCode,
( (
searchTerm: string, searchTerm: string,
contacts: Array<ConversationType> contacts: Array<ConversationType>,
regionCode: string | undefined
): Array<ConversationType> => { ): Array<ConversationType> => {
return filterAndSortConversationsByTitle(contacts, searchTerm); return filterAndSortConversationsByTitle(contacts, searchTerm, regionCode);
} }
); );
export const getFilteredComposeGroups = createSelector( export const getFilteredComposeGroups = createSelector(
getNormalizedComposerConversationSearchTerm, getNormalizedComposerConversationSearchTerm,
getComposableGroups, getComposableGroups,
getRegionCode,
( (
searchTerm: string, searchTerm: string,
groups: Array<ConversationType> groups: Array<ConversationType>,
regionCode: string | undefined
): Array<ConversationType> => { ): Array<ConversationType> => {
return filterAndSortConversationsByTitle(groups, searchTerm); return filterAndSortConversationsByTitle(groups, searchTerm, regionCode);
} }
); );
export const getFilteredCandidateContactsForNewGroup = createSelector( export const getFilteredCandidateContactsForNewGroup = createSelector(
getCandidateContactsForNewGroup, getCandidateContactsForNewGroup,
getNormalizedComposerConversationSearchTerm, getNormalizedComposerConversationSearchTerm,
getRegionCode,
filterAndSortConversationsByTitle filterAndSortConversationsByTitle
); );

View file

@ -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<string>;
maxGroupSize: number;
confirmAdds: () => void;
onClose: () => void;
removeSelectedContact: (_: string) => void;
searchTerm: string;
selectedConversationIds: ReadonlyArray<string>;
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,
'<SmartChooseGroupMemberModal> 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);

View file

@ -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<string>;
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,
'<SmartChooseGroupMemberModal> selected conversation not found'
);
return convo;
});
return {
...props,
selectedContacts,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartConfirmAdditionsModal = smart(ConfirmAdditionsModal);

View file

@ -1,6 +1,7 @@
// Copyright 2021-2022 Signal Messenger, LLC // Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
@ -8,7 +9,6 @@ import { mapDispatchToProps } from '../actions';
import type { StateProps } from '../../components/conversation/conversation-details/ConversationDetails'; import type { StateProps } from '../../components/conversation/conversation-details/ConversationDetails';
import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails'; import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails';
import { import {
getCandidateContactsForNewGroup,
getConversationByIdSelector, getConversationByIdSelector,
getConversationByUuidSelector, getConversationByUuidSelector,
} from '../selectors/conversations'; } from '../selectors/conversations';
@ -24,6 +24,10 @@ import {
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; 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 = { export type SmartConversationDetailsProps = {
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>; addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
@ -56,6 +60,18 @@ export type SmartConversationDetailsProps = {
const ACCESS_ENUM = Proto.AccessControl.AccessRequired; const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
const renderChooseGroupMembersModal = (
props: SmartChooseGroupMembersModalPropsType
) => {
return <SmartChooseGroupMembersModal {...props} />;
};
const renderConfirmAdditionsModal = (
props: SmartConfirmAdditionsModalPropsType
) => {
return <SmartConfirmAdditionsModal {...props} />;
};
const mapStateToProps = ( const mapStateToProps = (
state: StateType, state: StateType,
props: SmartConversationDetailsProps props: SmartConversationDetailsProps
@ -69,7 +85,6 @@ const mapStateToProps = (
const canEditGroupInfo = Boolean(conversation.canEditGroupInfo); const canEditGroupInfo = Boolean(conversation.canEditGroupInfo);
const isAdmin = Boolean(conversation.areWeAdmin); const isAdmin = Boolean(conversation.areWeAdmin);
const candidateContactsToAdd = getCandidateContactsForNewGroup(state);
const hasGroupLink = const hasGroupLink =
Boolean(conversation.groupLink) && Boolean(conversation.groupLink) &&
@ -88,7 +103,6 @@ const mapStateToProps = (
areWeASubscriber: getAreWeASubscriber(state), areWeASubscriber: getAreWeASubscriber(state),
badges, badges,
canEditGroupInfo, canEditGroupInfo,
candidateContactsToAdd,
conversation: { conversation: {
...conversation, ...conversation,
...getConversationColorAttributes(conversation), ...getConversationColorAttributes(conversation),
@ -102,6 +116,8 @@ const mapStateToProps = (
hasGroupLink, hasGroupLink,
isGroup: conversation.type === 'group', isGroup: conversation.type === 'group',
theme: getTheme(state), theme: getTheme(state),
renderChooseGroupMembersModal,
renderConfirmAdditionsModal,
}; };
}; };

View file

@ -11,7 +11,7 @@ import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { getAllComposableConversations } from '../selectors/conversations'; import { getAllComposableConversations } from '../selectors/conversations';
import { getLinkPreview } from '../selectors/linkPreviews'; import { getLinkPreview } from '../selectors/linkPreviews';
import { getIntl, getTheme } from '../selectors/user'; import { getIntl, getTheme, getRegionCode } from '../selectors/user';
import { getEmojiSkinTone } from '../selectors/items'; import { getEmojiSkinTone } from '../selectors/items';
import { selectRecentEmojis } from '../selectors/emojis'; import { selectRecentEmojis } from '../selectors/emojis';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
@ -69,6 +69,7 @@ const mapStateToProps = (
skinTone, skinTone,
onTextTooLong, onTextTooLong,
theme: getTheme(state), theme: getTheme(state),
regionCode: getRegionCode(state),
}; };
}; };

View file

@ -9,6 +9,7 @@ import type { PropsType as LeftPanePropsType } from '../../components/LeftPane';
import { LeftPane, LeftPaneMode } from '../../components/LeftPane'; import { LeftPane, LeftPaneMode } from '../../components/LeftPane';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { lookupConversationWithoutUuid } from '../../util/lookupConversationWithoutUuid';
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums'; import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
import { import {
@ -32,11 +33,11 @@ import {
getComposeGroupName, getComposeGroupName,
getComposerConversationSearchTerm, getComposerConversationSearchTerm,
getComposerStep, getComposerStep,
getComposerUUIDFetchState,
getComposeSelectedContacts, getComposeSelectedContacts,
getFilteredCandidateContactsForNewGroup, getFilteredCandidateContactsForNewGroup,
getFilteredComposeContacts, getFilteredComposeContacts,
getFilteredComposeGroups, getFilteredComposeGroups,
getIsFetchingUsername,
getLeftPaneLists, getLeftPaneLists,
getMaximumGroupSizeModalState, getMaximumGroupSizeModalState,
getRecommendedGroupSizeModalState, getRecommendedGroupSizeModalState,
@ -141,7 +142,7 @@ const getModeSpecificProps = (
regionCode: getRegionCode(state), regionCode: getRegionCode(state),
searchTerm: getComposerConversationSearchTerm(state), searchTerm: getComposerConversationSearchTerm(state),
isUsernamesEnabled: getUsernamesEnabled(state), isUsernamesEnabled: getUsernamesEnabled(state),
isFetchingUsername: getIsFetchingUsername(state), uuidFetchState: getComposerUUIDFetchState(state),
}; };
case ComposerStep.ChooseGroupMembers: case ComposerStep.ChooseGroupMembers:
return { return {
@ -152,8 +153,10 @@ const getModeSpecificProps = (
OneTimeModalState.Showing, OneTimeModalState.Showing,
isShowingMaximumGroupSizeModal: isShowingMaximumGroupSizeModal:
getMaximumGroupSizeModalState(state) === OneTimeModalState.Showing, getMaximumGroupSizeModalState(state) === OneTimeModalState.Showing,
regionCode: getRegionCode(state),
searchTerm: getComposerConversationSearchTerm(state), searchTerm: getComposerConversationSearchTerm(state),
selectedContacts: getComposeSelectedContacts(state), selectedContacts: getComposeSelectedContacts(state),
uuidFetchState: getComposerUUIDFetchState(state),
}; };
case ComposerStep.SetGroupMetadata: case ComposerStep.SetGroupMetadata:
return { return {
@ -192,6 +195,7 @@ const mapStateToProps = (state: StateType) => {
renderUpdateDialog, renderUpdateDialog,
renderCaptchaDialog, renderCaptchaDialog,
renderCrashReportDialog, renderCrashReportDialog,
lookupConversationWithoutUuid,
theme: getTheme(state), theme: getTheme(state),
}; };
}; };

View file

@ -7,12 +7,13 @@ import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAdd
export const defaultStartDirectConversationComposerState = { export const defaultStartDirectConversationComposerState = {
step: ComposerStep.StartDirectConversation as const, step: ComposerStep.StartDirectConversation as const,
searchTerm: '', searchTerm: '',
isFetchingUsername: false, uuidFetchState: {},
}; };
export const defaultChooseGroupMembersComposerState = { export const defaultChooseGroupMembersComposerState = {
step: ComposerStep.ChooseGroupMembers as const, step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '', searchTerm: '',
uuidFetchState: {},
groupAvatar: undefined, groupAvatar: undefined,
groupName: '', groupName: '',
groupExpireTimer: 0, groupExpireTimer: 0,

View file

@ -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<UUIDFetchStateKeyType>([
'e164:+12125551234',
'username:bobross',
]);
export function makeFakeLookupConversationWithoutUuid(
saveConversation?: (convo: ConversationType) => void
): typeof lookupConversationWithoutUuid {
const cache = new Map<UUIDFetchStateKeyType, ConversationType>();
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];
}

View file

@ -977,8 +977,7 @@ describe('both/state/selectors/conversations', () => {
const result = getFilteredComposeContacts(state); const result = getFilteredComposeContacts(state);
const ids = result.map(contact => contact.id); 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']);
assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-6']);
}); });
it('can search for note to self', () => { it('can search for note to self', () => {

View file

@ -38,9 +38,11 @@ describe('filterAndSortConversationsByTitle', () => {
]; ];
it('without a search term, sorts conversations by title (but puts no-name contacts at the bottom)', () => { it('without a search term, sorts conversations by title (but puts no-name contacts at the bottom)', () => {
const titles = filterAndSortConversationsByTitle(conversations, '').map( const titles = filterAndSortConversationsByTitle(
contact => contact.title conversations,
); '',
'US'
).map(contact => contact.title);
assert.deepEqual(titles, [ assert.deepEqual(titles, [
'Aaron Aardvark', 'Aaron Aardvark',
'Belinda Beetle', 'Belinda Beetle',
@ -53,7 +55,8 @@ describe('filterAndSortConversationsByTitle', () => {
it('can search for contacts by title', () => { it('can search for contacts by title', () => {
const titles = filterAndSortConversationsByTitle( const titles = filterAndSortConversationsByTitle(
conversations, conversations,
'belind' 'belind',
'US'
).map(contact => contact.title); ).map(contact => contact.title);
assert.sameMembers(titles, ['Belinda Beetle', 'Belinda Zephyr']); 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)', () => { it('can search for contacts by phone number (and puts no-name contacts at the bottom)', () => {
const titles = filterAndSortConversationsByTitle( const titles = filterAndSortConversationsByTitle(
conversations, conversations,
'650555' '650555',
'US'
).map(contact => contact.title); ).map(contact => contact.title);
assert.sameMembers(titles, ['Carlos Santana', '+16505551234']); assert.sameMembers(titles, ['Carlos Santana', '+16505551234']);
}); });
it('can search for contacts by 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', () => { it('can search for contacts by username', () => {
const titles = filterAndSortConversationsByTitle( const titles = filterAndSortConversationsByTitle(
conversations, conversations,
'thisis' 'thisis',
'US'
).map(contact => contact.title); ).map(contact => contact.title);
assert.sameMembers(titles, ['Carlos Santana']); assert.sameMembers(titles, ['Carlos Santana']);
}); });
@ -100,9 +114,11 @@ describe('filterAndSortConversationsByRecent', () => {
]; ];
it('sorts by recency when no search term is provided', () => { it('sorts by recency when no search term is provided', () => {
const titles = filterAndSortConversationsByRecent(conversations, '').map( const titles = filterAndSortConversationsByRecent(
contact => contact.title conversations,
); '',
'US'
).map(contact => contact.title);
assert.sameMembers(titles, [ assert.sameMembers(titles, [
'+16505551234', '+16505551234',
'George Washington', 'George Washington',

View file

@ -13,10 +13,12 @@ import { updateRemoteConfig } from '../../../test-both/helpers/RemoteConfigStub'
describe('LeftPaneChooseGroupMembersHelper', () => { describe('LeftPaneChooseGroupMembersHelper', () => {
const defaults = { const defaults = {
uuidFetchState: {},
candidateContacts: [], candidateContacts: [],
isShowingRecommendedGroupSizeModal: false, isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false, isShowingMaximumGroupSizeModal: false,
searchTerm: '', searchTerm: '',
regionCode: 'US',
selectedContacts: [], selectedContacts: [],
}; };

View file

@ -29,7 +29,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.strictEqual(helper.getBackAction({ showInbox }), showInbox); assert.strictEqual(helper.getBackAction({ showInbox }), showInbox);
@ -45,7 +45,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
1 1
); );
@ -59,7 +59,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
4 4
); );
@ -73,7 +73,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
7 7
); );
@ -87,7 +87,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'someone', searchTerm: 'someone',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
8 8
); );
@ -101,7 +101,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'someone', searchTerm: 'someone',
isUsernamesEnabled: false, isUsernamesEnabled: false,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
6 6
); );
@ -115,7 +115,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
2 2
); );
@ -126,7 +126,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
5 5
); );
@ -137,13 +137,13 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
7 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( assert.strictEqual(
new LeftPaneComposeHelper({ new LeftPaneComposeHelper({
composeContacts: [], composeContacts: [],
@ -151,9 +151,9 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+16505551234',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
1 2
); );
}); });
@ -165,13 +165,13 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'someone', searchTerm: 'someone',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
2 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( assert.strictEqual(
new LeftPaneComposeHelper({ new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()], composeContacts: [getDefaultConversation(), getDefaultConversation()],
@ -179,9 +179,9 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+16505551234',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}).getRowCount(), }).getRowCount(),
4 5
); );
}); });
}); });
@ -194,7 +194,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
@ -214,7 +214,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
@ -249,7 +249,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
@ -288,7 +288,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: false, isUsernamesEnabled: false,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isUndefined(helper.getRow(0)); assert.isUndefined(helper.getRow(0));
@ -306,7 +306,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.deepEqual(helper.getRow(1), { assert.deepEqual(helper.getRow(1), {
@ -324,21 +324,29 @@ describe('LeftPaneComposeHelper', () => {
composeContacts: [], composeContacts: [],
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+1(650) 555 12 34',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
type: RowType.StartNewConversation, type: RowType.Header,
phoneNumber: '+16505551234', 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', () => { it('returns just a "find by username" header if no results', () => {
const username = 'someone'; const username = 'someone';
const isFetchingUsername = true;
const helper = new LeftPaneComposeHelper({ const helper = new LeftPaneComposeHelper({
composeContacts: [], composeContacts: [],
@ -346,7 +354,9 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: username, searchTerm: username,
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername, uuidFetchState: {
[`username:${username}`]: true,
},
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
@ -356,7 +366,7 @@ describe('LeftPaneComposeHelper', () => {
assert.deepEqual(helper.getRow(1), { assert.deepEqual(helper.getRow(1), {
type: RowType.UsernameSearchResult, type: RowType.UsernameSearchResult,
username, username,
isFetchingUsername, isFetchingUsername: true,
}); });
assert.isUndefined(helper.getRow(2)); assert.isUndefined(helper.getRow(2));
}); });
@ -370,27 +380,36 @@ describe('LeftPaneComposeHelper', () => {
composeContacts, composeContacts,
composeGroups: [], composeGroups: [],
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+1(650) 555 12 34',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.deepEqual(helper.getRow(0), { assert.deepEqual(helper.getRow(0), {
type: RowType.StartNewConversation,
phoneNumber: '+16505551234',
});
assert.deepEqual(helper.getRow(1), {
type: RowType.Header, type: RowType.Header,
i18nKey: 'contactsHeader', i18nKey: 'contactsHeader',
}); });
assert.deepEqual(helper.getRow(2), { assert.deepEqual(helper.getRow(1), {
type: RowType.Contact, type: RowType.Contact,
contact: composeContacts[0], contact: composeContacts[0],
}); });
assert.deepEqual(helper.getRow(3), { assert.deepEqual(helper.getRow(2), {
type: RowType.Contact, type: RowType.Contact,
contact: composeContacts[1], 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', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isUndefined(helper.getConversationAndMessageAtIndex(0)); assert.isUndefined(helper.getConversationAndMessageAtIndex(0));
@ -417,7 +436,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isUndefined( assert.isUndefined(
@ -438,7 +457,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isFalse( assert.isFalse(
@ -448,7 +467,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'different search', searchTerm: 'different search',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}) })
); );
assert.isFalse( assert.isFalse(
@ -458,7 +477,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'last search', searchTerm: 'last search',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}) })
); );
}); });
@ -470,7 +489,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isFalse( assert.isFalse(
@ -480,17 +499,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
})
);
assert.isFalse(
helper.shouldRecomputeRowHeights({
composeContacts: [getDefaultConversation()],
composeGroups: [],
regionCode: 'US',
searchTerm: '+16505559876',
isUsernamesEnabled: true,
isFetchingUsername: false,
}) })
); );
}); });
@ -502,7 +511,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isTrue( assert.isTrue(
@ -512,7 +521,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}) })
); );
assert.isTrue( assert.isTrue(
@ -522,7 +531,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '+16505551234', searchTerm: '+16505551234',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}) })
); );
}); });
@ -534,7 +543,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: '', searchTerm: '',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isTrue( assert.isTrue(
@ -544,7 +553,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}) })
); );
}); });
@ -556,7 +565,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isTrue( assert.isTrue(
@ -566,7 +575,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}) })
); );
@ -576,7 +585,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isTrue( assert.isTrue(
@ -586,7 +595,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'foo bar', searchTerm: 'foo bar',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}) })
); );
}); });
@ -598,7 +607,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'soup', searchTerm: 'soup',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}); });
assert.isTrue( assert.isTrue(
@ -608,7 +617,7 @@ describe('LeftPaneComposeHelper', () => {
regionCode: 'US', regionCode: 'US',
searchTerm: 'soup', searchTerm: 'soup',
isUsernamesEnabled: true, isUsernamesEnabled: true,
isFetchingUsername: false, uuidFetchState: {},
}) })
); );
}); });

View file

@ -1,16 +1,16 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { FuseOptions } from 'fuse.js';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import { parseAndFormatPhoneNumber } from './libphonenumberInstance';
const FUSE_OPTIONS: FuseOptions<ConversationType> = { const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationType> = {
// A small-but-nonzero threshold lets us match parts of E164s better, and makes the // A small-but-nonzero threshold lets us match parts of E164s better, and makes the
// search a little more forgiving. // search a little more forgiving.
threshold: 0.05, threshold: 0.1,
tokenize: true, useExtendedSearch: true,
keys: [ keys: [
{ {
name: 'searchableTitle', name: 'searchableTitle',
@ -37,21 +37,45 @@ const FUSE_OPTIONS: FuseOptions<ConversationType> = {
const collator = new Intl.Collator(); const collator = new Intl.Collator();
const cachedIndices = new WeakMap<
ReadonlyArray<ConversationType>,
Fuse<ConversationType>
>();
// See https://fusejs.io/examples.html#extended-search for
// extended search documentation.
function searchConversations( function searchConversations(
conversations: ReadonlyArray<ConversationType>, conversations: ReadonlyArray<ConversationType>,
searchTerm: string searchTerm: string,
regionCode: string | undefined
): Array<ConversationType> { ): Array<ConversationType> {
return new Fuse<ConversationType>(conversations, FUSE_OPTIONS).search( const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
searchTerm
); // Escape the search term
let extendedSearchTerm = searchTerm;
// OR phoneNumber
if (phoneNumber) {
extendedSearchTerm += ` | ${phoneNumber.e164}`;
}
let index = cachedIndices.get(conversations);
if (!index) {
index = new Fuse<ConversationType>(conversations, FUSE_OPTIONS);
cachedIndices.set(conversations, index);
}
const results = index.search(extendedSearchTerm);
return results.map(result => result.item);
} }
export function filterAndSortConversationsByRecent( export function filterAndSortConversationsByRecent(
conversations: ReadonlyArray<ConversationType>, conversations: ReadonlyArray<ConversationType>,
searchTerm: string searchTerm: string,
regionCode: string | undefined
): Array<ConversationType> { ): Array<ConversationType> {
if (searchTerm.length) { if (searchTerm.length) {
return searchConversations(conversations, searchTerm); return searchConversations(conversations, searchTerm, regionCode);
} }
return conversations.concat().sort((a, b) => { return conversations.concat().sort((a, b) => {
@ -65,10 +89,11 @@ export function filterAndSortConversationsByRecent(
export function filterAndSortConversationsByTitle( export function filterAndSortConversationsByTitle(
conversations: ReadonlyArray<ConversationType>, conversations: ReadonlyArray<ConversationType>,
searchTerm: string searchTerm: string,
regionCode: string | undefined
): Array<ConversationType> { ): Array<ConversationType> {
if (searchTerm.length) { if (searchTerm.length) {
return searchConversations(conversations, searchTerm); return searchConversations(conversations, searchTerm, regionCode);
} }
return conversations.concat().sort((a, b) => { return conversations.concat().sort((a, b) => {

View file

@ -2,8 +2,34 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import libphonenumber from 'google-libphonenumber'; import libphonenumber from 'google-libphonenumber';
import type { PhoneNumber } from 'google-libphonenumber';
const instance = libphonenumber.PhoneNumberUtil.getInstance(); const instance = libphonenumber.PhoneNumberUtil.getInstance();
const { PhoneNumberFormat } = libphonenumber; const { PhoneNumberFormat } = libphonenumber;
export { instance, PhoneNumberFormat }; 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),
};
}

View file

@ -1636,6 +1636,18 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-09-11T17:24:56.124Z" "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(", "rule": "jQuery-load(",
"path": "node_modules/get-uri/node_modules/debug/src/browser.js", "path": "node_modules/get-uri/node_modules/debug/src/browser.js",

View file

@ -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<string | undefined> {
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<FoundUsernameType | undefined> {
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;
}
}

19
ts/util/uuidFetchState.ts Normal file
View file

@ -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<UUIDFetchStateKeyType, boolean>;
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}`]);
};

View file

@ -5,6 +5,7 @@ import * as Backbone from 'backbone';
import * as log from '../logging/log'; import * as log from '../logging/log';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import { showToast } from '../util/showToast'; import { showToast } from '../util/showToast';
import { strictAssert } from '../util/assert';
import { ToastStickerPackInstallFailed } from '../components/ToastStickerPackInstallFailed'; import { ToastStickerPackInstallFailed } from '../components/ToastStickerPackInstallFailed';
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -115,15 +116,9 @@ Whisper.InboxView = Whisper.View.extend({
this.conversation_stack.unload(); this.conversation_stack.unload();
}); });
window.Whisper.events.on( window.Whisper.events.on('showConversation', (id, messageId) => {
'showConversation', const conversation = window.ConversationController.get(id);
async (id, messageId, username) => { strictAssert(conversation, 'Conversation must be found');
const conversation =
await window.ConversationController.getOrCreateAndWait(
id,
'private',
{ username }
);
conversation.setMarkedUnread(false); conversation.setMarkedUnread(false);
@ -133,8 +128,7 @@ Whisper.InboxView = Whisper.View.extend({
} }
this.conversation_stack.open(conversation, messageId); this.conversation_stack.open(conversation, messageId);
} });
);
window.Whisper.events.on('loadingProgress', count => { window.Whisper.events.on('loadingProgress', count => {
const view = this.appLoadingScreen; const view = this.appLoadingScreen;

View file

@ -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" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.1.1.tgz#79d35927f07b8e7103d819fed475b64ccf7225ea"
integrity sha512-U0kNHUoxwPNPWOJaMG7Z00d4a/qZVrFtzWJRaK8V9goaVOCXBSQSJpt3MYGNtkScKEBKovxLjnNdC9MlXwo5Pw== integrity sha512-U0kNHUoxwPNPWOJaMG7Z00d4a/qZVrFtzWJRaK8V9goaVOCXBSQSJpt3MYGNtkScKEBKovxLjnNdC9MlXwo5Pw==
fuse.js@3.4.4: fuse.js@6.5.3:
version "3.4.4" version "6.5.3"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.4.tgz#f98f55fcb3b595cf6a3e629c5ffaf10982103e95" resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.5.3.tgz#7446c0acbc4ab0ab36fa602e97499bdb69452b93"
integrity sha512-pyLQo/1oR5Ywf+a/tY8z4JygnIglmRxVUOiyFAbd11o9keUDpUJSMGRWJngcnkURj30kDHPmhoKY8ChJiz3EpQ== integrity sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg==
fuse.js@^3.4.4: fuse.js@^3.4.4:
version "3.4.5" version "3.4.5"