Add "new conversation" composer for direct messages

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

View File

@ -1893,6 +1893,18 @@
"message": "Start new conversation…",
"description": "Label underneath number a user enters that is not an existing contact"
},
"newConversation": {
"message": "New conversation",
"description": "Label for header when starting a new conversation"
},
"newConversationContactSearchPlaceholder": {
"message": "Search by name or phone number",
"description": "Placeholder to use when searching for contacts in the composer"
},
"newConversationNoContacts": {
"message": "No contacts found",
"description": "Label shown when there are no contacts to compose to"
},
"notSupportedSMS": {
"message": "SMS/MMS messages are not supported.",
"description": "Label underneath number informing user that SMS is not supported on desktop"
@ -2326,6 +2338,10 @@
"message": "Open conversation menu",
"description": "Shown in the shortcuts guide"
},
"Keyboard--new-conversation": {
"message": "Start new conversation",
"description": "Shown in the shortcuts guide"
},
"Keyboard--archive-conversation": {
"message": "Archive conversation",
"description": "Shown in the shortcuts guide"

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m21.561 4.561-2.122-2.122a1.5 1.5 0 0 0 -2.121 0l-13.495 13.495a1.5 1.5 0 0 0 -.394.7l-1.112 4.442a.5.5 0 0 0 .607.607l4.445-1.112a1.5 1.5 0 0 0 .7-.394l13.5-13.495a1.5 1.5 0 0 0 -.008-2.121zm-14.556 14.555-2.828.707.707-2.823 9.772-9.773 2.122 2.122zm10.833-10.833-2.121-2.121 2.662-2.662 2.121 2.121z"/></svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@ -212,7 +212,7 @@
const { openConversationExternal } = window.reduxActions.conversations;
if (openConversationExternal) {
openConversationExternal(id, messageId);
openConversationExternal(conversation.id, messageId);
}
this.conversation_stack.open(conversation, messageId);

File diff suppressed because it is too large Load Diff

View File

@ -730,7 +730,7 @@ type WhatIsThis = import('./window.d').WhatIsThis;
),
messagesByConversation: {},
messagesLookup: {},
selectedConversation: undefined,
selectedConversationId: undefined,
selectedMessage: undefined,
selectedMessageCounter: 0,
selectedConversationPanelDepth: 0,
@ -866,85 +866,9 @@ type WhatIsThis = import('./window.d').WhatIsThis;
}
};
function getConversationsToSearch() {
const state = store.getState();
const {
archivedConversations,
conversations: unpinnedConversations,
pinnedConversations,
} = window.Signal.State.Selectors.conversations.getLeftPaneLists(state);
return state.conversations.showArchived
? archivedConversations
: [...pinnedConversations, ...unpinnedConversations];
}
function getConversationByIndex(index: WhatIsThis) {
const conversationsToSearch = getConversationsToSearch();
const target = conversationsToSearch[index];
if (target) {
return target.id;
}
return null;
}
function findConversation(
conversationId: WhatIsThis,
direction: WhatIsThis,
unreadOnly: WhatIsThis
) {
const conversationsToSearch = getConversationsToSearch();
const increment = direction === 'up' ? -1 : 1;
let startIndex: WhatIsThis;
if (conversationId) {
const index = conversationsToSearch.findIndex(
(item: WhatIsThis) => item.id === conversationId
);
if (index >= 0) {
startIndex = index + increment;
}
} else {
startIndex = direction === 'up' ? conversationsToSearch.length - 1 : 0;
}
for (
let i = startIndex, max = conversationsToSearch.length;
i >= 0 && i < max;
i += increment
) {
const target = conversationsToSearch[i];
if (!unreadOnly) {
return target.id;
}
if ((target.unreadCount || 0) > 0) {
return target.id;
}
}
return null;
}
const NUMBERS: Record<string, number> = {
'1': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 5,
'6': 6,
'7': 7,
'8': 8,
'9': 9,
};
document.addEventListener('keydown', event => {
const { altKey, ctrlKey, key, metaKey, shiftKey } = event;
const { ctrlKey, key, metaKey, shiftKey } = event;
const optionOrAlt = altKey;
const commandKey = window.platform === 'darwin' && metaKey;
const controlKey = window.platform !== 'darwin' && ctrlKey;
const commandOrCtrl = commandKey || controlKey;
@ -952,9 +876,6 @@ type WhatIsThis = import('./window.d').WhatIsThis;
const state = store.getState();
const selectedId = state.conversations.selectedConversationId;
const conversation = window.ConversationController.get(selectedId);
const isSearching = window.Signal.State.Selectors.search.isSearching(
state
);
// NAVIGATION
@ -976,8 +897,14 @@ type WhatIsThis = import('./window.d').WhatIsThis;
const targets: Array<HTMLElement | null> = [
document.querySelector('.module-main-header .module-avatar-button'),
document.querySelector('.module-left-pane__to-inbox-button'),
document.querySelector(
'.module-left-pane__header__contents__back-button'
),
document.querySelector('.module-main-header__search__input'),
document.querySelector('.module-main-header__compose-icon'),
document.querySelector(
'.module-left-pane__compose-search-form__input'
),
document.querySelector('.module-left-pane__list'),
document.querySelector('.module-search-results'),
document.querySelector('.module-composition-area .ql-editor'),
@ -1128,94 +1055,6 @@ type WhatIsThis = import('./window.d').WhatIsThis;
return;
}
// Change currently selected conversation by index
if (!isSearching && commandOrCtrl && NUMBERS[key]) {
const targetId = getConversationByIndex(
(NUMBERS[key] as WhatIsThis) - 1
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// Change currently selected conversation
// up/previous
if (
(!isSearching && optionOrAlt && !shiftKey && key === 'ArrowUp') ||
(!isSearching && commandOrCtrl && shiftKey && key === '[') ||
(!isSearching && ctrlKey && shiftKey && key === 'Tab')
) {
const unreadOnly = false;
const targetId = findConversation(
conversation ? conversation.id : null,
'up',
unreadOnly
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// down/next
if (
(!isSearching && optionOrAlt && !shiftKey && key === 'ArrowDown') ||
(!isSearching && commandOrCtrl && shiftKey && key === ']') ||
(!isSearching && ctrlKey && key === 'Tab')
) {
const unreadOnly = false;
const targetId = findConversation(
conversation ? conversation.id : null,
'down',
unreadOnly
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// previous unread
if (!isSearching && optionOrAlt && shiftKey && key === 'ArrowUp') {
const unreadOnly = true;
const targetId = findConversation(
conversation ? conversation.id : null,
'up',
unreadOnly
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// next unread
if (!isSearching && optionOrAlt && shiftKey && key === 'ArrowDown') {
const unreadOnly = true;
const targetId = findConversation(
conversation ? conversation.id : null,
'down',
unreadOnly
);
if (targetId) {
window.Whisper.events.trigger('showConversation', targetId);
event.preventDefault();
event.stopPropagation();
return;
}
}
// Preferences - handled by Electron-managed keyboard shortcuts
// Open the top-right menu for current conversation
@ -1323,8 +1162,7 @@ type WhatIsThis = import('./window.d').WhatIsThis;
);
// It's very likely that the act of archiving a conversation will set focus to
// 'none,' or the top-level body element. This resets it to the left pane,
// whether in the normal conversation list or search results.
// 'none,' or the top-level body element. This resets it to the left pane.
if (document.activeElement === document.body) {
const leftPaneEl: HTMLElement | null = document.querySelector(
'.module-left-pane__list'
@ -1332,13 +1170,6 @@ type WhatIsThis = import('./window.d').WhatIsThis;
if (leftPaneEl) {
leftPaneEl.focus();
}
const searchResultsEl: HTMLElement | null = document.querySelector(
'.module-search-results'
);
if (searchResultsEl) {
searchResultsEl.focus();
}
}
event.preventDefault();

View File

@ -0,0 +1,471 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { omit } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean, date, select, text } from '@storybook/addon-knobs';
import { ConversationList, PropsType, RowType, Row } from './ConversationList';
import { MessageSearchResult } from './conversationList/MessageSearchResult';
import {
PropsData as ConversationListItemPropsType,
MessageStatuses,
} from './conversationList/ConversationListItem';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/ConversationList', module);
const defaultConversations: Array<ConversationListItemPropsType> = [
{
id: 'fred-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Fred Willard',
type: 'direct',
},
{
id: 'marc-convo',
isSelected: true,
lastUpdated: Date.now(),
markedUnread: false,
unreadCount: 12,
title: 'Marc Barraca',
type: 'direct',
},
];
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
dimensions: {
width: 300,
height: 350,
},
rowCount: rows.length,
getRow: (index: number) => rows[index],
shouldRecomputeRowHeights: false,
i18n,
onSelectConversation: action('onSelectConversation'),
onClickArchiveButton: action('onClickArchiveButton'),
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
<MessageSearchResult
conversationId="marc-convo"
from={defaultConversations[0]}
i18n={i18n}
id={id}
openConversationInternal={action('openConversationInternal')}
sentAt={1587358800000}
snippet="Lorem <<left>>ipsum<<right>> wow"
style={style}
to={defaultConversations[1]}
/>
),
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
});
story.add('Archive button', () => (
<ConversationList
{...createProps([
{
type: RowType.ArchiveButton,
archivedConversationsCount: 123,
},
])}
/>
));
story.add('Contact: note to self', () => (
<ConversationList
{...createProps([
{
type: RowType.Contact,
contact: {
...defaultConversations[0],
isMe: true,
about: '🤠 should be ignored',
},
},
])}
/>
));
story.add('Contact: direct', () => (
<ConversationList
{...createProps([
{
type: RowType.Contact,
contact: defaultConversations[0],
},
])}
/>
));
story.add('Contact: direct with short about', () => (
<ConversationList
{...createProps([
{
type: RowType.Contact,
contact: { ...defaultConversations[0], about: '🤠 yee haw' },
},
])}
/>
));
story.add('Contact: direct with long about', () => (
<ConversationList
{...createProps([
{
type: RowType.Contact,
contact: {
...defaultConversations[0],
about:
'🤠 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue.',
},
},
])}
/>
));
story.add('Contact: group', () => (
<ConversationList
{...createProps([
{
type: RowType.Contact,
contact: { ...defaultConversations[0], type: 'group' },
},
])}
/>
));
{
const createConversation = (
overrideProps: Partial<ConversationListItemPropsType> = {}
): ConversationListItemPropsType => ({
...overrideProps,
acceptedMessageRequest: boolean(
'acceptedMessageRequest',
overrideProps.acceptedMessageRequest !== undefined
? overrideProps.acceptedMessageRequest
: true
),
isMe: boolean('isMe', overrideProps.isMe || false),
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
id: overrideProps.id || '',
isSelected: boolean('isSelected', overrideProps.isSelected || false),
title: text('title', overrideProps.title || 'Some Person'),
name: overrideProps.name || 'Some Person',
type: overrideProps.type || 'direct',
markedUnread: boolean('markedUnread', overrideProps.markedUnread || false),
lastMessage: overrideProps.lastMessage || {
text: text('lastMessage.text', 'Hi there!'),
status: select(
'status',
MessageStatuses.reduce((m, s) => ({ ...m, [s]: s }), {}),
'read'
),
},
lastUpdated: date(
'lastUpdated',
new Date(overrideProps.lastUpdated || Date.now() - 5 * 60 * 1000)
),
});
const renderConversation = (
overrideProps: Partial<ConversationListItemPropsType> = {}
) => (
<ConversationList
{...createProps([
{
type: RowType.Conversation,
conversation: createConversation(overrideProps),
},
])}
/>
);
story.add('Conversation: name', () => renderConversation());
story.add('Conversation: name and avatar', () =>
renderConversation({
avatarPath: '/fixtures/kitten-1-64-64.jpg',
})
);
story.add('Conversation: with yourself', () =>
renderConversation({
lastMessage: {
text: 'Just a second',
status: 'read',
},
name: 'Myself',
title: 'Myself',
isMe: true,
})
);
story.add('Conversations: Message Statuses', () => (
<ConversationList
{...createProps(
MessageStatuses.map(status => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: { text: status, status },
}),
}))
)}
/>
));
story.add('Conversation: Typing Status', () =>
renderConversation({
typingContact: {
name: 'Someone Here',
},
})
);
story.add('Conversation: With draft', () =>
renderConversation({
shouldShowDraft: true,
draftPreview: "I'm in the middle of typing this...",
})
);
story.add('Conversation: Deleted for everyone', () =>
renderConversation({
lastMessage: {
status: 'sent',
text: 'You should not see this!',
deletedForEveryone: true,
},
})
);
story.add('Conversation: Message Request', () =>
renderConversation({
acceptedMessageRequest: false,
lastMessage: {
text: 'A Message',
status: 'delivered',
},
})
);
story.add('Conversations: unread count', () => (
<ConversationList
{...createProps(
[4, 10, 250].map(unreadCount => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: { text: 'Hey there!', status: 'delivered' },
unreadCount,
}),
}))
)}
/>
));
story.add('Conversation: marked unread', () =>
renderConversation({ markedUnread: true })
);
story.add('Conversation: Selected', () =>
renderConversation({
lastMessage: {
text: 'Hey there!',
status: 'read',
},
isSelected: true,
})
);
story.add('Conversation: Emoji in Message', () =>
renderConversation({
lastMessage: {
text: '🔥',
status: 'read',
},
})
);
story.add('Conversation: Link in Message', () =>
renderConversation({
lastMessage: {
text: 'Download at http://signal.org',
status: 'read',
},
})
);
story.add('Conversation: long name', () => {
const name =
'Long contact name. Esquire. The third. And stuff. And more! And more!';
return renderConversation({
name,
title: name,
});
});
story.add('Conversation: Long Message', () => {
const messages = [
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
`Many lines. This is a many-line message.
Line 2 is really exciting but it shouldn't be seen.
Line three is even better.
Line 4, well.`,
];
return (
<ConversationList
{...createProps(
messages.map(messageText => ({
type: RowType.Conversation,
conversation: createConversation({
lastMessage: {
text: messageText,
status: 'read',
},
}),
}))
)}
/>
);
});
story.add('Conversations: Various Times', () => {
const times: Array<[number, string]> = [
[Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'],
[Date.now() - 24 * 60 * 60 * 1000, 'One day ago'],
[Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'],
[Date.now() - 365 * 24 * 60 * 60 * 1000, 'One year ago'],
];
return (
<ConversationList
{...createProps(
times.map(([lastUpdated, messageText]) => ({
type: RowType.Conversation,
conversation: createConversation({
lastUpdated,
lastMessage: {
text: messageText,
status: 'read',
},
}),
}))
)}
/>
);
});
story.add('Conversation: Missing Date', () => {
const row = {
type: RowType.Conversation as const,
conversation: omit(createConversation(), 'lastUpdated'),
};
return <ConversationList {...createProps([row])} />;
});
story.add('Conversation: Missing Message', () => {
const row = {
type: RowType.Conversation as const,
conversation: omit(createConversation(), 'lastMessage'),
};
return <ConversationList {...createProps([row])} />;
});
story.add('Conversation: Missing Text', () =>
renderConversation({
lastMessage: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
text: undefined as any,
status: 'sent',
},
})
);
story.add('Conversation: Muted Conversation', () =>
renderConversation({
muteExpiresAt: Date.now() + 1000 * 60 * 60,
})
);
story.add('Conversation: At Mention', () =>
renderConversation({
title: 'The Rebellion',
type: 'group',
lastMessage: {
text: '@Leia Organa I know',
status: 'read',
},
})
);
}
story.add('Headers', () => (
<ConversationList
{...createProps([
{
type: RowType.Header,
i18nKey: 'conversationsHeader',
},
{
type: RowType.Header,
i18nKey: 'messagesHeader',
},
])}
/>
));
story.add('Start new conversation', () => (
<ConversationList
{...createProps([
{
type: RowType.StartNewConversation,
phoneNumber: '+12345559876',
},
])}
/>
));
story.add('Kitchen sink', () => (
<ConversationList
{...createProps([
{
type: RowType.StartNewConversation,
phoneNumber: '+12345559876',
},
{
type: RowType.Header,
i18nKey: 'messagesHeader',
},
{
type: RowType.Contact,
contact: defaultConversations[0],
},
{
type: RowType.Conversation,
conversation: defaultConversations[1],
},
{
type: RowType.MessageSearchResult,
messageId: '123',
},
{ type: RowType.Spinner },
{
type: RowType.ArchiveButton,
archivedConversationsCount: 123,
},
])}
/>
));

View File

@ -0,0 +1,243 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useEffect, useCallback, CSSProperties } from 'react';
import { List, ListRowRenderer } from 'react-virtualized';
import { missingCaseError } from '../util/missingCaseError';
import { assert } from '../util/assert';
import { LocalizerType } from '../types/Util';
import {
ConversationListItem,
PropsData as ConversationListItemPropsType,
} from './conversationList/ConversationListItem';
import {
ContactListItem,
PropsDataType as ContactListItemPropsType,
} from './conversationList/ContactListItem';
import { Spinner as SpinnerComponent } from './Spinner';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
export enum RowType {
ArchiveButton,
Contact,
Conversation,
Header,
MessageSearchResult,
Spinner,
StartNewConversation,
}
type ArchiveButtonRowType = {
type: RowType.ArchiveButton;
archivedConversationsCount: number;
};
type ContactRowType = {
type: RowType.Contact;
contact: ContactListItemPropsType;
};
type ConversationRowType = {
type: RowType.Conversation;
conversation: ConversationListItemPropsType;
};
type MessageRowType = {
type: RowType.MessageSearchResult;
messageId: string;
};
type HeaderRowType = {
type: RowType.Header;
i18nKey: string;
};
type SpinnerRowType = { type: RowType.Spinner };
type StartNewConversationRowType = {
type: RowType.StartNewConversation;
phoneNumber: string;
};
export type Row =
| ArchiveButtonRowType
| ContactRowType
| ConversationRowType
| MessageRowType
| HeaderRowType
| SpinnerRowType
| StartNewConversationRowType;
export type PropsType = {
dimensions?: {
width: number;
height: number;
};
rowCount: number;
// If `getRow` is called with an invalid index, it should return `undefined`. However,
// this should only happen if there is a bug somewhere. For example, an inaccurate
// `rowCount`.
getRow: (index: number) => undefined | Row;
scrollToRowIndex?: number;
shouldRecomputeRowHeights: boolean;
i18n: LocalizerType;
onSelectConversation: (conversationId: string, messageId?: string) => void;
onClickArchiveButton: () => void;
renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element;
startNewConversationFromPhoneNumber: (e164: string) => void;
};
export const ConversationList: React.FC<PropsType> = ({
dimensions,
getRow,
i18n,
onClickArchiveButton,
onSelectConversation,
renderMessageSearchResult,
rowCount,
scrollToRowIndex,
shouldRecomputeRowHeights,
startNewConversationFromPhoneNumber,
}) => {
const listRef = useRef<null | List>(null);
useEffect(() => {
const list = listRef.current;
if (shouldRecomputeRowHeights && list) {
list.recomputeRowHeights();
}
}, [shouldRecomputeRowHeights]);
const calculateRowHeight = useCallback(
({ index }: { index: number }): number => {
const row = getRow(index);
if (!row) {
assert(false, `Expected a row at index ${index}`);
return 68;
}
return row.type === RowType.Header ? 40 : 68;
},
[getRow]
);
const renderRow: ListRowRenderer = useCallback(
({ key, index, style }) => {
const row = getRow(index);
if (!row) {
assert(false, `Expected a row at index ${index}`);
return <div key={key} style={style} />;
}
switch (row.type) {
case RowType.ArchiveButton:
return (
<button
key={key}
className="module-conversation-list__item--archive-button"
style={style}
onClick={onClickArchiveButton}
type="button"
>
{i18n('archivedConversations')}{' '}
<span className="module-conversation-list__item--archive-button__archived-count">
{row.archivedConversationsCount}
</span>
</button>
);
case RowType.Contact:
return (
<ContactListItem
{...row.contact}
key={key}
style={style}
onClick={onSelectConversation}
i18n={i18n}
/>
);
case RowType.Conversation:
return (
<ConversationListItem
{...row.conversation}
key={key}
style={style}
onClick={onSelectConversation}
i18n={i18n}
/>
);
case RowType.Header:
return (
<div
className="module-conversation-list__item--header"
key={key}
style={style}
>
{i18n(row.i18nKey)}
</div>
);
case RowType.Spinner:
return (
<div
className="module-conversation-list__item--spinner"
key={key}
style={style}
>
<SpinnerComponent size="24px" svgSize="small" />
</div>
);
case RowType.MessageSearchResult:
return (
<React.Fragment key={key}>
{renderMessageSearchResult(row.messageId, style)}
</React.Fragment>
);
case RowType.StartNewConversation:
return (
<StartNewConversationComponent
i18n={i18n}
key={key}
phoneNumber={row.phoneNumber}
onClick={() => {
startNewConversationFromPhoneNumber(row.phoneNumber);
}}
style={style}
/>
);
default:
throw missingCaseError(row);
}
},
[
getRow,
i18n,
onClickArchiveButton,
onSelectConversation,
renderMessageSearchResult,
startNewConversationFromPhoneNumber,
]
);
// Though `width` and `height` are required properties, we want to be careful in case
// the caller sends bogus data. Notably, react-measure's types seem to be inaccurate.
const { width = 0, height = 0 } = dimensions || {};
if (!width || !height) {
return null;
}
return (
<List
className="module-conversation-list"
height={height}
ref={listRef}
rowCount={rowCount}
rowHeight={calculateRowHeight}
rowRenderer={renderRow}
scrollToIndex={scrollToRowIndex}
tabIndex={-1}
width={width}
/>
);
};

View File

@ -1,304 +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 { boolean, date, select, text } from '@storybook/addon-knobs';
import {
ConversationListItem,
MessageStatuses,
Props,
} from './ConversationListItem';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/ConversationListItem', module);
story.addDecorator(storyFn => (
<div style={{ width: '300px' }}>{storyFn()}</div>
));
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
...overrideProps,
i18n,
acceptedMessageRequest: boolean(
'acceptedMessageRequest',
overrideProps.acceptedMessageRequest !== undefined
? overrideProps.acceptedMessageRequest
: true
),
isMe: boolean('isMe', overrideProps.isMe || false),
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
id: overrideProps.id || '',
isSelected: boolean('isSelected', overrideProps.isSelected || false),
title: text('title', overrideProps.title || 'Some Person'),
name: overrideProps.name || 'Some Person',
type: overrideProps.type || 'direct',
onClick: action('onClick'),
markedUnread: boolean('markedUnread', overrideProps.markedUnread || false),
lastMessage: overrideProps.lastMessage || {
text: text('lastMessage.text', 'Hi there!'),
status: select(
'status',
MessageStatuses.reduce((m, s) => ({ ...m, [s]: s }), {}),
'read'
),
},
lastUpdated: date(
'lastUpdated',
new Date(overrideProps.lastUpdated || Date.now() - 5 * 60 * 1000)
),
});
story.add('Name', () => {
const props = createProps();
return <ConversationListItem {...props} />;
});
story.add('Name and Avatar', () => {
const props = createProps({
avatarPath: '/fixtures/kitten-1-64-64.jpg',
});
return <ConversationListItem {...props} />;
});
story.add('Conversation with Yourself', () => {
const props = createProps({
lastMessage: {
text: 'Just a second',
status: 'read',
},
name: 'Myself',
title: 'Myself',
isMe: true,
});
return <ConversationListItem {...props} />;
});
story.add('Message Statuses', () => {
return MessageStatuses.map(status => {
const props = createProps({
lastMessage: {
text: status,
status,
},
});
return <ConversationListItem key={status} {...props} />;
});
});
story.add('Typing Status', () => {
const props = createProps({
typingContact: {
name: 'Someone Here',
},
});
return <ConversationListItem {...props} />;
});
story.add('With draft', () => {
const props = createProps({
shouldShowDraft: true,
draftPreview: "I'm in the middle of typing this...",
});
return <ConversationListItem {...props} />;
});
story.add('Deleted for everyone', () => {
const props = createProps({
lastMessage: {
status: 'sent',
text: 'You should not see this!',
deletedForEveryone: true,
},
});
return <ConversationListItem {...props} />;
});
story.add('Message Request', () => {
const props = createProps({
acceptedMessageRequest: false,
lastMessage: {
text: 'A Message',
status: 'delivered',
},
});
return <ConversationListItem {...props} />;
});
story.add('Unread', () => {
const counts = [4, 10, 250];
const defaultProps = createProps({
lastMessage: {
text: 'Hey there!',
status: 'delivered',
},
});
const items = counts.map(unreadCount => {
const props = {
...defaultProps,
unreadCount,
};
return <ConversationListItem key={unreadCount} {...props} />;
});
const markedUnreadProps = {
...defaultProps,
markedUnread: true,
};
const markedUnreadItem = [
<ConversationListItem key={5} {...markedUnreadProps} />,
];
return [...items, ...markedUnreadItem];
});
story.add('Selected', () => {
const props = createProps({
lastMessage: {
text: 'Hey there!',
status: 'read',
},
isSelected: true,
});
return <ConversationListItem {...props} />;
});
story.add('Emoji in Message', () => {
const props = createProps({
lastMessage: {
text: '🔥',
status: 'read',
},
});
return <ConversationListItem {...props} />;
});
story.add('Link in Message', () => {
const props = createProps({
lastMessage: {
text: 'Download at http://signal.org',
status: 'read',
},
});
return <ConversationListItem {...props} />;
});
story.add('Long Name', () => {
const name =
'Long contact name. Esquire. The third. And stuff. And more! And more!';
const props = createProps({
name,
title: name,
});
return <ConversationListItem {...props} />;
});
story.add('Long Message', () => {
const messages = [
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
`Many lines. This is a many-line message.
Line 2 is really exciting but it shouldn't be seen.
Line three is even better.
Line 4, well.`,
];
return messages.map(message => {
const props = createProps({
lastMessage: {
text: message,
status: 'read',
},
});
return <ConversationListItem key={message.length} {...props} />;
});
});
story.add('Various Times', () => {
const times: Array<[number, string]> = [
[Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'],
[Date.now() - 24 * 60 * 60 * 1000, 'One day ago'],
[Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'],
[Date.now() - 365 * 24 * 60 * 60 * 1000, 'One year ago'],
];
return times.map(([lastUpdated, messageText]) => {
const props = createProps({
lastUpdated,
lastMessage: {
text: messageText,
status: 'read',
},
});
return <ConversationListItem key={lastUpdated} {...props} />;
});
});
story.add('Missing Date', () => {
const props = createProps();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <ConversationListItem {...props} lastUpdated={undefined as any} />;
});
story.add('Missing Message', () => {
const props = createProps();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <ConversationListItem {...props} lastMessage={undefined as any} />;
});
story.add('Missing Text', () => {
const props = createProps();
return (
<ConversationListItem
{...props}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lastMessage={{ text: undefined as any, status: 'sent' }}
/>
);
});
story.add('Muted Conversation', () => {
const props = createProps();
const muteExpiresAt = Date.now() + 1000 * 60 * 60;
return <ConversationListItem {...props} muteExpiresAt={muteExpiresAt} />;
});
story.add('At Mention', () => {
const props = createProps({
title: 'The Rebellion',
type: 'group',
lastMessage: {
text: '@Leia Organa I know',
status: 'read',
},
});
return <ConversationListItem {...props} />;
});

View File

@ -1,283 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties } from 'react';
import classNames from 'classnames';
import { isNumber } from 'lodash';
import { Avatar } from './Avatar';
import { MessageBody } from './conversation/MessageBody';
import { Timestamp } from './conversation/Timestamp';
import { ContactName } from './conversation/ContactName';
import { TypingAnimation } from './conversation/TypingAnimation';
import { cleanId } from './_util';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
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 class ConversationListItem extends React.PureComponent<Props> {
public renderAvatar(): JSX.Element {
const {
avatarPath,
color,
type,
i18n,
isMe,
name,
phoneNumber,
profileName,
title,
} = this.props;
return (
<div className="module-conversation-list-item__avatar-container">
<Avatar
avatarPath={avatarPath}
color={color}
noteToSelf={isMe}
conversationType={type}
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={52}
/>
{this.renderUnread()}
</div>
);
}
isUnread(): boolean {
const { markedUnread, unreadCount } = this.props;
return Boolean((isNumber(unreadCount) && unreadCount > 0) || markedUnread);
}
public renderUnread(): JSX.Element | null {
const { unreadCount } = this.props;
if (this.isUnread()) {
return (
<div className="module-conversation-list-item__unread-count">
{unreadCount || ''}
</div>
);
}
return null;
}
public renderHeader(): JSX.Element {
const {
i18n,
isMe,
lastUpdated,
name,
phoneNumber,
profileName,
title,
} = this.props;
return (
<div className="module-conversation-list-item__header">
<div
className={classNames(
'module-conversation-list-item__header__name',
this.isUnread()
? 'module-conversation-list-item__header__name--with-unread'
: null
)}
>
{isMe ? (
i18n('noteToSelf')
) : (
<ContactName
phoneNumber={phoneNumber}
name={name}
profileName={profileName}
title={title}
i18n={i18n}
/>
)}
</div>
<div
className={classNames(
'module-conversation-list-item__header__date',
this.isUnread()
? 'module-conversation-list-item__header__date--has-unread'
: null
)}
>
<Timestamp
timestamp={lastUpdated}
extended={false}
module="module-conversation-list-item__header__timestamp"
withUnread={this.isUnread()}
i18n={i18n}
/>
</div>
</div>
);
}
public renderMessage(): JSX.Element | null {
const {
draftPreview,
i18n,
acceptedMessageRequest,
lastMessage,
muteExpiresAt,
shouldShowDraft,
typingContact,
} = this.props;
if (!lastMessage && !typingContact) {
return null;
}
const messageBody = lastMessage ? lastMessage.text : '';
const showingDraft = shouldShowDraft && draftPreview;
const deletedForEveryone = Boolean(
lastMessage && lastMessage.deletedForEveryone
);
/* eslint-disable no-nested-ternary */
return (
<div className="module-conversation-list-item__message">
<div
dir="auto"
className={classNames(
'module-conversation-list-item__message__text',
this.isUnread()
? 'module-conversation-list-item__message__text--has-unread'
: null
)}
>
{muteExpiresAt && Date.now() < muteExpiresAt && (
<span className="module-conversation-list-item__muted" />
)}
{!acceptedMessageRequest ? (
<span className="module-conversation-list-item__message-request">
{i18n('ConversationListItem--message-request')}
</span>
) : typingContact ? (
<TypingAnimation i18n={i18n} />
) : (
<>
{showingDraft ? (
<>
<span className="module-conversation-list-item__message__draft-prefix">
{i18n('ConversationListItem--draft-prefix')}
</span>
<MessageBody
text={(draftPreview || '').split('\n')[0]}
disableJumbomoji
disableLinks
i18n={i18n}
/>
</>
) : deletedForEveryone ? (
<span className="module-conversation-list-item__message__deleted-for-everyone">
{i18n('message--deletedForEveryone')}
</span>
) : (
<MessageBody
text={(messageBody || '').split('\n')[0]}
disableJumbomoji
disableLinks
i18n={i18n}
/>
)}
</>
)}
</div>
{!showingDraft && lastMessage && lastMessage.status ? (
<div
className={classNames(
'module-conversation-list-item__message__status-icon',
`module-conversation-list-item__message__status-icon--${lastMessage.status}`
)}
/>
) : null}
</div>
);
}
/* eslint-enable no-nested-ternary */
public render(): JSX.Element {
const { id, isSelected, onClick, style } = this.props;
return (
<button
type="button"
onClick={() => {
if (onClick) {
onClick(id);
}
}}
style={style}
className={classNames(
'module-conversation-list-item',
this.isUnread() ? 'module-conversation-list-item--has-unread' : null,
isSelected ? 'module-conversation-list-item--is-selected' : null
)}
data-id={cleanId(id)}
>
{this.renderAvatar()}
<div className="module-conversation-list-item__content">
{this.renderHeader()}
{this.renderMessage()}
</div>
</button>
);
}
}

View File

@ -1,14 +1,14 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { LeftPane, PropsType } from './LeftPane';
import { PropsData } from './ConversationListItem';
import { LeftPane, LeftPaneMode, PropsType } from './LeftPane';
import { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
import { MessageSearchResult } from './conversationList/MessageSearchResult';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
@ -16,7 +16,7 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/LeftPane', module);
const defaultConversations: Array<PropsData> = [
const defaultConversations: Array<ConversationListItemPropsType> = [
{
id: 'fred-convo',
isSelected: false,
@ -35,7 +35,7 @@ const defaultConversations: Array<PropsData> = [
},
];
const defaultArchivedConversations: Array<PropsData> = [
const defaultArchivedConversations: Array<ConversationListItemPropsType> = [
{
id: 'michelle-archive-convo',
isSelected: false,
@ -46,7 +46,7 @@ const defaultArchivedConversations: Array<PropsData> = [
},
];
const pinnedConversations: Array<PropsData> = [
const pinnedConversations: Array<ConversationListItemPropsType> = [
{
id: 'philly-convo',
isPinned: true,
@ -67,107 +67,311 @@ const pinnedConversations: Array<PropsData> = [
},
];
const defaultModeSpecificProps = {
mode: LeftPaneMode.Inbox as const,
pinnedConversations,
conversations: defaultConversations,
archivedConversations: defaultArchivedConversations,
};
const emptySearchResultsGroup = { isLoading: false, results: [] };
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
archivedConversations:
overrideProps.archivedConversations || defaultArchivedConversations,
conversations: overrideProps.conversations || defaultConversations,
i18n,
modeSpecificProps: defaultModeSpecificProps,
openConversationInternal: action('openConversationInternal'),
pinnedConversations: overrideProps.pinnedConversations || [],
regionCode: 'US',
renderExpiredBuildDialog: () => <div />,
renderMainHeader: () => <div />,
renderMessageSearchResult: () => <div />,
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
<MessageSearchResult
conversationId="marc-convo"
from={defaultConversations[0]}
i18n={i18n}
id={id}
openConversationInternal={action('openConversationInternal')}
sentAt={1587358800000}
snippet="Lorem <<left>>ipsum<<right>> wow"
style={style}
to={defaultConversations[1]}
/>
),
renderNetworkStatus: () => <div />,
renderRelinkDialog: () => <div />,
renderUpdateDialog: () => <div />,
searchResults: overrideProps.searchResults,
selectedConversationId: text(
'selectedConversationId',
overrideProps.selectedConversationId || null
),
showArchived: boolean('showArchived', overrideProps.showArchived || false),
selectedConversationId: undefined,
selectedMessageId: undefined,
setComposeSearchTerm: action('setComposeSearchTerm'),
showArchivedConversations: action('showArchivedConversations'),
showInbox: action('showInbox'),
startNewConversation: action('startNewConversation'),
startComposing: action('startComposing'),
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
...overrideProps,
});
story.add('Conversation States (Active, Selected, Archived)', () => {
const props = createProps();
// Inbox stories
return <LeftPane {...props} />;
});
story.add('Inbox: no conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
conversations: [],
archivedConversations: [],
},
})}
/>
));
story.add('Pinned and Non-pinned Conversations', () => {
const props = createProps({
pinnedConversations,
});
story.add('Inbox: only pinned conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
conversations: [],
archivedConversations: [],
},
})}
/>
));
return <LeftPane {...props} />;
});
story.add('Inbox: only non-pinned conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
conversations: defaultConversations,
archivedConversations: [],
},
})}
/>
));
story.add('Only Pinned Conversations', () => {
const props = createProps({
archivedConversations: [],
conversations: [],
pinnedConversations,
});
story.add('Inbox: only archived conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
conversations: [],
archivedConversations: defaultArchivedConversations,
},
})}
/>
));
return <LeftPane {...props} />;
});
story.add('Inbox: pinned and archived conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
conversations: [],
archivedConversations: defaultArchivedConversations,
},
})}
/>
));
story.add('Archived Conversations Shown', () => {
const props = createProps({
showArchived: true,
});
return <LeftPane {...props} />;
});
story.add('Inbox: non-pinned and archived conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations: [],
conversations: defaultConversations,
archivedConversations: defaultArchivedConversations,
},
})}
/>
));
story.add('Search Results', () => {
const props = createProps({
searchResults: {
discussionsLoading: false,
items: [
{
type: 'conversations-header',
data: undefined,
story.add('Inbox: pinned and non-pinned conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
conversations: defaultConversations,
archivedConversations: [],
},
})}
/>
));
story.add('Inbox: pinned, non-pinned, and archived conversations', () => (
<LeftPane {...createProps()} />
));
// Search stories
story.add('Search: no results when searching everywhere', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: emptySearchResultsGroup,
contactResults: emptySearchResultsGroup,
messageResults: emptySearchResultsGroup,
searchTerm: 'foo bar',
},
})}
/>
));
story.add('Search: no results when searching in a conversation', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: emptySearchResultsGroup,
contactResults: emptySearchResultsGroup,
messageResults: emptySearchResultsGroup,
searchConversationName: 'Bing Bong',
searchTerm: 'foo bar',
},
})}
/>
));
story.add('Search: all results loading', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo bar',
},
})}
/>
));
story.add('Search: some results loading', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: {
isLoading: false,
results: defaultConversations,
},
{
type: 'conversation',
data: {
id: 'fred-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'People Named Fred',
type: 'group',
},
},
{
type: 'start-new-conversation',
data: undefined,
},
{
type: 'contacts-header',
data: undefined,
},
{
type: 'contact',
data: {
id: 'fred-contact',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Fred Willard',
type: 'direct',
},
},
],
messagesLoading: false,
noResults: false,
regionCode: 'en',
searchTerm: 'Fred',
},
});
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo bar',
},
})}
/>
));
return <LeftPane {...props} />;
});
story.add('Search: has conversations and contacts, but not messages', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: {
isLoading: false,
results: defaultConversations,
},
contactResults: { isLoading: false, results: defaultConversations },
messageResults: { isLoading: false, results: [] },
searchTerm: 'foo bar',
},
})}
/>
));
story.add('Search: all results', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Search,
conversationResults: {
isLoading: false,
results: defaultConversations,
},
contactResults: { isLoading: false, results: defaultConversations },
messageResults: {
isLoading: false,
results: [
{ id: 'msg1', conversationId: 'foo' },
{ id: 'msg2', conversationId: 'bar' },
],
},
searchTerm: 'foo bar',
},
})}
/>
));
// Archived stories
story.add('Archive: no archived conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Archive,
archivedConversations: [],
},
})}
/>
));
story.add('Archive: archived conversations', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Archive,
archivedConversations: defaultConversations,
},
})}
/>
));
// Compose stories
story.add('Compose: no contacts', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: [],
regionCode: 'US',
searchTerm: '',
},
})}
/>
));
story.add('Compose: some contacts, no search term', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
regionCode: 'US',
searchTerm: '',
},
})}
/>
));
story.add('Compose: some contacts with a search term', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Compose,
composeContacts: defaultConversations,
regionCode: 'US',
searchTerm: 'foo bar',
},
})}
/>
));

View File

@ -1,649 +1,327 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Measure, { BoundingRect, MeasuredComponentProps } from 'react-measure';
import React, { CSSProperties } from 'react';
import { List } from 'react-virtualized';
import { debounce, get } from 'lodash';
import React, { useRef, useEffect, useMemo, CSSProperties } from 'react';
import Measure, { MeasuredComponentProps } from 'react-measure';
import { isNumber } from 'lodash';
import {
ConversationListItem,
PropsData as ConversationListItemPropsType,
} from './ConversationListItem';
LeftPaneHelper,
FindDirection,
ToFindType,
} from './leftPane/LeftPaneHelper';
import {
PropsDataType as SearchResultsProps,
SearchResults,
} from './SearchResults';
LeftPaneInboxHelper,
LeftPaneInboxPropsType,
} from './leftPane/LeftPaneInboxHelper';
import {
LeftPaneSearchHelper,
LeftPaneSearchPropsType,
} from './leftPane/LeftPaneSearchHelper';
import {
LeftPaneArchiveHelper,
LeftPaneArchivePropsType,
} from './leftPane/LeftPaneArchiveHelper';
import {
LeftPaneComposeHelper,
LeftPaneComposePropsType,
} from './leftPane/LeftPaneComposeHelper';
import * as OS from '../OS';
import { LocalizerType } from '../types/Util';
import { cleanId } from './_util';
import { missingCaseError } from '../util/missingCaseError';
import { ConversationList } from './ConversationList';
export enum LeftPaneMode {
Inbox,
Search,
Archive,
Compose,
}
export type PropsType = {
conversations?: Array<ConversationListItemPropsType>;
archivedConversations?: Array<ConversationListItemPropsType>;
pinnedConversations?: Array<ConversationListItemPropsType>;
selectedConversationId?: string;
searchResults?: SearchResultsProps;
showArchived?: boolean;
// These help prevent invalid states. For example, we don't need the list of pinned
// conversations if we're trying to start a new conversation. Ideally these would be
// at the top level, but this is not supported by react-redux + TypeScript.
modeSpecificProps:
| ({
mode: LeftPaneMode.Inbox;
} & LeftPaneInboxPropsType)
| ({
mode: LeftPaneMode.Search;
} & LeftPaneSearchPropsType)
| ({
mode: LeftPaneMode.Archive;
} & LeftPaneArchivePropsType)
| ({
mode: LeftPaneMode.Compose;
} & LeftPaneComposePropsType);
i18n: LocalizerType;
selectedConversationId: undefined | string;
selectedMessageId: undefined | string;
regionCode: string;
// Action Creators
startNewConversation: (
query: string,
options: { regionCode: string }
) => void;
openConversationInternal: (id: string, messageId?: string) => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
openConversationInternal: (_: {
conversationId: string;
messageId?: string;
switchToAssociatedView?: boolean;
}) => void;
showArchivedConversations: () => void;
showInbox: () => void;
startComposing: () => void;
setComposeSearchTerm: (composeSearchTerm: string) => void;
// Render Props
renderExpiredBuildDialog: () => JSX.Element;
renderMainHeader: () => JSX.Element;
renderMessageSearchResult: (id: string) => JSX.Element;
renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element;
renderNetworkStatus: () => JSX.Element;
renderRelinkDialog: () => JSX.Element;
renderUpdateDialog: () => JSX.Element;
};
// 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<string, unknown>;
style: CSSProperties;
};
export const LeftPane: React.FC<PropsType> = ({
i18n,
modeSpecificProps,
openConversationInternal,
renderExpiredBuildDialog,
renderMainHeader,
renderMessageSearchResult,
renderNetworkStatus,
renderRelinkDialog,
renderUpdateDialog,
selectedConversationId,
selectedMessageId,
setComposeSearchTerm,
showArchivedConversations,
showInbox,
startComposing,
startNewConversationFromPhoneNumber,
}) => {
const previousModeSpecificPropsRef = useRef(modeSpecificProps);
const previousModeSpecificProps = previousModeSpecificPropsRef.current;
previousModeSpecificPropsRef.current = modeSpecificProps;
export enum RowType {
ArchiveButton,
ArchivedConversation,
Conversation,
Header,
PinnedConversation,
Undefined,
}
export enum HeaderType {
Pinned,
Chats,
}
type ArchiveButtonRow = {
type: RowType.ArchiveButton;
};
type ConversationRow = {
index: number;
type:
| RowType.ArchivedConversation
| RowType.Conversation
| RowType.PinnedConversation;
};
type HeaderRow = {
headerType: HeaderType;
type: RowType.Header;
};
type UndefinedRow = {
type: RowType.Undefined;
};
type Row = ArchiveButtonRow | ConversationRow | HeaderRow | UndefinedRow;
export class LeftPane extends React.Component<PropsType> {
public listRef = React.createRef<List>();
public containerRef = React.createRef<HTMLDivElement>();
public setFocusToFirstNeeded = false;
public setFocusToLastNeeded = false;
public calculateRowHeight = ({ index }: { index: number }): number => {
const { type } = this.getRowFromIndex(index);
return type === RowType.Header ? 40 : 68;
};
public getRowFromIndex = (index: number): Row => {
const {
archivedConversations,
conversations,
pinnedConversations,
showArchived,
} = this.props;
if (!conversations || !pinnedConversations || !archivedConversations) {
return {
type: RowType.Undefined,
};
// The left pane can be in various modes: the inbox, the archive, the composer, etc.
// Ideally, this would render subcomponents such as `<LeftPaneInbox>` or
// `<LeftPaneArchive>` (and if there's a way to do that cleanly, we should refactor
// this).
//
// But doing that presents two problems:
//
// 1. Different components render the same logical inputs (the main header's search),
// but React doesn't know that they're the same, so you can lose focus as you change
// modes.
// 2. These components render virtualized lists, which are somewhat slow to initialize.
// Switching between modes can cause noticable hiccups.
//
// To get around those problems, we use "helpers" which all correspond to the same
// interface.
//
// Unfortunately, there's a little bit of repetition here because TypeScript isn't quite
// smart enough.
let helper: LeftPaneHelper<unknown>;
let shouldRecomputeRowHeights: boolean;
switch (modeSpecificProps.mode) {
case LeftPaneMode.Inbox: {
const inboxHelper = new LeftPaneInboxHelper(modeSpecificProps);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? inboxHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
: true;
helper = inboxHelper;
break;
}
if (showArchived) {
return {
index,
type: RowType.ArchivedConversation,
};
case LeftPaneMode.Search: {
const searchHelper = new LeftPaneSearchHelper(modeSpecificProps);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? searchHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
: true;
helper = searchHelper;
break;
}
let conversationIndex = index;
if (pinnedConversations.length) {
if (conversations.length) {
if (index === 0) {
return {
headerType: HeaderType.Pinned,
type: RowType.Header,
};
}
if (index <= pinnedConversations.length) {
return {
index: index - 1,
type: RowType.PinnedConversation,
};
}
if (index === pinnedConversations.length + 1) {
return {
headerType: HeaderType.Chats,
type: RowType.Header,
};
}
conversationIndex -= pinnedConversations.length + 2;
} else if (index < pinnedConversations.length) {
return {
index,
type: RowType.PinnedConversation,
};
} else {
conversationIndex = 0;
}
case LeftPaneMode.Archive: {
const archiveHelper = new LeftPaneArchiveHelper(modeSpecificProps);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? archiveHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
: true;
helper = archiveHelper;
break;
}
if (conversationIndex === conversations.length) {
return {
type: RowType.ArchiveButton,
};
case LeftPaneMode.Compose: {
const composeHelper = new LeftPaneComposeHelper(modeSpecificProps);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? composeHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
: true;
helper = composeHelper;
break;
}
return {
index: conversationIndex,
type: RowType.Conversation,
};
};
public renderConversationRow(
conversation: ConversationListItemPropsType,
key: string,
style: CSSProperties
): JSX.Element {
const { i18n, openConversationInternal } = this.props;
return (
<div
key={key}
className="module-left-pane__conversation-container"
style={style}
>
<ConversationListItem
{...conversation}
onClick={openConversationInternal}
i18n={i18n}
/>
</div>
);
default:
throw missingCaseError(modeSpecificProps);
}
public renderHeaderRow = (
index: number,
key: string,
style: CSSProperties
): JSX.Element => {
const { i18n } = this.props;
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const { ctrlKey, shiftKey, altKey, metaKey, key } = event;
const commandOrCtrl = OS.isMacOS() ? metaKey : ctrlKey;
switch (index) {
case HeaderType.Pinned: {
return (
<div className="module-left-pane__header-row" key={key} style={style}>
{i18n('LeftPane--pinned')}
</div>
if (
commandOrCtrl &&
!shiftKey &&
!altKey &&
(key === 'n' || key === 'N')
) {
startComposing();
event.preventDefault();
event.stopPropagation();
return;
}
let conversationToOpen:
| undefined
| {
conversationId: string;
messageId?: string;
};
const numericIndex = keyboardKeyToNumericIndex(event.key);
if (commandOrCtrl && isNumber(numericIndex)) {
conversationToOpen = helper.getConversationAndMessageAtIndex(
numericIndex
);
}
case HeaderType.Chats: {
return (
<div className="module-left-pane__header-row" key={key} style={style}>
{i18n('LeftPane--chats')}
</div>
);
}
default: {
window.log.warn('LeftPane: invalid HeaderRowIndex received');
return <></>;
}
}
};
public renderRow = ({
index,
key,
style,
}: RowRendererParamsType): JSX.Element => {
const {
archivedConversations,
conversations,
pinnedConversations,
} = this.props;
if (!conversations || !pinnedConversations || !archivedConversations) {
throw new Error(
'renderRow: Tried to render without conversations or pinnedConversations or archivedConversations'
);
}
const row = this.getRowFromIndex(index);
switch (row.type) {
case RowType.ArchiveButton: {
return this.renderArchivedButton(key, style);
}
case RowType.ArchivedConversation: {
return this.renderConversationRow(
archivedConversations[row.index],
key,
style
);
}
case RowType.Conversation: {
return this.renderConversationRow(conversations[row.index], key, style);
}
case RowType.Header: {
return this.renderHeaderRow(row.headerType, key, style);
}
case RowType.PinnedConversation: {
return this.renderConversationRow(
pinnedConversations[row.index],
key,
style
);
}
default:
window.log.warn('LeftPane: unknown RowType received');
return <></>;
}
};
public renderArchivedButton = (
key: string,
style: CSSProperties
): JSX.Element => {
const {
archivedConversations,
i18n,
showArchivedConversations,
} = this.props;
if (!archivedConversations || !archivedConversations.length) {
throw new Error(
'renderArchivedButton: Tried to render without archivedConversations'
);
}
return (
<button
key={key}
className="module-left-pane__archived-button"
style={style}
onClick={showArchivedConversations}
type="button"
>
{i18n('archivedConversations')}{' '}
<span className="module-left-pane__archived-button__archived-count">
{archivedConversations.length}
</span>
</button>
);
};
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
const commandOrCtrl = commandKey || controlKey;
if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowUp') {
this.scrollToRow(0);
this.setFocusToFirstNeeded = true;
event.preventDefault();
event.stopPropagation();
return;
}
if (commandOrCtrl && !event.shiftKey && event.key === 'ArrowDown') {
const length = this.getLength();
this.scrollToRow(length - 1);
this.setFocusToLastNeeded = true;
event.preventDefault();
event.stopPropagation();
}
};
public handleFocus = (): void => {
const { selectedConversationId } = this.props;
const { current: container } = this.containerRef;
if (!container) {
return;
}
if (document.activeElement === container) {
const scrollingContainer = this.getScrollContainer();
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;
} else {
let toFind: undefined | ToFindType;
if (
(altKey && !shiftKey && key === 'ArrowUp') ||
(commandOrCtrl && shiftKey && key === '[') ||
(ctrlKey && shiftKey && key === 'Tab')
) {
toFind = { direction: FindDirection.Up, unreadOnly: false };
} else if (
(altKey && !shiftKey && key === 'ArrowDown') ||
(commandOrCtrl && shiftKey && key === ']') ||
(ctrlKey && key === 'Tab')
) {
toFind = { direction: FindDirection.Down, unreadOnly: false };
} else if (altKey && shiftKey && key === 'ArrowUp') {
toFind = { direction: FindDirection.Up, unreadOnly: true };
} else if (altKey && shiftKey && key === 'ArrowDown') {
toFind = { direction: FindDirection.Down, unreadOnly: true };
}
if (toFind) {
conversationToOpen = helper.getConversationAndMessageInDirection(
toFind,
selectedConversationId,
selectedMessageId
);
}
}
this.setFocusToFirst();
}
};
public scrollToRow = (row: number): void => {
if (!this.listRef || !this.listRef.current) {
return;
}
this.listRef.current.scrollToRow(row);
};
public recomputeRowHeights = (): void => {
if (!this.listRef || !this.listRef.current) {
return;
}
this.listRef.current.recomputeRowHeights();
};
public getScrollContainer = (): HTMLDivElement | null => {
if (!this.listRef || !this.listRef.current) {
return null;
}
const list = this.listRef.current;
// TODO: DESKTOP-689
// 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 setFocusToFirst = (): void => {
const scrollContainer = this.getScrollContainer();
if (!scrollContainer) {
return;
}
const item: HTMLElement | null = scrollContainer.querySelector(
'.module-conversation-list-item'
);
if (item && item.focus) {
item.focus();
}
};
public onScroll = debounce(
(): void => {
if (this.setFocusToFirstNeeded) {
this.setFocusToFirstNeeded = false;
this.setFocusToFirst();
if (conversationToOpen) {
const { conversationId, messageId } = conversationToOpen;
openConversationInternal({ conversationId, messageId });
event.preventDefault();
event.stopPropagation();
}
if (this.setFocusToLastNeeded) {
this.setFocusToLastNeeded = false;
};
const scrollContainer = this.getScrollContainer();
if (!scrollContainer) {
return;
}
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [
helper,
openConversationInternal,
selectedConversationId,
selectedMessageId,
startComposing,
]);
const button: HTMLElement | null = scrollContainer.querySelector(
'.module-left-pane__archived-button'
);
if (button && button.focus) {
button.focus();
return;
}
const items: NodeListOf<HTMLElement> = scrollContainer.querySelectorAll(
'.module-conversation-list-item'
);
if (items && items.length > 0) {
const last = items[items.length - 1];
if (last && last.focus) {
last.focus();
}
}
}
const preRowsNode = helper.getPreRowsNode({
i18n,
onChangeComposeSearchTerm: event => {
setComposeSearchTerm(event.target.value);
},
100,
{ maxWait: 100 }
);
});
const getRow = useMemo(() => helper.getRow.bind(helper), [helper]);
public getLength = (): number => {
const {
archivedConversations,
conversations,
pinnedConversations,
showArchived,
} = this.props;
// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
// that AutoSizer properly detects the new size of its slot in the flexbox. The
// archive explainer text at the top of the archive view causes problems otherwise.
// It also ensures that we scroll to the top when switching views.
const listKey = preRowsNode ? 1 : 0;
if (!conversations || !archivedConversations || !pinnedConversations) {
return 0;
}
if (showArchived) {
return archivedConversations.length;
}
let { length } = conversations;
if (pinnedConversations.length) {
if (length) {
// includes two additional rows for pinned/chats headers
length += 2;
}
length += pinnedConversations.length;
}
// includes one additional row for 'archived conversations' button
if (archivedConversations.length) {
length += 1;
}
return length;
};
public renderList = ({
height,
width,
}: BoundingRect): JSX.Element | Array<JSX.Element | null> => {
const {
archivedConversations,
i18n,
conversations,
openConversationInternal,
pinnedConversations,
renderMessageSearchResult,
startNewConversation,
searchResults,
showArchived,
} = this.props;
if (searchResults) {
return (
<SearchResults
{...searchResults}
height={height || 0}
width={width || 0}
openConversationInternal={openConversationInternal}
startNewConversation={startNewConversation}
renderMessageSearchResult={renderMessageSearchResult}
i18n={i18n}
/>
);
}
if (!conversations || !archivedConversations || !pinnedConversations) {
throw new Error(
'render: must provided conversations and archivedConverstions if no search results are provided'
);
}
const length = this.getLength();
// We ensure that the listKey differs between inbox and archive views, which ensures
// that AutoSizer properly detects the new size of its slot in the flexbox. The
// archive explainer text at the top of the archive view causes problems otherwise.
// It also ensures that we scroll to the top when switching views.
const listKey = showArchived ? 1 : 0;
// Note: conversations is not a known prop for List, but it is required to ensure that
// it re-renders when our conversation data changes. Otherwise it would just render
// on startup and scroll.
return (
<div
aria-live="polite"
className="module-left-pane__list"
key={listKey}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
ref={this.containerRef}
role="presentation"
tabIndex={-1}
>
<List
className="module-left-pane__virtual-list"
conversations={conversations}
height={height || 0}
onScroll={this.onScroll}
ref={this.listRef}
rowCount={length}
rowHeight={this.calculateRowHeight}
rowRenderer={this.renderRow}
tabIndex={-1}
width={width || 0}
/>
return (
<div className="module-left-pane">
<div className="module-left-pane__header">
{helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()}
</div>
);
};
public renderArchivedHeader = (): JSX.Element => {
const { i18n, showInbox } = this.props;
return (
<div className="module-left-pane__archive-header">
<button
onClick={showInbox}
className="module-left-pane__to-inbox-button"
title={i18n('backToInbox')}
aria-label={i18n('backToInbox')}
type="button"
/>
<div className="module-left-pane__archive-header-text">
{i18n('archivedConversations')}
</div>
</div>
);
};
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 (
<div className="module-left-pane">
<div className="module-left-pane__header">
{showArchived ? this.renderArchivedHeader() : renderMainHeader()}
</div>
{renderExpiredBuildDialog()}
{renderRelinkDialog()}
{renderNetworkStatus()}
{renderUpdateDialog()}
{showArchived && (
<div className="module-left-pane__archive-helper-text" key={0}>
{i18n('archiveHelperText')}
</div>
)}
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div className="module-left-pane__list--measure" ref={measureRef}>
<div className="module-left-pane__list--wrapper">
{this.renderList(contentRect.bounds!)}
{renderExpiredBuildDialog()}
{renderRelinkDialog()}
{helper.shouldRenderNetworkStatusAndUpdateDialog() && (
<>
{renderNetworkStatus()}
{renderUpdateDialog()}
</>
)}
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div className="module-left-pane__list--measure" ref={measureRef}>
<div className="module-left-pane__list--wrapper">
<div
aria-live="polite"
className="module-left-pane__list"
key={listKey}
role="presentation"
tabIndex={-1}
>
<ConversationList
dimensions={contentRect.bounds}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={showArchivedConversations}
onSelectConversation={(
conversationId: string,
messageId?: string
) => {
openConversationInternal({
conversationId,
messageId,
switchToAssociatedView: true,
});
}}
renderMessageSearchResult={renderMessageSearchResult}
rowCount={helper.getRowCount()}
scrollToRowIndex={helper.getRowIndexToScrollTo(
selectedConversationId
)}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
startNewConversationFromPhoneNumber={
startNewConversationFromPhoneNumber
}
/>
</div>
</div>
)}
</Measure>
</div>
);
}
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();
}
</div>
)}
</Measure>
</div>
);
};
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;
}

View File

@ -57,6 +57,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
clearSearch: action('clearSearch'),
showArchivedConversations: action('showArchivedConversations'),
startComposing: action('startComposing'),
});
story.add('Basic', () => {

View File

@ -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<PropsType, StateType> {
color,
i18n,
name,
startComposing,
phoneNumber,
profileName,
title,
@ -354,6 +356,10 @@ export class MainHeader extends React.Component<PropsType, StateType> {
? i18n('searchIn', [searchConversationName])
: i18n('search');
const isSearching = Boolean(
searchConversationId || searchTerm.trim().length
);
return (
<div className="module-main-header">
<Manager>
@ -456,6 +462,15 @@ export class MainHeader extends React.Component<PropsType, StateType> {
/>
) : null}
</div>
{!isSearching && (
<button
aria-label={i18n('newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('newConversation')}
type="button"
/>
)}
</div>
);
}

View File

@ -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<PropsType> {
public renderFromName(): JSX.Element {
const { from, i18n, to } = this.props;
if (from.isMe && to.isMe) {
return (
<span className="module-message-search-result__header__name">
{i18n('noteToSelf')}
</span>
);
}
if (from.isMe) {
return (
<span className="module-message-search-result__header__name">
{i18n('you')}
</span>
);
}
return (
<ContactName
phoneNumber={from.phoneNumber}
name={from.name}
profileName={from.profileName}
title={from.title}
module="module-message-search-result__header__name"
i18n={i18n}
/>
);
}
public renderFrom(): JSX.Element {
const { i18n, to, isSearchingInConversation } = this.props;
const fromName = this.renderFromName();
if (!to.isMe && !isSearchingInConversation) {
return (
<div className="module-message-search-result__header__from">
{fromName} {i18n('toJoiner')}{' '}
<span className="module-mesages-search-result__header__group">
<ContactName
phoneNumber={to.phoneNumber}
name={to.name}
profileName={to.profileName}
title={to.title}
i18n={i18n}
/>
</span>
</div>
);
}
return (
<div className="module-message-search-result__header__from">
{fromName}
</div>
);
}
public renderAvatar(): JSX.Element {
const { from, i18n, to } = this.props;
const isNoteToSelf = from.isMe && to.isMe;
return (
<Avatar
avatarPath={from.avatarPath}
color={from.color}
conversationType="direct"
i18n={i18n}
name={from.name}
noteToSelf={isNoteToSelf}
phoneNumber={from.phoneNumber}
profileName={from.profileName}
title={from.title}
size={52}
/>
);
}
public render(): JSX.Element | null {
const {
from,
i18n,
id,
isSelected,
conversationId,
openConversationInternal,
sentAt,
snippet,
to,
} = this.props;
if (!from || !to) {
return null;
}
return (
<button
onClick={() => {
if (openConversationInternal) {
openConversationInternal(conversationId, id);
}
}}
className={classNames(
'module-message-search-result',
isSelected ? 'module-message-search-result--is-selected' : null
)}
data-id={id}
type="button"
>
{this.renderAvatar()}
<div className="module-message-search-result__text">
<div className="module-message-search-result__header">
{this.renderFrom()}
{sentAt ? (
<div className="module-message-search-result__header__timestamp">
<Timestamp timestamp={sentAt} i18n={i18n} />
</div>
) : null}
</div>
<div className="module-message-search-result__body">
<MessageBodyHighlight text={snippet} i18n={i18n} />
</div>
</div>
</button>
);
}
}

View File

@ -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<string, MessageSearchResultPropsType> = 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: '<<left>>Everyone<<right>>! 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 <<left>>everyone<<right>> 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, <<left>>everyone<<right>>! 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, <<left>>everyone<<right>>, 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 (
<MessageSearchResult
{...messageProps}
i18n={i18n}
openConversationInternal={action(
'MessageSearchResult-open-conversation-internal'
)}
/>
);
},
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 }) => (
<>
<h3>{title}</h3>
<div className="module-left-pane">
<SearchResults {...defaultProps} {...props} />
</div>
<hr />
</>
));
});

View File

@ -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<SearchResultRowType>;
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<string, unknown>;
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<PropsType, StateType> {
public setFocusToFirstNeeded = false;
public setFocusToLastNeeded = false;
public cellSizeCache = new CellMeasurerCache({
defaultHeight: 80,
fixedWidth: true,
});
public listRef = React.createRef<List>();
public containerRef = React.createRef<HTMLDivElement>();
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<HTMLDivElement>): 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<HTMLElement> = 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<HTMLElement> = 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<HTMLElement>;
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 (
<StartNewConversation
phoneNumber={searchTerm}
i18n={i18n}
onClick={this.handleStartNewConversation}
/>
);
}
if (row.type === 'sms-mms-not-supported-text') {
return (
<div className="module-search-results__sms-not-supported">
{i18n('notSupportedSMS')}
</div>
);
}
if (row.type === 'conversations-header') {
return (
<div
className="module-search-results__conversations-header"
role="heading"
aria-level={1}
>
{i18n('conversationsHeader')}
</div>
);
}
if (row.type === 'conversation') {
const { data } = row;
return (
<ConversationListItem
key={data.phoneNumber}
{...data}
onClick={openConversationInternal}
i18n={i18n}
/>
);
}
if (row.type === 'contacts-header') {
return (
<div
className="module-search-results__contacts-header"
role="heading"
aria-level={1}
>
{i18n('contactsHeader')}
</div>
);
}
if (row.type === 'contact') {
const { data } = row;
return (
<ConversationListItem
key={data.phoneNumber}
{...data}
onClick={openConversationInternal}
i18n={i18n}
/>
);
}
if (row.type === 'messages-header') {
return (
<div
className="module-search-results__messages-header"
role="heading"
aria-level={1}
>
{i18n('messagesHeader')}
</div>
);
}
if (row.type === 'message') {
const { data } = row;
return renderMessageSearchResult(data);
}
if (row.type === 'spinner') {
return (
<div className="module-search-results__spinner-container">
<Spinner size="24px" svgSize="small" />
</div>
);
}
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 (
<div role="row" key={key} style={style}>
<CellMeasurer
cache={this.cellSizeCache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
width={width}
>
{this.renderRowContents(row)}
</CellMeasurer>
</div>
);
};
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 (
<div
className="module-search-results"
tabIndex={-1}
ref={this.containerRef}
onFocus={this.handleFocus}
>
{!searchConversationName || searchTerm ? (
<div
// We need this for Ctrl-T shortcut cycling through parts of app
tabIndex={-1}
className="module-search-results__no-results"
key={searchTerm}
>
{searchConversationName ? (
<Intl
id="noSearchResultsInConversation"
i18n={i18n}
components={{
searchTerm,
conversationName: (
<Emojify key="item-1" text={searchConversationName} />
),
}}
/>
) : (
i18n('noSearchResults', [searchTerm])
)}
</div>
) : null}
</div>
);
}
return (
<div
className="module-search-results"
aria-live="polite"
role="presentation"
tabIndex={-1}
ref={this.containerRef}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
>
<List
className="module-search-results__virtual-list"
deferredMeasurementCache={this.cellSizeCache}
height={height}
items={items}
overscanRowCount={5}
ref={this.listRef}
rowCount={this.getRowCount()}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.renderRow}
scrollToIndex={scrollToIndex}
tabIndex={-1}
// TODO: DESKTOP-687
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScroll={this.onScroll as any}
width={width}
/>
</div>
);
}
}

View File

@ -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<ShortcutType> = [
description: 'Keyboard--open-conversation-menu',
keys: [['commandOrCtrl', 'shift', 'L']],
},
{
description: 'Keyboard--new-conversation',
keys: [['commandOrCtrl', 'N']],
},
{
description: 'Keyboard--search',
keys: [['commandOrCtrl', 'F']],

View File

@ -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> = {}): 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 <StartNewConversation {...props} />;
});
stories.add('Partial Phone Number', () => {
const props = createProps({
phoneNumber: '202',
});
return <StartNewConversation {...props} />;
});

View File

@ -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<Props> {
public render(): JSX.Element {
const { phoneNumber, i18n, onClick } = this.props;
return (
<button
type="button"
className="module-start-new-conversation"
onClick={onClick}
>
<Avatar
color="grey"
conversationType="direct"
i18n={i18n}
title={phoneNumber}
size={52}
/>
<div className="module-start-new-conversation__content">
<div className="module-start-new-conversation__number">
{phoneNumber}
</div>
<div className="module-start-new-conversation__text">
{i18n('startConversation')}
</div>
</div>
</button>
);
}
}

View File

@ -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 (
<span className="module-about__text" dir="auto">
<span className={className} dir="auto">
<Emojify text={text || ''} />
</span>
);

View File

@ -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<PropsType> = 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 (
<button
type="button"
onClick={onClick}
style={style}
className={classNames(BASE_CLASS_NAME, {
[`${BASE_CLASS_NAME}--has-unread`]: isUnread,
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
})}
data-id={id ? cleanId(id) : undefined}
>
<div className={`${BASE_CLASS_NAME}__avatar-container`}>
<Avatar
avatarPath={avatarPath}
color={color}
noteToSelf={isAvatarNoteToSelf}
conversationType={conversationType}
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={AvatarSize.FIFTY_TWO}
/>
{isUnread && (
<div className={`${BASE_CLASS_NAME}__unread-count`}>
{unreadCount || ''}
</div>
)}
</div>
<div className={CONTENT_CLASS_NAME}>
<div className={HEADER_CLASS_NAME}>
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
{isNumber(headerDate) && (
<div
className={classNames(DATE_CLASS_NAME, {
[`${DATE_CLASS_NAME}--has-unread`]: isUnread,
})}
>
<Timestamp
timestamp={headerDate}
extended={false}
module={TIMESTAMP_CLASS_NAME}
withUnread={isUnread}
i18n={i18n}
/>
</div>
)}
</div>
{messageText ? (
<div className={MESSAGE_CLASS_NAME}>
<div
dir="auto"
className={classNames(MESSAGE_TEXT_CLASS_NAME, {
[`${MESSAGE_TEXT_CLASS_NAME}--has-unread`]: isUnread,
})}
>
{messageText}
</div>
{messageStatusIcon}
</div>
) : null}
</div>
</button>
);
}
);

View File

@ -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<PropsType> = React.memo(
({
about,
avatarPath,
color,
i18n,
id,
isMe,
name,
onClick,
phoneNumber,
profileName,
style,
title,
type,
}) => {
const headerName = isMe ? (
i18n('noteToSelf')
) : (
<ContactName
phoneNumber={phoneNumber}
name={name}
profileName={profileName}
title={title}
i18n={i18n}
/>
);
const messageText =
about && !isMe ? <About className="" text={about} /> : null;
const onClickItem = useCallback(() => onClick(id), [onClick, id]);
return (
<BaseConversationListItem
avatarPath={avatarPath}
color={color}
conversationType={type}
headerName={headerName}
i18n={i18n}
id={id}
isMe={isMe}
isSelected={false}
messageText={messageText}
name={name}
onClick={onClickItem}
phoneNumber={phoneNumber}
profileName={profileName}
style={style}
title={title}
/>
);
}
);

View File

@ -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<Props> = 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')
) : (
<ContactName
phoneNumber={phoneNumber}
name={name}
profileName={profileName}
title={title}
i18n={i18n}
/>
);
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 && (
<span className={`${MESSAGE_CLASS_NAME}__muted`} />
)}
{!acceptedMessageRequest ? (
<span className={`${MESSAGE_CLASS_NAME}__message-request`}>
{i18n('ConversationListItem--message-request')}
</span>
) : typingContact ? (
<TypingAnimation i18n={i18n} />
) : (
<>
{showingDraft ? (
<>
<span className={`${MESSAGE_TEXT_CLASS_NAME}__draft-prefix`}>
{i18n('ConversationListItem--draft-prefix')}
</span>
<MessageBody
text={(draftPreview || '').split('\n')[0]}
disableJumbomoji
disableLinks
i18n={i18n}
/>
</>
) : deletedForEveryone ? (
<span
className={`${MESSAGE_TEXT_CLASS_NAME}__deleted-for-everyone`}
>
{i18n('message--deletedForEveryone')}
</span>
) : (
<MessageBody
text={(messageBody || '').split('\n')[0]}
disableJumbomoji
disableLinks
i18n={i18n}
/>
)}
</>
)}
</>
);
/* eslint-enable no-nested-ternary */
if (!showingDraft && lastMessage && lastMessage.status) {
messageStatusIcon = (
<div
className={classNames(
MESSAGE_STATUS_ICON_CLASS_NAME,
`${MESSAGE_STATUS_ICON_CLASS_NAME}--${lastMessage.status}`
)}
/>
);
}
}
const onClickItem = useCallback(() => onClick(id), [onClick, id]);
return (
<BaseConversationListItem
avatarPath={avatarPath}
color={color}
conversationType={type}
headerDate={lastUpdated}
headerName={headerName}
i18n={i18n}
id={id}
isMe={isMe}
isSelected={Boolean(isSelected)}
markedUnread={markedUnread}
messageStatusIcon={messageStatusIcon}
messageText={messageText}
name={name}
onClick={onClickItem}
phoneNumber={phoneNumber}
profileName={profileName}
style={style}
title={title}
unreadCount={unreadCount}
/>
);
}
);

View File

@ -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);

View File

@ -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<Props> {
public render(): JSX.Element | Array<JSX.Element> {
private renderContents(): ReactNode {
const { text, i18n } = this.props;
const results: Array<JSX.Element> = [];
const FIND_BEGIN_END = /<<left>>(.+?)<<right>>/g;
@ -106,4 +109,8 @@ export class MessageBodyHighlight extends React.Component<Props> {
return results;
}
public render(): ReactNode {
return <div className={CLASS_NAME}>{this.renderContents()}</div>;
}
}

View File

@ -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> = {}): 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 <MessageSearchResult {...props} />;

View File

@ -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')
) : (
<ContactName
phoneNumber={person.phoneNumber}
name={person.name}
profileName={person.profileName}
title={person.title}
i18n={i18n}
/>
);
export const MessageSearchResult: FunctionComponent<PropsType> = 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 <div style={style} />;
}
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 = <MessageBodyHighlight text={snippet} i18n={i18n} />;
return (
<BaseConversationListItem
avatarPath={from.avatarPath}
color={from.color}
conversationType="direct"
headerDate={sentAt}
headerName={headerName}
i18n={i18n}
id={id}
isNoteToSelf={isNoteToSelf}
isMe={from.isMe}
isSelected={false}
messageText={messageText}
name={from.name}
onClick={onClickItem}
phoneNumber={from.phoneNumber}
profileName={from.profileName}
style={style}
title={from.title}
/>
);
}
);

View File

@ -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<Props> = React.memo(
({ i18n, onClick, phoneNumber, style }) => {
const messageText = (
<div className={TEXT_CLASS_NAME}>{i18n('startConversation')}</div>
);
return (
<BaseConversationListItem
color="grey"
conversationType="direct"
headerName={phoneNumber}
i18n={i18n}
isSelected={false}
messageText={messageText}
onClick={onClick}
phoneNumber={phoneNumber}
style={style}
title={phoneNumber}
/>
);
}
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,9 +58,9 @@ export async function joinViaLink(hash: string): Promise<void> {
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<void> {
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<void> {
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<void> {
);
}
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) {

View File

@ -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')!,

View File

@ -2414,7 +2414,7 @@ async function searchMessages(
const rows = await db.all(
`SELECT
messages.json,
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 15) as snippet
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 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, '<<left>>', '<<right>>', '...', 15) as snippet
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 10) as snippet
FROM messages_fts
INNER JOIN messages on messages_fts.id = messages.id
WHERE

View File

@ -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<MediaItemType>;
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<MediaItemType>;
};
};
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<void, RootStateType, unknown, ShowInboxActionType> {
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;
}

View File

@ -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<string>;
conversations: Array<string>;
contactIds: Array<string>;
conversationIds: Array<string>;
query: string;
normalizedPhoneNumber?: string;
messageIds: Array<string>;
@ -63,8 +61,8 @@ type SearchMessagesResultsPayloadType = SearchResultsBaseType & {
messages: Array<MessageSearchResultType>;
};
type SearchDiscussionsResultsPayloadType = SearchResultsBaseType & {
conversations: Array<string>;
contacts: Array<string>;
conversationIds: Array<string>;
contactIds: Array<string>;
};
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<SearchDiscussionsResultsPayloadType> {
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<string>;
conversationIds: Array<string>;
}> {
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<string> = [];
let contacts: Array<string> = [];
let conversationIds: Array<string> = [];
let contactIds: Array<string> = [];
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,
};
}

View File

@ -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<ConversationType> =>
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<ConversationType> = {
// 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<ConversationType>,
noteToSelf: ConversationType,
noteToSelfTitle: string
): Array<ConversationType> => {
let result: Array<ConversationType>;
if (searchTerm.length) {
const fuse = new Fuse<ConversationType>(
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?

View File

@ -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<SearchResultRowType> = [];
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,
};
}
);

View File

@ -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),
};
};

View File

@ -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),

View File

@ -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 <SmartMainHeader />;
}
function renderMessageSearchResult(id: string): JSX.Element {
return <FilteredSmartMessageSearchResult id={id} />;
function renderMessageSearchResult(
id: string,
style: CSSProperties
): JSX.Element {
return <FilteredSmartMessageSearchResult id={id} style={style} />;
}
function renderNetworkStatus(): JSX.Element {
return <SmartNetworkStatus />;
@ -47,19 +58,47 @@ function renderUpdateDialog(): JSX.Element {
return <SmartUpdateDialog />;
}
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,

View File

@ -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);

View File

@ -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 = {

View File

@ -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<ConversationType> = [
getDefaultConversation('1'),
getDefaultConversation('2'),
];
const contacts: Array<ConversationType> = [
getDefaultConversation('3'),
getDefaultConversation('4'),
getDefaultConversation('5'),
];
const messages: Array<MessageSearchResultType> = [
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',
});
});
});
});

View File

@ -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]);
});
});

View File

@ -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 }));
});
});

View File

@ -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(),
})
);
});
});

View File

@ -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: '' });
});
});
});
});

View File

@ -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: () => <div />,
renderMainHeader: () => <div />,
renderMessageSearchResult: () => <div />,
renderNetworkStatus: () => <div />,
renderRelinkDialog: () => <div />,
renderUpdateDialog: () => <div />,
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,
});
});
});
});
});

View File

@ -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()],
})
);
});
});
});

View File

@ -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: '',
})
);
});
});
});

View File

@ -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()],
})
);
});
});
});

View File

@ -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',
})
);
});
});
});

View File

@ -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)
);
});
});
});

View File

@ -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 = <T>(
lookup: Record<string, T>,
keys: ReadonlyArray<string>
): Array<T> => {
const result: Array<T> = [];
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;
};

View File

@ -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));

View File

@ -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
);
}

View File

@ -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."
}
]
]