Add "new conversation" composer for direct messages

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

View file

@ -0,0 +1,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;
};