+
+ {helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()}
- );
- };
-
- public renderArchivedHeader = (): JSX.Element => {
- const { i18n, showInbox } = this.props;
-
- return (
-
-
-
- {i18n('archivedConversations')}
-
-
- );
- };
-
- public render(): JSX.Element {
- const {
- i18n,
- renderExpiredBuildDialog,
- renderMainHeader,
- renderNetworkStatus,
- renderRelinkDialog,
- renderUpdateDialog,
- showArchived,
- } = this.props;
-
- // Relying on 3rd party code for contentRect.bounds
- /* eslint-disable @typescript-eslint/no-non-null-assertion */
- return (
-
-
- {showArchived ? this.renderArchivedHeader() : renderMainHeader()}
-
- {renderExpiredBuildDialog()}
- {renderRelinkDialog()}
- {renderNetworkStatus()}
- {renderUpdateDialog()}
- {showArchived && (
-
- {i18n('archiveHelperText')}
-
- )}
-
- {({ contentRect, measureRef }: MeasuredComponentProps) => (
-
-
- {this.renderList(contentRect.bounds!)}
+ {renderExpiredBuildDialog()}
+ {renderRelinkDialog()}
+ {helper.shouldRenderNetworkStatusAndUpdateDialog() && (
+ <>
+ {renderNetworkStatus()}
+ {renderUpdateDialog()}
+ >
+ )}
+ {preRowsNode &&
{preRowsNode}}
+
+ {({ contentRect, measureRef }: MeasuredComponentProps) => (
+
+
+
+ {
+ openConversationInternal({
+ conversationId,
+ messageId,
+ switchToAssociatedView: true,
+ });
+ }}
+ renderMessageSearchResult={renderMessageSearchResult}
+ rowCount={helper.getRowCount()}
+ scrollToRowIndex={helper.getRowIndexToScrollTo(
+ selectedConversationId
+ )}
+ shouldRecomputeRowHeights={shouldRecomputeRowHeights}
+ startNewConversationFromPhoneNumber={
+ startNewConversationFromPhoneNumber
+ }
+ />
- )}
-
-
- );
- }
-
- componentDidUpdate(oldProps: PropsType): void {
- const {
- conversations: oldConversations = [],
- pinnedConversations: oldPinnedConversations = [],
- archivedConversations: oldArchivedConversations = [],
- showArchived: oldShowArchived,
- } = oldProps;
- const {
- conversations: newConversations = [],
- pinnedConversations: newPinnedConversations = [],
- archivedConversations: newArchivedConversations = [],
- showArchived: newShowArchived,
- } = this.props;
-
- const oldHasArchivedConversations = Boolean(
- oldArchivedConversations.length
- );
- const newHasArchivedConversations = Boolean(
- newArchivedConversations.length
- );
-
- // This could probably be optimized further, but we want to be extra-careful that our
- // heights are correct.
- if (
- oldConversations.length !== newConversations.length ||
- oldPinnedConversations.length !== newPinnedConversations.length ||
- oldHasArchivedConversations !== newHasArchivedConversations ||
- oldShowArchived !== newShowArchived
- ) {
- this.recomputeRowHeights();
- }
+
+ )}
+
+
+ );
+};
+
+function keyboardKeyToNumericIndex(key: string): undefined | number {
+ if (key.length !== 1) {
+ return undefined;
}
+ const result = parseInt(key, 10) - 1;
+ const isValidIndex = Number.isInteger(result) && result >= 0 && result <= 8;
+ return isValidIndex ? result : undefined;
}
diff --git a/ts/components/MainHeader.stories.tsx b/ts/components/MainHeader.stories.tsx
index 42bae1449..22f8d5d0d 100644
--- a/ts/components/MainHeader.stories.tsx
+++ b/ts/components/MainHeader.stories.tsx
@@ -57,6 +57,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({
clearSearch: action('clearSearch'),
showArchivedConversations: action('showArchivedConversations'),
+ startComposing: action('startComposing'),
});
story.add('Basic', () => {
diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx
index e0c719855..f88550dd0 100644
--- a/ts/components/MainHeader.tsx
+++ b/ts/components/MainHeader.tsx
@@ -62,6 +62,7 @@ export type PropsType = {
clearSearch: () => void;
showArchivedConversations: () => void;
+ startComposing: () => void;
};
type StateType = {
@@ -340,6 +341,7 @@ export class MainHeader extends React.Component {
color,
i18n,
name,
+ startComposing,
phoneNumber,
profileName,
title,
@@ -354,6 +356,10 @@ export class MainHeader extends React.Component {
? i18n('searchIn', [searchConversationName])
: i18n('search');
+ const isSearching = Boolean(
+ searchConversationId || searchTerm.trim().length
+ );
+
return (
@@ -456,6 +462,15 @@ export class MainHeader extends React.Component {
/>
) : null}
+ {!isSearching && (
+
+ )}
);
}
diff --git a/ts/components/MessageSearchResult.tsx b/ts/components/MessageSearchResult.tsx
deleted file mode 100644
index 800dc2196..000000000
--- a/ts/components/MessageSearchResult.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-// Copyright 2019-2020 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-import React from 'react';
-import classNames from 'classnames';
-
-import { Avatar } from './Avatar';
-import { MessageBodyHighlight } from './MessageBodyHighlight';
-import { Timestamp } from './conversation/Timestamp';
-import { ContactName } from './conversation/ContactName';
-
-import { LocalizerType } from '../types/Util';
-import { ColorType } from '../types/Colors';
-
-export type PropsDataType = {
- isSelected?: boolean;
- isSearchingInConversation?: boolean;
-
- id: string;
- conversationId: string;
- sentAt?: number;
-
- snippet: string;
-
- from: {
- phoneNumber?: string;
- title: string;
- isMe?: boolean;
- name?: string;
- color?: ColorType;
- profileName?: string;
- avatarPath?: string;
- };
-
- to: {
- groupName?: string;
- phoneNumber?: string;
- title: string;
- isMe?: boolean;
- name?: string;
- profileName?: string;
- };
-};
-
-type PropsHousekeepingType = {
- i18n: LocalizerType;
- openConversationInternal: (
- conversationId: string,
- messageId?: string
- ) => void;
-};
-
-export type PropsType = PropsDataType & PropsHousekeepingType;
-
-export class MessageSearchResult extends React.PureComponent
{
- public renderFromName(): JSX.Element {
- const { from, i18n, to } = this.props;
-
- if (from.isMe && to.isMe) {
- return (
-
- {i18n('noteToSelf')}
-
- );
- }
- if (from.isMe) {
- return (
-
- {i18n('you')}
-
- );
- }
-
- return (
-
- );
- }
-
- public renderFrom(): JSX.Element {
- const { i18n, to, isSearchingInConversation } = this.props;
- const fromName = this.renderFromName();
-
- if (!to.isMe && !isSearchingInConversation) {
- return (
-
- {fromName} {i18n('toJoiner')}{' '}
-
-
-
-
- );
- }
-
- return (
-
- {fromName}
-
- );
- }
-
- public renderAvatar(): JSX.Element {
- const { from, i18n, to } = this.props;
- const isNoteToSelf = from.isMe && to.isMe;
-
- return (
-
- );
- }
-
- public render(): JSX.Element | null {
- const {
- from,
- i18n,
- id,
- isSelected,
- conversationId,
- openConversationInternal,
- sentAt,
- snippet,
- to,
- } = this.props;
-
- if (!from || !to) {
- return null;
- }
-
- return (
-
- );
- }
-}
diff --git a/ts/components/SearchResults.stories.tsx b/ts/components/SearchResults.stories.tsx
deleted file mode 100644
index cfb9b972a..000000000
--- a/ts/components/SearchResults.stories.tsx
+++ /dev/null
@@ -1,423 +0,0 @@
-// Copyright 2020 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-import * as React from 'react';
-import { storiesOf } from '@storybook/react';
-import { action } from '@storybook/addon-actions';
-
-import { SearchResults } from './SearchResults';
-import {
- MessageSearchResult,
- PropsDataType as MessageSearchResultPropsType,
-} from './MessageSearchResult';
-import { setup as setupI18n } from '../../js/modules/i18n';
-import enMessages from '../../_locales/en/messages.json';
-import {
- gifUrl,
- landscapeGreenUrl,
- landscapePurpleUrl,
- pngUrl,
-} from '../storybook/Fixtures';
-
-const i18n = setupI18n('en', enMessages);
-
-const messageLookup: Map = new Map();
-
-const CONTACT = 'contact' as const;
-const CONTACTS_HEADER = 'contacts-header' as const;
-const CONVERSATION = 'conversation' as const;
-const CONVERSATIONS_HEADER = 'conversations-header' as const;
-const DIRECT = 'direct' as const;
-const GROUP = 'group' as const;
-const MESSAGE = 'message' as const;
-const MESSAGES_HEADER = 'messages-header' as const;
-const SENT = 'sent' as const;
-const START_NEW_CONVERSATION = 'start-new-conversation' as const;
-const SMS_MMS_NOT_SUPPORTED = 'sms-mms-not-supported-text' as const;
-
-messageLookup.set('1-guid-guid-guid-guid-guid', {
- id: '1-guid-guid-guid-guid-guid',
- conversationId: '(202) 555-0015',
- sentAt: Date.now() - 5 * 60 * 1000,
- snippet: '<>Everyone<>! Get in!',
-
- from: {
- phoneNumber: '(202) 555-0020',
- title: '(202) 555-0020',
- isMe: true,
- color: 'blue',
- avatarPath: gifUrl,
- },
- to: {
- phoneNumber: '(202) 555-0015',
- title: 'Mr. Fire 🔥',
- name: 'Mr. Fire 🔥',
- },
-});
-
-messageLookup.set('2-guid-guid-guid-guid-guid', {
- id: '2-guid-guid-guid-guid-guid',
- conversationId: '(202) 555-0016',
- sentAt: Date.now() - 20 * 60 * 1000,
- snippet: 'Why is <>everyone<> so frustrated?',
- from: {
- phoneNumber: '(202) 555-0016',
- name: 'Jon ❄️',
- title: 'Jon ❄️',
- color: 'green',
- },
- to: {
- phoneNumber: '(202) 555-0020',
- title: '(202) 555-0020',
- isMe: true,
- },
-});
-
-messageLookup.set('3-guid-guid-guid-guid-guid', {
- id: '3-guid-guid-guid-guid-guid',
- conversationId: 'EveryoneGroupID',
- sentAt: Date.now() - 24 * 60 * 1000,
- snippet: 'Hello, <>everyone<>! Woohooo!',
- from: {
- phoneNumber: '(202) 555-0011',
- name: 'Someone',
- title: 'Someone',
- color: 'green',
- avatarPath: pngUrl,
- },
- to: {
- phoneNumber: '(202) 555-0016',
- name: "Y'all 🌆",
- title: "Y'all 🌆",
- },
-});
-
-messageLookup.set('4-guid-guid-guid-guid-guid', {
- id: '4-guid-guid-guid-guid-guid',
- conversationId: 'EveryoneGroupID',
- sentAt: Date.now() - 24 * 60 * 1000,
- snippet: 'Well, <>everyone<>, happy new year!',
- from: {
- phoneNumber: '(202) 555-0020',
- title: '(202) 555-0020',
- isMe: true,
- color: 'light_green',
- avatarPath: gifUrl,
- },
- to: {
- phoneNumber: '(202) 555-0016',
- name: "Y'all 🌆",
- title: "Y'all 🌆",
- },
-});
-
-const defaultProps = {
- discussionsLoading: false,
- height: 700,
- items: [],
- i18n,
- messagesLoading: false,
- noResults: false,
- openConversationInternal: action('open-conversation-internal'),
- regionCode: 'US',
- renderMessageSearchResult(id: string): JSX.Element {
- const messageProps = messageLookup.get(id) as MessageSearchResultPropsType;
-
- return (
-
- );
- },
- searchConversationName: undefined,
- searchTerm: '1234567890',
- selectedConversationId: undefined,
- selectedMessageId: undefined,
- startNewConversation: action('start-new-conversation'),
- width: 320,
-};
-
-const conversations = [
- {
- type: CONVERSATION,
- data: {
- id: '+12025550011',
- phoneNumber: '(202) 555-0011',
- name: 'Everyone 🌆',
- title: 'Everyone 🌆',
- type: GROUP,
- color: 'signal-blue' as const,
- avatarPath: landscapeGreenUrl,
- isMe: false,
- lastUpdated: Date.now() - 5 * 60 * 1000,
- unreadCount: 0,
- isSelected: false,
- lastMessage: {
- text: 'The rabbit hopped silently in the night.',
- status: SENT,
- },
- markedUnread: false,
- },
- },
- {
- type: CONVERSATION,
- data: {
- id: '+12025550012',
- phoneNumber: '(202) 555-0012',
- name: 'Everyone Else 🔥',
- title: 'Everyone Else 🔥',
- color: 'pink' as const,
- type: DIRECT,
- avatarPath: landscapePurpleUrl,
- isMe: false,
- lastUpdated: Date.now() - 5 * 60 * 1000,
- unreadCount: 0,
- isSelected: false,
- lastMessage: {
- text: "What's going on?",
- status: SENT,
- },
- markedUnread: false,
- },
- },
-];
-
-const contacts = [
- {
- type: CONTACT,
- data: {
- id: '+12025550013',
- phoneNumber: '(202) 555-0013',
- name: 'The one Everyone',
- title: 'The one Everyone',
- color: 'blue' as const,
- type: DIRECT,
- avatarPath: gifUrl,
- isMe: false,
- lastUpdated: Date.now() - 10 * 60 * 1000,
- unreadCount: 0,
- isSelected: false,
- markedUnread: false,
- },
- },
- {
- type: CONTACT,
- data: {
- id: '+12025550014',
- phoneNumber: '(202) 555-0014',
- name: 'No likey everyone',
- title: 'No likey everyone',
- type: DIRECT,
- color: 'red' as const,
- isMe: false,
- lastUpdated: Date.now() - 11 * 60 * 1000,
- unreadCount: 0,
- isSelected: false,
- markedUnread: false,
- },
- },
-];
-
-const messages = [
- {
- type: MESSAGE,
- data: '1-guid-guid-guid-guid-guid',
- },
- {
- type: MESSAGE,
- data: '2-guid-guid-guid-guid-guid',
- },
- {
- type: MESSAGE,
- data: '3-guid-guid-guid-guid-guid',
- },
- {
- type: MESSAGE,
- data: '4-guid-guid-guid-guid-guid',
- },
-];
-
-const messagesMany = Array.from(Array(100), (_, i) => messages[i % 4]);
-
-const permutations = [
- {
- title: 'SMS/MMS Not Supported Text',
- props: {
- items: [
- {
- type: START_NEW_CONVERSATION,
- data: undefined,
- },
- {
- type: SMS_MMS_NOT_SUPPORTED,
- data: undefined,
- },
- ],
- },
- },
- {
- title: 'All Result Types',
- props: {
- items: [
- {
- type: CONVERSATIONS_HEADER,
- data: undefined,
- },
- ...conversations,
- {
- type: CONTACTS_HEADER,
- data: undefined,
- },
- ...contacts,
- {
- type: MESSAGES_HEADER,
- data: undefined,
- },
- ...messages,
- ],
- },
- },
- {
- title: 'Start new Conversation',
- props: {
- items: [
- {
- type: START_NEW_CONVERSATION,
- data: undefined,
- },
- {
- type: CONVERSATIONS_HEADER,
- data: undefined,
- },
- ...conversations,
- {
- type: CONTACTS_HEADER,
- data: undefined,
- },
- ...contacts,
- {
- type: MESSAGES_HEADER,
- data: undefined,
- },
- ...messages,
- ],
- },
- },
- {
- title: 'No Conversations',
- props: {
- items: [
- {
- type: CONTACTS_HEADER,
- data: undefined,
- },
- ...contacts,
- {
- type: MESSAGES_HEADER,
- data: undefined,
- },
- ...messages,
- ],
- },
- },
- {
- title: 'No Contacts',
- props: {
- items: [
- {
- type: CONVERSATIONS_HEADER,
- data: undefined,
- },
- ...conversations,
- {
- type: MESSAGES_HEADER,
- data: undefined,
- },
- ...messages,
- ],
- },
- },
- {
- title: 'No Messages',
- props: {
- items: [
- {
- type: CONVERSATIONS_HEADER,
- data: undefined,
- },
- ...conversations,
- {
- type: CONTACTS_HEADER,
- data: undefined,
- },
- ...contacts,
- ],
- },
- },
- {
- title: 'No Results',
- props: {
- noResults: true,
- },
- },
- {
- title: 'No Results, Searching in Conversation',
- props: {
- noResults: true,
- searchInConversationName: 'Everyone 🔥',
- searchTerm: 'something',
- },
- },
- {
- title: 'Searching in Conversation no search term',
- props: {
- noResults: true,
- searchInConversationName: 'Everyone 🔥',
- searchTerm: '',
- },
- },
- {
- title: 'Lots of results',
- props: {
- items: [
- {
- type: CONVERSATIONS_HEADER,
- data: undefined,
- },
- ...conversations,
- {
- type: CONTACTS_HEADER,
- data: undefined,
- },
- ...contacts,
- {
- type: MESSAGES_HEADER,
- data: undefined,
- },
- ...messagesMany,
- ],
- },
- },
- {
- title: 'Messages, no header',
- props: {
- items: messages,
- },
- },
-];
-
-storiesOf('Components/SearchResults', module).add('Iterations', () => {
- return permutations.map(({ props, title }) => (
- <>
- {title}
-
-
-
-
- >
- ));
-});
diff --git a/ts/components/SearchResults.tsx b/ts/components/SearchResults.tsx
deleted file mode 100644
index 135e7a2c0..000000000
--- a/ts/components/SearchResults.tsx
+++ /dev/null
@@ -1,611 +0,0 @@
-// Copyright 2019-2020 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-import React, { CSSProperties } from 'react';
-import { CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
-import { debounce, get, isNumber } from 'lodash';
-
-import { Intl } from './Intl';
-import { Emojify } from './conversation/Emojify';
-import { Spinner } from './Spinner';
-import {
- ConversationListItem,
- PropsData as ConversationListItemPropsType,
-} from './ConversationListItem';
-import { StartNewConversation } from './StartNewConversation';
-import { cleanId } from './_util';
-
-import { LocalizerType } from '../types/Util';
-
-export type PropsDataType = {
- discussionsLoading: boolean;
- items: Array;
- messagesLoading: boolean;
- noResults: boolean;
- regionCode: string;
- searchConversationName?: string;
- searchTerm: string;
- selectedConversationId?: string;
- selectedMessageId?: string;
-};
-
-type StartNewConversationType = {
- type: 'start-new-conversation';
- data: undefined;
-};
-type NotSupportedSMS = {
- type: 'sms-mms-not-supported-text';
- data: undefined;
-};
-type ConversationHeaderType = {
- type: 'conversations-header';
- data: undefined;
-};
-type ContactsHeaderType = {
- type: 'contacts-header';
- data: undefined;
-};
-type MessagesHeaderType = {
- type: 'messages-header';
- data: undefined;
-};
-type ConversationType = {
- type: 'conversation';
- data: ConversationListItemPropsType;
-};
-type ContactsType = {
- type: 'contact';
- data: ConversationListItemPropsType;
-};
-type MessageType = {
- type: 'message';
- data: string;
-};
-type SpinnerType = {
- type: 'spinner';
- data: undefined;
-};
-
-export type SearchResultRowType =
- | StartNewConversationType
- | NotSupportedSMS
- | ConversationHeaderType
- | ContactsHeaderType
- | MessagesHeaderType
- | ConversationType
- | ContactsType
- | MessageType
- | SpinnerType;
-
-type PropsHousekeepingType = {
- i18n: LocalizerType;
- openConversationInternal: (id: string, messageId?: string) => void;
- startNewConversation: (
- query: string,
- options: { regionCode: string }
- ) => void;
- height: number;
- width: number;
-
- renderMessageSearchResult: (id: string) => JSX.Element;
-};
-
-type PropsType = PropsDataType & PropsHousekeepingType;
-type StateType = {
- scrollToIndex?: number;
-};
-
-// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
-type RowRendererParamsType = {
- index: number;
- isScrolling: boolean;
- isVisible: boolean;
- key: string;
- parent: Record;
- style: CSSProperties;
-};
-type OnScrollParamsType = {
- scrollTop: number;
- clientHeight: number;
- scrollHeight: number;
-
- clientWidth: number;
- scrollWidth?: number;
- scrollLeft?: number;
- scrollToColumn?: number;
- _hasScrolledToColumnTarget?: boolean;
- scrollToRow?: number;
- _hasScrolledToRowTarget?: boolean;
-};
-
-export class SearchResults extends React.Component {
- public setFocusToFirstNeeded = false;
-
- public setFocusToLastNeeded = false;
-
- public cellSizeCache = new CellMeasurerCache({
- defaultHeight: 80,
- fixedWidth: true,
- });
-
- public listRef = React.createRef();
-
- public containerRef = React.createRef();
-
- constructor(props: PropsType) {
- super(props);
- this.state = {
- scrollToIndex: undefined,
- };
- }
-
- public handleStartNewConversation = (): void => {
- const { regionCode, searchTerm, startNewConversation } = this.props;
-
- startNewConversation(searchTerm, { regionCode });
- };
-
- public handleKeyDown = (event: React.KeyboardEvent): void => {
- const { items } = this.props;
- const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
- const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
- const commandOrCtrl = commandKey || controlKey;
-
- if (!items || items.length < 1) {
- return;
- }
-
- if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') {
- this.setState({ scrollToIndex: 0 });
- this.setFocusToFirstNeeded = true;
-
- event.preventDefault();
- event.stopPropagation();
-
- return;
- }
-
- if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') {
- const lastIndex = items.length - 1;
- this.setState({ scrollToIndex: lastIndex });
- this.setFocusToLastNeeded = true;
-
- event.preventDefault();
- event.stopPropagation();
- }
- };
-
- public handleFocus = (): void => {
- const { selectedConversationId, selectedMessageId } = this.props;
- const { current: container } = this.containerRef;
-
- if (!container) {
- return;
- }
-
- if (document.activeElement === container) {
- const scrollingContainer = this.getScrollContainer();
-
- // First we try to scroll to the selected message
- if (selectedMessageId && scrollingContainer) {
- const target: HTMLElement | null = scrollingContainer.querySelector(
- `.module-message-search-result[data-id="${selectedMessageId}"]`
- );
-
- if (target && target.focus) {
- target.focus();
-
- return;
- }
- }
-
- // Then we try for the selected conversation
- if (selectedConversationId && scrollingContainer) {
- const escapedId = cleanId(selectedConversationId).replace(
- /["\\]/g,
- '\\$&'
- );
- const target: HTMLElement | null = scrollingContainer.querySelector(
- `.module-conversation-list-item[data-id="${escapedId}"]`
- );
-
- if (target && target.focus) {
- target.focus();
-
- return;
- }
- }
-
- // Otherwise we set focus to the first non-header item
- this.setFocusToFirst();
- }
- };
-
- public setFocusToFirst = (): void => {
- const { current: container } = this.containerRef;
-
- if (container) {
- const noResultsItem: HTMLElement | null = container.querySelector(
- '.module-search-results__no-results'
- );
- if (noResultsItem && noResultsItem.focus) {
- noResultsItem.focus();
-
- return;
- }
- }
-
- const scrollContainer = this.getScrollContainer();
- if (!scrollContainer) {
- return;
- }
-
- const startItem: HTMLElement | null = scrollContainer.querySelector(
- '.module-start-new-conversation'
- );
- if (startItem && startItem.focus) {
- startItem.focus();
-
- return;
- }
-
- const conversationItem: HTMLElement | null = scrollContainer.querySelector(
- '.module-conversation-list-item'
- );
- if (conversationItem && conversationItem.focus) {
- conversationItem.focus();
-
- return;
- }
-
- const messageItem: HTMLElement | null = scrollContainer.querySelector(
- '.module-message-search-result'
- );
- if (messageItem && messageItem.focus) {
- messageItem.focus();
- }
- };
-
- public getScrollContainer = (): HTMLDivElement | null => {
- if (!this.listRef || !this.listRef.current) {
- return null;
- }
-
- const list = this.listRef.current;
-
- // We're using an internal variable (_scrollingContainer)) here,
- // so cannot rely on the public type.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const grid: any = list.Grid;
- if (!grid || !grid._scrollingContainer) {
- return null;
- }
-
- return grid._scrollingContainer as HTMLDivElement;
- };
-
- public onScroll = debounce(
- (data: OnScrollParamsType) => {
- // Ignore scroll events generated as react-virtualized recursively scrolls and
- // re-measures to get us where we want to go.
- if (
- isNumber(data.scrollToRow) &&
- data.scrollToRow >= 0 &&
- !data._hasScrolledToRowTarget
- ) {
- return;
- }
-
- this.setState({ scrollToIndex: undefined });
-
- if (this.setFocusToFirstNeeded) {
- this.setFocusToFirstNeeded = false;
- this.setFocusToFirst();
- }
-
- if (this.setFocusToLastNeeded) {
- this.setFocusToLastNeeded = false;
-
- const scrollContainer = this.getScrollContainer();
- if (!scrollContainer) {
- return;
- }
-
- const messageItems: NodeListOf = scrollContainer.querySelectorAll(
- '.module-message-search-result'
- );
- if (messageItems && messageItems.length > 0) {
- const last = messageItems[messageItems.length - 1];
-
- if (last && last.focus) {
- last.focus();
-
- return;
- }
- }
-
- const contactItems: NodeListOf = scrollContainer.querySelectorAll(
- '.module-conversation-list-item'
- );
- if (contactItems && contactItems.length > 0) {
- const last = contactItems[contactItems.length - 1];
-
- if (last && last.focus) {
- last.focus();
-
- return;
- }
- }
-
- const startItem = scrollContainer.querySelectorAll(
- '.module-start-new-conversation'
- ) as NodeListOf;
- if (startItem && startItem.length > 0) {
- const last = startItem[startItem.length - 1];
-
- if (last && last.focus) {
- last.focus();
- }
- }
- }
- },
- 100,
- { maxWait: 100 }
- );
-
- public renderRowContents(row: SearchResultRowType): JSX.Element {
- const {
- searchTerm,
- i18n,
- openConversationInternal,
- renderMessageSearchResult,
- } = this.props;
-
- if (row.type === 'start-new-conversation') {
- return (
-
- );
- }
- if (row.type === 'sms-mms-not-supported-text') {
- return (
-
- {i18n('notSupportedSMS')}
-
- );
- }
- if (row.type === 'conversations-header') {
- return (
-
- {i18n('conversationsHeader')}
-
- );
- }
- if (row.type === 'conversation') {
- const { data } = row;
-
- return (
-
- );
- }
- if (row.type === 'contacts-header') {
- return (
-
- {i18n('contactsHeader')}
-
- );
- }
- if (row.type === 'contact') {
- const { data } = row;
-
- return (
-
- );
- }
- if (row.type === 'messages-header') {
- return (
-
- {i18n('messagesHeader')}
-
- );
- }
- if (row.type === 'message') {
- const { data } = row;
-
- return renderMessageSearchResult(data);
- }
- if (row.type === 'spinner') {
- return (
-
-
-
- );
- }
- throw new Error(
- 'SearchResults.renderRowContents: Encountered unknown row type'
- );
- }
-
- public renderRow = ({
- index,
- key,
- parent,
- style,
- }: RowRendererParamsType): JSX.Element => {
- const { items, width } = this.props;
-
- const row = items[index];
-
- return (
-
-
- {this.renderRowContents(row)}
-
-
- );
- };
-
- public componentDidUpdate(prevProps: PropsType): void {
- const {
- items,
- searchTerm,
- discussionsLoading,
- messagesLoading,
- } = this.props;
-
- if (searchTerm !== prevProps.searchTerm) {
- this.resizeAll();
- } else if (
- discussionsLoading !== prevProps.discussionsLoading ||
- messagesLoading !== prevProps.messagesLoading
- ) {
- this.resizeAll();
- } else if (
- items &&
- prevProps.items &&
- prevProps.items.length !== items.length
- ) {
- this.resizeAll();
- }
- }
-
- public getList = (): List | null => {
- if (!this.listRef) {
- return null;
- }
-
- const { current } = this.listRef;
-
- return current;
- };
-
- public recomputeRowHeights = (row?: number): void => {
- const list = this.getList();
- if (!list) {
- return;
- }
-
- list.recomputeRowHeights(row);
- };
-
- public resizeAll = (): void => {
- this.cellSizeCache.clearAll();
- this.recomputeRowHeights(0);
- };
-
- public getRowCount(): number {
- const { items } = this.props;
-
- return items ? items.length : 0;
- }
-
- public render(): JSX.Element {
- const {
- height,
- i18n,
- items,
- noResults,
- searchConversationName,
- searchTerm,
- width,
- } = this.props;
- const { scrollToIndex } = this.state;
-
- if (noResults) {
- return (
-
- {!searchConversationName || searchTerm ? (
-
- {searchConversationName ? (
-
- ),
- }}
- />
- ) : (
- i18n('noSearchResults', [searchTerm])
- )}
-
- ) : null}
-
- );
- }
-
- return (
-
-
-
- );
- }
-}
diff --git a/ts/components/ShortcutGuide.tsx b/ts/components/ShortcutGuide.tsx
index b1810ece0..4adf76dbf 100644
--- a/ts/components/ShortcutGuide.tsx
+++ b/ts/components/ShortcutGuide.tsx
@@ -1,4 +1,4 @@
-// Copyright 2019-2020 Signal Messenger, LLC
+// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@@ -32,6 +32,7 @@ type KeyType =
| 'J'
| 'L'
| 'M'
+ | 'N'
| 'P'
| 'R'
| 'S'
@@ -84,6 +85,10 @@ const NAVIGATION_SHORTCUTS: Array = [
description: 'Keyboard--open-conversation-menu',
keys: [['commandOrCtrl', 'shift', 'L']],
},
+ {
+ description: 'Keyboard--new-conversation',
+ keys: [['commandOrCtrl', 'N']],
+ },
{
description: 'Keyboard--search',
keys: [['commandOrCtrl', 'F']],
diff --git a/ts/components/StartNewConversation.stories.tsx b/ts/components/StartNewConversation.stories.tsx
deleted file mode 100644
index d6bc78fda..000000000
--- a/ts/components/StartNewConversation.stories.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright 2020 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-import * as React from 'react';
-
-import { storiesOf } from '@storybook/react';
-import { action } from '@storybook/addon-actions';
-import { text } from '@storybook/addon-knobs';
-import { Props, StartNewConversation } from './StartNewConversation';
-
-import { setup as setupI18n } from '../../js/modules/i18n';
-import enMessages from '../../_locales/en/messages.json';
-
-const i18n = setupI18n('en', enMessages);
-
-const createProps = (overrideProps: Partial = {}): Props => ({
- i18n,
- onClick: action('onClick'),
- phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
-});
-
-const stories = storiesOf('Components/StartNewConversation', module);
-
-stories.add('Full Phone Number', () => {
- const props = createProps({
- phoneNumber: '(202) 555-0011',
- });
-
- return ;
-});
-
-stories.add('Partial Phone Number', () => {
- const props = createProps({
- phoneNumber: '202',
- });
-
- return ;
-});
diff --git a/ts/components/StartNewConversation.tsx b/ts/components/StartNewConversation.tsx
deleted file mode 100644
index 34cc5947a..000000000
--- a/ts/components/StartNewConversation.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright 2019-2020 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-import React from 'react';
-
-import { Avatar } from './Avatar';
-
-import { LocalizerType } from '../types/Util';
-
-export type Props = {
- phoneNumber: string;
- i18n: LocalizerType;
- onClick: () => void;
-};
-
-export class StartNewConversation extends React.PureComponent {
- public render(): JSX.Element {
- const { phoneNumber, i18n, onClick } = this.props;
-
- return (
-
- );
- }
-}
diff --git a/ts/components/conversation/About.tsx b/ts/components/conversation/About.tsx
index 779cded2e..fb3ee855a 100644
--- a/ts/components/conversation/About.tsx
+++ b/ts/components/conversation/About.tsx
@@ -1,4 +1,4 @@
-// Copyright 2018-2020 Signal Messenger, LLC
+// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@@ -6,16 +6,20 @@ import React from 'react';
import { Emojify } from './Emojify';
export type PropsType = {
+ className?: string;
text?: string;
};
-export const About = ({ text }: PropsType): JSX.Element | null => {
+export const About = ({
+ className = 'module-about__text',
+ text,
+}: PropsType): JSX.Element | null => {
if (!text) {
return null;
}
return (
-
+
);
diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx
new file mode 100644
index 000000000..7307eb8f2
--- /dev/null
+++ b/ts/components/conversationList/BaseConversationListItem.tsx
@@ -0,0 +1,143 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { ReactNode, CSSProperties, FunctionComponent } from 'react';
+import classNames from 'classnames';
+import { isBoolean, isNumber } from 'lodash';
+
+import { Avatar, AvatarSize } from '../Avatar';
+import { Timestamp } from '../conversation/Timestamp';
+import { isConversationUnread } from '../../util/isConversationUnread';
+import { cleanId } from '../_util';
+import { ColorType } from '../../types/Colors';
+import { LocalizerType } from '../../types/Util';
+
+const BASE_CLASS_NAME =
+ 'module-conversation-list__item--contact-or-conversation';
+const CONTENT_CLASS_NAME = `${BASE_CLASS_NAME}__content`;
+const HEADER_CLASS_NAME = `${CONTENT_CLASS_NAME}__header`;
+export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`;
+const TIMESTAMP_CLASS_NAME = `${DATE_CLASS_NAME}__timestamp`;
+export const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
+export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
+
+type PropsType = {
+ avatarPath?: string;
+ color?: ColorType;
+ conversationType: 'group' | 'direct';
+ headerDate?: number;
+ headerName: ReactNode;
+ i18n: LocalizerType;
+ id?: string;
+ isMe?: boolean;
+ isNoteToSelf?: boolean;
+ isSelected: boolean;
+ markedUnread?: boolean;
+ messageId?: string;
+ messageStatusIcon?: ReactNode;
+ messageText?: ReactNode;
+ name?: string;
+ onClick: () => void;
+ phoneNumber?: string;
+ profileName?: string;
+ style: CSSProperties;
+ title: string;
+ unreadCount?: number;
+};
+
+export const BaseConversationListItem: FunctionComponent = React.memo(
+ ({
+ avatarPath,
+ color,
+ conversationType,
+ headerDate,
+ headerName,
+ i18n,
+ id,
+ isMe,
+ isNoteToSelf,
+ isSelected,
+ markedUnread,
+ messageStatusIcon,
+ messageText,
+ name,
+ onClick,
+ phoneNumber,
+ profileName,
+ style,
+ title,
+ unreadCount,
+ }) => {
+ const isUnread = isConversationUnread({ markedUnread, unreadCount });
+
+ const isAvatarNoteToSelf = isBoolean(isNoteToSelf)
+ ? isNoteToSelf
+ : Boolean(isMe);
+
+ return (
+
+ );
+ }
+);
diff --git a/ts/components/conversationList/ContactListItem.tsx b/ts/components/conversationList/ContactListItem.tsx
new file mode 100644
index 000000000..7632ad6f6
--- /dev/null
+++ b/ts/components/conversationList/ContactListItem.tsx
@@ -0,0 +1,86 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { useCallback, CSSProperties, FunctionComponent } from 'react';
+
+import { BaseConversationListItem } from './BaseConversationListItem';
+import { ColorType } from '../../types/Colors';
+import { LocalizerType } from '../../types/Util';
+import { ContactName } from '../conversation/ContactName';
+import { About } from '../conversation/About';
+
+export type PropsDataType = {
+ about?: string;
+ avatarPath?: string;
+ color?: ColorType;
+ id: string;
+ isMe?: boolean;
+ name?: string;
+ phoneNumber?: string;
+ profileName?: string;
+ title: string;
+ type: 'group' | 'direct';
+};
+
+type PropsHousekeepingType = {
+ i18n: LocalizerType;
+ style: CSSProperties;
+ onClick: (id: string) => void;
+};
+
+type PropsType = PropsDataType & PropsHousekeepingType;
+
+export const ContactListItem: FunctionComponent = React.memo(
+ ({
+ about,
+ avatarPath,
+ color,
+ i18n,
+ id,
+ isMe,
+ name,
+ onClick,
+ phoneNumber,
+ profileName,
+ style,
+ title,
+ type,
+ }) => {
+ const headerName = isMe ? (
+ i18n('noteToSelf')
+ ) : (
+
+ );
+
+ const messageText =
+ about && !isMe ? : null;
+
+ const onClickItem = useCallback(() => onClick(id), [onClick, id]);
+
+ return (
+
+ );
+ }
+);
diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx
new file mode 100644
index 000000000..86ac06dfd
--- /dev/null
+++ b/ts/components/conversationList/ConversationListItem.tsx
@@ -0,0 +1,206 @@
+// Copyright 2018-2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, {
+ useCallback,
+ CSSProperties,
+ FunctionComponent,
+ ReactNode,
+} from 'react';
+import classNames from 'classnames';
+
+import {
+ BaseConversationListItem,
+ MESSAGE_CLASS_NAME,
+ MESSAGE_TEXT_CLASS_NAME,
+} from './BaseConversationListItem';
+import { MessageBody } from '../conversation/MessageBody';
+import { ContactName } from '../conversation/ContactName';
+import { TypingAnimation } from '../conversation/TypingAnimation';
+
+import { LocalizerType } from '../../types/Util';
+import { ColorType } from '../../types/Colors';
+
+const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
+
+export const MessageStatuses = [
+ 'sending',
+ 'sent',
+ 'delivered',
+ 'read',
+ 'error',
+ 'partial-sent',
+] as const;
+
+export type MessageStatusType = typeof MessageStatuses[number];
+
+export type PropsData = {
+ id: string;
+ phoneNumber?: string;
+ color?: ColorType;
+ profileName?: string;
+ title: string;
+ name?: string;
+ type: 'group' | 'direct';
+ avatarPath?: string;
+ isMe?: boolean;
+ muteExpiresAt?: number;
+
+ lastUpdated?: number;
+ unreadCount?: number;
+ markedUnread?: boolean;
+ isSelected?: boolean;
+
+ acceptedMessageRequest?: boolean;
+ draftPreview?: string;
+ shouldShowDraft?: boolean;
+
+ typingContact?: unknown;
+ lastMessage?: {
+ status: MessageStatusType;
+ text: string;
+ deletedForEveryone?: boolean;
+ };
+ isPinned?: boolean;
+};
+
+type PropsHousekeeping = {
+ i18n: LocalizerType;
+ style: CSSProperties;
+ onClick: (id: string) => void;
+};
+
+export type Props = PropsData & PropsHousekeeping;
+
+export const ConversationListItem: FunctionComponent = React.memo(
+ ({
+ acceptedMessageRequest,
+ avatarPath,
+ color,
+ draftPreview,
+ i18n,
+ id,
+ isMe,
+ isSelected,
+ lastMessage,
+ lastUpdated,
+ markedUnread,
+ muteExpiresAt,
+ name,
+ onClick,
+ phoneNumber,
+ profileName,
+ shouldShowDraft,
+ style,
+ title,
+ type,
+ typingContact,
+ unreadCount,
+ }) => {
+ const headerName = isMe ? (
+ i18n('noteToSelf')
+ ) : (
+
+ );
+
+ let messageText: ReactNode = null;
+ let messageStatusIcon: ReactNode = null;
+
+ if (lastMessage || typingContact) {
+ const messageBody = lastMessage ? lastMessage.text : '';
+ const showingDraft = shouldShowDraft && draftPreview;
+ const deletedForEveryone = Boolean(
+ lastMessage && lastMessage.deletedForEveryone
+ );
+
+ /* eslint-disable no-nested-ternary */
+ messageText = (
+ <>
+ {muteExpiresAt && Date.now() < muteExpiresAt && (
+
+ )}
+ {!acceptedMessageRequest ? (
+
+ {i18n('ConversationListItem--message-request')}
+
+ ) : typingContact ? (
+
+ ) : (
+ <>
+ {showingDraft ? (
+ <>
+
+ {i18n('ConversationListItem--draft-prefix')}
+
+
+ >
+ ) : deletedForEveryone ? (
+
+ {i18n('message--deletedForEveryone')}
+
+ ) : (
+
+ )}
+ >
+ )}
+ >
+ );
+ /* eslint-enable no-nested-ternary */
+
+ if (!showingDraft && lastMessage && lastMessage.status) {
+ messageStatusIcon = (
+
+ );
+ }
+ }
+
+ const onClickItem = useCallback(() => onClick(id), [onClick, id]);
+
+ return (
+
+ );
+ }
+);
diff --git a/ts/components/MessageBodyHighlight.stories.tsx b/ts/components/conversationList/MessageBodyHighlight.stories.tsx
similarity index 90%
rename from ts/components/MessageBodyHighlight.stories.tsx
rename to ts/components/conversationList/MessageBodyHighlight.stories.tsx
index ff689af1b..0700a6c2e 100644
--- a/ts/components/MessageBodyHighlight.stories.tsx
+++ b/ts/components/conversationList/MessageBodyHighlight.stories.tsx
@@ -1,12 +1,12 @@
-// Copyright 2020 Signal Messenger, LLC
+// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { text, withKnobs } from '@storybook/addon-knobs';
-import { setup as setupI18n } from '../../js/modules/i18n';
-import enMessages from '../../_locales/en/messages.json';
+import { setup as setupI18n } from '../../../js/modules/i18n';
+import enMessages from '../../../_locales/en/messages.json';
import { MessageBodyHighlight, Props } from './MessageBodyHighlight';
const i18n = setupI18n('en', enMessages);
diff --git a/ts/components/MessageBodyHighlight.tsx b/ts/components/conversationList/MessageBodyHighlight.tsx
similarity index 76%
rename from ts/components/MessageBodyHighlight.tsx
rename to ts/components/conversationList/MessageBodyHighlight.tsx
index 14a59c146..43729cbcf 100644
--- a/ts/components/MessageBodyHighlight.tsx
+++ b/ts/components/conversationList/MessageBodyHighlight.tsx
@@ -1,15 +1,18 @@
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React from 'react';
+import React, { ReactNode } from 'react';
-import { MessageBody } from './conversation/MessageBody';
-import { Emojify } from './conversation/Emojify';
-import { AddNewLines } from './conversation/AddNewLines';
+import { MESSAGE_TEXT_CLASS_NAME } from './BaseConversationListItem';
+import { MessageBody } from '../conversation/MessageBody';
+import { Emojify } from '../conversation/Emojify';
+import { AddNewLines } from '../conversation/AddNewLines';
-import { SizeClassType } from './emoji/lib';
+import { SizeClassType } from '../emoji/lib';
-import { LocalizerType, RenderTextCallbackType } from '../types/Util';
+import { LocalizerType, RenderTextCallbackType } from '../../types/Util';
+
+const CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__message-search-result-contents`;
export type Props = {
text: string;
@@ -41,7 +44,7 @@ const renderEmoji = ({
);
export class MessageBodyHighlight extends React.Component {
- public render(): JSX.Element | Array {
+ private renderContents(): ReactNode {
const { text, i18n } = this.props;
const results: Array = [];
const FIND_BEGIN_END = /<>(.+?)<>/g;
@@ -106,4 +109,8 @@ export class MessageBodyHighlight extends React.Component {
return results;
}
+
+ public render(): ReactNode {
+ return {this.renderContents()}
;
+ }
}
diff --git a/ts/components/MessageSearchResult.stories.tsx b/ts/components/conversationList/MessageSearchResult.stories.tsx
similarity index 93%
rename from ts/components/MessageSearchResult.stories.tsx
rename to ts/components/conversationList/MessageSearchResult.stories.tsx
index c7a6923ca..cde32a194 100644
--- a/ts/components/MessageSearchResult.stories.tsx
+++ b/ts/components/conversationList/MessageSearchResult.stories.tsx
@@ -1,4 +1,4 @@
-// Copyright 2020 Signal Messenger, LLC
+// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@@ -6,8 +6,8 @@ import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean, text, withKnobs } from '@storybook/addon-knobs';
-import { setup as setupI18n } from '../../js/modules/i18n';
-import enMessages from '../../_locales/en/messages.json';
+import { setup as setupI18n } from '../../../js/modules/i18n';
+import enMessages from '../../../_locales/en/messages.json';
import { MessageSearchResult, PropsType } from './MessageSearchResult';
const i18n = setupI18n('en', enMessages);
@@ -51,6 +51,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({
'isSearchingInConversation',
overrideProps.isSearchingInConversation || false
),
+ style: {},
});
story.add('Default', () => {
@@ -135,7 +136,7 @@ story.add('Long Search Result', () => {
});
});
-story.add('Empty', () => {
+story.add('Empty (should be invalid)', () => {
const props = createProps();
return ;
diff --git a/ts/components/conversationList/MessageSearchResult.tsx b/ts/components/conversationList/MessageSearchResult.tsx
new file mode 100644
index 000000000..4b603bdd4
--- /dev/null
+++ b/ts/components/conversationList/MessageSearchResult.tsx
@@ -0,0 +1,140 @@
+// Copyright 2019-2020 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, {
+ useCallback,
+ CSSProperties,
+ FunctionComponent,
+ ReactNode,
+} from 'react';
+
+import { MessageBodyHighlight } from './MessageBodyHighlight';
+import { ContactName } from '../conversation/ContactName';
+
+import { LocalizerType } from '../../types/Util';
+import { ColorType } from '../../types/Colors';
+import { BaseConversationListItem } from './BaseConversationListItem';
+
+export type PropsDataType = {
+ isSelected?: boolean;
+ isSearchingInConversation?: boolean;
+
+ id: string;
+ conversationId: string;
+ sentAt?: number;
+
+ snippet: string;
+
+ from: {
+ phoneNumber?: string;
+ title: string;
+ isMe?: boolean;
+ name?: string;
+ color?: ColorType;
+ profileName?: string;
+ avatarPath?: string;
+ };
+
+ to: {
+ groupName?: string;
+ phoneNumber?: string;
+ title: string;
+ isMe?: boolean;
+ name?: string;
+ profileName?: string;
+ };
+};
+
+type PropsHousekeepingType = {
+ i18n: LocalizerType;
+ openConversationInternal: (_: {
+ conversationId: string;
+ messageId?: string;
+ }) => void;
+ style: CSSProperties;
+};
+
+export type PropsType = PropsDataType & PropsHousekeepingType;
+
+const renderPerson = (
+ i18n: LocalizerType,
+ person: Readonly<{
+ isMe?: boolean;
+ name?: string;
+ phoneNumber?: string;
+ profileName?: string;
+ title: string;
+ }>
+): ReactNode =>
+ person.isMe ? (
+ i18n('you')
+ ) : (
+
+ );
+
+export const MessageSearchResult: FunctionComponent = React.memo(
+ ({
+ id,
+ conversationId,
+ from,
+ to,
+ sentAt,
+ i18n,
+ openConversationInternal,
+ style,
+ snippet,
+ }) => {
+ const onClickItem = useCallback(() => {
+ openConversationInternal({ conversationId, messageId: id });
+ }, [openConversationInternal, conversationId, id]);
+
+ if (!from || !to) {
+ return ;
+ }
+
+ const isNoteToSelf = from.isMe && to.isMe;
+
+ let headerName: ReactNode;
+ if (isNoteToSelf) {
+ headerName = i18n('noteToSelf');
+ } else {
+ // This isn't perfect because (1) it doesn't work with RTL languages (2)
+ // capitalization may be incorrect for some languages, like English.
+ headerName = (
+ <>
+ {renderPerson(i18n, from)} {i18n('toJoiner')} {renderPerson(i18n, to)}
+ >
+ );
+ }
+
+ const messageText = ;
+
+ return (
+
+ );
+ }
+);
diff --git a/ts/components/conversationList/StartNewConversation.tsx b/ts/components/conversationList/StartNewConversation.tsx
new file mode 100644
index 000000000..5e140a161
--- /dev/null
+++ b/ts/components/conversationList/StartNewConversation.tsx
@@ -0,0 +1,48 @@
+// Copyright 2019-2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { CSSProperties, FunctionComponent } from 'react';
+
+import {
+ BaseConversationListItem,
+ MESSAGE_TEXT_CLASS_NAME,
+} from './BaseConversationListItem';
+
+import { LocalizerType } from '../../types/Util';
+
+const TEXT_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__start-new-conversation`;
+
+type PropsData = {
+ phoneNumber: string;
+};
+
+type PropsHousekeeping = {
+ i18n: LocalizerType;
+ style: CSSProperties;
+ onClick: () => void;
+};
+
+export type Props = PropsData & PropsHousekeeping;
+
+export const StartNewConversation: FunctionComponent = React.memo(
+ ({ i18n, onClick, phoneNumber, style }) => {
+ const messageText = (
+ {i18n('startConversation')}
+ );
+
+ return (
+
+ );
+ }
+);
diff --git a/ts/components/leftPane/LeftPaneArchiveHelper.tsx b/ts/components/leftPane/LeftPaneArchiveHelper.tsx
new file mode 100644
index 000000000..634bbc9cb
--- /dev/null
+++ b/ts/components/leftPane/LeftPaneArchiveHelper.tsx
@@ -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;
+};
+
+/* eslint-disable class-methods-use-this */
+
+export class LeftPaneArchiveHelper extends LeftPaneHelper<
+ LeftPaneArchivePropsType
+> {
+ private readonly archivedConversations: ReadonlyArray<
+ ConversationListItemPropsType
+ >;
+
+ constructor({ archivedConversations }: Readonly) {
+ super();
+
+ this.archivedConversations = archivedConversations;
+ }
+
+ getHeaderContents({
+ i18n,
+ showInbox,
+ }: Readonly<{
+ i18n: LocalizerType;
+ showInbox: () => void;
+ }>): ReactChild {
+ return (
+
+
+
+ {i18n('archivedConversations')}
+
+
+ );
+ }
+
+ getPreRowsNode({ i18n }: Readonly<{ i18n: LocalizerType }>): ReactChild {
+ return (
+
+ {i18n('archiveHelperText')}
+
+ );
+ }
+
+ 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,
+ selectedConversationId: undefined | string,
+ _selectedMessageId: unknown
+ ): undefined | { conversationId: string } {
+ return getConversationInDirection(
+ this.archivedConversations,
+ toFind,
+ selectedConversationId
+ );
+ }
+
+ shouldRecomputeRowHeights(_old: unknown): boolean {
+ return false;
+ }
+}
diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx
new file mode 100644
index 000000000..5800c8101
--- /dev/null
+++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx
@@ -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;
+ regionCode: string;
+ searchTerm: string;
+};
+
+/* eslint-disable class-methods-use-this */
+
+export class LeftPaneComposeHelper extends LeftPaneHelper<
+ LeftPaneComposePropsType
+> {
+ private readonly composeContacts: ReadonlyArray;
+
+ private readonly searchTerm: string;
+
+ private readonly phoneNumber: undefined | PhoneNumber;
+
+ constructor({
+ composeContacts,
+ regionCode,
+ searchTerm,
+ }: Readonly) {
+ super();
+
+ this.composeContacts = composeContacts;
+ this.searchTerm = searchTerm;
+ this.phoneNumber = parsePhoneNumber(searchTerm, regionCode);
+ }
+
+ getHeaderContents({
+ i18n,
+ showInbox,
+ }: Readonly<{
+ i18n: LocalizerType;
+ showInbox: () => void;
+ }>): ReactChild {
+ return (
+
+
+
+ {i18n('newConversation')}
+
+
+ );
+ }
+
+ getPreRowsNode({
+ i18n,
+ onChangeComposeSearchTerm,
+ }: Readonly<{
+ i18n: LocalizerType;
+ onChangeComposeSearchTerm: (
+ event: ChangeEvent
+ ) => unknown;
+ }>): ReactChild {
+ return (
+ <>
+
+
+
+
+ {this.getRowCount() ? null : (
+
+ {i18n('newConversationNoContacts')}
+
+ )}
+ >
+ );
+ }
+
+ 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
+ ): undefined {
+ return undefined;
+ }
+
+ getConversationAndMessageInDirection(
+ ..._args: ReadonlyArray
+ ): 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;
+}
diff --git a/ts/components/leftPane/LeftPaneHelper.tsx b/ts/components/leftPane/LeftPaneHelper.tsx
new file mode 100644
index 000000000..b51e6ade9
--- /dev/null
+++ b/ts/components/leftPane/LeftPaneHelper.tsx
@@ -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 {
+ getHeaderContents(
+ _: Readonly<{
+ i18n: LocalizerType;
+ showInbox: () => void;
+ }>
+ ): null | ReactChild {
+ return null;
+ }
+
+ shouldRenderNetworkStatusAndUpdateDialog(): boolean {
+ return false;
+ }
+
+ getPreRowsNode(
+ _: Readonly<{
+ i18n: LocalizerType;
+ onChangeComposeSearchTerm: (
+ event: ChangeEvent
+ ) => 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,
+ selectedConversationId: undefined | string,
+ selectedMessageId: undefined | string
+ ): undefined | { conversationId: string; messageId?: string };
+
+ abstract shouldRecomputeRowHeights(old: Readonly): boolean;
+}
diff --git a/ts/components/leftPane/LeftPaneInboxHelper.ts b/ts/components/leftPane/LeftPaneInboxHelper.ts
new file mode 100644
index 000000000..b461aee0e
--- /dev/null
+++ b/ts/components/leftPane/LeftPaneInboxHelper.ts
@@ -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;
+ archivedConversations: ReadonlyArray;
+ pinnedConversations: ReadonlyArray;
+};
+
+/* eslint-disable class-methods-use-this */
+
+export class LeftPaneInboxHelper extends LeftPaneHelper<
+ LeftPaneInboxPropsType
+> {
+ private readonly conversations: ReadonlyArray;
+
+ private readonly archivedConversations: ReadonlyArray<
+ ConversationListItemPropsType
+ >;
+
+ private readonly pinnedConversations: ReadonlyArray<
+ ConversationListItemPropsType
+ >;
+
+ constructor({
+ conversations,
+ archivedConversations,
+ pinnedConversations,
+ }: Readonly) {
+ 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
+ ) => 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): 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,
+ 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
+ );
+ }
+}
diff --git a/ts/components/leftPane/LeftPaneSearchHelper.tsx b/ts/components/leftPane/LeftPaneSearchHelper.tsx
new file mode 100644
index 000000000..ca4cffa29
--- /dev/null
+++ b/ts/components/leftPane/LeftPaneSearchHelper.tsx
@@ -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 =
+ | { isLoading: true }
+ | { isLoading: false; results: Array };
+
+export type LeftPaneSearchPropsType = {
+ conversationResults: MaybeLoadedSearchResultsType<
+ ConversationListItemPropsType
+ >;
+ contactResults: MaybeLoadedSearchResultsType;
+ 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) {
+ 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 ? (
+
+ {searchConversationName ? (
+
+ ),
+ }}
+ />
+ ) : (
+ i18n('noSearchResults', [searchTerm])
+ )}
+
+ ) : 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): 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,
+ _selectedConversationId: undefined | string,
+ _selectedMessageId: unknown
+ ): undefined | { conversationId: string } {
+ return undefined;
+ }
+
+ private allResults() {
+ return [this.conversationResults, this.contactResults, this.messageResults];
+ }
+}
+
+function getRowCountForSearchResult(
+ searchResults: Readonly>
+): 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;
+}
diff --git a/ts/components/leftPane/getConversationInDirection.ts b/ts/components/leftPane/getConversationInDirection.ts
new file mode 100644
index 000000000..1220aef80
--- /dev/null
+++ b/ts/components/leftPane/getConversationInDirection.ts
@@ -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,
+ toFind: Readonly,
+ 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;
+};
diff --git a/ts/groups/joinViaLink.ts b/ts/groups/joinViaLink.ts
index f2bcb75e8..1d23c1a24 100644
--- a/ts/groups/joinViaLink.ts
+++ b/ts/groups/joinViaLink.ts
@@ -58,9 +58,9 @@ export async function joinViaLink(hash: string): Promise {
window.log.warn(
`joinViaLink/${logId}: Already a member of group, opening conversation`
);
- window.reduxActions.conversations.openConversationInternal(
- existingConversation.id
- );
+ window.reduxActions.conversations.openConversationInternal({
+ conversationId: existingConversation.id,
+ });
window.window.Whisper.ToastView.show(
window.Whisper.AlreadyGroupMemberToast,
document.getElementsByClassName('conversation-stack')[0]
@@ -132,9 +132,9 @@ export async function joinViaLink(hash: string): Promise {
window.log.warn(
`joinViaLink/${logId}: Already awaiting approval, opening conversation`
);
- window.reduxActions.conversations.openConversationInternal(
- existingConversation.id
- );
+ window.reduxActions.conversations.openConversationInternal({
+ conversationId: existingConversation.id,
+ });
window.Whisper.ToastView.show(
window.Whisper.AlreadyRequestedToJoinToast,
@@ -221,9 +221,9 @@ export async function joinViaLink(hash: string): Promise {
window.log.warn(
`joinViaLink/${logId}: User is part of group on second check, opening conversation`
);
- window.reduxActions.conversations.openConversationInternal(
- targetConversation.id
- );
+ window.reduxActions.conversations.openConversationInternal({
+ conversationId: targetConversation.id,
+ });
return;
}
@@ -302,9 +302,9 @@ export async function joinViaLink(hash: string): Promise {
);
}
- window.reduxActions.conversations.openConversationInternal(
- targetConversation.id
- );
+ window.reduxActions.conversations.openConversationInternal({
+ conversationId: targetConversation.id,
+ });
} catch (error) {
// Delete newly-created conversation if we encountered any errors
if (tempConversation) {
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index 129ec242c..c896e055e 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -24,6 +24,7 @@ import {
import { ColorType } from '../types/Colors';
import { MessageModel } from './messages';
import { isMuted } from '../util/isMuted';
+import { isConversationUnregistered } from '../util/isConversationUnregistered';
import { missingCaseError } from '../util/missingCaseError';
import { sniffImageMimeType } from '../util/sniffImageMimeType';
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
@@ -736,15 +737,7 @@ export class ConversationModel extends window.Backbone.Model<
}
isUnregistered(): boolean {
- const now = Date.now();
- const sixHoursAgo = now - 1000 * 60 * 60 * 6;
- const discoveredUnregisteredAt = this.get('discoveredUnregisteredAt');
-
- if (discoveredUnregisteredAt && discoveredUnregisteredAt > sixHoursAgo) {
- return true;
- }
-
- return false;
+ return isConversationUnregistered(this.attributes);
}
setUnregistered(): void {
@@ -1316,6 +1309,7 @@ export class ConversationModel extends window.Backbone.Model<
canEditGroupInfo: this.canEditGroupInfo(),
avatarPath: this.getAvatarPath()!,
color,
+ discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'),
draftBodyRanges,
draftPreview,
draftText,
@@ -1329,7 +1323,6 @@ export class ConversationModel extends window.Backbone.Model<
isMe: this.isMe(),
isGroupV1AndDisabled: this.isGroupV1AndDisabled(),
isPinned: this.get('isPinned'),
- isMissingMandatoryProfileSharing: this.isMissingRequiredProfileSharing(),
isUntrusted: this.isUntrusted(),
isVerified: this.isVerified(),
lastMessage: {
@@ -1354,6 +1347,7 @@ export class ConversationModel extends window.Backbone.Model<
name: this.get('name')!,
phoneNumber: this.getNumber()!,
profileName: this.getProfileName()!,
+ profileSharing: this.get('profileSharing'),
publicParams: this.get('publicParams'),
secretParams: this.get('secretParams'),
sharedGroupNames: this.get('sharedGroupNames')!,
diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts
index ed2d3613c..f84965902 100644
--- a/ts/sql/Server.ts
+++ b/ts/sql/Server.ts
@@ -2414,7 +2414,7 @@ async function searchMessages(
const rows = await db.all(
`SELECT
messages.json,
- snippet(messages_fts, -1, '<>', '<>', '...', 15) as snippet
+ snippet(messages_fts, -1, '<>', '<>', '...', 10) as snippet
FROM messages_fts
INNER JOIN messages on messages_fts.id = messages.id
WHERE
@@ -2442,7 +2442,7 @@ async function searchMessagesInConversation(
const rows = await db.all(
`SELECT
messages.json,
- snippet(messages_fts, -1, '<>', '<>', '...', 15) as snippet
+ snippet(messages_fts, -1, '<>', '<>', '...', 10) as snippet
FROM messages_fts
INNER JOIN messages on messages_fts.id = messages.id
WHERE
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index 0c73476db..1777ea356 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -18,8 +18,8 @@ import {
import { StateType as RootStateType } from '../reducer';
import { calling } from '../../services/calling';
import { getOwn } from '../../util/getOwn';
+import { assert } from '../../util/assert';
import { trigger } from '../../shims/events';
-import { NoopActionType } from './noop';
import { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Colors';
import { BodyRangeType } from '../../types/Util';
@@ -65,6 +65,7 @@ export type ConversationType = {
canChangeTimer?: boolean;
canEditGroupInfo?: boolean;
color?: ColorType;
+ discoveredUnregisteredAt?: number;
isAccepted?: boolean;
isArchived?: boolean;
isBlocked?: boolean;
@@ -110,6 +111,7 @@ export type ConversationType = {
profileName?: string;
} | null;
recentMediaItems?: Array;
+ profileSharing?: boolean;
shouldShowDraft?: boolean;
draftText?: string | null;
@@ -120,7 +122,6 @@ export type ConversationType = {
groupVersion?: 1 | 2;
groupId?: string;
groupLink?: string;
- isMissingMandatoryProfileSharing?: boolean;
messageRequestsEnabled?: boolean;
acceptedMessageRequest?: boolean;
secretParams?: string;
@@ -231,6 +232,9 @@ export type ConversationsStateType = {
selectedConversationTitle?: string;
selectedConversationPanelDepth: number;
showArchived: boolean;
+ composer?: {
+ contactSearchTerm: string;
+ };
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
@@ -431,6 +435,10 @@ export type ShowArchivedConversationsActionType = {
type: 'SHOW_ARCHIVED_CONVERSATIONS';
payload: null;
};
+type SetComposeSearchTermActionType = {
+ type: 'SET_COMPOSE_SEARCH_TERM';
+ payload: { contactSearchTerm: string };
+};
type SetRecentMediaItemsActionType = {
type: 'SET_RECENT_MEDIA_ITEMS';
payload: {
@@ -438,6 +446,13 @@ type SetRecentMediaItemsActionType = {
recentMediaItems: Array;
};
};
+type StartComposingActionType = {
+ type: 'START_COMPOSING';
+};
+export type SwitchToAssociatedViewActionType = {
+ type: 'SWITCH_TO_ASSOCIATED_VIEW';
+ payload: { conversationId: string };
+};
export type ConversationActionType =
| ClearChangedMessagesActionType
@@ -458,6 +473,7 @@ export type ConversationActionType =
| RepairOldestMessageActionType
| ScrollToMessageActionType
| SelectedConversationChangedActionType
+ | SetComposeSearchTermActionType
| SetConversationHeaderTitleActionType
| SetIsNearBottomActionType
| SetLoadCountdownStartActionType
@@ -466,7 +482,9 @@ export type ConversationActionType =
| SetRecentMediaItemsActionType
| SetSelectedConversationPanelDepthActionType
| ShowArchivedConversationsActionType
- | ShowInboxActionType;
+ | ShowInboxActionType
+ | StartComposingActionType
+ | SwitchToAssociatedViewActionType;
// Action Creators
@@ -490,6 +508,7 @@ export const actions = {
repairOldestMessage,
scrollToMessage,
selectMessage,
+ setComposeSearchTerm,
setIsNearBottom,
setLoadCountdownStart,
setMessagesLoading,
@@ -499,6 +518,8 @@ export const actions = {
setSelectedConversationPanelDepth,
showArchivedConversations,
showInbox,
+ startComposing,
+ startNewConversationFromPhoneNumber,
};
function setPreJoinConversation(
@@ -770,19 +791,56 @@ function scrollToMessage(
};
}
+function setComposeSearchTerm(
+ contactSearchTerm: string
+): SetComposeSearchTermActionType {
+ return {
+ type: 'SET_COMPOSE_SEARCH_TERM',
+ payload: { contactSearchTerm },
+ };
+}
+
+function startComposing(): StartComposingActionType {
+ return { type: 'START_COMPOSING' };
+}
+
+function startNewConversationFromPhoneNumber(
+ e164: string
+): ThunkAction {
+ return dispatch => {
+ trigger('showConversation', e164);
+
+ dispatch(showInbox());
+ };
+}
+
// Note: we need two actions here to simplify. Operations outside of the left pane can
// trigger an 'openConversation' so we go through Whisper.events for all
// conversation selection. Internal just triggers the Whisper.event, and External
// makes the changes to the store.
-function openConversationInternal(
- id: string,
- messageId?: string
-): NoopActionType {
- trigger('showConversation', id, messageId);
+function openConversationInternal({
+ conversationId,
+ messageId,
+ switchToAssociatedView,
+}: Readonly<{
+ conversationId: string;
+ messageId?: string;
+ switchToAssociatedView?: boolean;
+}>): ThunkAction<
+ void,
+ RootStateType,
+ unknown,
+ SwitchToAssociatedViewActionType
+> {
+ return dispatch => {
+ trigger('showConversation', conversationId, messageId);
- return {
- type: 'NOOP',
- payload: null,
+ if (switchToAssociatedView) {
+ dispatch({
+ type: 'SWITCH_TO_ASSOCIATED_VIEW',
+ payload: { conversationId },
+ });
+ }
};
}
function openConversationExternal(
@@ -1626,13 +1684,13 @@ export function reducer(
}
if (action.type === 'SHOW_INBOX') {
return {
- ...state,
+ ...omit(state, 'composer'),
showArchived: false,
};
}
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
return {
- ...state,
+ ...omit(state, 'composer'),
showArchived: true,
};
}
@@ -1669,5 +1727,52 @@ export function reducer(
};
}
+ if (action.type === 'START_COMPOSING') {
+ if (state.composer) {
+ return state;
+ }
+
+ return {
+ ...state,
+ showArchived: false,
+ composer: {
+ contactSearchTerm: '',
+ },
+ };
+ }
+
+ if (action.type === 'SET_COMPOSE_SEARCH_TERM') {
+ const { composer } = state;
+ if (!composer) {
+ assert(
+ false,
+ 'Setting compose search term with the composer closed is a no-op'
+ );
+ return state;
+ }
+
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ contactSearchTerm: action.payload.contactSearchTerm,
+ },
+ };
+ }
+
+ if (action.type === 'SWITCH_TO_ASSOCIATED_VIEW') {
+ const conversation = getOwn(
+ state.conversationLookup,
+ action.payload.conversationId
+ );
+ if (!conversation) {
+ return state;
+ }
+ return {
+ ...omit(state, 'composer'),
+ showArchived: Boolean(conversation.isArchived),
+ };
+ }
+
return state;
}
diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts
index 4976649f2..b6073483a 100644
--- a/ts/state/ducks/search.ts
+++ b/ts/state/ducks/search.ts
@@ -4,7 +4,6 @@
import { omit, reject } from 'lodash';
import { normalize } from '../../types/PhoneNumber';
-import { trigger } from '../../shims/events';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import dataInterface from '../../sql/Client';
import { makeLookup } from '../../util/makeLookup';
@@ -39,9 +38,8 @@ export type SearchStateType = {
startSearchCounter: number;
searchConversationId?: string;
searchConversationName?: string;
- // We store just ids of conversations, since that data is always cached in memory
- contacts: Array;
- conversations: Array;
+ contactIds: Array;
+ conversationIds: Array;
query: string;
normalizedPhoneNumber?: string;
messageIds: Array;
@@ -63,8 +61,8 @@ type SearchMessagesResultsPayloadType = SearchResultsBaseType & {
messages: Array;
};
type SearchDiscussionsResultsPayloadType = SearchResultsBaseType & {
- conversations: Array;
- contacts: Array;
+ conversationIds: Array;
+ contactIds: Array;
};
type SearchMessagesResultsKickoffActionType = {
type: 'SEARCH_MESSAGES_RESULTS';
@@ -135,7 +133,6 @@ export const actions = {
clearConversationSearch,
searchInConversation,
updateSearchTerm,
- startNewConversation,
};
function searchMessages(
@@ -190,7 +187,7 @@ async function doSearchDiscussions(
}
): Promise {
const { ourConversationId, noteToSelf } = options;
- const { conversations, contacts } = await queryConversationsAndContacts(
+ const { conversationIds, contactIds } = await queryConversationsAndContacts(
query,
{
ourConversationId,
@@ -199,8 +196,8 @@ async function doSearchDiscussions(
);
return {
- conversations,
- contacts,
+ conversationIds,
+ contactIds,
query,
};
}
@@ -243,22 +240,6 @@ function updateSearchTerm(query: string): UpdateSearchTermActionType {
},
};
}
-function startNewConversation(
- query: string,
- options: { regionCode: string }
-): ClearSearchActionType {
- const { regionCode } = options;
- const normalized = normalize(query, { regionCode });
- if (!normalized) {
- throw new Error('Attempted to start new conversation with invalid number');
- }
- trigger('showConversation', normalized);
-
- return {
- type: 'SEARCH_CLEAR',
- payload: null,
- };
-}
async function queryMessages(query: string, searchConversationId?: string) {
try {
@@ -280,7 +261,10 @@ async function queryConversationsAndContacts(
ourConversationId: string;
noteToSelf: string;
}
-) {
+): Promise<{
+ contactIds: Array;
+ conversationIds: Array;
+}> {
const { ourConversationId, noteToSelf } = options;
const query = providedQuery.replace(/[+.()]*/g, '');
@@ -289,16 +273,16 @@ async function queryConversationsAndContacts(
);
// Split into two groups - active conversations and items just from address book
- let conversations: Array = [];
- let contacts: Array = [];
+ let conversationIds: Array = [];
+ let contactIds: Array = [];
const max = searchResults.length;
for (let i = 0; i < max; i += 1) {
const conversation = searchResults[i];
if (conversation.type === 'private' && !conversation.lastMessage) {
- contacts.push(conversation.id);
+ contactIds.push(conversation.id);
} else {
- conversations.push(conversation.id);
+ conversationIds.push(conversation.id);
}
}
@@ -312,13 +296,13 @@ async function queryConversationsAndContacts(
// Inject synthetic Note to Self entry if query matches localized 'Note to Self'
if (noteToSelf.indexOf(providedQuery.toLowerCase()) !== -1) {
// ensure that we don't have duplicates in our results
- contacts = contacts.filter(id => id !== ourConversationId);
- conversations = conversations.filter(id => id !== ourConversationId);
+ contactIds = contactIds.filter(id => id !== ourConversationId);
+ conversationIds = conversationIds.filter(id => id !== ourConversationId);
- contacts.unshift(ourConversationId);
+ contactIds.unshift(ourConversationId);
}
- return { conversations, contacts };
+ return { conversationIds, contactIds };
}
// Reducer
@@ -329,8 +313,8 @@ export function getEmptyState(): SearchStateType {
query: '',
messageIds: [],
messageLookup: {},
- conversations: [],
- contacts: [],
+ conversationIds: [],
+ contactIds: [],
discussionsLoading: false,
messagesLoading: false,
};
@@ -373,8 +357,8 @@ export function reducer(
messageIds: [],
messageLookup: {},
discussionsLoading: !isWithinConversation,
- contacts: [],
- conversations: [],
+ contactIds: [],
+ conversationIds: [],
}
: {}),
};
@@ -431,12 +415,12 @@ export function reducer(
if (action.type === 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED') {
const { payload } = action;
- const { contacts, conversations } = payload;
+ const { contactIds, conversationIds } = payload;
return {
...state,
- contacts,
- conversations,
+ contactIds,
+ conversationIds,
discussionsLoading: false,
};
}
diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts
index 079f7a9e9..f00bdb996 100644
--- a/ts/state/selectors/conversations.ts
+++ b/ts/state/selectors/conversations.ts
@@ -2,8 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import memoizee from 'memoizee';
-import { fromPairs, isNumber } from 'lodash';
+import { fromPairs, isNumber, isString } from 'lodash';
import { createSelector } from 'reselect';
+import Fuse, { FuseOptions } from 'fuse.js';
import { StateType } from '../reducer';
import {
@@ -16,6 +17,7 @@ import {
MessageType,
PreJoinConversationType,
} from '../ducks/conversations';
+import { LocalizerType } from '../../types/Util';
import { getOwn } from '../../util/getOwn';
import type { CallsByConversationType } from '../ducks/calling';
import { getCallsByConversation } from './calling';
@@ -23,6 +25,7 @@ import { getBubbleProps } from '../../shims/Whisper';
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import { TimelineItemType } from '../../components/conversation/TimelineItem';
import { assert } from '../../util/assert';
+import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import {
getInteractionMode,
@@ -135,6 +138,16 @@ export const getShowArchived = createSelector(
}
);
+const getComposerState = createSelector(
+ getConversations,
+ (state: ConversationsStateType) => state.composer
+);
+
+export const isComposing = createSelector(
+ getComposerState,
+ (composerState): boolean => Boolean(composerState)
+);
+
export const getMessages = createSelector(
getConversations,
(state: ConversationsStateType): MessageLookupType => {
@@ -148,6 +161,20 @@ export const getMessagesByConversation = createSelector(
}
);
+export const getIsConversationEmptySelector = createSelector(
+ getMessagesByConversation,
+ (messagesByConversation: MessagesByConversationType) => (
+ conversationId: string
+ ): boolean => {
+ const messages = getOwn(messagesByConversation, conversationId);
+ if (!messages) {
+ assert(false, 'Could not find conversation with this ID');
+ return true;
+ }
+ return messages.messageIds.length === 0;
+ }
+);
+
const collator = new Intl.Collator();
// Note: we will probably want to put i18n and regionCode back when we are formatting
@@ -256,6 +283,86 @@ export const getMe = createSelector(
}
);
+export const getComposerContactSearchTerm = createSelector(
+ getComposerState,
+ (composer): string => {
+ if (!composer) {
+ assert(false, 'getComposerContactSearchTerm: composer is not open');
+ return '';
+ }
+ return composer.contactSearchTerm;
+ }
+);
+
+/**
+ * This returns contacts for the composer, which isn't just your primary's system
+ * contacts. It may include false positives, which is better than missing contacts.
+ *
+ * Because it filters unregistered contacts and that's (partially) determined by the
+ * current time, it's possible for this to return stale contacts that have unregistered
+ * if no other conversations change. This should be a rare false positive.
+ */
+const getContacts = createSelector(
+ getConversationLookup,
+ (conversationLookup: ConversationLookupType): Array =>
+ Object.values(conversationLookup).filter(
+ contact =>
+ contact.type === 'direct' &&
+ !contact.isMe &&
+ !contact.isBlocked &&
+ !isConversationUnregistered(contact) &&
+ (isString(contact.name) || contact.profileSharing)
+ )
+);
+
+const getNormalizedComposerContactSearchTerm = createSelector(
+ getComposerContactSearchTerm,
+ (searchTerm: string): string => searchTerm.trim()
+);
+
+const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) =>
+ i18n('noteToSelf').toLowerCase()
+);
+
+const COMPOSE_CONTACTS_FUSE_OPTIONS: FuseOptions = {
+ // A small-but-nonzero threshold lets us match parts of E164s better, and makes the
+ // search a little more forgiving.
+ threshold: 0.05,
+ keys: ['title', 'name', 'e164'],
+};
+
+export const getComposeContacts = createSelector(
+ getNormalizedComposerContactSearchTerm,
+ getContacts,
+ getMe,
+ getNoteToSelfTitle,
+ (
+ searchTerm: string,
+ contacts: Array,
+ noteToSelf: ConversationType,
+ noteToSelfTitle: string
+ ): Array => {
+ let result: Array;
+
+ if (searchTerm.length) {
+ const fuse = new Fuse(
+ contacts,
+ COMPOSE_CONTACTS_FUSE_OPTIONS
+ );
+ result = fuse.search(searchTerm);
+ if (noteToSelfTitle.includes(searchTerm)) {
+ result.push(noteToSelf);
+ }
+ } else {
+ result = contacts.concat();
+ result.sort((a, b) => collator.compare(a.title, b.title));
+ result.push(noteToSelf);
+ }
+
+ return result;
+ }
+);
+
// This is where we will put Conversation selector logic, replicating what
// is currently in models/conversation.getProps()
// What needs to happen to pull that selector logic here?
diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts
index 2bca7627d..51085eb79 100644
--- a/ts/state/selectors/search.ts
+++ b/ts/state/selectors/search.ts
@@ -1,9 +1,10 @@
-// Copyright 2019-2020 Signal Messenger, LLC
+// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import memoizee from 'memoizee';
import { createSelector } from 'reselect';
-import { instance } from '../../util/libphonenumberInstance';
+
+import { deconstructLookup } from '../../util/deconstructLookup';
import { StateType } from '../reducer';
@@ -17,19 +18,14 @@ import {
ConversationType,
} from '../ducks/conversations';
-import {
- PropsDataType as SearchResultsPropsType,
- SearchResultRowType,
-} from '../../components/SearchResults';
-import { PropsDataType as MessageSearchResultPropsDataType } from '../../components/MessageSearchResult';
+import { LeftPaneSearchPropsType } from '../../components/leftPane/LeftPaneSearchHelper';
+import { PropsDataType as MessageSearchResultPropsDataType } from '../../components/conversationList/MessageSearchResult';
-import { getRegionCode, getUserConversationId } from './user';
-import { getUserAgent } from './items';
+import { getUserConversationId } from './user';
import {
GetConversationByIdType,
getConversationLookup,
getConversationSelector,
- getSelectedConversationId,
} from './conversations';
export const getSearch = (state: StateType): SearchStateType => state.search;
@@ -72,148 +68,44 @@ export const getMessageSearchResultLookup = createSelector(
getSearch,
(state: SearchStateType) => state.messageLookup
);
+
export const getSearchResults = createSelector(
- [
- getSearch,
- getRegionCode,
- getUserAgent,
- getConversationLookup,
- getSelectedConversationId,
- getSelectedMessage,
- ],
+ [getSearch, getConversationLookup],
(
state: SearchStateType,
- regionCode: string,
- userAgent: string,
- lookup: ConversationLookupType,
- selectedConversationId?: string,
- selectedMessageId?: string
- ): SearchResultsPropsType | undefined => {
+ conversationLookup: ConversationLookupType
+ ): LeftPaneSearchPropsType => {
const {
- contacts,
- conversations,
+ contactIds,
+ conversationIds,
discussionsLoading,
messageIds,
+ messageLookup,
messagesLoading,
searchConversationName,
} = state;
- const showStartNewConversation = Boolean(
- state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber]
- );
- const haveConversations = conversations && conversations.length;
- const haveContacts = contacts && contacts.length;
- const haveMessages = messageIds && messageIds.length;
- const noResults =
- !discussionsLoading &&
- !messagesLoading &&
- !showStartNewConversation &&
- !haveConversations &&
- !haveContacts &&
- !haveMessages;
-
- const items: Array = [];
-
- if (showStartNewConversation) {
- items.push({
- type: 'start-new-conversation',
- data: undefined,
- });
-
- const isIOS = userAgent === 'OWI';
- let isValidNumber = false;
- try {
- // Sometimes parse() throws, like for invalid country codes
- const parsedNumber = instance.parse(state.query, regionCode);
- isValidNumber = instance.isValidNumber(parsedNumber);
- } catch (_) {
- // no-op
- }
-
- if (!isIOS && isValidNumber) {
- items.push({
- type: 'sms-mms-not-supported-text',
- data: undefined,
- });
- }
- }
-
- if (haveConversations) {
- items.push({
- type: 'conversations-header',
- data: undefined,
- });
- conversations.forEach(id => {
- const data = lookup[id];
- items.push({
- type: 'conversation',
- data: {
- ...data,
- isSelected: Boolean(data && id === selectedConversationId),
- },
- });
- });
- } else if (discussionsLoading) {
- items.push({
- type: 'conversations-header',
- data: undefined,
- });
- items.push({
- type: 'spinner',
- data: undefined,
- });
- }
-
- if (haveContacts) {
- items.push({
- type: 'contacts-header',
- data: undefined,
- });
- contacts.forEach(id => {
- const data = lookup[id];
-
- items.push({
- type: 'contact',
- data: {
- ...data,
- isSelected: Boolean(data && id === selectedConversationId),
- },
- });
- });
- }
-
- if (haveMessages) {
- items.push({
- type: 'messages-header',
- data: undefined,
- });
- messageIds.forEach(messageId => {
- items.push({
- type: 'message',
- data: messageId,
- });
- });
- } else if (messagesLoading) {
- items.push({
- type: 'messages-header',
- data: undefined,
- });
- items.push({
- type: 'spinner',
- data: undefined,
- });
- }
-
return {
- discussionsLoading,
- items,
- messagesLoading,
- noResults,
- regionCode,
+ conversationResults: discussionsLoading
+ ? { isLoading: true }
+ : {
+ isLoading: false,
+ results: deconstructLookup(conversationLookup, conversationIds),
+ },
+ contactResults: discussionsLoading
+ ? { isLoading: true }
+ : {
+ isLoading: false,
+ results: deconstructLookup(conversationLookup, contactIds),
+ },
+ messageResults: messagesLoading
+ ? { isLoading: true }
+ : {
+ isLoading: false,
+ results: deconstructLookup(messageLookup, messageIds),
+ },
searchConversationName,
searchTerm: state.query,
- selectedConversationId,
- selectedMessageId,
};
}
);
diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx
index 10f473bd5..1bed153be 100644
--- a/ts/state/smart/CompositionArea.tsx
+++ b/ts/state/smart/CompositionArea.tsx
@@ -1,4 +1,4 @@
-// Copyright 2019-2020 Signal Messenger, LLC
+// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
@@ -10,7 +10,10 @@ import { StateType } from '../reducer';
import { isShortName } from '../../components/emoji/lib';
import { getIntl } from '../selectors/user';
-import { getConversationSelector } from '../selectors/conversations';
+import {
+ getConversationSelector,
+ getIsConversationEmptySelector,
+} from '../selectors/conversations';
import {
getBlessedStickerPacks,
getInstalledStickerPacks,
@@ -78,6 +81,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
// Message Requests
...conversation,
conversationType: conversation.type,
+ isMissingMandatoryProfileSharing:
+ !conversation.profileSharing &&
+ window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') &&
+ !getIsConversationEmptySelector(state)(id),
};
};
diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx
index 1b19a3895..53905d1da 100644
--- a/ts/state/smart/ConversationHeader.tsx
+++ b/ts/state/smart/ConversationHeader.tsx
@@ -7,7 +7,10 @@ import {
ConversationHeader,
OutgoingCallButtonStyle,
} from '../../components/conversation/ConversationHeader';
-import { getConversationSelector } from '../selectors/conversations';
+import {
+ getConversationSelector,
+ getIsConversationEmptySelector,
+} from '../selectors/conversations';
import { StateType } from '../reducer';
import { CallMode } from '../../types/Calling';
import {
@@ -78,7 +81,9 @@ const getOutgoingCallButtonStyle = (
};
const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
- const conversation = getConversationSelector(state)(ownProps.id);
+ const { id } = ownProps;
+
+ const conversation = getConversationSelector(state)(id);
if (!conversation) {
throw new Error('Could not find conversation');
}
@@ -92,7 +97,6 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
'expireTimer',
'isArchived',
'isMe',
- 'isMissingMandatoryProfileSharing',
'isPinned',
'isVerified',
'left',
@@ -106,6 +110,10 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
'groupVersion',
]),
conversationTitle: state.conversations.selectedConversationTitle,
+ isMissingMandatoryProfileSharing:
+ !conversation.profileSharing &&
+ window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') &&
+ !getIsConversationEmptySelector(state)(id),
i18n: getIntl(state),
showBackButton: state.conversations.selectedConversationPanelDepth > 0,
outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state),
diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx
index 839e1d8d9..7f0ffb502 100644
--- a/ts/state/smart/LeftPane.tsx
+++ b/ts/state/smart/LeftPane.tsx
@@ -1,18 +1,26 @@
-// Copyright 2019-2020 Signal Messenger, LLC
+// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React from 'react';
+import React, { CSSProperties } from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
-import { LeftPane } from '../../components/LeftPane';
+import {
+ LeftPane,
+ LeftPaneMode,
+ PropsType as LeftPanePropsType,
+} from '../../components/LeftPane';
import { StateType } from '../reducer';
import { getSearchResults, isSearching } from '../selectors/search';
-import { getIntl } from '../selectors/user';
+import { getIntl, getRegionCode } from '../selectors/user';
import {
+ getComposeContacts,
+ getComposerContactSearchTerm,
getLeftPaneLists,
getSelectedConversationId,
+ getSelectedMessage,
getShowArchived,
+ isComposing,
} from '../selectors/conversations';
import { SmartExpiredBuildDialog } from './ExpiredBuildDialog';
@@ -34,8 +42,11 @@ function renderExpiredBuildDialog(): JSX.Element {
function renderMainHeader(): JSX.Element {
return ;
}
-function renderMessageSearchResult(id: string): JSX.Element {
- return ;
+function renderMessageSearchResult(
+ id: string,
+ style: CSSProperties
+): JSX.Element {
+ return ;
}
function renderNetworkStatus(): JSX.Element {
return ;
@@ -47,19 +58,47 @@ function renderUpdateDialog(): JSX.Element {
return ;
}
-const mapStateToProps = (state: StateType) => {
- const showSearch = isSearching(state);
+const getModeSpecificProps = (
+ state: StateType
+): LeftPanePropsType['modeSpecificProps'] => {
+ if (isComposing(state)) {
+ return {
+ mode: LeftPaneMode.Compose,
+ composeContacts: getComposeContacts(state),
+ regionCode: getRegionCode(state),
+ searchTerm: getComposerContactSearchTerm(state),
+ };
+ }
- const lists = showSearch ? undefined : getLeftPaneLists(state);
- const searchResults = showSearch ? getSearchResults(state) : undefined;
- const selectedConversationId = getSelectedConversationId(state);
+ if (getShowArchived(state)) {
+ const { archivedConversations } = getLeftPaneLists(state);
+ return {
+ mode: LeftPaneMode.Archive,
+ archivedConversations,
+ };
+ }
+
+ if (isSearching(state)) {
+ return {
+ mode: LeftPaneMode.Search,
+ ...getSearchResults(state),
+ };
+ }
return {
- ...lists,
- searchResults,
- selectedConversationId,
+ mode: LeftPaneMode.Inbox,
+ ...getLeftPaneLists(state),
+ };
+};
+
+const mapStateToProps = (state: StateType) => {
+ return {
+ modeSpecificProps: getModeSpecificProps(state),
+ selectedConversationId: getSelectedConversationId(state),
+ selectedMessageId: getSelectedMessage(state)?.id,
showArchived: getShowArchived(state),
i18n: getIntl(state),
+ regionCode: getRegionCode(state),
renderExpiredBuildDialog,
renderMainHeader,
renderMessageSearchResult,
diff --git a/ts/state/smart/MessageSearchResult.tsx b/ts/state/smart/MessageSearchResult.tsx
index 5e3ebfdf9..78e5f6004 100644
--- a/ts/state/smart/MessageSearchResult.tsx
+++ b/ts/state/smart/MessageSearchResult.tsx
@@ -1,27 +1,30 @@
-// Copyright 2019-2020 Signal Messenger, LLC
+// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+import { CSSProperties } from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { StateType } from '../reducer';
-import { MessageSearchResult } from '../../components/MessageSearchResult';
+import { MessageSearchResult } from '../../components/conversationList/MessageSearchResult';
import { getIntl } from '../selectors/user';
import { getMessageSearchResultSelector } from '../selectors/search';
type SmartProps = {
id: string;
+ style: CSSProperties;
};
function mapStateToProps(state: StateType, ourProps: SmartProps) {
- const { id } = ourProps;
+ const { id, style } = ourProps;
const props = getMessageSearchResultSelector(state)(id);
return {
...props,
i18n: getIntl(state),
+ style,
};
}
const smart = connect(mapStateToProps, mapDispatchToProps);
diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts
index 6ba2a611b..4238107c6 100644
--- a/ts/test-both/state/selectors/conversations_test.ts
+++ b/ts/test-both/state/selectors/conversations_test.ts
@@ -1,4 +1,4 @@
-// Copyright 2019-2020 Signal Messenger, LLC
+// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
@@ -11,13 +11,19 @@ import {
import {
_getConversationComparator,
_getLeftPaneLists,
+ getComposeContacts,
+ getComposerContactSearchTerm,
getConversationSelector,
+ getIsConversationEmptySelector,
getPlaceholderContact,
getSelectedConversation,
getSelectedConversationId,
+ isComposing,
} from '../../../state/selectors/conversations';
import { noopAction } from '../../../state/ducks/noop';
import { StateType, reducer as rootReducer } from '../../../state/reducer';
+import { setup as setupI18n } from '../../../../js/modules/i18n';
+import enMessages from '../../../../_locales/en/messages.json';
describe('both/state/selectors/conversations', () => {
const getEmptyRootState = (): StateType => {
@@ -32,6 +38,8 @@ describe('both/state/selectors/conversations', () => {
};
}
+ const i18n = setupI18n('en', enMessages);
+
describe('#getConversationSelector', () => {
it('returns empty placeholder if falsey id provided', () => {
const state = getEmptyRootState();
@@ -211,6 +219,217 @@ describe('both/state/selectors/conversations', () => {
});
});
+ describe('#getIsConversationEmptySelector', () => {
+ it('returns a selector that returns true for conversations that have no messages', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ messagesByConversation: {
+ abc123: {
+ heightChangeMessageIds: [],
+ isLoadingMessages: false,
+ messageIds: [],
+ metrics: { totalUnread: 0 },
+ resetCounter: 0,
+ scrollToMessageCounter: 0,
+ },
+ },
+ },
+ };
+ const selector = getIsConversationEmptySelector(state);
+
+ assert.isTrue(selector('abc123'));
+ });
+
+ it('returns a selector that returns true for conversations that have no messages, even if loading', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ messagesByConversation: {
+ abc123: {
+ heightChangeMessageIds: [],
+ isLoadingMessages: true,
+ messageIds: [],
+ metrics: { totalUnread: 0 },
+ resetCounter: 0,
+ scrollToMessageCounter: 0,
+ },
+ },
+ },
+ };
+ const selector = getIsConversationEmptySelector(state);
+
+ assert.isTrue(selector('abc123'));
+ });
+
+ it('returns a selector that returns false for conversations that have messages', () => {
+ const state = {
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ messagesByConversation: {
+ abc123: {
+ heightChangeMessageIds: [],
+ isLoadingMessages: false,
+ messageIds: ['xyz'],
+ metrics: { totalUnread: 0 },
+ resetCounter: 0,
+ scrollToMessageCounter: 0,
+ },
+ },
+ },
+ };
+ const selector = getIsConversationEmptySelector(state);
+
+ assert.isFalse(selector('abc123'));
+ });
+ });
+
+ describe('#isComposing', () => {
+ it('returns false if there is no composer state', () => {
+ assert.isFalse(isComposing(getEmptyRootState()));
+ });
+
+ it('returns true if there is composer state', () => {
+ assert.isTrue(
+ isComposing({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ contactSearchTerm: '',
+ },
+ },
+ })
+ );
+ });
+ });
+
+ describe('#getComposeContacts', () => {
+ const getRootState = (contactSearchTerm = ''): StateType => {
+ const rootState = getEmptyRootState();
+ return {
+ ...rootState,
+ conversations: {
+ ...getEmptyState(),
+ conversationLookup: {
+ 'our-conversation-id': {
+ ...getDefaultConversation('our-conversation-id'),
+ isMe: true,
+ },
+ },
+ composer: {
+ contactSearchTerm,
+ },
+ },
+ user: {
+ ...rootState.user,
+ ourConversationId: 'our-conversation-id',
+ i18n,
+ },
+ };
+ };
+
+ const getRootStateWithConverastions = (
+ contactSearchTerm = ''
+ ): StateType => {
+ const result = getRootState(contactSearchTerm);
+ Object.assign(result.conversations.conversationLookup, {
+ 'convo-1': {
+ ...getDefaultConversation('convo-1'),
+ name: 'In System Contacts',
+ title: 'A. Sorted First',
+ },
+ 'convo-2': {
+ ...getDefaultConversation('convo-2'),
+ title: 'Should Be Dropped (no name, no profile sharing)',
+ },
+ 'convo-3': {
+ ...getDefaultConversation('convo-3'),
+ type: 'group',
+ title: 'Should Be Dropped (group)',
+ },
+ 'convo-4': {
+ ...getDefaultConversation('convo-4'),
+ isBlocked: true,
+ title: 'Should Be Dropped (blocked)',
+ },
+ 'convo-5': {
+ ...getDefaultConversation('convo-5'),
+ discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(),
+ title: 'Should Be Dropped (unregistered)',
+ },
+ 'convo-6': {
+ ...getDefaultConversation('convo-6'),
+ profileSharing: true,
+ title: 'C. Has Profile Sharing',
+ },
+ 'convo-7': {
+ ...getDefaultConversation('convo-7'),
+ discoveredUnregisteredAt: Date.now(),
+ name: 'In System Contacts (and only recently unregistered)',
+ title: 'B. Sorted Second',
+ },
+ });
+ return result;
+ };
+
+ it('only returns Note to Self when there are no other contacts', () => {
+ const state = getRootState();
+ const result = getComposeContacts(state);
+
+ assert.lengthOf(result, 1);
+ assert.strictEqual(result[0]?.id, 'our-conversation-id');
+ });
+
+ it("returns no results when search doesn't match Note to Self and there are no other contacts", () => {
+ const state = getRootState('foo bar baz');
+ const result = getComposeContacts(state);
+
+ assert.isEmpty(result);
+ });
+
+ it('returns contacts with Note to Self at the end when there is no search term', () => {
+ const state = getRootStateWithConverastions();
+ const result = getComposeContacts(state);
+
+ const ids = result.map(contact => contact.id);
+ assert.deepEqual(ids, [
+ 'convo-1',
+ 'convo-7',
+ 'convo-6',
+ 'our-conversation-id',
+ ]);
+ });
+
+ it('can search for contacts', () => {
+ const state = getRootStateWithConverastions('in system');
+ const result = getComposeContacts(state);
+
+ const ids = result.map(contact => contact.id);
+ assert.deepEqual(ids, ['convo-1', 'convo-7']);
+ });
+ });
+
+ describe('#getComposerContactSearchTerm', () => {
+ it("returns the composer's contact search term", () => {
+ assert.strictEqual(
+ getComposerContactSearchTerm({
+ ...getEmptyRootState(),
+ conversations: {
+ ...getEmptyState(),
+ composer: {
+ contactSearchTerm: 'foo bar',
+ },
+ },
+ }),
+ 'foo bar'
+ );
+ });
+ });
+
describe('#getLeftPaneList', () => {
it('sorts conversations based on timestamp then by intl-friendly title', () => {
const data: ConversationLookupType = {
diff --git a/ts/test-both/state/selectors/search_test.ts b/ts/test-both/state/selectors/search_test.ts
index b0bdb4aca..07a4bf1a7 100644
--- a/ts/test-both/state/selectors/search_test.ts
+++ b/ts/test-both/state/selectors/search_test.ts
@@ -9,9 +9,16 @@ import {
MessageType,
} from '../../../state/ducks/conversations';
import { noopAction } from '../../../state/ducks/noop';
-import { getEmptyState as getEmptySearchState } from '../../../state/ducks/search';
+import {
+ getEmptyState as getEmptySearchState,
+ MessageSearchResultType,
+} from '../../../state/ducks/search';
import { getEmptyState as getEmptyUserState } from '../../../state/ducks/user';
-import { getMessageSearchResultSelector } from '../../../state/selectors/search';
+import {
+ getMessageSearchResultSelector,
+ getSearchResults,
+} from '../../../state/selectors/search';
+import { makeLookup } from '../../../util/makeLookup';
import { StateType, reducer as rootReducer } from '../../../state/reducer';
@@ -34,6 +41,13 @@ describe('both/state/selectors/search', () => {
};
}
+ function getDefaultSearchMessage(id: string): MessageSearchResultType {
+ return {
+ ...getDefaultMessage(id),
+ snippet: 'foo bar',
+ };
+ }
+
function getDefaultConversation(id: string): ConversationType {
return {
id,
@@ -209,4 +223,81 @@ describe('both/state/selectors/search', () => {
assert.notStrictEqual(actual, thirdActual);
});
});
+
+ describe('#getSearchResults', () => {
+ it("returns loading search results when they're loading", () => {
+ const state = {
+ ...getEmptyRootState(),
+ search: {
+ ...getEmptySearchState(),
+ query: 'foo bar',
+ discussionsLoading: true,
+ messagesLoading: true,
+ },
+ };
+
+ assert.deepEqual(getSearchResults(state), {
+ conversationResults: { isLoading: true },
+ contactResults: { isLoading: true },
+ messageResults: { isLoading: true },
+ searchConversationName: undefined,
+ searchTerm: 'foo bar',
+ });
+ });
+
+ it('returns loaded search results', () => {
+ const conversations: Array = [
+ getDefaultConversation('1'),
+ getDefaultConversation('2'),
+ ];
+ const contacts: Array = [
+ getDefaultConversation('3'),
+ getDefaultConversation('4'),
+ getDefaultConversation('5'),
+ ];
+ const messages: Array = [
+ getDefaultSearchMessage('a'),
+ getDefaultSearchMessage('b'),
+ getDefaultSearchMessage('c'),
+ ];
+
+ const getId = ({ id }: Readonly<{ id: string }>) => id;
+
+ const state: StateType = {
+ ...getEmptyRootState(),
+ conversations: {
+ // This test state is invalid, but is good enough for this test.
+ ...getEmptyConversationState(),
+ conversationLookup: makeLookup([...conversations, ...contacts], 'id'),
+ },
+ search: {
+ ...getEmptySearchState(),
+ query: 'foo bar',
+ conversationIds: conversations.map(getId),
+ contactIds: contacts.map(getId),
+ messageIds: messages.map(getId),
+ messageLookup: makeLookup(messages, 'id'),
+ discussionsLoading: false,
+ messagesLoading: false,
+ },
+ };
+
+ assert.deepEqual(getSearchResults(state), {
+ conversationResults: {
+ isLoading: false,
+ results: conversations,
+ },
+ contactResults: {
+ isLoading: false,
+ results: contacts,
+ },
+ messageResults: {
+ isLoading: false,
+ results: messages,
+ },
+ searchConversationName: undefined,
+ searchTerm: 'foo bar',
+ });
+ });
+ });
});
diff --git a/ts/test-both/util/deconstructLookup_test.ts b/ts/test-both/util/deconstructLookup_test.ts
new file mode 100644
index 000000000..6dc73fb94
--- /dev/null
+++ b/ts/test-both/util/deconstructLookup_test.ts
@@ -0,0 +1,19 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { deconstructLookup } from '../../util/deconstructLookup';
+
+describe('deconstructLookup', () => {
+ it('looks up an array of properties in a lookup', () => {
+ const lookup = {
+ high: 5,
+ seven: 89,
+ big: 999,
+ };
+ const keys = ['seven', 'high'];
+
+ assert.deepEqual(deconstructLookup(lookup, keys), [89, 5]);
+ });
+});
diff --git a/ts/test-both/util/isConversationUnread_test.ts b/ts/test-both/util/isConversationUnread_test.ts
new file mode 100644
index 000000000..18ad7723f
--- /dev/null
+++ b/ts/test-both/util/isConversationUnread_test.ts
@@ -0,0 +1,46 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { isConversationUnread } from '../../util/isConversationUnread';
+
+describe('isConversationUnread', () => {
+ it('returns false if both markedUnread and unreadCount are undefined', () => {
+ assert.isFalse(isConversationUnread({}));
+ assert.isFalse(
+ isConversationUnread({
+ markedUnread: undefined,
+ unreadCount: undefined,
+ })
+ );
+ });
+
+ it('returns false if markedUnread is false', () => {
+ assert.isFalse(isConversationUnread({ markedUnread: false }));
+ });
+
+ it('returns false if unreadCount is 0', () => {
+ assert.isFalse(isConversationUnread({ unreadCount: 0 }));
+ });
+
+ it('returns true if markedUnread is true, regardless of unreadCount', () => {
+ assert.isTrue(isConversationUnread({ markedUnread: true }));
+ assert.isTrue(isConversationUnread({ markedUnread: true, unreadCount: 0 }));
+ assert.isTrue(
+ isConversationUnread({ markedUnread: true, unreadCount: 100 })
+ );
+ });
+
+ it('returns true if unreadCount is positive, regardless of markedUnread', () => {
+ assert.isTrue(isConversationUnread({ unreadCount: 1 }));
+ assert.isTrue(isConversationUnread({ unreadCount: 99 }));
+ assert.isTrue(
+ isConversationUnread({ markedUnread: false, unreadCount: 2 })
+ );
+ });
+
+ it('returns true if both markedUnread is true and unreadCount is positive', () => {
+ assert.isTrue(isConversationUnread({ markedUnread: true, unreadCount: 1 }));
+ });
+});
diff --git a/ts/test-both/util/isConversationUnregistered_test.ts b/ts/test-both/util/isConversationUnregistered_test.ts
new file mode 100644
index 000000000..c00a3f5bd
--- /dev/null
+++ b/ts/test-both/util/isConversationUnregistered_test.ts
@@ -0,0 +1,50 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { isConversationUnregistered } from '../../util/isConversationUnregistered';
+
+describe('isConversationUnregistered', () => {
+ it('returns false if passed an undefined discoveredUnregisteredAt', () => {
+ assert.isFalse(isConversationUnregistered({}));
+ assert.isFalse(
+ isConversationUnregistered({ discoveredUnregisteredAt: undefined })
+ );
+ });
+
+ it('returns false if passed a time fewer than 6 hours ago', () => {
+ assert.isFalse(
+ isConversationUnregistered({ discoveredUnregisteredAt: Date.now() })
+ );
+
+ const fiveHours = 1000 * 60 * 60 * 5;
+ assert.isFalse(
+ isConversationUnregistered({
+ discoveredUnregisteredAt: Date.now() - fiveHours,
+ })
+ );
+ });
+
+ it('returns false if passed a time in the future', () => {
+ assert.isFalse(
+ isConversationUnregistered({ discoveredUnregisteredAt: Date.now() + 123 })
+ );
+ });
+
+ it('returns true if passed a time more than 6 hours ago', () => {
+ const oneMinute = 1000 * 60;
+ const sixHours = 1000 * 60 * 60 * 6;
+
+ assert.isTrue(
+ isConversationUnregistered({
+ discoveredUnregisteredAt: Date.now() - sixHours - oneMinute,
+ })
+ );
+ assert.isTrue(
+ isConversationUnregistered({
+ discoveredUnregisteredAt: new Date(1999, 3, 20).getTime(),
+ })
+ );
+ });
+});
diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts
index 871c62a2f..56e163253 100644
--- a/ts/test-electron/state/ducks/conversations_test.ts
+++ b/ts/test-electron/state/ducks/conversations_test.ts
@@ -1,8 +1,11 @@
-// Copyright 2020 Signal Messenger, LLC
+// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
+import * as sinon from 'sinon';
import { set } from 'lodash/fp';
+import { reducer as rootReducer } from '../../../state/reducer';
+import { noopAction } from '../../../state/ducks/noop';
import {
actions,
ConversationMessageType,
@@ -13,17 +16,35 @@ import {
MessageType,
reducer,
updateConversationLookups,
+ SwitchToAssociatedViewActionType,
} from '../../../state/ducks/conversations';
import { CallMode } from '../../../types/Calling';
const {
messageSizeChanged,
+ openConversationInternal,
repairNewestMessage,
repairOldestMessage,
+ setComposeSearchTerm,
setPreJoinConversation,
+ showArchivedConversations,
+ showInbox,
+ startComposing,
} = actions;
describe('both/state/ducks/conversations', () => {
+ const getEmptyRootState = () => rootReducer(undefined, noopAction());
+
+ let sinonSandbox: sinon.SinonSandbox;
+
+ beforeEach(() => {
+ sinonSandbox = sinon.createSandbox();
+ });
+
+ afterEach(() => {
+ sinonSandbox.restore();
+ });
+
describe('helpers', () => {
describe('getConversationCallMode', () => {
const fakeConversation: ConversationType = {
@@ -295,6 +316,132 @@ describe('both/state/ducks/conversations', () => {
};
}
+ describe('openConversationInternal', () => {
+ beforeEach(() => {
+ sinonSandbox.stub(window.Whisper.events, 'trigger');
+ });
+
+ it("returns a thunk that triggers a 'showConversation' event when passed a conversation ID", () => {
+ const dispatch = sinon.spy();
+
+ openConversationInternal({ conversationId: 'abc123' })(
+ dispatch,
+ getEmptyRootState,
+ null
+ );
+
+ sinon.assert.calledOnce(
+ window.Whisper.events.trigger as sinon.SinonSpy
+ );
+ sinon.assert.calledWith(
+ window.Whisper.events.trigger as sinon.SinonSpy,
+ 'showConversation',
+ 'abc123',
+ undefined
+ );
+ });
+
+ it("returns a thunk that triggers a 'showConversation' event when passed a conversation ID and message ID", () => {
+ const dispatch = sinon.spy();
+
+ openConversationInternal({
+ conversationId: 'abc123',
+ messageId: 'xyz987',
+ })(dispatch, getEmptyRootState, null);
+
+ sinon.assert.calledOnce(
+ window.Whisper.events.trigger as sinon.SinonSpy
+ );
+ sinon.assert.calledWith(
+ window.Whisper.events.trigger as sinon.SinonSpy,
+ 'showConversation',
+ 'abc123',
+ 'xyz987'
+ );
+ });
+
+ it("returns a thunk that doesn't dispatch any actions by default", () => {
+ const dispatch = sinon.spy();
+
+ openConversationInternal({ conversationId: 'abc123' })(
+ dispatch,
+ getEmptyRootState,
+ null
+ );
+
+ sinon.assert.notCalled(dispatch);
+ });
+
+ it('dispatches a SWITCH_TO_ASSOCIATED_VIEW action if called with a flag', () => {
+ const dispatch = sinon.spy();
+
+ openConversationInternal({
+ conversationId: 'abc123',
+ switchToAssociatedView: true,
+ })(dispatch, getEmptyRootState, null);
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'SWITCH_TO_ASSOCIATED_VIEW',
+ payload: { conversationId: 'abc123' },
+ });
+ });
+
+ describe('SWITCH_TO_ASSOCIATED_VIEW', () => {
+ let action: SwitchToAssociatedViewActionType;
+
+ beforeEach(() => {
+ const dispatch = sinon.spy();
+ openConversationInternal({
+ conversationId: 'fake-conversation-id',
+ switchToAssociatedView: true,
+ })(dispatch, getEmptyRootState, null);
+ [action] = dispatch.getCall(0).args;
+ });
+
+ it('shows the inbox if the conversation is not archived', () => {
+ const state = {
+ ...getEmptyState(),
+ conversationLookup: {
+ 'fake-conversation-id': {
+ id: 'fake-conversation-id',
+ type: 'direct' as const,
+ title: 'Foo Bar',
+ },
+ },
+ };
+ const result = reducer(state, action);
+
+ assert.isUndefined(result.composer);
+ assert.isFalse(result.showArchived);
+ });
+
+ it('shows the archive if the conversation is archived', () => {
+ const state = {
+ ...getEmptyState(),
+ conversationLookup: {
+ 'fake-conversation-id': {
+ id: 'fake-conversation-id',
+ type: 'group' as const,
+ title: 'Baz Qux',
+ isArchived: true,
+ },
+ },
+ };
+ const result = reducer(state, action);
+
+ assert.isUndefined(result.composer);
+ assert.isTrue(result.showArchived);
+ });
+
+ it('does nothing if the conversation is not found', () => {
+ const state = getEmptyState();
+ const result = reducer(state, action);
+
+ assert.strictEqual(result, state);
+ });
+ });
+ });
+
describe('MESSAGE_SIZE_CHANGED', () => {
const stateWithActiveConversation = {
...getEmptyState(),
@@ -579,6 +726,21 @@ describe('both/state/ducks/conversations', () => {
});
});
+ describe('SET_COMPOSE_SEARCH_TERM', () => {
+ it('updates the contact search term', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ contactSearchTerm: '',
+ },
+ };
+ const action = setComposeSearchTerm('foo bar');
+ const result = reducer(state, action);
+
+ assert.strictEqual(result.composer?.contactSearchTerm, 'foo bar');
+ });
+ });
+
describe('SET_PRE_JOIN_CONVERSATION', () => {
const startState = {
...getEmptyState(),
@@ -612,5 +774,116 @@ describe('both/state/ducks/conversations', () => {
assert.isUndefined(resetState.preJoinConversation);
});
});
+
+ describe('SHOW_ARCHIVED_CONVERSATIONS', () => {
+ it('is a no-op when already at the archive', () => {
+ const state = {
+ ...getEmptyState(),
+ showArchived: true,
+ };
+ const action = showArchivedConversations();
+ const result = reducer(state, action);
+
+ assert.isTrue(result.showArchived);
+ assert.isUndefined(result.composer);
+ });
+
+ it('switches from the inbox to the archive', () => {
+ const state = getEmptyState();
+ const action = showArchivedConversations();
+ const result = reducer(state, action);
+
+ assert.isTrue(result.showArchived);
+ assert.isUndefined(result.composer);
+ });
+
+ it('switches from the composer to the archive', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ contactSearchTerm: '',
+ },
+ };
+ const action = showArchivedConversations();
+ const result = reducer(state, action);
+
+ assert.isTrue(result.showArchived);
+ assert.isUndefined(result.composer);
+ });
+ });
+
+ describe('SHOW_INBOX', () => {
+ it('is a no-op when already at the inbox', () => {
+ const state = getEmptyState();
+ const action = showInbox();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.isUndefined(result.composer);
+ });
+
+ it('switches from the archive to the inbox', () => {
+ const state = {
+ ...getEmptyState(),
+ showArchived: true,
+ };
+ const action = showInbox();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.isUndefined(result.composer);
+ });
+
+ it('switches from the composer to the inbox', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ contactSearchTerm: '',
+ },
+ };
+ const action = showInbox();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.isUndefined(result.composer);
+ });
+ });
+
+ describe('START_COMPOSING', () => {
+ it('if already at the composer, does nothing', () => {
+ const state = {
+ ...getEmptyState(),
+ composer: {
+ contactSearchTerm: 'foo bar',
+ },
+ };
+ const action = startComposing();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, { contactSearchTerm: 'foo bar' });
+ });
+
+ it('switches from the inbox to the composer', () => {
+ const state = getEmptyState();
+ const action = startComposing();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, { contactSearchTerm: '' });
+ });
+
+ it('switches from the archive to the inbox', () => {
+ const state = {
+ ...getEmptyState(),
+ showArchived: true,
+ };
+ const action = startComposing();
+ const result = reducer(state, action);
+
+ assert.isFalse(result.showArchived);
+ assert.deepEqual(result.composer, { contactSearchTerm: '' });
+ });
+ });
});
});
diff --git a/ts/test-node/components/LeftPane_test.tsx b/ts/test-node/components/LeftPane_test.tsx
deleted file mode 100644
index 755414a43..000000000
--- a/ts/test-node/components/LeftPane_test.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-// Copyright 2020 Signal Messenger, LLC
-// SPDX-License-Identifier: AGPL-3.0-only
-
-import React from 'react';
-import { assert } from 'chai';
-
-import { LeftPane, RowType, HeaderType } from '../../components/LeftPane';
-import { setup as setupI18n } from '../../../js/modules/i18n';
-import enMessages from '../../../_locales/en/messages.json';
-
-const i18n = setupI18n('en', enMessages);
-
-describe('LeftPane', () => {
- const defaultProps = {
- archivedConversations: [],
- conversations: [],
- i18n,
- openConversationInternal: () => null,
- pinnedConversations: [],
- renderExpiredBuildDialog: () => ,
- renderMainHeader: () => ,
- renderMessageSearchResult: () => ,
- renderNetworkStatus: () => ,
- renderRelinkDialog: () => ,
- renderUpdateDialog: () => ,
- showArchivedConversations: () => null,
- showInbox: () => null,
- startNewConversation: () => null,
- };
-
- describe('getRowFromIndex', () => {
- describe('given only pinned chats', () => {
- it('returns pinned chats, not headers', () => {
- const leftPane = new LeftPane({
- ...defaultProps,
- pinnedConversations: [
- {
- id: 'philly-convo',
- isPinned: true,
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Philip Glass',
- type: 'direct',
- },
- {
- id: 'robbo-convo',
- isPinned: true,
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Robert Moog',
- type: 'direct',
- },
- ],
- });
-
- assert.deepEqual(leftPane.getRowFromIndex(0), {
- index: 0,
- type: RowType.PinnedConversation,
- });
- assert.deepEqual(leftPane.getRowFromIndex(1), {
- index: 1,
- type: RowType.PinnedConversation,
- });
- });
- });
-
- describe('given only non-pinned chats', () => {
- it('returns conversations, not headers', () => {
- const leftPane = new LeftPane({
- ...defaultProps,
- conversations: [
- {
- id: 'fred-convo',
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Fred Willard',
- type: 'direct',
- },
- {
- id: 'robbo-convo',
- isPinned: false,
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Robert Moog',
- type: 'direct',
- },
- ],
- });
-
- assert.deepEqual(leftPane.getRowFromIndex(0), {
- index: 0,
- type: RowType.Conversation,
- });
- assert.deepEqual(leftPane.getRowFromIndex(1), {
- index: 1,
- type: RowType.Conversation,
- });
- });
- });
-
- describe('given only pinned and non-pinned chats', () => {
- it('returns headers and conversations', () => {
- const leftPane = new LeftPane({
- ...defaultProps,
- conversations: [
- {
- id: 'fred-convo',
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Fred Willard',
- type: 'direct',
- },
- ],
- pinnedConversations: [
- {
- id: 'philly-convo',
- isPinned: true,
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Philip Glass',
- type: 'direct',
- },
- ],
- });
-
- assert.deepEqual(leftPane.getRowFromIndex(0), {
- headerType: HeaderType.Pinned,
- type: RowType.Header,
- });
- assert.deepEqual(leftPane.getRowFromIndex(1), {
- index: 0,
- type: RowType.PinnedConversation,
- });
- assert.deepEqual(leftPane.getRowFromIndex(2), {
- headerType: HeaderType.Chats,
- type: RowType.Header,
- });
- assert.deepEqual(leftPane.getRowFromIndex(3), {
- index: 0,
- type: RowType.Conversation,
- });
- });
- });
-
- describe('given not showing archive with archived conversation', () => {
- it('returns an archive button last', () => {
- const leftPane = new LeftPane({
- ...defaultProps,
- archivedConversations: [
- {
- id: 'jerry-convo',
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Jerry Jordan',
- type: 'direct',
- },
- ],
- conversations: [
- {
- id: 'fred-convo',
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Fred Willard',
- type: 'direct',
- },
- ],
- showArchived: false,
- });
-
- assert.deepEqual(leftPane.getRowFromIndex(1), {
- type: RowType.ArchiveButton,
- });
- });
- });
-
- describe('given showing archive and archive chats', () => {
- it('returns archived conversations', () => {
- const leftPane = new LeftPane({
- ...defaultProps,
- archivedConversations: [
- {
- id: 'fred-convo',
- isSelected: false,
- lastUpdated: Date.now(),
- markedUnread: false,
- title: 'Fred Willard',
- type: 'direct',
- },
- ],
- showArchived: true,
- });
-
- assert.deepEqual(leftPane.getRowFromIndex(0), {
- index: 0,
- type: RowType.ArchivedConversation,
- });
- });
- });
- });
-});
diff --git a/ts/test-node/components/leftPane/LeftPaneArchiveHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneArchiveHelper_test.ts
new file mode 100644
index 000000000..1d83e9d2c
--- /dev/null
+++ b/ts/test-node/components/leftPane/LeftPaneArchiveHelper_test.ts
@@ -0,0 +1,162 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import { v4 as uuid } from 'uuid';
+import { RowType } from '../../../components/ConversationList';
+import { FindDirection } from '../../../components/leftPane/LeftPaneHelper';
+
+import { LeftPaneArchiveHelper } from '../../../components/leftPane/LeftPaneArchiveHelper';
+
+describe('LeftPaneArchiveHelper', () => {
+ const fakeConversation = () => ({
+ id: uuid(),
+ title: uuid(),
+ type: 'direct' as const,
+ });
+
+ describe('getRowCount', () => {
+ it('returns the number of archived conversations', () => {
+ assert.strictEqual(
+ new LeftPaneArchiveHelper({ archivedConversations: [] }).getRowCount(),
+ 0
+ );
+ assert.strictEqual(
+ new LeftPaneArchiveHelper({
+ archivedConversations: [fakeConversation(), fakeConversation()],
+ }).getRowCount(),
+ 2
+ );
+ });
+ });
+
+ describe('getRowIndexToScrollTo', () => {
+ it('returns undefined if no conversation is selected', () => {
+ const helper = new LeftPaneArchiveHelper({
+ archivedConversations: [fakeConversation(), fakeConversation()],
+ });
+
+ assert.isUndefined(helper.getRowIndexToScrollTo(undefined));
+ });
+
+ it('returns undefined if the selected conversation is not pinned or non-pinned', () => {
+ const helper = new LeftPaneArchiveHelper({
+ archivedConversations: [fakeConversation(), fakeConversation()],
+ });
+
+ assert.isUndefined(helper.getRowIndexToScrollTo(uuid()));
+ });
+
+ it("returns the archived conversation's index", () => {
+ const archivedConversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneArchiveHelper({ archivedConversations });
+
+ assert.strictEqual(
+ helper.getRowIndexToScrollTo(archivedConversations[0].id),
+ 0
+ );
+ assert.strictEqual(
+ helper.getRowIndexToScrollTo(archivedConversations[1].id),
+ 1
+ );
+ });
+ });
+
+ describe('getRow', () => {
+ it('returns each conversation as a row', () => {
+ const archivedConversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneArchiveHelper({ archivedConversations });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Conversation,
+ conversation: archivedConversations[0],
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: archivedConversations[1],
+ });
+ });
+ });
+
+ describe('getConversationAndMessageAtIndex', () => {
+ it('returns the conversation at the given index when it exists', () => {
+ const archivedConversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneArchiveHelper({ archivedConversations });
+
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(0)?.conversationId,
+ archivedConversations[0].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(1)?.conversationId,
+ archivedConversations[1].id
+ );
+ });
+
+ it('when requesting an index out of bounds, returns the last conversation', () => {
+ const archivedConversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneArchiveHelper({ archivedConversations });
+
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(2)?.conversationId,
+ archivedConversations[1].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(99)?.conversationId,
+ archivedConversations[1].id
+ );
+
+ // This is mostly a resilience measure in case we're ever called with an invalid
+ // index.
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(-1)?.conversationId,
+ archivedConversations[1].id
+ );
+ });
+
+ it('returns undefined if there are no archived conversations', () => {
+ const helper = new LeftPaneArchiveHelper({ archivedConversations: [] });
+
+ assert.isUndefined(helper.getConversationAndMessageAtIndex(0));
+ assert.isUndefined(helper.getConversationAndMessageAtIndex(1));
+ assert.isUndefined(helper.getConversationAndMessageAtIndex(-1));
+ });
+ });
+
+ describe('getConversationAndMessageInDirection', () => {
+ it('returns the next conversation when searching downward', () => {
+ const archivedConversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneArchiveHelper({ archivedConversations });
+
+ assert.deepEqual(
+ helper.getConversationAndMessageInDirection(
+ { direction: FindDirection.Down, unreadOnly: false },
+ archivedConversations[0].id,
+ undefined
+ ),
+ { conversationId: archivedConversations[1].id }
+ );
+ });
+
+ // Additional tests are found with `getConversationInDirection`.
+ });
+
+ describe('shouldRecomputeRowHeights', () => {
+ it('always returns false because row heights are constant', () => {
+ const helper = new LeftPaneArchiveHelper({
+ archivedConversations: [fakeConversation(), fakeConversation()],
+ });
+
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ archivedConversations: [fakeConversation()],
+ })
+ );
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ archivedConversations: [fakeConversation(), fakeConversation()],
+ })
+ );
+ });
+ });
+});
diff --git a/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts
new file mode 100644
index 000000000..9967ac845
--- /dev/null
+++ b/ts/test-node/components/leftPane/LeftPaneComposeHelper_test.ts
@@ -0,0 +1,144 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import { v4 as uuid } from 'uuid';
+import { RowType } from '../../../components/ConversationList';
+import { FindDirection } from '../../../components/leftPane/LeftPaneHelper';
+
+import { LeftPaneComposeHelper } from '../../../components/leftPane/LeftPaneComposeHelper';
+
+describe('LeftPaneComposeHelper', () => {
+ const fakeContact = () => ({
+ id: uuid(),
+ title: uuid(),
+ type: 'direct' as const,
+ });
+
+ describe('getRowCount', () => {
+ it('returns the number of contacts if not searching for a phone number', () => {
+ assert.strictEqual(
+ new LeftPaneComposeHelper({
+ composeContacts: [],
+ regionCode: 'US',
+ searchTerm: 'foo bar',
+ }).getRowCount(),
+ 0
+ );
+ assert.strictEqual(
+ new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: '',
+ }).getRowCount(),
+ 2
+ );
+ });
+
+ it('returns the number of contacts + 1 if searching for a phone number', () => {
+ assert.strictEqual(
+ new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: '+16505551234',
+ }).getRowCount(),
+ 3
+ );
+ });
+ });
+
+ describe('getRow', () => {
+ it('returns each contact as a row if not searching for a phone number', () => {
+ const composeContacts = [fakeContact(), fakeContact()];
+ const helper = new LeftPaneComposeHelper({
+ composeContacts,
+ regionCode: 'US',
+ searchTerm: '',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Contact,
+ contact: composeContacts[0],
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Contact,
+ contact: composeContacts[1],
+ });
+ });
+
+ it('returns a "start new conversation" row if searching for a phone number', () => {
+ const composeContacts = [fakeContact(), fakeContact()];
+ const helper = new LeftPaneComposeHelper({
+ composeContacts,
+ regionCode: 'US',
+ searchTerm: '+16505551234',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.StartNewConversation,
+ phoneNumber: '+16505551234',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Contact,
+ contact: composeContacts[0],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Contact,
+ contact: composeContacts[1],
+ });
+ });
+ });
+
+ describe('getConversationAndMessageAtIndex', () => {
+ it('returns undefined because keyboard shortcuts are not supported', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: 'foo bar',
+ });
+
+ assert.isUndefined(helper.getConversationAndMessageAtIndex(0));
+ });
+ });
+
+ describe('getConversationAndMessageInDirection', () => {
+ it('returns undefined because keyboard shortcuts are not supported', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: 'foo bar',
+ });
+
+ assert.isUndefined(
+ helper.getConversationAndMessageInDirection(
+ { direction: FindDirection.Down, unreadOnly: false },
+ undefined,
+ undefined
+ )
+ );
+ });
+ });
+
+ describe('shouldRecomputeRowHeights', () => {
+ it('always returns false because row heights are constant', () => {
+ const helper = new LeftPaneComposeHelper({
+ composeContacts: [fakeContact(), fakeContact()],
+ regionCode: 'US',
+ searchTerm: 'foo bar',
+ });
+
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ composeContacts: [fakeContact()],
+ searchTerm: 'foo bar',
+ })
+ );
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ composeContacts: [fakeContact(), fakeContact(), fakeContact()],
+ searchTerm: '',
+ })
+ );
+ });
+ });
+});
diff --git a/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.ts
new file mode 100644
index 000000000..0e9568ac1
--- /dev/null
+++ b/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.ts
@@ -0,0 +1,635 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import { v4 as uuid } from 'uuid';
+import { RowType } from '../../../components/ConversationList';
+import { FindDirection } from '../../../components/leftPane/LeftPaneHelper';
+
+import { LeftPaneInboxHelper } from '../../../components/leftPane/LeftPaneInboxHelper';
+
+describe('LeftPaneInboxHelper', () => {
+ const fakeConversation = () => ({
+ id: uuid(),
+ title: uuid(),
+ type: 'direct' as const,
+ });
+
+ describe('getRowCount', () => {
+ it('returns 0 if there are no conversations', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations: [],
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(helper.getRowCount(), 0);
+ });
+
+ it('returns 1 if there are only archived conversations', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations: [],
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.strictEqual(helper.getRowCount(), 1);
+ });
+
+ it("returns the number of non-pinned conversations if that's all there is", () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ pinnedConversations: [],
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(helper.getRowCount(), 3);
+ });
+
+ it("returns the number of pinned conversations if that's all there is", () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(helper.getRowCount(), 3);
+ });
+
+ it('adds 2 rows for each header if there are pinned and non-pinned conversations,', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ pinnedConversations: [fakeConversation()],
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(helper.getRowCount(), 6);
+ });
+
+ it('adds 1 row for the archive button if there are any archived conversations', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ pinnedConversations: [],
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.strictEqual(helper.getRowCount(), 4);
+ });
+ });
+
+ describe('getRowIndexToScrollTo', () => {
+ it('returns undefined if no conversation is selected', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [fakeConversation(), fakeConversation()],
+ pinnedConversations: [fakeConversation()],
+ archivedConversations: [],
+ });
+
+ assert.isUndefined(helper.getRowIndexToScrollTo(undefined));
+ });
+
+ it('returns undefined if the selected conversation is not pinned or non-pinned', () => {
+ const archivedConversations = [fakeConversation()];
+ const helper = new LeftPaneInboxHelper({
+ conversations: [fakeConversation(), fakeConversation()],
+ pinnedConversations: [fakeConversation()],
+ archivedConversations,
+ });
+
+ assert.isUndefined(
+ helper.getRowIndexToScrollTo(archivedConversations[0].id)
+ );
+ });
+
+ it("returns the pinned conversation's index if there are only pinned conversations", () => {
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations,
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(
+ helper.getRowIndexToScrollTo(pinnedConversations[0].id),
+ 0
+ );
+ assert.strictEqual(
+ helper.getRowIndexToScrollTo(pinnedConversations[1].id),
+ 1
+ );
+ });
+
+ it("returns the conversation's index if there are only non-pinned conversations", () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations: [],
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(helper.getRowIndexToScrollTo(conversations[0].id), 0);
+ assert.strictEqual(helper.getRowIndexToScrollTo(conversations[1].id), 1);
+ });
+
+ it("returns the pinned conversation's index + 1 (for the header) if there are both pinned and non-pinned conversations", () => {
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneInboxHelper({
+ conversations: [fakeConversation()],
+ pinnedConversations,
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(
+ helper.getRowIndexToScrollTo(pinnedConversations[0].id),
+ 1
+ );
+ assert.strictEqual(
+ helper.getRowIndexToScrollTo(pinnedConversations[1].id),
+ 2
+ );
+ });
+
+ it("returns the non-pinned conversation's index + pinnedConversations.length + 2 (for the headers) if there are both pinned and non-pinned conversations", () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(helper.getRowIndexToScrollTo(conversations[0].id), 5);
+ assert.strictEqual(helper.getRowIndexToScrollTo(conversations[1].id), 6);
+ });
+ });
+
+ describe('getRow', () => {
+ it('returns the archive button if there are only archived conversations', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations: [],
+ archivedConversations: [fakeConversation(), fakeConversation()],
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.ArchiveButton,
+ archivedConversationsCount: 2,
+ });
+ assert.isUndefined(helper.getRow(1));
+ });
+
+ it("returns pinned conversations if that's all there are", () => {
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations,
+ archivedConversations: [],
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Conversation,
+ conversation: pinnedConversations[0],
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: pinnedConversations[1],
+ });
+ assert.isUndefined(helper.getRow(2));
+ });
+
+ it('returns pinned conversations and an archive button if there are no non-pinned conversations', () => {
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations,
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Conversation,
+ conversation: pinnedConversations[0],
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: pinnedConversations[1],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.ArchiveButton,
+ archivedConversationsCount: 1,
+ });
+ assert.isUndefined(helper.getRow(3));
+ });
+
+ it("returns non-pinned conversations if that's all there are", () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations: [],
+ archivedConversations: [],
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Conversation,
+ conversation: conversations[0],
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: conversations[1],
+ });
+ assert.isUndefined(helper.getRow(2));
+ });
+
+ it('returns non-pinned conversations and an archive button if there are no pinned conversations', () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations: [],
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Conversation,
+ conversation: conversations[0],
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: conversations[1],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.ArchiveButton,
+ archivedConversationsCount: 1,
+ });
+ assert.isUndefined(helper.getRow(3));
+ });
+
+ it('returns headers if there are both pinned and non-pinned conversations', () => {
+ const conversations = [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ];
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations,
+ archivedConversations: [],
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'LeftPane--pinned',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: pinnedConversations[0],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Conversation,
+ conversation: pinnedConversations[1],
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.Header,
+ i18nKey: 'LeftPane--chats',
+ });
+ assert.deepEqual(helper.getRow(4), {
+ type: RowType.Conversation,
+ conversation: conversations[0],
+ });
+ assert.deepEqual(helper.getRow(5), {
+ type: RowType.Conversation,
+ conversation: conversations[1],
+ });
+ assert.deepEqual(helper.getRow(6), {
+ type: RowType.Conversation,
+ conversation: conversations[2],
+ });
+ assert.isUndefined(helper.getRow(7));
+ });
+
+ it('returns headers if there are both pinned and non-pinned conversations, and an archive button', () => {
+ const conversations = [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ];
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations,
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'LeftPane--pinned',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: pinnedConversations[0],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Conversation,
+ conversation: pinnedConversations[1],
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.Header,
+ i18nKey: 'LeftPane--chats',
+ });
+ assert.deepEqual(helper.getRow(4), {
+ type: RowType.Conversation,
+ conversation: conversations[0],
+ });
+ assert.deepEqual(helper.getRow(5), {
+ type: RowType.Conversation,
+ conversation: conversations[1],
+ });
+ assert.deepEqual(helper.getRow(6), {
+ type: RowType.Conversation,
+ conversation: conversations[2],
+ });
+ assert.deepEqual(helper.getRow(7), {
+ type: RowType.ArchiveButton,
+ archivedConversationsCount: 1,
+ });
+ assert.isUndefined(helper.getRow(8));
+ });
+ });
+
+ describe('getConversationAndMessageAtIndex', () => {
+ it('returns pinned converastions, then non-pinned conversations', () => {
+ const conversations = [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ];
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations,
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(0)?.conversationId,
+ pinnedConversations[0].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(1)?.conversationId,
+ pinnedConversations[1].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(2)?.conversationId,
+ conversations[0].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(3)?.conversationId,
+ conversations[1].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(4)?.conversationId,
+ conversations[2].id
+ );
+ });
+
+ it("when requesting an index out of bounds, returns the last pinned conversation when that's all there is", () => {
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations,
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(2)?.conversationId,
+ pinnedConversations[1].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(99)?.conversationId,
+ pinnedConversations[1].id
+ );
+ // This is mostly a resilience measure in case we're ever called with an invalid
+ // index.
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(-1)?.conversationId,
+ pinnedConversations[1].id
+ );
+ });
+
+ it("when requesting an index out of bounds, returns the last non-pinned conversation when that's all there is", () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations: [],
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(2)?.conversationId,
+ conversations[1].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(99)?.conversationId,
+ conversations[1].id
+ );
+ // This is mostly a resilience measure in case we're ever called with an invalid
+ // index.
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(-1)?.conversationId,
+ conversations[1].id
+ );
+ });
+
+ it('when requesting an index out of bounds, returns the last non-pinned conversation when there are both pinned and non-pinned conversations', () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations,
+ archivedConversations: [],
+ });
+
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(4)?.conversationId,
+ conversations[1].id
+ );
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(99)?.conversationId,
+ conversations[1].id
+ );
+ // This is mostly a resilience measure in case we're ever called with an invalid
+ // index.
+ assert.strictEqual(
+ helper.getConversationAndMessageAtIndex(-1)?.conversationId,
+ conversations[1].id
+ );
+ });
+
+ it('returns undefined if there are no conversations', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [],
+ pinnedConversations: [],
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.isUndefined(helper.getConversationAndMessageAtIndex(0));
+ assert.isUndefined(helper.getConversationAndMessageAtIndex(1));
+ assert.isUndefined(helper.getConversationAndMessageAtIndex(-1));
+ });
+ });
+
+ describe('getConversationAndMessageInDirection', () => {
+ it('returns the next conversation when searching downward', () => {
+ const pinnedConversations = [fakeConversation(), fakeConversation()];
+ const conversations = [fakeConversation()];
+ const helper = new LeftPaneInboxHelper({
+ conversations,
+ pinnedConversations,
+ archivedConversations: [],
+ });
+
+ assert.deepEqual(
+ helper.getConversationAndMessageInDirection(
+ { direction: FindDirection.Down, unreadOnly: false },
+ pinnedConversations[1].id,
+ undefined
+ ),
+ { conversationId: conversations[0].id }
+ );
+ });
+
+ // Additional tests are found with `getConversationInDirection`.
+ });
+
+ describe('shouldRecomputeRowHeights', () => {
+ it("returns false if the number of conversations in each section doesn't change", () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ pinnedConversations: [fakeConversation(), fakeConversation()],
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ conversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ pinnedConversations: [fakeConversation(), fakeConversation()],
+ archivedConversations: [fakeConversation(), fakeConversation()],
+ })
+ );
+ });
+
+ it('returns false if the only thing changed is whether conversations are archived', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ pinnedConversations: [fakeConversation(), fakeConversation()],
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ conversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ pinnedConversations: [fakeConversation(), fakeConversation()],
+ archivedConversations: [],
+ })
+ );
+ });
+
+ it('returns false if the only thing changed is the number of non-pinned conversations', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ pinnedConversations: [fakeConversation(), fakeConversation()],
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ conversations: [fakeConversation()],
+ pinnedConversations: [fakeConversation(), fakeConversation()],
+ archivedConversations: [fakeConversation(), fakeConversation()],
+ })
+ );
+ });
+
+ it('returns true if the number of pinned conversations changes', () => {
+ const helper = new LeftPaneInboxHelper({
+ conversations: [fakeConversation()],
+ pinnedConversations: [fakeConversation(), fakeConversation()],
+ archivedConversations: [fakeConversation()],
+ });
+
+ assert.isTrue(
+ helper.shouldRecomputeRowHeights({
+ conversations: [fakeConversation()],
+ pinnedConversations: [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ],
+ archivedConversations: [fakeConversation()],
+ })
+ );
+ assert.isTrue(
+ helper.shouldRecomputeRowHeights({
+ conversations: [fakeConversation()],
+ pinnedConversations: [fakeConversation()],
+ archivedConversations: [fakeConversation()],
+ })
+ );
+ assert.isTrue(
+ helper.shouldRecomputeRowHeights({
+ conversations: [fakeConversation()],
+ pinnedConversations: [],
+ archivedConversations: [fakeConversation()],
+ })
+ );
+ });
+ });
+});
diff --git a/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts
new file mode 100644
index 000000000..5c9e558f9
--- /dev/null
+++ b/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts
@@ -0,0 +1,331 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import { v4 as uuid } from 'uuid';
+import { RowType } from '../../../components/ConversationList';
+
+import { LeftPaneSearchHelper } from '../../../components/leftPane/LeftPaneSearchHelper';
+
+describe('LeftPaneSearchHelper', () => {
+ const fakeConversation = () => ({
+ id: uuid(),
+ title: uuid(),
+ type: 'direct' as const,
+ });
+
+ const fakeMessage = () => ({
+ id: uuid(),
+ conversationId: uuid(),
+ });
+
+ describe('getRowCount', () => {
+ it('returns 0 when there are no search results', () => {
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: { isLoading: false, results: [] },
+ contactResults: { isLoading: false, results: [] },
+ messageResults: { isLoading: false, results: [] },
+ searchTerm: 'foo',
+ });
+
+ assert.strictEqual(helper.getRowCount(), 0);
+ });
+
+ it("returns 2 rows for each section of search results that's loading", () => {
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: { isLoading: true },
+ contactResults: { isLoading: false, results: [] },
+ messageResults: { isLoading: true },
+ searchTerm: 'foo',
+ });
+
+ assert.strictEqual(helper.getRowCount(), 4);
+ });
+
+ it('returns 1 + the number of results, dropping empty sections', () => {
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: {
+ isLoading: false,
+ results: [fakeConversation(), fakeConversation()],
+ },
+ contactResults: { isLoading: false, results: [] },
+ messageResults: { isLoading: false, results: [fakeMessage()] },
+ searchTerm: 'foo',
+ });
+
+ assert.strictEqual(helper.getRowCount(), 5);
+ });
+ });
+
+ describe('getRow', () => {
+ it('returns header + spinner for loading sections', () => {
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: { isLoading: true },
+ contactResults: { isLoading: true },
+ messageResults: { isLoading: true },
+ searchTerm: 'foo',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'conversationsHeader',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Spinner,
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.Spinner,
+ });
+ assert.deepEqual(helper.getRow(4), {
+ type: RowType.Header,
+ i18nKey: 'messagesHeader',
+ });
+ assert.deepEqual(helper.getRow(5), {
+ type: RowType.Spinner,
+ });
+ });
+
+ it('returns header + results when all sections have loaded with results', () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+ const contacts = [fakeConversation()];
+ const messages = [fakeMessage(), fakeMessage()];
+
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: {
+ isLoading: false,
+ results: conversations,
+ },
+ contactResults: { isLoading: false, results: contacts },
+ messageResults: { isLoading: false, results: messages },
+ searchTerm: 'foo',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'conversationsHeader',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: conversations[0],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Conversation,
+ conversation: conversations[1],
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ });
+ assert.deepEqual(helper.getRow(4), {
+ type: RowType.Conversation,
+ conversation: contacts[0],
+ });
+ assert.deepEqual(helper.getRow(5), {
+ type: RowType.Header,
+ i18nKey: 'messagesHeader',
+ });
+ assert.deepEqual(helper.getRow(6), {
+ type: RowType.MessageSearchResult,
+ messageId: messages[0].id,
+ });
+ assert.deepEqual(helper.getRow(7), {
+ type: RowType.MessageSearchResult,
+ messageId: messages[1].id,
+ });
+ });
+
+ it('omits conversations when there are no conversation results', () => {
+ const contacts = [fakeConversation()];
+ const messages = [fakeMessage(), fakeMessage()];
+
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: {
+ isLoading: false,
+ results: [],
+ },
+ contactResults: { isLoading: false, results: contacts },
+ messageResults: { isLoading: false, results: messages },
+ searchTerm: 'foo',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: contacts[0],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Header,
+ i18nKey: 'messagesHeader',
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.MessageSearchResult,
+ messageId: messages[0].id,
+ });
+ assert.deepEqual(helper.getRow(4), {
+ type: RowType.MessageSearchResult,
+ messageId: messages[1].id,
+ });
+ });
+
+ it('omits contacts when there are no contact results', () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+ const messages = [fakeMessage(), fakeMessage()];
+
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: {
+ isLoading: false,
+ results: conversations,
+ },
+ contactResults: { isLoading: false, results: [] },
+ messageResults: { isLoading: false, results: messages },
+ searchTerm: 'foo',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'conversationsHeader',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: conversations[0],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Conversation,
+ conversation: conversations[1],
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.Header,
+ i18nKey: 'messagesHeader',
+ });
+ assert.deepEqual(helper.getRow(4), {
+ type: RowType.MessageSearchResult,
+ messageId: messages[0].id,
+ });
+ assert.deepEqual(helper.getRow(5), {
+ type: RowType.MessageSearchResult,
+ messageId: messages[1].id,
+ });
+ });
+ });
+
+ it('omits messages when there are no message results', () => {
+ const conversations = [fakeConversation(), fakeConversation()];
+ const contacts = [fakeConversation()];
+
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: {
+ isLoading: false,
+ results: conversations,
+ },
+ contactResults: { isLoading: false, results: contacts },
+ messageResults: { isLoading: false, results: [] },
+ searchTerm: 'foo',
+ });
+
+ assert.deepEqual(helper.getRow(0), {
+ type: RowType.Header,
+ i18nKey: 'conversationsHeader',
+ });
+ assert.deepEqual(helper.getRow(1), {
+ type: RowType.Conversation,
+ conversation: conversations[0],
+ });
+ assert.deepEqual(helper.getRow(2), {
+ type: RowType.Conversation,
+ conversation: conversations[1],
+ });
+ assert.deepEqual(helper.getRow(3), {
+ type: RowType.Header,
+ i18nKey: 'contactsHeader',
+ });
+ assert.deepEqual(helper.getRow(4), {
+ type: RowType.Conversation,
+ conversation: contacts[0],
+ });
+ assert.isUndefined(helper.getRow(5));
+ });
+
+ describe('shouldRecomputeRowHeights', () => {
+ it("returns false if the number of results doesn't change", () => {
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: {
+ isLoading: false,
+ results: [fakeConversation(), fakeConversation()],
+ },
+ contactResults: { isLoading: false, results: [] },
+ messageResults: {
+ isLoading: false,
+ results: [fakeMessage(), fakeMessage(), fakeMessage()],
+ },
+ searchTerm: 'foo',
+ });
+
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ conversationResults: {
+ isLoading: false,
+ results: [fakeConversation(), fakeConversation()],
+ },
+ contactResults: { isLoading: false, results: [] },
+ messageResults: {
+ isLoading: false,
+ results: [fakeMessage(), fakeMessage(), fakeMessage()],
+ },
+ searchTerm: 'bar',
+ })
+ );
+ });
+
+ it('returns false when a section goes from loading to loaded with 1 result', () => {
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: { isLoading: true },
+ contactResults: { isLoading: true },
+ messageResults: { isLoading: true },
+ searchTerm: 'foo',
+ });
+
+ assert.isFalse(
+ helper.shouldRecomputeRowHeights({
+ conversationResults: {
+ isLoading: false,
+ results: [fakeConversation()],
+ },
+ contactResults: { isLoading: true },
+ messageResults: { isLoading: true },
+ searchTerm: 'bar',
+ })
+ );
+ });
+
+ it('returns true if the number of results in a section changes', () => {
+ const helper = new LeftPaneSearchHelper({
+ conversationResults: {
+ isLoading: false,
+ results: [fakeConversation(), fakeConversation()],
+ },
+ contactResults: { isLoading: false, results: [] },
+ messageResults: { isLoading: false, results: [] },
+ searchTerm: 'foo',
+ });
+
+ assert.isTrue(
+ helper.shouldRecomputeRowHeights({
+ conversationResults: {
+ isLoading: false,
+ results: [fakeConversation()],
+ },
+ contactResults: { isLoading: true },
+ messageResults: { isLoading: true },
+ searchTerm: 'bar',
+ })
+ );
+ });
+ });
+});
diff --git a/ts/test-node/components/leftPane/getConversationInDirection_test.ts b/ts/test-node/components/leftPane/getConversationInDirection_test.ts
new file mode 100644
index 000000000..02455f3b8
--- /dev/null
+++ b/ts/test-node/components/leftPane/getConversationInDirection_test.ts
@@ -0,0 +1,199 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import { v4 as uuid } from 'uuid';
+import {
+ FindDirection,
+ ToFindType,
+} from '../../../components/leftPane/LeftPaneHelper';
+
+import { getConversationInDirection } from '../../../components/leftPane/getConversationInDirection';
+
+describe('getConversationInDirection', () => {
+ const fakeConversation = (markedUnread = false) => ({
+ id: uuid(),
+ title: uuid(),
+ type: 'direct' as const,
+ markedUnread,
+ });
+
+ const fakeConversations = [
+ fakeConversation(),
+ fakeConversation(true),
+ fakeConversation(true),
+ fakeConversation(),
+ ];
+
+ describe('searching for any conversation', () => {
+ const up: ToFindType = {
+ direction: FindDirection.Up,
+ unreadOnly: false,
+ };
+ const down: ToFindType = {
+ direction: FindDirection.Down,
+ unreadOnly: false,
+ };
+
+ it('returns undefined if there are no conversations', () => {
+ assert.isUndefined(getConversationInDirection([], up, undefined));
+ assert.isUndefined(getConversationInDirection([], down, undefined));
+ });
+
+ it('if no conversation is selected, returns the last conversation when going up', () => {
+ assert.deepEqual(
+ getConversationInDirection(fakeConversations, up, undefined),
+ { conversationId: fakeConversations[3].id }
+ );
+ });
+
+ it('if no conversation is selected, returns the first conversation when going down', () => {
+ assert.deepEqual(
+ getConversationInDirection(fakeConversations, down, undefined),
+ { conversationId: fakeConversations[0].id }
+ );
+ });
+
+ it('if the first conversation is selected, returns the last conversation when going up', () => {
+ assert.deepEqual(
+ getConversationInDirection(
+ fakeConversations,
+ up,
+ fakeConversations[0].id
+ ),
+ { conversationId: fakeConversations[3].id }
+ );
+ });
+
+ it('if the last conversation is selected, returns the first conversation when going down', () => {
+ assert.deepEqual(
+ getConversationInDirection(
+ fakeConversations,
+ down,
+ fakeConversations[3].id
+ ),
+ { conversationId: fakeConversations[0].id }
+ );
+ });
+
+ it('goes up one conversation in normal cases', () => {
+ assert.deepEqual(
+ getConversationInDirection(
+ fakeConversations,
+ up,
+ fakeConversations[2].id
+ ),
+ { conversationId: fakeConversations[1].id }
+ );
+ });
+
+ it('goes down one conversation in normal cases', () => {
+ assert.deepEqual(
+ getConversationInDirection(
+ fakeConversations,
+ down,
+ fakeConversations[0].id
+ ),
+ { conversationId: fakeConversations[1].id }
+ );
+ });
+ });
+
+ describe('searching for unread conversations', () => {
+ const up: ToFindType = {
+ direction: FindDirection.Up,
+ unreadOnly: true,
+ };
+ const down: ToFindType = {
+ direction: FindDirection.Down,
+ unreadOnly: true,
+ };
+
+ const noUnreads = [
+ fakeConversation(),
+ fakeConversation(),
+ fakeConversation(),
+ ];
+
+ it('returns undefined if there are no conversations', () => {
+ assert.isUndefined(getConversationInDirection([], up, undefined));
+ assert.isUndefined(getConversationInDirection([], down, undefined));
+ });
+
+ it('if no conversation is selected, finds the last unread conversation (if it exists) when searching up', () => {
+ assert.deepEqual(
+ getConversationInDirection(fakeConversations, up, undefined),
+ { conversationId: fakeConversations[2].id }
+ );
+ assert.isUndefined(getConversationInDirection(noUnreads, up, undefined));
+ });
+
+ it('if no conversation is selected, finds the first unread conversation (if it exists) when searching down', () => {
+ assert.deepEqual(
+ getConversationInDirection(fakeConversations, down, undefined),
+ { conversationId: fakeConversations[1].id }
+ );
+ assert.isUndefined(
+ getConversationInDirection(noUnreads, down, undefined)
+ );
+ });
+
+ it("searches up for unread conversations, returning undefined if no conversation exists (doesn't wrap around)", () => {
+ assert.deepEqual(
+ getConversationInDirection(
+ fakeConversations,
+ up,
+ fakeConversations[3].id
+ ),
+ { conversationId: fakeConversations[2].id }
+ );
+ assert.deepEqual(
+ getConversationInDirection(
+ fakeConversations,
+ up,
+ fakeConversations[2].id
+ ),
+ { conversationId: fakeConversations[1].id }
+ );
+ assert.isUndefined(
+ getConversationInDirection(
+ fakeConversations,
+ up,
+ fakeConversations[1].id
+ )
+ );
+ assert.isUndefined(
+ getConversationInDirection(noUnreads, up, noUnreads[2].id)
+ );
+ });
+
+ it("searches down for unread conversations, returning undefined if no conversation exists (doesn't wrap around)", () => {
+ assert.deepEqual(
+ getConversationInDirection(
+ fakeConversations,
+ down,
+ fakeConversations[0].id
+ ),
+ { conversationId: fakeConversations[1].id }
+ );
+ assert.deepEqual(
+ getConversationInDirection(
+ fakeConversations,
+ down,
+ fakeConversations[1].id
+ ),
+ { conversationId: fakeConversations[2].id }
+ );
+ assert.isUndefined(
+ getConversationInDirection(
+ fakeConversations,
+ down,
+ fakeConversations[2].id
+ )
+ );
+ assert.isUndefined(
+ getConversationInDirection(noUnreads, down, noUnreads[1].id)
+ );
+ });
+ });
+});
diff --git a/ts/util/deconstructLookup.ts b/ts/util/deconstructLookup.ts
new file mode 100644
index 000000000..c89dc940a
--- /dev/null
+++ b/ts/util/deconstructLookup.ts
@@ -0,0 +1,21 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { getOwn } from './getOwn';
+import { assert } from './assert';
+
+export const deconstructLookup = (
+ lookup: Record,
+ keys: ReadonlyArray
+): Array => {
+ const result: Array = [];
+ keys.forEach((key: string) => {
+ const value = getOwn(lookup, key);
+ if (value) {
+ result.push(value);
+ } else {
+ assert(false, `deconstructLookup: lookup failed for ${key}; dropping`);
+ }
+ });
+ return result;
+};
diff --git a/ts/util/isConversationUnread.ts b/ts/util/isConversationUnread.ts
new file mode 100644
index 000000000..a4a642250
--- /dev/null
+++ b/ts/util/isConversationUnread.ts
@@ -0,0 +1,13 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { isNumber } from 'lodash';
+
+export const isConversationUnread = ({
+ markedUnread,
+ unreadCount,
+}: Readonly<{
+ unreadCount?: number;
+ markedUnread?: boolean;
+}>): boolean =>
+ Boolean(markedUnread || (isNumber(unreadCount) && unreadCount > 0));
diff --git a/ts/util/isConversationUnregistered.ts b/ts/util/isConversationUnregistered.ts
new file mode 100644
index 000000000..e50b0f119
--- /dev/null
+++ b/ts/util/isConversationUnregistered.ts
@@ -0,0 +1,13 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+const SIX_HOURS = 1000 * 60 * 60 * 6;
+
+export function isConversationUnregistered({
+ discoveredUnregisteredAt,
+}: Readonly<{ discoveredUnregisteredAt?: number }>): boolean {
+ return Boolean(
+ discoveredUnregisteredAt &&
+ discoveredUnregisteredAt < Date.now() - SIX_HOURS
+ );
+}
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index 63727572f..0255e8207 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -14531,6 +14531,15 @@
"updated": "2020-10-26T23:56:13.482Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/ConversationList.js",
+ "line": " const listRef = react_1.useRef(null);",
+ "lineNumber": 44,
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-02-12T16:25:08.285Z",
+ "reasonDetail": "Used for scroll calculations"
+ },
{
"rule": "React-useRef",
"path": "ts/components/DirectCallRemoteParticipant.js",
@@ -14584,22 +14593,22 @@
"updated": "2020-07-21T18:34:59.251Z"
},
{
- "rule": "React-createRef",
+ "rule": "React-useRef",
"path": "ts/components/LeftPane.js",
- "line": " this.listRef = react_1.default.createRef();",
- "lineNumber": 33,
+ "line": " const previousModeSpecificPropsRef = react_1.useRef(modeSpecificProps);",
+ "lineNumber": 47,
"reasonCategory": "usageTrusted",
- "updated": "2020-09-11T17:24:56.124Z",
- "reasonDetail": "Used for scroll calculations"
+ "updated": "2021-02-12T16:25:08.285Z",
+ "reasonDetail": "Doesn't interact with the DOM."
},
{
- "rule": "React-createRef",
- "path": "ts/components/LeftPane.js",
- "line": " this.containerRef = react_1.default.createRef();",
- "lineNumber": 34,
+ "rule": "React-useRef",
+ "path": "ts/components/LeftPane.tsx",
+ "line": " const previousModeSpecificPropsRef = useRef(modeSpecificProps);",
+ "lineNumber": 104,
"reasonCategory": "usageTrusted",
- "updated": "2020-09-11T17:24:56.124Z",
- "reasonDetail": "Used for scroll calculations"
+ "updated": "2021-02-12T16:25:08.285Z",
+ "reasonDetail": "Doesn't interact with the DOM."
},
{
"rule": "React-createRef",
@@ -14640,7 +14649,7 @@
"rule": "React-createRef",
"path": "ts/components/MainHeader.tsx",
"line": " this.inputRef = React.createRef();",
- "lineNumber": 78,
+ "lineNumber": 79,
"reasonCategory": "usageTrusted",
"updated": "2020-02-14T20:02:37.507Z",
"reasonDetail": "Used only to set focus"
@@ -14654,29 +14663,11 @@
"updated": "2020-06-23T06:48:06.829Z",
"reasonDetail": "Used to focus cancel button when dialog opens"
},
- {
- "rule": "React-createRef",
- "path": "ts/components/SearchResults.js",
- "line": " this.listRef = react_1.default.createRef();",
- "lineNumber": 27,
- "reasonCategory": "usageTrusted",
- "updated": "2019-08-09T00:44:31.008Z",
- "reasonDetail": "SearchResults needs to interact with its child List directly"
- },
- {
- "rule": "React-createRef",
- "path": "ts/components/SearchResults.js",
- "line": " this.containerRef = react_1.default.createRef();",
- "lineNumber": 28,
- "reasonCategory": "usageTrusted",
- "updated": "2019-08-09T00:44:31.008Z",
- "reasonDetail": "SearchResults needs to interact with its child List directly"
- },
{
"rule": "React-useRef",
"path": "ts/components/ShortcutGuide.js",
"line": " const focusRef = React.useRef(null);",
- "lineNumber": 182,
+ "lineNumber": 186,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Only used to focus the element."
@@ -15321,4 +15312,4 @@
"updated": "2021-01-08T15:46:32.143Z",
"reasonDetail": "Doesn't manipulate the DOM. This is just a function."
}
-]
\ No newline at end of file
+]