Search for username in compose mode

This commit is contained in:
Scott Nonnenberg 2021-11-11 17:17:29 -08:00 committed by GitHub
parent 6731cc6629
commit cbae7f8ee9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 997 additions and 72 deletions

View file

@ -54,6 +54,12 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
onClick: action('onClick'),
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
searchResult: boolean(
'searchResult',
typeof overrideProps.searchResult === 'boolean'
? overrideProps.searchResult
: false
),
sharedGroupNames: [],
size: 80,
title: overrideProps.title || '',
@ -153,6 +159,14 @@ story.add('Group Icon', () => {
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Search Icon', () => {
const props = createProps({
searchResult: true,
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Colors', () => {
const props = createProps();

View file

@ -65,6 +65,7 @@ export type Props = {
theme?: ThemeType;
title: string;
unblurredAvatarPath?: string;
searchResult?: boolean;
onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
@ -108,6 +109,7 @@ export const Avatar: FunctionComponent<Props> = ({
theme,
title,
unblurredAvatarPath,
searchResult,
blur = getDefaultBlur({
acceptedMessageRequest,
avatarPath,
@ -181,6 +183,15 @@ export const Avatar: FunctionComponent<Props> = ({
)}
</>
);
} else if (searchResult) {
contentsChildren = (
<div
className={classNames(
'module-Avatar__icon',
'module-Avatar__icon--search-result'
)}
/>
);
} else if (noteToSelf) {
contentsChildren = (
<div

View file

@ -84,6 +84,9 @@ const Wrapper = ({
startNewConversationFromPhoneNumber={action(
'startNewConversationFromPhoneNumber'
)}
startNewConversationFromUsername={action(
'startNewConversationFromUsername'
)}
theme={theme}
/>
);
@ -492,6 +495,10 @@ story.add('Headers', () => (
type: RowType.Header,
i18nKey: 'messagesHeader',
},
{
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
},
]}
/>
));
@ -507,6 +514,27 @@ story.add('Start new conversation', () => (
/>
));
story.add('Find by username', () => (
<Wrapper
rows={[
{
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
},
{
type: RowType.UsernameSearchResult,
username: 'jowerty',
isFetchingUsername: false,
},
{
type: RowType.UsernameSearchResult,
username: 'jowerty',
isFetchingUsername: true,
},
]}
/>
));
story.add('Search results loading skeleton', () => (
<Wrapper
scrollable={false}
@ -528,12 +556,16 @@ story.add('Kitchen sink', () => (
},
{
type: RowType.Header,
i18nKey: 'messagesHeader',
i18nKey: 'contactsHeader',
},
{
type: RowType.Contact,
contact: defaultConversations[0],
},
{
type: RowType.Header,
i18nKey: 'messagesHeader',
},
{
type: RowType.Conversation,
conversation: defaultConversations[1],
@ -542,6 +574,15 @@ story.add('Kitchen sink', () => (
type: RowType.MessageSearchResult,
messageId: '123',
},
{
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
},
{
type: RowType.UsernameSearchResult,
username: 'jowerty',
isFetchingUsername: false,
},
{
type: RowType.ArchiveButton,
archivedConversationsCount: 123,

View file

@ -26,6 +26,7 @@ import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
export enum RowType {
ArchiveButton,
@ -39,6 +40,7 @@ export enum RowType {
SearchResultsLoadingFakeHeader,
SearchResultsLoadingFakeRow,
StartNewConversation,
UsernameSearchResult,
}
type ArchiveButtonRowType = {
@ -93,6 +95,12 @@ type StartNewConversationRowType = {
phoneNumber: string;
};
type UsernameRowType = {
type: RowType.UsernameSearchResult;
username: string;
isFetchingUsername: boolean;
};
export type Row =
| ArchiveButtonRowType
| BlankRowType
@ -104,7 +112,8 @@ export type Row =
| HeaderRowType
| SearchResultsLoadingFakeHeaderType
| SearchResultsLoadingFakeRowType
| StartNewConversationRowType;
| StartNewConversationRowType
| UsernameRowType;
export type PropsType = {
badgesById?: Record<string, BadgeType>;
@ -134,6 +143,7 @@ export type PropsType = {
renderMessageSearchResult: (id: string) => JSX.Element;
showChooseGroupMembers: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
startNewConversationFromUsername: (username: string) => void;
};
const NORMAL_ROW_HEIGHT = 76;
@ -155,6 +165,7 @@ export const ConversationList: React.FC<PropsType> = ({
shouldRecomputeRowHeights,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
theme,
}) => {
const listRef = useRef<null | List>(null);
@ -327,6 +338,16 @@ export const ConversationList: React.FC<PropsType> = ({
/>
);
break;
case RowType.UsernameSearchResult:
result = (
<UsernameSearchResultListItem
i18n={i18n}
username={row.username}
isFetchingUsername={row.isFetchingUsername}
onClick={startNewConversationFromUsername}
/>
);
break;
default:
throw missingCaseError(row);
}
@ -349,6 +370,7 @@ export const ConversationList: React.FC<PropsType> = ({
renderMessageSearchResult,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
theme,
]
);

View file

@ -420,6 +420,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
}
startNewConversationFromUsername={shouldNeverBeCalled}
theme={theme}
/>
</div>

View file

@ -2,9 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ContactModalStateType } from '../state/ducks/globalModals';
import type {
ContactModalStateType,
UsernameNotFoundModalStateType,
} from '../state/ducks/globalModals';
import type { LocalizerType } from '../types/Util';
import { ButtonVariant } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog';
import { WhatsNewModal } from './WhatsNewModal';
type PropsType = {
@ -18,6 +23,9 @@ type PropsType = {
// SafetyNumberModal
safetyNumberModalContactId?: string;
renderSafetyNumber: () => JSX.Element;
// UsernameNotFoundModal
hideUsernameNotFoundModal: () => unknown;
usernameNotFoundModalState?: UsernameNotFoundModalStateType;
// WhatsNewModal
isWhatsNewVisible: boolean;
hideWhatsNewModal: () => unknown;
@ -34,6 +42,9 @@ export const GlobalModalContainer = ({
// SafetyNumberModal
safetyNumberModalContactId,
renderSafetyNumber,
// UsernameNotFoundModal
hideUsernameNotFoundModal,
usernameNotFoundModalState,
// WhatsNewModal
hideWhatsNewModal,
isWhatsNewVisible,
@ -42,6 +53,23 @@ export const GlobalModalContainer = ({
return renderSafetyNumber();
}
if (usernameNotFoundModalState) {
return (
<ConfirmationDialog
cancelText={i18n('ok')}
cancelButtonVariant={ButtonVariant.Secondary}
i18n={i18n}
onClose={hideUsernameNotFoundModal}
>
{i18n('startConversation--username-not-found', {
atUsername: i18n('at-username', {
username: usernameNotFoundModalState.username,
}),
})}
</ConfirmationDialog>
);
}
if (contactModalState) {
return renderContactModal();
}

View file

@ -146,6 +146,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
startNewConversationFromUsername: action('startNewConversationFromUsername'),
startSearch: action('startSearch'),
startSettingGroupMetadata: action('startSettingGroupMetadata'),
theme: React.useContext(StorybookThemeContext),
@ -439,13 +440,15 @@ story.add('Archive: searching a conversation', () => (
// Compose stories
story.add('Compose: no contacts or groups', () => (
story.add('Compose: no results', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: '',
},
@ -453,13 +456,15 @@ story.add('Compose: no contacts or groups', () => (
/>
));
story.add('Compose: some contacts, no groups, no search term', () => (
story.add('Compose: some contacts, no search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: '',
},
@ -467,13 +472,15 @@ story.add('Compose: some contacts, no groups, no search term', () => (
/>
));
story.add('Compose: some contacts, no groups, with a search term', () => (
story.add('Compose: some contacts, with a search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'ar',
},
@ -481,13 +488,15 @@ story.add('Compose: some contacts, no groups, with a search term', () => (
/>
));
story.add('Compose: some groups, no contacts, no search term', () => (
story.add('Compose: some groups, no search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: '',
},
@ -495,13 +504,15 @@ story.add('Compose: some groups, no contacts, no search term', () => (
/>
));
story.add('Compose: some groups, no contacts, with search term', () => (
story.add('Compose: some groups, with search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'ar',
},
@ -509,13 +520,63 @@ story.add('Compose: some groups, no contacts, with search term', () => (
/>
));
story.add('Compose: some contacts, some groups, no search term', () => (
story.add('Compose: search is valid username', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'someone',
},
})}
/>
));
story.add('Compose: search is valid username, fetching username', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: true,
isFetchingUsername: true,
regionCode: 'US',
searchTerm: 'someone',
},
})}
/>
));
story.add('Compose: search is valid username, but flag is not enabled', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
composeGroups: [],
isUsernamesEnabled: false,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'someone',
},
})}
/>
));
story.add('Compose: all kinds of results, no search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: '',
},
@ -523,15 +584,17 @@ story.add('Compose: some contacts, some groups, no search term', () => (
/>
));
story.add('Compose: some contacts, some groups, with a search term', () => (
story.add('Compose: all kinds of results, with a search term', () => (
<LeftPane
{...useProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
composeGroups: defaultGroups,
isUsernamesEnabled: true,
isFetchingUsername: false,
regionCode: 'US',
searchTerm: 'ar',
searchTerm: 'someone',
},
})}
/>

View file

@ -103,6 +103,7 @@ export type PropsType = {
closeRecommendedGroupSizeModal: () => void;
createGroup: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
startNewConversationFromUsername: (username: string) => void;
openConversationInternal: (_: {
conversationId: string;
messageId?: string;
@ -185,6 +186,7 @@ export const LeftPane: React.FC<PropsType> = ({
startComposing,
startSearch,
startNewConversationFromPhoneNumber,
startNewConversationFromUsername,
startSettingGroupMetadata,
theme,
toggleComposeEditingAvatar,
@ -607,6 +609,9 @@ export const LeftPane: React.FC<PropsType> = ({
startNewConversationFromPhoneNumber={
startNewConversationFromPhoneNumber
}
startNewConversationFromUsername={
startNewConversationFromUsername
}
theme={theme}
/>
</div>

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import * as log from '../logging/log';
import type { AvatarColorType } from '../types/Colors';
@ -627,8 +628,15 @@ export const ProfileEditor = ({
value={newUsername}
/>
<div className="ProfileEditor__error">{usernameError}</div>
<div className="ProfileEditor__info">
{usernameError && (
<div className="ProfileEditor__error">{usernameError}</div>
)}
<div
className={classNames(
'ProfileEditor__info',
!usernameError ? 'ProfileEditor__info--no-error' : undefined
)}
>
<Intl i18n={i18n} id="ProfileEditor--username--helper" />
</div>

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 ToastFailedToFetchUsername = ({
i18n,
onClose,
}: PropsType): JSX.Element => {
return (
<Toast onClose={onClose} style={{ maxWidth: '280px' }}>
{i18n('Toast--failed-to-fetch-username')}
</Toast>
);
};

View file

@ -229,6 +229,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
startNewConversationFromUsername={shouldNeverBeCalled}
theme={theme}
/>
</div>

View file

@ -14,6 +14,7 @@ import { isConversationUnread } from '../../util/isConversationUnread';
import { cleanId } from '../_util';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
import { Spinner } from '../Spinner';
const BASE_CLASS_NAME =
'module-conversation-list__item--contact-or-conversation';
@ -38,12 +39,14 @@ type PropsType = {
i18n: LocalizerType;
isNoteToSelf?: boolean;
isSelected: boolean;
isUsernameSearchResult?: boolean;
markedUnread?: boolean;
messageId?: string;
messageStatusIcon?: ReactNode;
messageText?: ReactNode;
messageTextIsAlwaysFullSize?: boolean;
onClick?: () => void;
shouldShowSpinner?: boolean;
theme?: ThemeType;
unreadCount?: number;
} & Pick<
@ -76,6 +79,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
id,
isMe,
isNoteToSelf,
isUsernameSearchResult,
isSelected,
markedUnread,
messageStatusIcon,
@ -86,6 +90,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
phoneNumber,
profileName,
sharedGroupNames,
shouldShowSpinner,
theme,
title,
unblurredAvatarPath,
@ -101,8 +106,12 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
const isCheckbox = isBoolean(checked);
let checkboxNode: ReactNode;
if (isCheckbox) {
let actionNode: ReactNode;
if (shouldShowSpinner) {
actionNode = (
<Spinner size="20px" svgSize="small" direction="on-progress-dialog" />
);
} else if (isCheckbox) {
let ariaLabel: string;
if (disabled) {
ariaLabel = i18n('cannotSelectContact', [title]);
@ -111,7 +120,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
} else {
ariaLabel = i18n('selectContact', [title]);
}
checkboxNode = (
actionNode = (
<input
aria-label={ariaLabel}
checked={checked}
@ -138,6 +147,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
color={color}
conversationType={conversationType}
noteToSelf={isAvatarNoteToSelf}
searchResult={isUsernameSearchResult}
i18n={i18n}
isMe={isMe}
name={name}
@ -187,7 +197,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
</div>
) : null}
</div>
{checkboxNode}
{actionNode}
</>
);

View file

@ -0,0 +1,52 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React from 'react';
import { noop } from 'lodash';
import { BaseConversationListItem } from './BaseConversationListItem';
import type { LocalizerType } from '../../types/Util';
type PropsData = {
username: string;
isFetchingUsername: boolean;
};
type PropsHousekeeping = {
i18n: LocalizerType;
onClick: (username: string) => void;
};
export type Props = PropsData & PropsHousekeeping;
export const UsernameSearchResultListItem: FunctionComponent<Props> = ({
i18n,
isFetchingUsername,
onClick,
username,
}) => {
const usernameText = i18n('at-username', { username });
const boundOnClick = isFetchingUsername
? noop
: () => {
onClick(username);
};
return (
<BaseConversationListItem
acceptedMessageRequest={false}
conversationType="direct"
headerName={usernameText}
i18n={i18n}
isMe={false}
isSelected={false}
isUsernameSearchResult
shouldShowSpinner={isFetchingUsername}
onClick={boundOnClick}
sharedGroupNames={[]}
title={usernameText}
/>
);
};

View file

@ -18,12 +18,16 @@ import {
} from '../../util/libphonenumberInstance';
import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { getUsernameFromSearch } from '../../types/Username';
export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray<ContactListItemPropsType>;
composeGroups: ReadonlyArray<ConversationListItemPropsType>;
regionCode: string;
searchTerm: string;
isFetchingUsername: boolean;
isUsernamesEnabled: boolean;
};
enum TopButton {
@ -37,6 +41,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
private readonly composeGroups: ReadonlyArray<ConversationListItemPropsType>;
private readonly isFetchingUsername: boolean;
private readonly isUsernamesEnabled: boolean;
private readonly searchTerm: string;
private readonly phoneNumber: undefined | PhoneNumber;
@ -46,13 +54,17 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
composeGroups,
regionCode,
searchTerm,
isUsernamesEnabled,
isFetchingUsername,
}: Readonly<LeftPaneComposePropsType>) {
super();
this.composeContacts = composeContacts;
this.composeGroups = composeGroups;
this.searchTerm = searchTerm;
this.phoneNumber = parsePhoneNumber(searchTerm, regionCode);
this.composeGroups = composeGroups;
this.composeContacts = composeContacts;
this.isFetchingUsername = isFetchingUsername;
this.isUsernamesEnabled = isUsernamesEnabled;
}
getHeaderContents({
@ -121,6 +133,9 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
if (this.hasGroupsHeader()) {
result += 1;
}
if (this.getUsernameFromSearch()) {
result += 2;
}
return result;
}
@ -187,10 +202,36 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
virtualRowIndex -= 1;
const group = this.composeGroups[virtualRowIndex];
return {
type: RowType.Conversation,
conversation: group,
};
if (group) {
return {
type: RowType.Conversation,
conversation: group,
};
}
virtualRowIndex -= this.composeGroups.length;
}
const username = this.getUsernameFromSearch();
if (username) {
if (virtualRowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'findByUsernameHeader',
};
}
virtualRowIndex -= 1;
if (virtualRowIndex === 0) {
return {
type: RowType.UsernameSearchResult,
username,
isFetchingUsername: this.isFetchingUsername,
};
virtualRowIndex -= 1;
}
}
return undefined;
@ -220,7 +261,8 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
return (
currHeaderIndices.top !== prevHeaderIndices.top ||
currHeaderIndices.contact !== prevHeaderIndices.contact ||
currHeaderIndices.group !== prevHeaderIndices.group
currHeaderIndices.group !== prevHeaderIndices.group ||
currHeaderIndices.username !== prevHeaderIndices.username
);
}
@ -246,31 +288,56 @@ 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;
username?: number;
} {
let top: number | undefined;
let contact: number | undefined;
let group: number | undefined;
let username: number | undefined;
let rowCount = 0;
if (this.hasTopButton()) {
top = 0;
rowCount += 1;
}
if (this.composeContacts.length) {
if (this.hasContactsHeader()) {
contact = rowCount;
rowCount += this.composeContacts.length;
}
if (this.composeGroups.length) {
if (this.hasGroupsHeader()) {
group = rowCount;
rowCount += this.composeContacts.length;
}
if (this.getUsernameFromSearch()) {
username = rowCount;
}
return {
top,
contact,
group,
username,
};
}
}