Add to group by username

This commit is contained in:
Fedor Indutny 2022-06-16 17:38:28 -07:00 committed by GitHub
parent 8dd321d0b6
commit 973b2264fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 332 additions and 45 deletions

View File

@ -25,6 +25,7 @@ import { ContactListItem } from './conversationList/ContactListItem';
import type { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import { ContactCheckbox as ContactCheckboxComponent } from './conversationList/ContactCheckbox';
import { PhoneNumberCheckbox as PhoneNumberCheckboxComponent } from './conversationList/PhoneNumberCheckbox';
import { UsernameCheckbox as UsernameCheckboxComponent } from './conversationList/UsernameCheckbox';
import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
@ -32,19 +33,20 @@ import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } f
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
export enum RowType {
ArchiveButton,
Blank,
Contact,
ContactCheckbox,
PhoneNumberCheckbox,
Conversation,
CreateNewGroup,
Header,
MessageSearchResult,
SearchResultsLoadingFakeHeader,
SearchResultsLoadingFakeRow,
StartNewConversation,
UsernameSearchResult,
ArchiveButton = 'ArchiveButton',
Blank = 'Blank',
Contact = 'Contact',
ContactCheckbox = 'ContactCheckbox',
PhoneNumberCheckbox = 'PhoneNumberCheckbox',
UsernameCheckbox = 'UsernameCheckbox',
Conversation = 'Conversation',
CreateNewGroup = 'CreateNewGroup',
Header = 'Header',
MessageSearchResult = 'MessageSearchResult',
SearchResultsLoadingFakeHeader = 'SearchResultsLoadingFakeHeader',
SearchResultsLoadingFakeRow = 'SearchResultsLoadingFakeRow',
StartNewConversation = 'StartNewConversation',
UsernameSearchResult = 'UsernameSearchResult',
}
type ArchiveButtonRowType = {
@ -74,6 +76,13 @@ type PhoneNumberCheckboxRowType = {
isFetching: boolean;
};
type UsernameCheckboxRowType = {
type: RowType.UsernameCheckbox;
username: string;
isChecked: boolean;
isFetching: boolean;
};
type ConversationRowType = {
type: RowType.Conversation;
conversation: ConversationListItemPropsType;
@ -119,6 +128,7 @@ export type Row =
| ContactRowType
| ContactCheckboxRowType
| PhoneNumberCheckboxRowType
| UsernameCheckboxRowType
| ConversationRowType
| CreateNewGroupRowType
| MessageRowType
@ -283,6 +293,23 @@ export const ConversationList: React.FC<PropsType> = ({
/>
);
break;
case RowType.UsernameCheckbox:
result = (
<UsernameCheckboxComponent
username={row.username}
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: {
const itemProps = pick(row.conversation, [
'acceptedMessageRequest',

View File

@ -883,6 +883,7 @@ export const ChooseGroupMembersPartialPhoneNumber = (): JSX.Element => (
candidateContacts: [],
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
isUsernamesEnabled: true,
searchTerm: '+1(212) 555',
regionCode: 'US',
selectedContacts: [],
@ -904,6 +905,7 @@ export const ChooseGroupMembersValidPhoneNumber = (): JSX.Element => (
candidateContacts: [],
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
isUsernamesEnabled: true,
searchTerm: '+1(212) 555 5454',
regionCode: 'US',
selectedContacts: [],
@ -916,6 +918,28 @@ ChooseGroupMembersValidPhoneNumber.story = {
name: 'Choose Group Members: Valid phone number',
};
export const ChooseGroupMembersUsername = (): JSX.Element => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.ChooseGroupMembers,
uuidFetchState: {},
candidateContacts: [],
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
isUsernamesEnabled: true,
searchTerm: '@signal',
regionCode: 'US',
selectedContacts: [],
},
})}
/>
);
ChooseGroupMembersUsername.story = {
name: 'Choose Group Members: username',
};
export const GroupMetadataNoTimer = (): JSX.Element => (
<LeftPane
{...useProps({

View File

@ -68,6 +68,7 @@ const createProps = (
i18n={i18n}
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showUserNotFoundModal={action('showUserNotFoundModal')}
isUsernamesEnabled
/>
);
},

View File

@ -14,6 +14,7 @@ import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { LocalizerType, ThemeType } from '../../../../types/Util';
import { getUsernameFromSearch } from '../../../../types/Username';
import { assert } from '../../../../util/assert';
import { refMerger } from '../../../../util/refMerger';
import { useRestoreFocus } from '../../../../hooks/useRestoreFocus';
@ -27,7 +28,10 @@ import type {
UUIDFetchStateKeyType,
UUIDFetchStateType,
} from '../../../../util/uuidFetchState';
import { isFetchingByE164 } from '../../../../util/uuidFetchState';
import {
isFetchingByE164,
isFetchingByUsername,
} from '../../../../util/uuidFetchState';
import { ModalHost } from '../../../ModalHost';
import { ContactPills } from '../../../ContactPills';
import { ContactPill } from '../../../ContactPill';
@ -53,6 +57,7 @@ export type StatePropsType = {
removeSelectedContact: (_: string) => void;
setSearchTerm: (_: string) => void;
toggleSelectedContact: (conversationId: string) => void;
isUsernamesEnabled: boolean;
} & Pick<
LookupConversationWithoutUuidActionsType,
'lookupConversationWithoutUuid'
@ -83,6 +88,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
toggleSelectedContact,
lookupConversationWithoutUuid,
showUserNotFoundModal,
isUsernamesEnabled,
}) => {
const [focusRef] = useRestoreFocus();
@ -99,6 +105,21 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
phoneNumber &&
candidateContacts.every(contact => contact.e164 !== phoneNumber.e164);
let username: string | undefined;
let isUsernameChecked = false;
let isUsernameVisible = false;
if (!phoneNumber && isUsernamesEnabled) {
username = getUsernameFromSearch(searchTerm);
isUsernameChecked = selectedContacts.some(
contact => contact.username === username
);
isUsernameVisible = candidateContacts.every(
contact => contact.username !== username
);
}
const inputRef = useRef<null | HTMLInputElement>(null);
const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size;
@ -157,19 +178,24 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
if (filteredContacts.length) {
rowCount += filteredContacts.length;
}
if (isPhoneNumberVisible) {
if (isPhoneNumberVisible || isUsernameVisible) {
// "Contacts" header
if (filteredContacts.length) {
rowCount += 1;
}
// "Find by phone number" + phone number
// or
// "Find by username" + username
rowCount += 2;
}
const getRow = (index: number): undefined | Row => {
let virtualIndex = index;
if (isPhoneNumberVisible && filteredContacts.length) {
if (
(isPhoneNumberVisible || isUsernameVisible) &&
filteredContacts.length
) {
if (virtualIndex === 0) {
return {
type: RowType.Header,
@ -221,6 +247,24 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
virtualIndex -= 2;
}
if (username) {
if (virtualIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
};
}
if (virtualIndex === 1) {
return {
type: RowType.UsernameCheckbox,
isChecked: isUsernameChecked,
isFetching: isFetchingByUsername(uuidFetchState, username),
username,
};
}
virtualIndex -= 2;
}
return undefined;
};

View File

@ -110,6 +110,7 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
i18n={i18n}
lookupConversationWithoutUuid={makeFakeLookupConversationWithoutUuid()}
showUserNotFoundModal={action('showUserNotFoundModal')}
isUsernamesEnabled
/>
);
},

View File

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

View File

@ -0,0 +1,84 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { AvatarColors } from '../../types/Colors';
import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid';
export type PropsDataType = {
username: string;
isChecked: boolean;
isFetching: boolean;
};
type PropsHousekeepingType = {
i18n: LocalizerType;
theme: ThemeType;
toggleConversationInChooseMembers: (conversationId: string) => void;
} & LookupConversationWithoutUuidActionsType;
type PropsType = PropsDataType & PropsHousekeepingType;
export const UsernameCheckbox: FunctionComponent<PropsType> = React.memo(
function UsernameCheckbox({
username,
isChecked,
isFetching,
theme,
i18n,
lookupConversationWithoutUuid,
showUserNotFoundModal,
setIsFetchingUUID,
toggleConversationInChooseMembers,
}) {
const onClickItem = React.useCallback(async () => {
if (isFetching) {
return;
}
const conversationId = await lookupConversationWithoutUuid({
showUserNotFoundModal,
setIsFetchingUUID,
type: 'username',
username,
});
if (conversationId !== undefined) {
toggleConversationInChooseMembers(conversationId);
}
}, [
isFetching,
toggleConversationInChooseMembers,
lookupConversationWithoutUuid,
showUserNotFoundModal,
setIsFetchingUUID,
username,
]);
const title = i18n('at-username', { username });
return (
<BaseConversationListItem
acceptedMessageRequest={false}
checked={isChecked}
color={AvatarColors[0]}
conversationType="direct"
headerName={title}
i18n={i18n}
isMe={false}
isSelected={false}
isUsernameSearchResult
onClick={onClickItem}
shouldShowSpinner={isFetching}
theme={theme}
sharedGroupNames={[]}
title={title}
/>
);
}
);

View File

@ -18,10 +18,14 @@ import {
} from '../AddGroupMemberErrorDialog';
import { Button } from '../Button';
import type { LocalizerType } from '../../types/Util';
import { getUsernameFromSearch } from '../../types/Username';
import type { ParsedE164Type } from '../../util/libphonenumberInstance';
import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance';
import type { UUIDFetchStateType } from '../../util/uuidFetchState';
import { isFetchingByE164 } from '../../util/uuidFetchState';
import {
isFetchingByUsername,
isFetchingByE164,
} from '../../util/uuidFetchState';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
@ -32,6 +36,7 @@ export type LeftPaneChooseGroupMembersPropsType = {
candidateContacts: ReadonlyArray<ConversationType>;
isShowingRecommendedGroupSizeModal: boolean;
isShowingMaximumGroupSizeModal: boolean;
isUsernamesEnabled: boolean;
searchTerm: string;
regionCode: string | undefined;
selectedContacts: Array<ConversationType>;
@ -42,6 +47,8 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
private readonly isPhoneNumberChecked: boolean;
private readonly isUsernameChecked: boolean;
private readonly isShowingMaximumGroupSizeModal: boolean;
private readonly isShowingRecommendedGroupSizeModal: boolean;
@ -50,6 +57,8 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
private readonly phoneNumber: ParsedE164Type | undefined;
private readonly username: string | undefined;
private readonly selectedContacts: Array<ConversationType>;
private readonly selectedConversationIdsSet: Set<string>;
@ -60,6 +69,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
candidateContacts,
isShowingMaximumGroupSizeModal,
isShowingRecommendedGroupSizeModal,
isUsernamesEnabled,
searchTerm,
regionCode,
selectedContacts,
@ -90,6 +100,22 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
} else {
this.isPhoneNumberChecked = false;
}
if (!this.phoneNumber && isUsernamesEnabled) {
const username = getUsernameFromSearch(searchTerm);
const isVisible = this.candidateContacts.every(
contact => contact.username !== username
);
if (isVisible) {
this.username = username;
}
this.isUsernameChecked = selectedContacts.some(
contact => contact.username === this.username
);
} else {
this.isUsernameChecked = false;
}
this.selectedContacts = selectedContacts;
this.selectedConversationIdsSet = new Set(
@ -246,6 +272,11 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
rowCount += 2;
}
// Header + Username
if (this.username) {
rowCount += 2;
}
// Header + Contacts
if (this.candidateContacts.length) {
rowCount += 1 + this.candidateContacts.length;
@ -260,7 +291,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
}
getRow(actualRowIndex: number): undefined | Row {
if (!this.candidateContacts.length && !this.phoneNumber) {
if (!this.candidateContacts.length && !this.phoneNumber && !this.username) {
return undefined;
}
@ -322,6 +353,24 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
virtualRowIndex -= 2;
}
if (this.username) {
if (virtualRowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
};
}
if (virtualRowIndex === 1) {
return {
type: RowType.UsernameCheckbox,
isChecked: this.isUsernameChecked,
isFetching: isFetchingByUsername(this.uuidFetchState, this.username),
username: this.username,
};
}
virtualRowIndex -= 2;
}
return undefined;
}

View File

@ -43,14 +43,16 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
private readonly uuidFetchState: UUIDFetchStateType;
private readonly isUsernamesEnabled: boolean;
private readonly searchTerm: string;
private readonly phoneNumber: ParsedE164Type | undefined;
private readonly isPhoneNumberVisible: boolean;
private readonly username: string | undefined;
private readonly isUsernameVisible: boolean;
constructor({
composeContacts,
composeGroups,
@ -74,7 +76,18 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
this.isPhoneNumberVisible = false;
}
this.uuidFetchState = uuidFetchState;
this.isUsernamesEnabled = isUsernamesEnabled;
if (isUsernamesEnabled && !this.phoneNumber) {
this.username = getUsernameFromSearch(this.searchTerm);
this.isUsernameVisible =
isUsernamesEnabled &&
Boolean(this.username) &&
this.composeContacts.every(
contact => contact.username !== this.username
);
} else {
this.isUsernameVisible = false;
}
}
override getHeaderContents({
@ -148,7 +161,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
if (this.hasGroupsHeader()) {
result += 1;
}
if (this.getUsernameFromSearch()) {
if (this.isUsernameVisible) {
result += 2;
}
if (this.isPhoneNumberVisible) {
@ -218,8 +231,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
virtualRowIndex -= this.composeGroups.length;
}
const username = this.getUsernameFromSearch();
if (username) {
if (this.username && this.isUsernameVisible) {
if (virtualRowIndex === 0) {
return {
type: RowType.Header,
@ -232,10 +244,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
if (virtualRowIndex === 0) {
return {
type: RowType.UsernameSearchResult,
username,
username: this.username,
isFetchingUsername: isFetchingByUsername(
this.uuidFetchState,
username
this.username
),
};
@ -295,7 +307,8 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
currHeaderIndices.top !== prevHeaderIndices.top ||
currHeaderIndices.contact !== prevHeaderIndices.contact ||
currHeaderIndices.group !== prevHeaderIndices.group ||
currHeaderIndices.username !== prevHeaderIndices.username
currHeaderIndices.username !== prevHeaderIndices.username ||
currHeaderIndices.phoneNumber !== prevHeaderIndices.phoneNumber
);
}
@ -318,31 +331,17 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
return Boolean(this.composeGroups.length);
}
private getUsernameFromSearch(): string | undefined {
if (!this.isUsernamesEnabled) {
return undefined;
}
if (this.phoneNumber) {
return undefined;
}
if (this.searchTerm) {
return getUsernameFromSearch(this.searchTerm);
}
return undefined;
}
private getHeaderIndices(): {
top?: number;
contact?: number;
group?: number;
phoneNumber?: number;
username?: number;
} {
let top: number | undefined;
let contact: number | undefined;
let group: number | undefined;
let phoneNumber: number | undefined;
let username: number | undefined;
let rowCount = 0;
@ -359,7 +358,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
group = rowCount;
rowCount += this.composeContacts.length;
}
if (this.getUsernameFromSearch()) {
if (this.phoneNumber) {
phoneNumber = rowCount;
}
if (this.username) {
username = rowCount;
}
@ -367,6 +369,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
top,
contact,
group,
phoneNumber,
username,
};
}

View File

@ -12,6 +12,7 @@ import type { StatePropsType } from '../../components/conversation/conversation-
import { ChooseGroupMembersModal } from '../../components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal';
import { getIntl, getTheme, getRegionCode } from '../selectors/user';
import { getUsernamesEnabled } from '../selectors/items';
import {
getCandidateContactsForNewGroup,
getConversationByIdSelector,
@ -55,6 +56,7 @@ const mapStateToProps = (
theme: getTheme(state),
selectedContacts,
lookupConversationWithoutUuid,
isUsernamesEnabled: getUsernamesEnabled(state),
};
};

View File

@ -156,6 +156,7 @@ const getModeSpecificProps = (
regionCode: getRegionCode(state),
searchTerm: getComposerConversationSearchTerm(state),
selectedContacts: getComposeSelectedContacts(state),
isUsernamesEnabled: getUsernamesEnabled(state),
uuidFetchState: getComposerUUIDFetchState(state),
};
case ComposerStep.SetGroupMetadata:

View File

@ -17,6 +17,7 @@ describe('LeftPaneChooseGroupMembersHelper', () => {
candidateContacts: [],
isShowingRecommendedGroupSizeModal: false,
isShowingMaximumGroupSizeModal: false,
isUsernamesEnabled: true,
searchTerm: '',
regionCode: 'US',
selectedContacts: [],
@ -62,6 +63,7 @@ describe('LeftPaneChooseGroupMembersHelper', () => {
candidateContacts: [],
searchTerm: 'foo bar',
selectedContacts: [getDefaultConversation()],
isUsernamesEnabled: false,
}).getRowCount(),
0
);
@ -107,6 +109,7 @@ describe('LeftPaneChooseGroupMembersHelper', () => {
candidateContacts: [],
searchTerm: 'foo bar',
selectedContacts: [getDefaultConversation()],
isUsernamesEnabled: false,
}).getRow(0)
);
});
@ -120,6 +123,7 @@ describe('LeftPaneChooseGroupMembersHelper', () => {
...defaults,
candidateContacts,
searchTerm: 'foo bar',
isUsernamesEnabled: false,
selectedContacts: [candidateContacts[1]],
});
@ -164,5 +168,51 @@ describe('LeftPaneChooseGroupMembersHelper', () => {
disabledReason: undefined,
});
});
it('returns a header, then the phone number, then a blank space if there are contacts', () => {
const helper = new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts: [],
searchTerm: '212 555',
selectedContacts: [],
});
assert.deepEqual(helper.getRow(0), {
type: RowType.Header,
i18nKey: 'findByPhoneNumberHeader',
});
assert.deepEqual(helper.getRow(1), {
type: RowType.PhoneNumberCheckbox,
phoneNumber: {
isValid: false,
userInput: '212 555',
e164: '+1212555',
},
isChecked: false,
isFetching: false,
});
assert.deepEqual(helper.getRow(2), { type: RowType.Blank });
});
it('returns a header, then the username, then a blank space if there are contacts', () => {
const helper = new LeftPaneChooseGroupMembersHelper({
...defaults,
candidateContacts: [],
searchTerm: 'signal',
selectedContacts: [],
});
assert.deepEqual(helper.getRow(0), {
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
});
assert.deepEqual(helper.getRow(1), {
type: RowType.UsernameCheckbox,
username: 'signal',
isChecked: false,
isFetching: false,
});
assert.deepEqual(helper.getRow(2), { type: RowType.Blank });
});
});
});

View File

@ -1626,7 +1626,7 @@ export function initialize({
return (await _ajax({
call: 'profile',
httpType: 'GET',
urlParameters: `username/${usernameToFetch}`,
urlParameters: `/username/${usernameToFetch}`,
responseType: 'json',
redactUrl: _createRedactor(usernameToFetch),
})) as ProfileType;