Search for username in compose mode
This commit is contained in:
parent
6731cc6629
commit
cbae7f8ee9
36 changed files with 997 additions and 72 deletions
|
@ -823,6 +823,20 @@
|
|||
"message": "Messages",
|
||||
"description": "Shown to separate the types of search results"
|
||||
},
|
||||
"findByUsernameHeader": {
|
||||
"message": "Find by Username",
|
||||
"description": "Shown to separate the types of search results"
|
||||
},
|
||||
"at-username": {
|
||||
"message": "@$username$",
|
||||
"description": "@ added to username to signify it as a username. Should it be on the right in your language?",
|
||||
"placeholders": {
|
||||
"username": {
|
||||
"content": "$1",
|
||||
"example": "sammy45"
|
||||
}
|
||||
}
|
||||
},
|
||||
"welcomeToSignal": {
|
||||
"message": "Welcome to Signal"
|
||||
},
|
||||
|
@ -2238,6 +2252,20 @@
|
|||
"message": "No conversations found",
|
||||
"description": "Label shown when there are no conversations to compose to"
|
||||
},
|
||||
"Toast--failed-to-fetch-username": {
|
||||
"message": "Failed to fetch username. Check your connection and try again.",
|
||||
"description": "Shown if request to Signal servers to find username fails"
|
||||
},
|
||||
"startConversation--username-not-found": {
|
||||
"message": "User not found. $atUsername$ is not a Signal user; make sure you’ve entered the complete username.",
|
||||
"description": "Shown in dialog if username is not found. Note that 'username' will be the output of at-username",
|
||||
"placeholders": {
|
||||
"atUsername": {
|
||||
"content": "$1",
|
||||
"example": "@alex"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chooseGroupMembers__title": {
|
||||
"message": "Choose members",
|
||||
"description": "The title for the 'choose group members' left pane screen"
|
||||
|
@ -3285,7 +3313,7 @@
|
|||
"message": "Unblock",
|
||||
"description": "Shown as a button to let the user unblock a message request"
|
||||
},
|
||||
"MessageRequests--unblock-confirm-title": {
|
||||
"MessageRequests--unblock-direct-confirm-title": {
|
||||
"message": "Unblock $name$?",
|
||||
"description": "Shown as a button to let the user unblock a message request",
|
||||
"placeholders": {
|
||||
|
@ -6185,7 +6213,7 @@
|
|||
"description": "Placeholder for the username field"
|
||||
},
|
||||
"ProfileEditor--username--helper": {
|
||||
"message": "Usernames on Signal are optional. If you choose to create a username and make it searchable, other Signal users will be able to find you by this username and contact you without knowing your phone number.",
|
||||
"message": "Usernames on Signal are optional. If you choose to create a username other Signal users will be able to find you by this username and contact you without knowing your phone number.",
|
||||
"description": "Shown on the edit username screen"
|
||||
},
|
||||
"ProfileEditor--username--check-characters": {
|
||||
|
|
|
@ -38,6 +38,7 @@ $color-white-alpha-90: rgba($color-white, 0.9);
|
|||
|
||||
$color-black-alpha-05: rgba($color-black, 0.05);
|
||||
$color-black-alpha-06: rgba($color-black, 0.06);
|
||||
$color-black-alpha-08: rgba($color-black, 0.08);
|
||||
$color-black-alpha-12: rgba($color-black, 0.12);
|
||||
$color-black-alpha-20: rgba($color-black, 0.2);
|
||||
$color-black-alpha-30: rgba($color-black, 0.3);
|
||||
|
|
|
@ -100,6 +100,11 @@
|
|||
&--note-to-self {
|
||||
-webkit-mask-image: url('../images/icons/v2/note-24.svg');
|
||||
}
|
||||
|
||||
&--search-result {
|
||||
-webkit-mask-image: url('../images/icons/v2/search-24.svg');
|
||||
-webkit-mask-size: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner-container {
|
||||
|
|
|
@ -98,5 +98,11 @@
|
|||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// To account for missing error section - 16px previous margin, 34px for
|
||||
// 16px margin of error plus 18px line height.
|
||||
&--no-error {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
@include font-body-2;
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-gray-02;
|
||||
background-color: $color-black-alpha-08;
|
||||
border: solid 1px $color-gray-02;
|
||||
color: $color-gray-90;
|
||||
}
|
||||
|
@ -40,7 +40,7 @@
|
|||
}
|
||||
|
||||
&__input {
|
||||
background: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding-left: 16px;
|
||||
width: 100%;
|
||||
|
|
|
@ -952,6 +952,10 @@ export async function startApp(): Promise<void> {
|
|||
conversations,
|
||||
'groupId'
|
||||
),
|
||||
conversationsByUsername: window.Signal.Util.makeLookup(
|
||||
conversations,
|
||||
'username'
|
||||
),
|
||||
messagesByConversation: {},
|
||||
messagesLookup: {},
|
||||
outboundMessagesPendingConversationVerification: {},
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -420,6 +420,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
|||
startNewConversationFromPhoneNumber={
|
||||
shouldNeverBeCalled
|
||||
}
|
||||
startNewConversationFromUsername={shouldNeverBeCalled}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
22
ts/components/ToastFailedToFetchUsername.tsx
Normal file
22
ts/components/ToastFailedToFetchUsername.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -229,6 +229,7 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
|
|||
shouldRecomputeRowHeights={false}
|
||||
showChooseGroupMembers={shouldNeverBeCalled}
|
||||
startNewConversationFromPhoneNumber={shouldNeverBeCalled}
|
||||
startNewConversationFromUsername={shouldNeverBeCalled}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4478,10 +4478,13 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
getTitle(): string {
|
||||
if (isDirectConversation(this.attributes)) {
|
||||
const username = this.get('username');
|
||||
|
||||
return (
|
||||
this.get('name') ||
|
||||
this.getProfileName() ||
|
||||
this.getNumber() ||
|
||||
(username && window.i18n('at-username', { username })) ||
|
||||
window.i18n('unknownContact')
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
// Matching Whisper.events.trigger API
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
|
||||
export function trigger(name: string, param1?: any, param2?: any): void {
|
||||
window.Whisper.events.trigger(name, param1, param2);
|
||||
export function trigger(name: string, ...rest: Array<any>): void {
|
||||
window.Whisper.events.trigger(name, ...rest);
|
||||
}
|
||||
|
|
|
@ -23,8 +23,14 @@ import { getOwn } from '../../util/getOwn';
|
|||
import { assert, strictAssert } from '../../util/assert';
|
||||
import * as universalExpireTimer from '../../util/universalExpireTimer';
|
||||
import { trigger } from '../../shims/events';
|
||||
import type { ToggleProfileEditorErrorActionType } from './globalModals';
|
||||
import { TOGGLE_PROFILE_EDITOR_ERROR } from './globalModals';
|
||||
import type {
|
||||
ShowUsernameNotFoundModalActionType,
|
||||
ToggleProfileEditorErrorActionType,
|
||||
} from './globalModals';
|
||||
import {
|
||||
TOGGLE_PROFILE_EDITOR_ERROR,
|
||||
actions as globalModalActions,
|
||||
} from './globalModals';
|
||||
import { isRecord } from '../../util/isRecord';
|
||||
|
||||
import type {
|
||||
|
@ -41,6 +47,7 @@ import type { BodyRangeType } from '../../types/Util';
|
|||
import { CallMode } from '../../types/Calling';
|
||||
import type { MediaItemType } from '../../types/MediaItem';
|
||||
import type { UUIDStringType } from '../../types/UUID';
|
||||
import { UUID } from '../../types/UUID';
|
||||
import {
|
||||
getGroupSizeRecommendedLimit,
|
||||
getGroupSizeHardLimit,
|
||||
|
@ -53,6 +60,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing';
|
|||
import { writeProfile } from '../../services/writeProfile';
|
||||
import { writeUsername } from '../../services/writeUsername';
|
||||
import {
|
||||
getConversationsByUsername,
|
||||
getMe,
|
||||
getMessageIdsPendingBecauseOfVerification,
|
||||
getUsernameSaveState,
|
||||
|
@ -69,6 +77,8 @@ import {
|
|||
} from './conversationsEnums';
|
||||
import { showToast } from '../../util/showToast';
|
||||
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
|
||||
import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchUsername';
|
||||
import { isValidUsername } from '../../types/Username';
|
||||
|
||||
import type { NoopActionType } from './noop';
|
||||
|
||||
|
@ -278,10 +288,16 @@ type ComposerGroupCreationState = {
|
|||
userAvatarData: Array<AvatarDataType>;
|
||||
};
|
||||
|
||||
export type FoundUsernameType = {
|
||||
uuid: UUIDStringType;
|
||||
username: string;
|
||||
};
|
||||
|
||||
type ComposerStateType =
|
||||
| {
|
||||
step: ComposerStep.StartDirectConversation;
|
||||
searchTerm: string;
|
||||
isFetchingUsername: boolean;
|
||||
}
|
||||
| ({
|
||||
step: ComposerStep.ChooseGroupMembers;
|
||||
|
@ -314,6 +330,7 @@ export type ConversationsStateType = {
|
|||
conversationsByE164: ConversationLookupType;
|
||||
conversationsByUuid: ConversationLookupType;
|
||||
conversationsByGroupId: ConversationLookupType;
|
||||
conversationsByUsername: ConversationLookupType;
|
||||
selectedConversationId?: string;
|
||||
selectedMessage?: string;
|
||||
selectedMessageCounter: number;
|
||||
|
@ -676,6 +693,12 @@ type SetComposeSearchTermActionType = {
|
|||
type: 'SET_COMPOSE_SEARCH_TERM';
|
||||
payload: { searchTerm: string };
|
||||
};
|
||||
type SetIsFetchingUsernameActionType = {
|
||||
type: 'SET_IS_FETCHING_USERNAME';
|
||||
payload: {
|
||||
isFetchingUsername: boolean;
|
||||
};
|
||||
};
|
||||
type SetRecentMediaItemsActionType = {
|
||||
type: 'SET_RECENT_MEDIA_ITEMS';
|
||||
payload: {
|
||||
|
@ -768,6 +791,7 @@ export type ConversationActionType =
|
|||
| SetComposeGroupNameActionType
|
||||
| SetComposeSearchTermActionType
|
||||
| SetConversationHeaderTitleActionType
|
||||
| SetIsFetchingUsernameActionType
|
||||
| SetIsNearBottomActionType
|
||||
| SetLoadCountdownStartActionType
|
||||
| SetMessagesLoadingActionType
|
||||
|
@ -850,6 +874,7 @@ export const actions = {
|
|||
showInbox,
|
||||
startComposing,
|
||||
startNewConversationFromPhoneNumber,
|
||||
startNewConversationFromUsername,
|
||||
startSettingGroupMetadata,
|
||||
toggleAdmin,
|
||||
toggleConversationInChooseMembers,
|
||||
|
@ -1793,6 +1818,111 @@ function startNewConversationFromPhoneNumber(
|
|||
};
|
||||
}
|
||||
|
||||
async function checkForUsername(
|
||||
username: string
|
||||
): Promise<FoundUsernameType | undefined> {
|
||||
if (!isValidUsername(username)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await window.textsecure.messaging.getProfileForUsername(
|
||||
username
|
||||
);
|
||||
|
||||
if (!profile.username || profile.username !== username) {
|
||||
log.error("checkForUsername: Returned username didn't match searched");
|
||||
return;
|
||||
}
|
||||
if (!profile.uuid) {
|
||||
log.error("checkForUsername: Returned profile didn't include a uuid");
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
uuid: UUID.cast(profile.uuid),
|
||||
username: profile.username,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (!isRecord(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.code === 404) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function startNewConversationFromUsername(
|
||||
username: string
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
| ShowInboxActionType
|
||||
| SetIsFetchingUsernameActionType
|
||||
| ShowUsernameNotFoundModalActionType
|
||||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
const byUsername = getConversationsByUsername(state);
|
||||
const knownConversation = getOwn(byUsername, username);
|
||||
if (knownConversation && knownConversation.uuid) {
|
||||
trigger('showConversation', knownConversation.uuid, username);
|
||||
dispatch(showInbox());
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SET_IS_FETCHING_USERNAME',
|
||||
payload: {
|
||||
isFetchingUsername: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const foundUsername = await checkForUsername(username);
|
||||
dispatch({
|
||||
type: 'SET_IS_FETCHING_USERNAME',
|
||||
payload: {
|
||||
isFetchingUsername: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundUsername) {
|
||||
dispatch(globalModalActions.showUsernameNotFoundModal(username));
|
||||
return;
|
||||
}
|
||||
|
||||
trigger(
|
||||
'showConversation',
|
||||
foundUsername.uuid,
|
||||
undefined,
|
||||
foundUsername.username
|
||||
);
|
||||
dispatch(showInbox());
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'startNewConversationFromUsername: Something went wrong fetching username:',
|
||||
error.stack
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: 'SET_IS_FETCHING_USERNAME',
|
||||
payload: {
|
||||
isFetchingUsername: false,
|
||||
},
|
||||
});
|
||||
|
||||
showToast(ToastFailedToFetchUsername);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function startSettingGroupMetadata(): StartSettingGroupMetadataActionType {
|
||||
return { type: 'START_SETTING_GROUP_METADATA' };
|
||||
}
|
||||
|
@ -1951,6 +2081,7 @@ export function getEmptyState(): ConversationsStateType {
|
|||
conversationsByE164: {},
|
||||
conversationsByUuid: {},
|
||||
conversationsByGroupId: {},
|
||||
conversationsByUsername: {},
|
||||
outboundMessagesPendingConversationVerification: {},
|
||||
messagesByConversation: {},
|
||||
messagesLookup: {},
|
||||
|
@ -2033,12 +2164,16 @@ export function updateConversationLookups(
|
|||
state: ConversationsStateType
|
||||
): Pick<
|
||||
ConversationsStateType,
|
||||
'conversationsByE164' | 'conversationsByUuid' | 'conversationsByGroupId'
|
||||
| 'conversationsByE164'
|
||||
| 'conversationsByUuid'
|
||||
| 'conversationsByGroupId'
|
||||
| 'conversationsByUsername'
|
||||
> {
|
||||
const result = {
|
||||
conversationsByE164: state.conversationsByE164,
|
||||
conversationsByUuid: state.conversationsByUuid,
|
||||
conversationsByGroupId: state.conversationsByGroupId,
|
||||
conversationsByUsername: state.conversationsByUsername,
|
||||
};
|
||||
|
||||
if (removed && removed.e164) {
|
||||
|
@ -2053,6 +2188,12 @@ export function updateConversationLookups(
|
|||
removed.groupId
|
||||
);
|
||||
}
|
||||
if (removed && removed.username) {
|
||||
result.conversationsByUsername = omit(
|
||||
result.conversationsByUsername,
|
||||
removed.username
|
||||
);
|
||||
}
|
||||
|
||||
if (added && added.e164) {
|
||||
result.conversationsByE164 = {
|
||||
|
@ -2072,6 +2213,12 @@ export function updateConversationLookups(
|
|||
[added.groupId]: added,
|
||||
};
|
||||
}
|
||||
if (added && added.username) {
|
||||
result.conversationsByUsername = {
|
||||
...result.conversationsByUsername,
|
||||
[added.username]: added,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
@ -3045,6 +3192,7 @@ export function reducer(
|
|||
composer: {
|
||||
step: ComposerStep.StartDirectConversation,
|
||||
searchTerm: '',
|
||||
isFetchingUsername: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -3200,8 +3348,14 @@ export function reducer(
|
|||
);
|
||||
return state;
|
||||
}
|
||||
if (composer?.step === ComposerStep.SetGroupMetadata) {
|
||||
assert(false, 'Setting compose search term at this step is a no-op');
|
||||
if (
|
||||
composer.step !== ComposerStep.StartDirectConversation &&
|
||||
composer.step !== ComposerStep.ChooseGroupMembers
|
||||
) {
|
||||
assert(
|
||||
false,
|
||||
`Setting compose search term at step ${composer.step} is a no-op`
|
||||
);
|
||||
return state;
|
||||
}
|
||||
|
||||
|
@ -3214,6 +3368,30 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === 'SET_IS_FETCHING_USERNAME') {
|
||||
const { composer } = state;
|
||||
if (!composer) {
|
||||
assert(
|
||||
false,
|
||||
'Setting compose username with the composer closed is a no-op'
|
||||
);
|
||||
return state;
|
||||
}
|
||||
if (composer.step !== ComposerStep.StartDirectConversation) {
|
||||
assert(false, 'Setting compose username at this step is a no-op');
|
||||
return state;
|
||||
}
|
||||
const { isFetchingUsername } = action.payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
composer: {
|
||||
...composer,
|
||||
isFetchingUsername,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === COMPOSE_TOGGLE_EDITING_AVATAR) {
|
||||
const { composer } = state;
|
||||
|
||||
|
|
|
@ -6,9 +6,10 @@
|
|||
export type GlobalModalsStateType = {
|
||||
readonly contactModalState?: ContactModalStateType;
|
||||
readonly isProfileEditorVisible: boolean;
|
||||
readonly isWhatsNewVisible: boolean;
|
||||
readonly profileEditorHasError: boolean;
|
||||
readonly safetyNumberModalContactId?: string;
|
||||
readonly isWhatsNewVisible: boolean;
|
||||
readonly usernameNotFoundModalState?: UsernameNotFoundModalStateType;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
@ -16,6 +17,10 @@ export type GlobalModalsStateType = {
|
|||
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
|
||||
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
|
||||
const SHOW_WHATS_NEW_MODAL = 'globalModals/SHOW_WHATS_NEW_MODAL_MODAL';
|
||||
const SHOW_USERNAME_NOT_FOUND_MODAL =
|
||||
'globalModals/SHOW_USERNAME_NOT_FOUND_MODAL';
|
||||
const HIDE_USERNAME_NOT_FOUND_MODAL =
|
||||
'globalModals/HIDE_USERNAME_NOT_FOUND_MODAL';
|
||||
const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
|
||||
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
|
||||
export const TOGGLE_PROFILE_EDITOR_ERROR =
|
||||
|
@ -27,6 +32,10 @@ export type ContactModalStateType = {
|
|||
conversationId?: string;
|
||||
};
|
||||
|
||||
export type UsernameNotFoundModalStateType = {
|
||||
username: string;
|
||||
};
|
||||
|
||||
type HideContactModalActionType = {
|
||||
type: typeof HIDE_CONTACT_MODAL;
|
||||
};
|
||||
|
@ -44,6 +53,17 @@ type ShowWhatsNewModalActionType = {
|
|||
type: typeof SHOW_WHATS_NEW_MODAL;
|
||||
};
|
||||
|
||||
type HideUsernameNotFoundModalActionType = {
|
||||
type: typeof HIDE_USERNAME_NOT_FOUND_MODAL;
|
||||
};
|
||||
|
||||
export type ShowUsernameNotFoundModalActionType = {
|
||||
type: typeof SHOW_USERNAME_NOT_FOUND_MODAL;
|
||||
payload: {
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ToggleProfileEditorActionType = {
|
||||
type: typeof TOGGLE_PROFILE_EDITOR;
|
||||
};
|
||||
|
@ -62,6 +82,8 @@ export type GlobalModalsActionType =
|
|||
| ShowContactModalActionType
|
||||
| HideWhatsNewModalActionType
|
||||
| ShowWhatsNewModalActionType
|
||||
| HideUsernameNotFoundModalActionType
|
||||
| ShowUsernameNotFoundModalActionType
|
||||
| ToggleProfileEditorActionType
|
||||
| ToggleProfileEditorErrorActionType
|
||||
| ToggleSafetyNumberModalActionType;
|
||||
|
@ -73,6 +95,8 @@ export const actions = {
|
|||
showContactModal,
|
||||
hideWhatsNewModal,
|
||||
showWhatsNewModal,
|
||||
hideUsernameNotFoundModal,
|
||||
showUsernameNotFoundModal,
|
||||
toggleProfileEditor,
|
||||
toggleProfileEditorHasError,
|
||||
toggleSafetyNumberModal,
|
||||
|
@ -109,6 +133,23 @@ function showWhatsNewModal(): ShowWhatsNewModalActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function hideUsernameNotFoundModal(): HideUsernameNotFoundModalActionType {
|
||||
return {
|
||||
type: HIDE_USERNAME_NOT_FOUND_MODAL,
|
||||
};
|
||||
}
|
||||
|
||||
function showUsernameNotFoundModal(
|
||||
username: string
|
||||
): ShowUsernameNotFoundModalActionType {
|
||||
return {
|
||||
type: SHOW_USERNAME_NOT_FOUND_MODAL,
|
||||
payload: {
|
||||
username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toggleProfileEditor(): ToggleProfileEditorActionType {
|
||||
return { type: TOGGLE_PROFILE_EDITOR };
|
||||
}
|
||||
|
@ -168,6 +209,24 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === HIDE_USERNAME_NOT_FOUND_MODAL) {
|
||||
return {
|
||||
...state,
|
||||
usernameNotFoundModalState: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === SHOW_USERNAME_NOT_FOUND_MODAL) {
|
||||
const { username } = action.payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
usernameNotFoundModalState: {
|
||||
username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === SHOW_CONTACT_MODAL) {
|
||||
return {
|
||||
...state,
|
||||
|
|
|
@ -107,6 +107,12 @@ export const getConversationsByGroupId = createSelector(
|
|||
return state.conversationsByGroupId;
|
||||
}
|
||||
);
|
||||
export const getConversationsByUsername = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): ConversationLookupType => {
|
||||
return state.conversationsByUsername;
|
||||
}
|
||||
);
|
||||
|
||||
const getAllConversations = createSelector(
|
||||
getConversationLookup,
|
||||
|
@ -397,6 +403,24 @@ export const getComposerConversationSearchTerm = createSelector(
|
|||
}
|
||||
);
|
||||
|
||||
export const getIsFetchingUsername = createSelector(
|
||||
getComposerState,
|
||||
(composer): boolean => {
|
||||
if (!composer) {
|
||||
assert(false, 'getIsFetchingUsername: composer is not open');
|
||||
return false;
|
||||
}
|
||||
if (composer.step !== ComposerStep.StartDirectConversation) {
|
||||
assert(
|
||||
false,
|
||||
`getIsFetchingUsername: step ${composer.step} has no isFetchingUsername key`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return composer.isFetchingUsername;
|
||||
}
|
||||
);
|
||||
|
||||
function isTrusted(conversation: ConversationType): boolean {
|
||||
if (conversation.type === 'group') {
|
||||
return true;
|
||||
|
|
|
@ -21,19 +21,23 @@ import {
|
|||
} from '../selectors/search';
|
||||
import { getIntl, getRegionCode, getTheme } from '../selectors/user';
|
||||
import { getBadgesById } from '../selectors/badges';
|
||||
import { getPreferredLeftPaneWidth } from '../selectors/items';
|
||||
import {
|
||||
getPreferredLeftPaneWidth,
|
||||
getUsernamesEnabled,
|
||||
} from '../selectors/items';
|
||||
import {
|
||||
getCantAddContactForModal,
|
||||
getComposeAvatarData,
|
||||
getComposeGroupAvatar,
|
||||
getComposeGroupExpireTimer,
|
||||
getComposeGroupName,
|
||||
getComposeSelectedContacts,
|
||||
getComposerConversationSearchTerm,
|
||||
getComposerStep,
|
||||
getComposeSelectedContacts,
|
||||
getFilteredCandidateContactsForNewGroup,
|
||||
getFilteredComposeContacts,
|
||||
getFilteredComposeGroups,
|
||||
getIsFetchingUsername,
|
||||
getLeftPaneLists,
|
||||
getMaximumGroupSizeModalState,
|
||||
getRecommendedGroupSizeModalState,
|
||||
|
@ -126,6 +130,8 @@ const getModeSpecificProps = (
|
|||
composeGroups: getFilteredComposeGroups(state),
|
||||
regionCode: getRegionCode(state),
|
||||
searchTerm: getComposerConversationSearchTerm(state),
|
||||
isUsernamesEnabled: getUsernamesEnabled(state),
|
||||
isFetchingUsername: getIsFetchingUsername(state),
|
||||
};
|
||||
case ComposerStep.ChooseGroupMembers:
|
||||
return {
|
||||
|
|
|
@ -7,6 +7,7 @@ import { OneTimeModalState } from '../../groups/toggleSelectedContactForGroupAdd
|
|||
export const defaultStartDirectConversationComposerState = {
|
||||
step: ComposerStep.StartDirectConversation as const,
|
||||
searchTerm: '',
|
||||
isFetchingUsername: false,
|
||||
};
|
||||
|
||||
export const defaultChooseGroupMembersComposerState = {
|
||||
|
|
|
@ -21,6 +21,7 @@ describe('filterAndSortConversationsByTitle', () => {
|
|||
name: 'Carlos Santana',
|
||||
title: 'Carlos Santana',
|
||||
e164: '+16505559876',
|
||||
username: 'thisismyusername',
|
||||
}),
|
||||
getDefaultConversation({
|
||||
name: 'Aaron Aardvark',
|
||||
|
@ -64,6 +65,14 @@ describe('filterAndSortConversationsByTitle', () => {
|
|||
).map(contact => contact.title);
|
||||
assert.sameMembers(titles, ['Carlos Santana', '+16505551234']);
|
||||
});
|
||||
|
||||
it('can search for contacts by username', () => {
|
||||
const titles = filterAndSortConversationsByTitle(
|
||||
conversations,
|
||||
'thisis'
|
||||
).map(contact => contact.title);
|
||||
assert.sameMembers(titles, ['Carlos Santana']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndSortConversationsByRecent', () => {
|
||||
|
|
|
@ -1234,8 +1234,8 @@ describe('both/state/ducks/conversations', () => {
|
|||
...getEmptyState(),
|
||||
composer: defaultStartDirectConversationComposerState,
|
||||
};
|
||||
const action = setComposeSearchTerm('foo bar');
|
||||
const result = reducer(state, action);
|
||||
|
||||
const result = reducer(state, setComposeSearchTerm('foo bar'));
|
||||
|
||||
assert.deepEqual(result.composer, {
|
||||
...defaultStartDirectConversationComposerState,
|
||||
|
|
|
@ -28,6 +28,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.strictEqual(helper.getBackAction({ showInbox }), showInbox);
|
||||
|
@ -42,6 +44,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
1
|
||||
);
|
||||
|
@ -54,6 +58,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
4
|
||||
);
|
||||
|
@ -66,11 +72,41 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [getDefaultConversation(), getDefaultConversation()],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
7
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the number of contacts, number groups + 4 (for headers and username)', () => {
|
||||
assert.strictEqual(
|
||||
new LeftPaneComposeHelper({
|
||||
composeContacts: [getDefaultConversation(), getDefaultConversation()],
|
||||
composeGroups: [getDefaultConversation(), getDefaultConversation()],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'someone',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
8
|
||||
);
|
||||
});
|
||||
|
||||
it('if usernames are disabled, two less rows are shown', () => {
|
||||
assert.strictEqual(
|
||||
new LeftPaneComposeHelper({
|
||||
composeContacts: [getDefaultConversation(), getDefaultConversation()],
|
||||
composeGroups: [getDefaultConversation(), getDefaultConversation()],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'someone',
|
||||
isUsernamesEnabled: false,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
6
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the number of conversations + the headers, but not for a phone number', () => {
|
||||
assert.strictEqual(
|
||||
new LeftPaneComposeHelper({
|
||||
|
@ -78,8 +114,10 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
0
|
||||
2
|
||||
);
|
||||
assert.strictEqual(
|
||||
new LeftPaneComposeHelper({
|
||||
|
@ -87,8 +125,10 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
3
|
||||
5
|
||||
);
|
||||
assert.strictEqual(
|
||||
new LeftPaneComposeHelper({
|
||||
|
@ -96,8 +136,10 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [getDefaultConversation()],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
5
|
||||
7
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -108,18 +150,36 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '+16505551234',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the number of contacts + 2 (for the "Start new conversation" button and header) if searching for a phone number', () => {
|
||||
it('returns 2 if just username in results', () => {
|
||||
assert.strictEqual(
|
||||
new LeftPaneComposeHelper({
|
||||
composeContacts: [],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'someone',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the number of contacts + 4 (for the "Start new conversation" button and header) if searching for a phone number', () => {
|
||||
assert.strictEqual(
|
||||
new LeftPaneComposeHelper({
|
||||
composeContacts: [getDefaultConversation(), getDefaultConversation()],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '+16505551234',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
}).getRowCount(),
|
||||
4
|
||||
);
|
||||
|
@ -133,6 +193,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(helper.getRow(0), {
|
||||
|
@ -151,6 +213,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(helper.getRow(0), {
|
||||
|
@ -184,6 +248,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups,
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(helper.getRow(0), {
|
||||
|
@ -215,12 +281,14 @@ describe('LeftPaneComposeHelper', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns no rows if searching and there are no results', () => {
|
||||
it('returns no rows if searching, no results, and usernames are disabled', () => {
|
||||
const helper = new LeftPaneComposeHelper({
|
||||
composeContacts: [],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: false,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isUndefined(helper.getRow(0));
|
||||
|
@ -237,6 +305,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(helper.getRow(1), {
|
||||
|
@ -255,6 +325,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '+16505551234',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(helper.getRow(0), {
|
||||
|
@ -264,6 +336,31 @@ describe('LeftPaneComposeHelper', () => {
|
|||
assert.isUndefined(helper.getRow(1));
|
||||
});
|
||||
|
||||
it('returns just a "find by username" header if no results', () => {
|
||||
const username = 'someone';
|
||||
const isFetchingUsername = true;
|
||||
|
||||
const helper = new LeftPaneComposeHelper({
|
||||
composeContacts: [],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: username,
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername,
|
||||
});
|
||||
|
||||
assert.deepEqual(helper.getRow(0), {
|
||||
type: RowType.Header,
|
||||
i18nKey: 'findByUsernameHeader',
|
||||
});
|
||||
assert.deepEqual(helper.getRow(1), {
|
||||
type: RowType.UsernameSearchResult,
|
||||
username,
|
||||
isFetchingUsername,
|
||||
});
|
||||
assert.isUndefined(helper.getRow(2));
|
||||
});
|
||||
|
||||
it('returns a "start new conversation" row, a header, and contacts if searching for a phone number', () => {
|
||||
const composeContacts = [
|
||||
getDefaultConversation(),
|
||||
|
@ -274,6 +371,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '+16505551234',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(helper.getRow(0), {
|
||||
|
@ -302,6 +401,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isUndefined(helper.getConversationAndMessageAtIndex(0));
|
||||
|
@ -315,6 +416,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isUndefined(
|
||||
|
@ -328,42 +431,46 @@ describe('LeftPaneComposeHelper', () => {
|
|||
});
|
||||
|
||||
describe('shouldRecomputeRowHeights', () => {
|
||||
it('returns false if going from "no header" to "no header"', () => {
|
||||
it('returns false if just search changes, so "Find by username" header is in same position', () => {
|
||||
const helper = new LeftPaneComposeHelper({
|
||||
composeContacts: [getDefaultConversation(), getDefaultConversation()],
|
||||
composeContacts: [],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isFalse(
|
||||
helper.shouldRecomputeRowHeights({
|
||||
composeContacts: [getDefaultConversation()],
|
||||
composeContacts: [],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
searchTerm: 'different search',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
assert.isFalse(
|
||||
helper.shouldRecomputeRowHeights({
|
||||
composeContacts: [
|
||||
getDefaultConversation(),
|
||||
getDefaultConversation(),
|
||||
getDefaultConversation(),
|
||||
],
|
||||
composeContacts: [],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'bing bong',
|
||||
searchTerm: 'last search',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false if going from "has header" to "has header"', () => {
|
||||
it('returns true if "Find by usernames" header changes location or goes away', () => {
|
||||
const helper = new LeftPaneComposeHelper({
|
||||
composeContacts: [getDefaultConversation(), getDefaultConversation()],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isFalse(
|
||||
|
@ -372,6 +479,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
assert.isFalse(
|
||||
|
@ -380,16 +489,20 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '+16505559876',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true if going from "no header" to "has header"', () => {
|
||||
it('returns true if search changes or becomes an e164', () => {
|
||||
const helper = new LeftPaneComposeHelper({
|
||||
composeContacts: [getDefaultConversation(), getDefaultConversation()],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isTrue(
|
||||
|
@ -398,6 +511,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
assert.isTrue(
|
||||
|
@ -406,16 +521,20 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '+16505551234',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true if going from "has header" to "no header"', () => {
|
||||
it('returns true if going from no search to some search (showing "Find by username" section)', () => {
|
||||
const helper = new LeftPaneComposeHelper({
|
||||
composeContacts: [getDefaultConversation(), getDefaultConversation()],
|
||||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: '',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isTrue(
|
||||
|
@ -424,6 +543,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
@ -434,6 +555,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isTrue(
|
||||
|
@ -442,6 +565,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [getDefaultConversation(), getDefaultConversation()],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -450,6 +575,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [getDefaultConversation(), getDefaultConversation()],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isTrue(
|
||||
|
@ -458,6 +585,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'foo bar',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
@ -468,6 +597,8 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeGroups: [getDefaultConversation()],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'soup',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
});
|
||||
|
||||
assert.isTrue(
|
||||
|
@ -475,7 +606,9 @@ describe('LeftPaneComposeHelper', () => {
|
|||
composeContacts: [getDefaultConversation()],
|
||||
composeGroups: [getDefaultConversation(), getDefaultConversation()],
|
||||
regionCode: 'US',
|
||||
searchTerm: 'sandwich',
|
||||
searchTerm: 'soup',
|
||||
isUsernamesEnabled: true,
|
||||
isFetchingUsername: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
78
ts/test-node/types/Username_test.ts
Normal file
78
ts/test-node/types/Username_test.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import * as Username from '../../types/Username';
|
||||
|
||||
describe('Username', () => {
|
||||
describe('getUsernameFromSearch', () => {
|
||||
const { getUsernameFromSearch } = Username;
|
||||
|
||||
it('matches invalid username searches', () => {
|
||||
assert.strictEqual(getUsernameFromSearch('username!'), 'username!');
|
||||
assert.strictEqual(getUsernameFromSearch('1username'), '1username');
|
||||
assert.strictEqual(getUsernameFromSearch('us'), 'us');
|
||||
assert.strictEqual(
|
||||
getUsernameFromSearch('username901234567890123456'),
|
||||
'username901234567890123456'
|
||||
);
|
||||
});
|
||||
|
||||
it('matches valid username searches', () => {
|
||||
assert.strictEqual(getUsernameFromSearch('username_34'), 'username_34');
|
||||
assert.strictEqual(getUsernameFromSearch('u5ername'), 'u5ername');
|
||||
assert.strictEqual(getUsernameFromSearch('use'), 'use');
|
||||
assert.strictEqual(
|
||||
getUsernameFromSearch('username90123456789012345'),
|
||||
'username90123456789012345'
|
||||
);
|
||||
});
|
||||
|
||||
it('matches valid and invalid usernames with @ prefix', () => {
|
||||
assert.strictEqual(getUsernameFromSearch('@username!'), 'username!');
|
||||
assert.strictEqual(getUsernameFromSearch('@1username'), '1username');
|
||||
assert.strictEqual(getUsernameFromSearch('@username_34'), 'username_34');
|
||||
assert.strictEqual(getUsernameFromSearch('@u5ername'), 'u5ername');
|
||||
});
|
||||
|
||||
it('matches valid and invalid usernames with @ suffix', () => {
|
||||
assert.strictEqual(getUsernameFromSearch('username!@'), 'username!');
|
||||
assert.strictEqual(getUsernameFromSearch('1username@'), '1username');
|
||||
assert.strictEqual(getUsernameFromSearch('username_34@'), 'username_34');
|
||||
assert.strictEqual(getUsernameFromSearch('u5ername@'), 'u5ername');
|
||||
});
|
||||
|
||||
it('does not match something that looks like a phone number', () => {
|
||||
assert.isUndefined(getUsernameFromSearch('+'));
|
||||
assert.isUndefined(getUsernameFromSearch('2223'));
|
||||
assert.isUndefined(getUsernameFromSearch('+3'));
|
||||
assert.isUndefined(getUsernameFromSearch('+234234234233'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidUsername', () => {
|
||||
const { isValidUsername } = Username;
|
||||
|
||||
it('does not match invalid username searches', () => {
|
||||
assert.isFalse(isValidUsername('username!'));
|
||||
assert.isFalse(isValidUsername('1username'));
|
||||
assert.isFalse(isValidUsername('us'));
|
||||
assert.isFalse(isValidUsername('username901234567890123456'));
|
||||
});
|
||||
|
||||
it('matches valid usernames', () => {
|
||||
assert.isTrue(isValidUsername('username_34'));
|
||||
assert.isTrue(isValidUsername('u5ername'));
|
||||
assert.isTrue(isValidUsername('use'));
|
||||
assert.isTrue(isValidUsername('username90123456789012345'));
|
||||
});
|
||||
|
||||
it('does not match valid and invalid usernames with @ prefix or suffix', () => {
|
||||
assert.isFalse(isValidUsername('@username_34'));
|
||||
assert.isFalse(isValidUsername('@1username'));
|
||||
assert.isFalse(isValidUsername('username_34@'));
|
||||
assert.isFalse(isValidUsername('1username@'));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2043,7 +2043,7 @@ export default class MessageSender {
|
|||
profileKeyCredentialRequest?: string;
|
||||
userLanguages: ReadonlyArray<string>;
|
||||
}>
|
||||
): Promise<ReturnType<WebAPIType['getProfile']>> {
|
||||
): ReturnType<WebAPIType['getProfile']> {
|
||||
const { accessKey } = options;
|
||||
|
||||
if (accessKey) {
|
||||
|
@ -2057,6 +2057,12 @@ export default class MessageSender {
|
|||
return this.server.getProfile(number, options);
|
||||
}
|
||||
|
||||
async getProfileForUsername(
|
||||
username: string
|
||||
): ReturnType<WebAPIType['getProfileForUsername']> {
|
||||
return this.server.getProfileForUsername(username);
|
||||
}
|
||||
|
||||
async getUuidsForE164s(
|
||||
numbers: ReadonlyArray<string>
|
||||
): Promise<Dictionary<UUIDStringType | null>> {
|
||||
|
|
|
@ -770,6 +770,7 @@ export type WebAPIType = {
|
|||
userLanguages: ReadonlyArray<string>;
|
||||
}
|
||||
) => Promise<ProfileType>;
|
||||
getProfileForUsername: (username: string) => Promise<ProfileType>;
|
||||
getProfileUnauth: (
|
||||
identifier: string,
|
||||
options: {
|
||||
|
@ -1077,6 +1078,7 @@ export function initialize({
|
|||
getKeysForIdentifierUnauth,
|
||||
getMyKeys,
|
||||
getProfile,
|
||||
getProfileForUsername,
|
||||
getProfileUnauth,
|
||||
getBadgeImageFile,
|
||||
getProvisioningResource,
|
||||
|
@ -1385,6 +1387,12 @@ export function initialize({
|
|||
})) as ProfileType;
|
||||
}
|
||||
|
||||
async function getProfileForUsername(usernameToFetch: string) {
|
||||
return getProfile(`username/${usernameToFetch}`, {
|
||||
userLanguages: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function putProfile(
|
||||
jsonData: ProfileRequestDataType
|
||||
): Promise<UploadAvatarHeadersType | undefined> {
|
||||
|
|
20
ts/types/Username.ts
Normal file
20
ts/types/Username.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function isValidUsername(searchTerm: string): boolean {
|
||||
return /^[a-z][0-9a-z_]{2,24}$/.test(searchTerm);
|
||||
}
|
||||
|
||||
export function getUsernameFromSearch(searchTerm: string): string | undefined {
|
||||
if (/^[+0-9]+$/.test(searchTerm)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = /^@?(.*?)@?$/.exec(searchTerm);
|
||||
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
|
@ -24,6 +24,10 @@ const FUSE_OPTIONS: FuseOptions<ConversationType> = {
|
|||
name: 'name',
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
name: 'e164',
|
||||
weight: 0.5,
|
||||
|
|
|
@ -115,19 +115,26 @@ Whisper.InboxView = Whisper.View.extend({
|
|||
this.conversation_stack.unload();
|
||||
});
|
||||
|
||||
window.Whisper.events.on('showConversation', async (id, messageId) => {
|
||||
const conversation =
|
||||
await window.ConversationController.getOrCreateAndWait(id, 'private');
|
||||
window.Whisper.events.on(
|
||||
'showConversation',
|
||||
async (id, messageId, username) => {
|
||||
const conversation =
|
||||
await window.ConversationController.getOrCreateAndWait(
|
||||
id,
|
||||
'private',
|
||||
{ username }
|
||||
);
|
||||
|
||||
conversation.setMarkedUnread(false);
|
||||
conversation.setMarkedUnread(false);
|
||||
|
||||
const { openConversationExternal } = window.reduxActions.conversations;
|
||||
if (openConversationExternal) {
|
||||
openConversationExternal(conversation.id, messageId);
|
||||
const { openConversationExternal } = window.reduxActions.conversations;
|
||||
if (openConversationExternal) {
|
||||
openConversationExternal(conversation.id, messageId);
|
||||
}
|
||||
|
||||
this.conversation_stack.open(conversation, messageId);
|
||||
}
|
||||
|
||||
this.conversation_stack.open(conversation, messageId);
|
||||
});
|
||||
);
|
||||
|
||||
window.Whisper.events.on('loadingProgress', count => {
|
||||
const view = this.appLoadingScreen;
|
||||
|
|
Loading…
Reference in a new issue