Add "new conversation" composer for direct messages

This commit is contained in:
Evan Hahn 2021-02-23 14:34:28 -06:00 committed by Josh Perez
parent 84dc166b63
commit 06fb4fd0bc
61 changed files with 5960 additions and 3887 deletions

View file

@ -0,0 +1,113 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild } from 'react';
import { last } from 'lodash';
import { LeftPaneHelper, ToFindType } from './LeftPaneHelper';
import { getConversationInDirection } from './getConversationInDirection';
import { Row, RowType } from '../ConversationList';
import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
import { LocalizerType } from '../../types/Util';
export type LeftPaneArchivePropsType = {
archivedConversations: ReadonlyArray<ConversationListItemPropsType>;
};
/* eslint-disable class-methods-use-this */
export class LeftPaneArchiveHelper extends LeftPaneHelper<
LeftPaneArchivePropsType
> {
private readonly archivedConversations: ReadonlyArray<
ConversationListItemPropsType
>;
constructor({ archivedConversations }: Readonly<LeftPaneArchivePropsType>) {
super();
this.archivedConversations = archivedConversations;
}
getHeaderContents({
i18n,
showInbox,
}: Readonly<{
i18n: LocalizerType;
showInbox: () => void;
}>): ReactChild {
return (
<div className="module-left-pane__header__contents">
<button
onClick={showInbox}
className="module-left-pane__header__contents__back-button"
title={i18n('backToInbox')}
aria-label={i18n('backToInbox')}
type="button"
/>
<div className="module-left-pane__header__contents__text">
{i18n('archivedConversations')}
</div>
</div>
);
}
getPreRowsNode({ i18n }: Readonly<{ i18n: LocalizerType }>): ReactChild {
return (
<div className="module-left-pane__archive-helper-text">
{i18n('archiveHelperText')}
</div>
);
}
getRowCount(): number {
return this.archivedConversations.length;
}
getRow(rowIndex: number): undefined | Row {
const conversation = this.archivedConversations[rowIndex];
return conversation
? {
type: RowType.Conversation,
conversation,
}
: undefined;
}
getRowIndexToScrollTo(
selectedConversationId: undefined | string
): undefined | number {
if (!selectedConversationId) {
return undefined;
}
const result = this.archivedConversations.findIndex(
conversation => conversation.id === selectedConversationId
);
return result === -1 ? undefined : result;
}
getConversationAndMessageAtIndex(
conversationIndex: number
): undefined | { conversationId: string } {
const { archivedConversations } = this;
const conversation =
archivedConversations[conversationIndex] || last(archivedConversations);
return conversation ? { conversationId: conversation.id } : undefined;
}
getConversationAndMessageInDirection(
toFind: Readonly<ToFindType>,
selectedConversationId: undefined | string,
_selectedMessageId: unknown
): undefined | { conversationId: string } {
return getConversationInDirection(
this.archivedConversations,
toFind,
selectedConversationId
);
}
shouldRecomputeRowHeights(_old: unknown): boolean {
return false;
}
}

View file

@ -0,0 +1,171 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild, ChangeEvent } from 'react';
import { PhoneNumber } from 'google-libphonenumber';
import { LeftPaneHelper } from './LeftPaneHelper';
import { Row, RowType } from '../ConversationList';
import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem';
import { LocalizerType } from '../../types/Util';
import {
instance as phoneNumberInstance,
PhoneNumberFormat,
} from '../../util/libphonenumberInstance';
export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray<ContactListItemPropsType>;
regionCode: string;
searchTerm: string;
};
/* eslint-disable class-methods-use-this */
export class LeftPaneComposeHelper extends LeftPaneHelper<
LeftPaneComposePropsType
> {
private readonly composeContacts: ReadonlyArray<ContactListItemPropsType>;
private readonly searchTerm: string;
private readonly phoneNumber: undefined | PhoneNumber;
constructor({
composeContacts,
regionCode,
searchTerm,
}: Readonly<LeftPaneComposePropsType>) {
super();
this.composeContacts = composeContacts;
this.searchTerm = searchTerm;
this.phoneNumber = parsePhoneNumber(searchTerm, regionCode);
}
getHeaderContents({
i18n,
showInbox,
}: Readonly<{
i18n: LocalizerType;
showInbox: () => void;
}>): ReactChild {
return (
<div className="module-left-pane__header__contents">
<button
onClick={showInbox}
className="module-left-pane__header__contents__back-button"
title={i18n('backToInbox')}
aria-label={i18n('backToInbox')}
type="button"
/>
<div className="module-left-pane__header__contents__text">
{i18n('newConversation')}
</div>
</div>
);
}
getPreRowsNode({
i18n,
onChangeComposeSearchTerm,
}: Readonly<{
i18n: LocalizerType;
onChangeComposeSearchTerm: (
event: ChangeEvent<HTMLInputElement>
) => unknown;
}>): ReactChild {
return (
<>
<div className="module-left-pane__compose-search-form">
<input
type="text"
ref={focusRef}
className="module-left-pane__compose-search-form__input"
placeholder={i18n('newConversationContactSearchPlaceholder')}
dir="auto"
value={this.searchTerm}
onChange={onChangeComposeSearchTerm}
/>
</div>
{this.getRowCount() ? null : (
<div className="module-left-pane__compose-no-contacts">
{i18n('newConversationNoContacts')}
</div>
)}
</>
);
}
getRowCount(): number {
return this.composeContacts.length + (this.phoneNumber ? 1 : 0);
}
getRow(rowIndex: number): undefined | Row {
let contactIndex = rowIndex;
if (this.phoneNumber) {
if (rowIndex === 0) {
return {
type: RowType.StartNewConversation,
phoneNumber: phoneNumberInstance.format(
this.phoneNumber,
PhoneNumberFormat.E164
),
};
}
contactIndex -= 1;
}
const contact = this.composeContacts[contactIndex];
return contact
? {
type: RowType.Contact,
contact,
}
: undefined;
}
// This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
// the composer. The same is true for the "in direction" function below.
getConversationAndMessageAtIndex(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
getConversationAndMessageInDirection(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
shouldRecomputeRowHeights(_old: unknown): boolean {
return false;
}
}
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
function parsePhoneNumber(
str: string,
regionCode: string
): undefined | PhoneNumber {
let result: PhoneNumber;
try {
result = phoneNumberInstance.parse(str, regionCode);
} catch (err) {
return undefined;
}
if (!phoneNumberInstance.isValidNumber(result)) {
return undefined;
}
return result;
}

View file

@ -0,0 +1,67 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ChangeEvent, ReactChild } from 'react';
import { Row } from '../ConversationList';
import { LocalizerType } from '../../types/Util';
export enum FindDirection {
Up,
Down,
}
export type ToFindType = {
direction: FindDirection;
unreadOnly: boolean;
};
/* eslint-disable class-methods-use-this */
export abstract class LeftPaneHelper<T> {
getHeaderContents(
_: Readonly<{
i18n: LocalizerType;
showInbox: () => void;
}>
): null | ReactChild {
return null;
}
shouldRenderNetworkStatusAndUpdateDialog(): boolean {
return false;
}
getPreRowsNode(
_: Readonly<{
i18n: LocalizerType;
onChangeComposeSearchTerm: (
event: ChangeEvent<HTMLInputElement>
) => unknown;
}>
): null | ReactChild {
return null;
}
abstract getRowCount(): number;
abstract getRow(rowIndex: number): undefined | Row;
getRowIndexToScrollTo(
_selectedConversationId: undefined | string
): undefined | number {
return undefined;
}
abstract getConversationAndMessageAtIndex(
conversationIndex: number
): undefined | { conversationId: string; messageId?: string };
abstract getConversationAndMessageInDirection(
toFind: Readonly<ToFindType>,
selectedConversationId: undefined | string,
selectedMessageId: undefined | string
): undefined | { conversationId: string; messageId?: string };
abstract shouldRecomputeRowHeights(old: Readonly<T>): boolean;
}

View file

@ -0,0 +1,192 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { last } from 'lodash';
import { LeftPaneHelper, ToFindType } from './LeftPaneHelper';
import { getConversationInDirection } from './getConversationInDirection';
import { Row, RowType } from '../ConversationList';
import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
export type LeftPaneInboxPropsType = {
conversations: ReadonlyArray<ConversationListItemPropsType>;
archivedConversations: ReadonlyArray<ConversationListItemPropsType>;
pinnedConversations: ReadonlyArray<ConversationListItemPropsType>;
};
/* eslint-disable class-methods-use-this */
export class LeftPaneInboxHelper extends LeftPaneHelper<
LeftPaneInboxPropsType
> {
private readonly conversations: ReadonlyArray<ConversationListItemPropsType>;
private readonly archivedConversations: ReadonlyArray<
ConversationListItemPropsType
>;
private readonly pinnedConversations: ReadonlyArray<
ConversationListItemPropsType
>;
constructor({
conversations,
archivedConversations,
pinnedConversations,
}: Readonly<LeftPaneInboxPropsType>) {
super();
this.conversations = conversations;
this.archivedConversations = archivedConversations;
this.pinnedConversations = pinnedConversations;
}
shouldRenderNetworkStatusAndUpdateDialog(): boolean {
return true;
}
getRowCount(): number {
const headerCount = this.hasPinnedAndNonpinned() ? 2 : 0;
const buttonCount = this.archivedConversations.length ? 1 : 0;
return (
headerCount +
this.pinnedConversations.length +
this.conversations.length +
buttonCount
);
}
getRow(rowIndex: number): undefined | Row {
const { conversations, archivedConversations, pinnedConversations } = this;
const archivedConversationsCount = archivedConversations.length;
if (this.hasPinnedAndNonpinned()) {
switch (rowIndex) {
case 0:
return {
type: RowType.Header,
i18nKey: 'LeftPane--pinned',
};
case pinnedConversations.length + 1:
return {
type: RowType.Header,
i18nKey: 'LeftPane--chats',
};
case pinnedConversations.length + conversations.length + 2:
if (archivedConversationsCount) {
return {
type: RowType.ArchiveButton,
archivedConversationsCount,
};
}
return undefined;
default: {
const pinnedConversation = pinnedConversations[rowIndex - 1];
if (pinnedConversation) {
return {
type: RowType.Conversation,
conversation: pinnedConversation,
};
}
const conversation =
conversations[rowIndex - pinnedConversations.length - 2];
return conversation
? {
type: RowType.Conversation,
conversation,
}
: undefined;
}
}
}
const onlyConversations = pinnedConversations.length
? pinnedConversations
: conversations;
if (rowIndex < onlyConversations.length) {
const conversation = onlyConversations[rowIndex];
return conversation
? {
type: RowType.Conversation,
conversation,
}
: undefined;
}
if (rowIndex === onlyConversations.length && archivedConversationsCount) {
return {
type: RowType.ArchiveButton,
archivedConversationsCount,
};
}
return undefined;
}
getRowIndexToScrollTo(
selectedConversationId: undefined | string
): undefined | number {
if (!selectedConversationId) {
return undefined;
}
const isConversationSelected = (
conversation: Readonly<ConversationListItemPropsType>
) => conversation.id === selectedConversationId;
const hasHeaders = this.hasPinnedAndNonpinned();
const pinnedConversationIndex = this.pinnedConversations.findIndex(
isConversationSelected
);
if (pinnedConversationIndex !== -1) {
const headerOffset = hasHeaders ? 1 : 0;
return pinnedConversationIndex + headerOffset;
}
const conversationIndex = this.conversations.findIndex(
isConversationSelected
);
if (conversationIndex !== -1) {
const pinnedOffset = this.pinnedConversations.length;
const headerOffset = hasHeaders ? 2 : 0;
return conversationIndex + pinnedOffset + headerOffset;
}
return undefined;
}
shouldRecomputeRowHeights(old: Readonly<LeftPaneInboxPropsType>): boolean {
return old.pinnedConversations.length !== this.pinnedConversations.length;
}
getConversationAndMessageAtIndex(
conversationIndex: number
): undefined | { conversationId: string } {
const { conversations, pinnedConversations } = this;
const conversation =
pinnedConversations[conversationIndex] ||
conversations[conversationIndex - pinnedConversations.length] ||
last(conversations) ||
last(pinnedConversations);
return conversation ? { conversationId: conversation.id } : undefined;
}
getConversationAndMessageInDirection(
toFind: Readonly<ToFindType>,
selectedConversationId: undefined | string,
_selectedMessageId: unknown
): undefined | { conversationId: string } {
return getConversationInDirection(
[...this.pinnedConversations, ...this.conversations],
toFind,
selectedConversationId
);
}
private hasPinnedAndNonpinned(): boolean {
return Boolean(
this.pinnedConversations.length && this.conversations.length
);
}
}

View file

@ -0,0 +1,240 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild } from 'react';
import { LeftPaneHelper, ToFindType } from './LeftPaneHelper';
import { LocalizerType } from '../../types/Util';
import { Row, RowType } from '../ConversationList';
import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
import { Intl } from '../Intl';
import { Emojify } from '../conversation/Emojify';
type MaybeLoadedSearchResultsType<T> =
| { isLoading: true }
| { isLoading: false; results: Array<T> };
export type LeftPaneSearchPropsType = {
conversationResults: MaybeLoadedSearchResultsType<
ConversationListItemPropsType
>;
contactResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
messageResults: MaybeLoadedSearchResultsType<{
id: string;
conversationId: string;
}>;
searchConversationName?: string;
searchTerm: string;
};
const searchResultKeys: Array<
'conversationResults' | 'contactResults' | 'messageResults'
> = ['conversationResults', 'contactResults', 'messageResults'];
export class LeftPaneSearchHelper extends LeftPaneHelper<
LeftPaneSearchPropsType
> {
private readonly conversationResults: MaybeLoadedSearchResultsType<
ConversationListItemPropsType
>;
private readonly contactResults: MaybeLoadedSearchResultsType<
ConversationListItemPropsType
>;
private readonly messageResults: MaybeLoadedSearchResultsType<{
id: string;
conversationId: string;
}>;
private readonly searchConversationName?: string;
private readonly searchTerm: string;
constructor({
conversationResults,
contactResults,
messageResults,
searchConversationName,
searchTerm,
}: Readonly<LeftPaneSearchPropsType>) {
super();
this.conversationResults = conversationResults;
this.contactResults = contactResults;
this.messageResults = messageResults;
this.searchConversationName = searchConversationName;
this.searchTerm = searchTerm;
}
getPreRowsNode({
i18n,
}: Readonly<{ i18n: LocalizerType }>): null | ReactChild {
const mightHaveSearchResults = this.allResults().some(
searchResult => searchResult.isLoading || searchResult.results.length
);
if (mightHaveSearchResults) {
return null;
}
const { searchConversationName, searchTerm } = this;
return !searchConversationName || searchTerm ? (
<div
// We need this for Ctrl-T shortcut cycling through parts of app
tabIndex={-1}
className="module-left-pane__no-search-results"
key={searchTerm}
>
{searchConversationName ? (
<Intl
id="noSearchResultsInConversation"
i18n={i18n}
components={{
searchTerm,
conversationName: (
<Emojify key="item-1" text={searchConversationName} />
),
}}
/>
) : (
i18n('noSearchResults', [searchTerm])
)}
</div>
) : null;
}
getRowCount(): number {
return this.allResults().reduce(
(result: number, searchResults) =>
result + getRowCountForSearchResult(searchResults),
0
);
}
// This is currently unimplemented. See DESKTOP-1170.
// eslint-disable-next-line class-methods-use-this
getRowIndexToScrollTo(
_selectedConversationId: undefined | string
): undefined | number {
return undefined;
}
getRow(rowIndex: number): undefined | Row {
const { conversationResults, contactResults, messageResults } = this;
const conversationRowCount = getRowCountForSearchResult(
conversationResults
);
const contactRowCount = getRowCountForSearchResult(contactResults);
const messageRowCount = getRowCountForSearchResult(messageResults);
if (rowIndex < conversationRowCount) {
if (rowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'conversationsHeader',
};
}
if (conversationResults.isLoading) {
return { type: RowType.Spinner };
}
const conversation = conversationResults.results[rowIndex - 1];
return conversation
? {
type: RowType.Conversation,
conversation,
}
: undefined;
}
if (rowIndex < conversationRowCount + contactRowCount) {
const localIndex = rowIndex - conversationRowCount;
if (localIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
if (contactResults.isLoading) {
return { type: RowType.Spinner };
}
const conversation = contactResults.results[localIndex - 1];
return conversation
? {
type: RowType.Conversation,
conversation,
}
: undefined;
}
if (rowIndex >= conversationRowCount + contactRowCount + messageRowCount) {
return undefined;
}
const localIndex = rowIndex - conversationRowCount - contactRowCount;
if (localIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'messagesHeader',
};
}
if (messageResults.isLoading) {
return { type: RowType.Spinner };
}
const message = messageResults.results[localIndex - 1];
return message
? {
type: RowType.MessageSearchResult,
messageId: message.id,
}
: undefined;
}
shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean {
return searchResultKeys.some(
key =>
getRowCountForSearchResult(old[key]) !==
getRowCountForSearchResult(this[key])
);
}
// This is currently unimplemented. See DESKTOP-1170.
// eslint-disable-next-line class-methods-use-this
getConversationAndMessageAtIndex(
_conversationIndex: number
): undefined | { conversationId: string; messageId?: string } {
return undefined;
}
// This is currently unimplemented. See DESKTOP-1170.
// eslint-disable-next-line class-methods-use-this
getConversationAndMessageInDirection(
_toFind: Readonly<ToFindType>,
_selectedConversationId: undefined | string,
_selectedMessageId: unknown
): undefined | { conversationId: string } {
return undefined;
}
private allResults() {
return [this.conversationResults, this.contactResults, this.messageResults];
}
}
function getRowCountForSearchResult(
searchResults: Readonly<MaybeLoadedSearchResultsType<unknown>>
): number {
let hasHeader: boolean;
let resultRows: number;
if (searchResults.isLoading) {
hasHeader = true;
resultRows = 1; // For the spinner.
} else {
const resultCount = searchResults.results.length;
hasHeader = Boolean(resultCount);
resultRows = resultCount;
}
return (hasHeader ? 1 : 0) + resultRows;
}

View file

@ -0,0 +1,63 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { find as findFirst, findLast, first, last } from 'lodash';
import { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
import { isConversationUnread } from '../../util/isConversationUnread';
import { FindDirection, ToFindType } from './LeftPaneHelper';
/**
* This will look up or down in an array of conversations for the next one to select.
* Refer to the tests for the intended behavior.
*/
export const getConversationInDirection = (
conversations: ReadonlyArray<ConversationListItemPropsType>,
toFind: Readonly<ToFindType>,
selectedConversationId: undefined | string
): undefined | { conversationId: string } => {
// As an optimization, we don't need to search if no conversation is selected.
const selectedConversationIndex = selectedConversationId
? conversations.findIndex(({ id }) => id === selectedConversationId)
: -1;
let conversation: ConversationListItemPropsType | undefined;
if (selectedConversationIndex < 0) {
if (toFind.unreadOnly) {
conversation =
toFind.direction === FindDirection.Up
? findLast(conversations, isConversationUnread)
: findFirst(conversations, isConversationUnread);
} else {
conversation =
toFind.direction === FindDirection.Up
? last(conversations)
: first(conversations);
}
} else if (toFind.unreadOnly) {
conversation =
toFind.direction === FindDirection.Up
? findLast(
conversations.slice(0, selectedConversationIndex),
isConversationUnread
)
: findFirst(
conversations.slice(selectedConversationIndex + 1),
isConversationUnread
);
} else {
const newIndex =
selectedConversationIndex +
(toFind.direction === FindDirection.Up ? -1 : 1);
if (newIndex < 0) {
conversation = last(conversations);
} else if (newIndex >= conversations.length) {
conversation = first(conversations);
} else {
conversation = conversations[newIndex];
}
}
return conversation ? { conversationId: conversation.id } : undefined;
};