Virtualize search results - only render what's visible

This commit is contained in:
Scott Nonnenberg 2019-08-08 17:46:49 -07:00
parent 9d4f2afa5a
commit 6292019d30
19 changed files with 1633 additions and 438 deletions

View file

@ -1017,6 +1017,69 @@ async function updateToSchemaVersion17(currentVersion, instance) {
}
}
async function updateToSchemaVersion18(currentVersion, instance) {
if (currentVersion >= 18) {
return;
}
console.log('updateToSchemaVersion18: starting...');
await instance.run('BEGIN TRANSACTION;');
try {
// Delete and rebuild full-text search index to capture everything
await instance.run('DELETE FROM messages_fts;');
await instance.run(
"INSERT INTO messages_fts(messages_fts) VALUES('rebuild');"
);
await instance.run(`
INSERT INTO messages_fts(id, body)
SELECT id, body FROM messages WHERE isViewOnce IS NULL OR isViewOnce != 1;
`);
// Fixing full-text triggers
await instance.run('DROP TRIGGER messages_on_insert;');
await instance.run('DROP TRIGGER messages_on_update;');
await instance.run(`
CREATE TRIGGER messages_on_insert AFTER INSERT ON messages
WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1
BEGIN
INSERT INTO messages_fts (
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1
BEGIN
DELETE FROM messages_fts WHERE id = old.id;
INSERT INTO messages_fts(
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run('PRAGMA schema_version = 18;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion18: success!');
} catch (error) {
await instance.run('ROLLBACK;');
throw error;
}
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -1035,6 +1098,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion15,
updateToSchemaVersion16,
updateToSchemaVersion17,
updateToSchemaVersion18,
];
async function updateSchema(instance) {
@ -1552,7 +1616,7 @@ async function searchConversations(query, { limit } = {}) {
$id: `%${query}%`,
$name: `%${query}%`,
$profileName: `%${query}%`,
$limit: limit || 50,
$limit: limit || 100,
}
);
@ -1572,7 +1636,7 @@ async function searchMessages(query, { limit } = {}) {
LIMIT $limit;`,
{
$query: query,
$limit: limit || 100,
$limit: limit || 500,
}
);

View file

@ -71,7 +71,6 @@
position: relative;
max-width: 100%;
margin: 0;
margin-bottom: 10px;
.timeline-wrapper {
-webkit-padding-start: 0px;

View file

@ -3125,8 +3125,8 @@
// Module: Search Results
.module-search-results {
overflow-y: scroll;
max-height: 100%;
overflow: hidden;
flex-grow: 1;
}
.module-search-results__conversations-header {

View file

@ -1584,7 +1584,7 @@ body.dark-theme {
}
.module-message-search-result__body {
color: $color-gray-05;
color: $color-gray-15;
}
// Module: Left Pane

View file

@ -1,8 +1,196 @@
#### With search results
```jsx
window.searchResults = {};
window.searchResults.conversations = [
const items = [
{
type: 'conversations-header',
data: undefined,
},
{
type: 'conversation',
data: {
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
},
},
},
{
type: 'conversation',
data: {
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'sent',
},
},
},
{
type: 'contacts-header',
data: undefined,
},
{
type: 'contact',
data: {
name: 'The one Everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0013',
avatarPath: util.gifObjectUrl,
},
},
{
type: 'contact',
data: {
name: 'No likey everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0014',
color: 'red',
},
},
{
type: 'messages-header',
data: undefined,
},
];
const messages = [
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: 'Mr. Fire 🔥',
phoneNumber: '(202) 555-0015',
},
id: '1-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0015',
receivedAt: Date.now() - 5 * 60 * 1000,
snippet: '<<left>>Everyone<<right>>! Get in!',
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
name: 'Jon ❄️',
phoneNumber: '(202) 555-0016',
color: 'green',
},
to: {
isMe: true,
},
id: '2-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0016',
snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
receivedAt: Date.now() - 20 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
name: 'Someone',
phoneNumber: '(202) 555-0011',
color: 'green',
avatarPath: util.pngObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '3-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
receivedAt: Date.now() - 24 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '4-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
receivedAt: Date.now() - 24 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
];
const messageLookup = util._.fromPairs(
util._.map(messages, message => [message.id, message])
);
messages.forEach(message => {
items.push({
type: 'message',
data: message.id,
});
});
const searchResults = {
items,
openConversationInternal: (...args) =>
console.log('openConversationInternal', args),
startNewConversation: (...args) => console.log('startNewConversation', args),
onStartNewConversation: (...args) => {
console.log('onStartNewConversation', args);
},
renderMessageSearchResult: id => (
<MessageSearchResult
{...messageLookup[id]}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
),
};
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane
searchResults={searchResults}
startNewConversation={(query, options) =>
console.log('startNewConversation', query, options)
}
openConversationInternal={(id, messageId) =>
console.log('openConversation', id, messageId)
}
showArchivedConversations={() => console.log('showArchivedConversations')}
showInbox={() => console.log('showInbox')}
renderMainHeader={() => (
<MainHeader
searchTerm="Hi there!"
search={result => console.log('search', result)}
updateSearch={result => console.log('updateSearch', result)}
clearSearch={result => console.log('clearSearch', result)}
i18n={util.i18n}
/>
)}
renderMessageSearchResult={id => (
<MessageSearchResult
{...messageLookup[id]}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
)}
i18n={util.i18n}
/>
</util.LeftPaneContext>;
```
#### With just conversations
```jsx
const conversations = [
{
id: 'convo1',
name: 'Everyone 🌆',
@ -50,85 +238,10 @@ window.searchResults.conversations = [
},
];
window.searchResults.contacts = [
{
id: 'contact1',
name: 'The one Everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0013',
avatarPath: util.gifObjectUrl,
},
{
id: 'contact2',
e: 'No likey everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0014',
color: 'red',
},
];
window.searchResults.messages = [
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: 'Mr. Fire 🔥',
phoneNumber: '(202) 555-0015',
},
id: '1-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0015',
receivedAt: Date.now() - 5 * 60 * 1000,
snippet: '<<left>>Everyone<<right>>! Get in!',
},
{
from: {
name: 'Jon ❄️',
phoneNumber: '(202) 555-0016',
color: 'green',
},
to: {
isMe: true,
},
id: '2-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0016',
snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
receivedAt: Date.now() - 20 * 60 * 1000,
},
{
from: {
name: 'Someone',
phoneNumber: '(202) 555-0011',
color: 'green',
avatarPath: util.pngObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '3-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
receivedAt: Date.now() - 24 * 60 * 1000,
},
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '4-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
receivedAt: Date.now() - 24 * 60 * 1000,
},
];
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane
searchResults={window.searchResults}
conversations={conversations}
archivedConversations={[]}
startNewConversation={(query, options) =>
console.log('startNewConversation', query, options)
}
@ -151,42 +264,61 @@ window.searchResults.messages = [
</util.LeftPaneContext>;
```
#### With just conversations
```jsx
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane
conversations={window.searchResults.conversations}
archivedConversations={[]}
startNewConversation={(query, options) =>
console.log('startNewConversation', query, options)
}
openConversationInternal={(id, messageId) =>
console.log('openConversation', id, messageId)
}
showArchivedConversations={() => console.log('showArchivedConversations')}
showInbox={() => console.log('showInbox')}
renderMainHeader={() => (
<MainHeader
searchTerm="Hi there!"
search={result => console.log('search', result)}
updateSearch={result => console.log('updateSearch', result)}
clearSearch={result => console.log('clearSearch', result)}
i18n={util.i18n}
/>
)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
```
#### Showing inbox, with some archived
```jsx
const conversations = [
{
id: 'convo1',
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
},
},
{
id: 'convo2',
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'error',
},
},
{
id: 'convo3',
name: 'John the Turtle',
conversationType: 'direct',
phoneNumber: '(202) 555-0021',
lastUpdated: Date.now() - 24 * 60 * 60 * 1000,
lastMessage: {
text: 'I dunno',
},
},
{
id: 'convo4',
name: 'The Fly',
conversationType: 'direct',
phoneNumber: '(202) 555-0022',
avatarPath: util.pngObjectUrl,
lastUpdated: Date.now(),
lastMessage: {
text: 'Gimme!',
},
},
];
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane
conversations={window.searchResults.conversations.slice(0, 2)}
archivedConversations={window.searchResults.conversations.slice(2)}
conversations={conversations.slice(0, 2)}
archivedConversations={conversations.slice(2)}
startNewConversation={(query, options) =>
console.log('startNewConversation', query, options)
}
@ -206,16 +338,64 @@ window.searchResults.messages = [
)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
</util.LeftPaneContext>;
```
#### Showing archived conversations
```jsx
const conversations = [
{
id: 'convo1',
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
},
},
{
id: 'convo2',
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'error',
},
},
{
id: 'convo3',
name: 'John the Turtle',
conversationType: 'direct',
phoneNumber: '(202) 555-0021',
lastUpdated: Date.now() - 24 * 60 * 60 * 1000,
lastMessage: {
text: 'I dunno',
},
},
{
id: 'convo4',
name: 'The Fly',
conversationType: 'direct',
phoneNumber: '(202) 555-0022',
avatarPath: util.pngObjectUrl,
lastUpdated: Date.now(),
lastMessage: {
text: 'Gimme!',
},
},
];
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane
conversations={window.searchResults.conversations.slice(0, 2)}
archivedConversations={window.searchResults.conversations.slice(2)}
conversations={conversations.slice(0, 2)}
archivedConversations={conversations.slice(2)}
showArchived={true}
startNewConversation={(query, options) =>
console.log('startNewConversation', query, options)
@ -236,5 +416,5 @@ window.searchResults.messages = [
)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
</util.LeftPaneContext>;
```

View file

@ -6,12 +6,12 @@ import {
PropsData as ConversationListItemPropsType,
} from './ConversationListItem';
import {
PropsData as SearchResultsProps,
PropsDataType as SearchResultsProps,
SearchResults,
} from './SearchResults';
import { LocalizerType } from '../types/Util';
export interface Props {
export interface PropsType {
conversations?: Array<ConversationListItemPropsType>;
archivedConversations?: Array<ConversationListItemPropsType>;
searchResults?: SearchResultsProps;
@ -30,6 +30,7 @@ export interface Props {
// Render Props
renderMainHeader: () => JSX.Element;
renderMessageSearchResult: (id: string) => JSX.Element;
}
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
@ -42,7 +43,7 @@ type RowRendererParamsType = {
style: Object;
};
export class LeftPane extends React.Component<Props> {
export class LeftPane extends React.Component<PropsType> {
public renderRow = ({
index,
key,
@ -125,6 +126,7 @@ export class LeftPane extends React.Component<Props> {
i18n,
conversations,
openConversationInternal,
renderMessageSearchResult,
startNewConversation,
searchResults,
showArchived,
@ -134,8 +136,9 @@ export class LeftPane extends React.Component<Props> {
return (
<SearchResults
{...searchResults}
openConversation={openConversationInternal}
openConversationInternal={openConversationInternal}
startNewConversation={startNewConversation}
renderMessageSearchResult={renderMessageSearchResult}
i18n={i18n}
/>
);

View file

@ -8,7 +8,9 @@ import { ContactName } from './conversation/ContactName';
import { LocalizerType } from '../types/Util';
export type PropsData = {
export type PropsDataType = {
isSelected?: boolean;
id: string;
conversationId: string;
receivedAt: number;
@ -33,16 +35,17 @@ export type PropsData = {
};
};
type PropsHousekeeping = {
isSelected?: boolean;
type PropsHousekeepingType = {
i18n: LocalizerType;
onClick: (conversationId: string, messageId?: string) => void;
openConversationInternal: (
conversationId: string,
messageId?: string
) => void;
};
type Props = PropsData & PropsHousekeeping;
type PropsType = PropsDataType & PropsHousekeepingType;
export class MessageSearchResult extends React.PureComponent<Props> {
export class MessageSearchResult extends React.PureComponent<PropsType> {
public renderFromName() {
const { from, i18n, to } = this.props;
@ -123,7 +126,7 @@ export class MessageSearchResult extends React.PureComponent<Props> {
id,
isSelected,
conversationId,
onClick,
openConversationInternal,
receivedAt,
snippet,
to,
@ -137,8 +140,8 @@ export class MessageSearchResult extends React.PureComponent<Props> {
<div
role="button"
onClick={() => {
if (onClick) {
onClick(conversationId, id);
if (openConversationInternal) {
openConversationInternal(conversationId, id);
}
}}
className={classNames(

View file

@ -1,48 +1,68 @@
#### With all result types
```jsx
window.searchResults = {};
window.searchResults.conversations = [
const items = [
{
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
type: 'conversations-header',
data: undefined,
},
{
type: 'conversation',
data: {
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
},
},
},
{
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'sent',
type: 'conversation',
data: {
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'sent',
},
},
},
];
window.searchResults.contacts = [
{
name: 'The one Everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0013',
avatarPath: util.gifObjectUrl,
type: 'contacts-header',
data: undefined,
},
{
name: 'No likey everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0014',
color: 'red',
type: 'contact',
data: {
name: 'The one Everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0013',
avatarPath: util.gifObjectUrl,
},
},
{
type: 'contact',
data: {
name: 'No likey everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0014',
color: 'red',
},
},
{
type: 'messages-header',
data: undefined,
},
];
window.searchResults.messages = [
const messages = [
{
from: {
isMe: true,
@ -56,7 +76,7 @@ window.searchResults.messages = [
conversationId: '(202) 555-0015',
receivedAt: Date.now() - 5 * 60 * 1000,
snippet: '<<left>>Everyone<<right>>! Get in!',
onClick: () => console.log('onClick'),
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
@ -71,7 +91,7 @@ window.searchResults.messages = [
conversationId: '(202) 555-0016',
snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
receivedAt: Date.now() - 20 * 60 * 1000,
onClick: () => console.log('onClick'),
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
@ -87,7 +107,7 @@ window.searchResults.messages = [
conversationId: 'EveryoneGroupID',
snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
receivedAt: Date.now() - 24 * 60 * 1000,
onClick: () => console.log('onClick'),
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
@ -101,21 +121,45 @@ window.searchResults.messages = [
conversationId: 'EveryoneGroupID',
snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
receivedAt: Date.now() - 24 * 60 * 1000,
onClick: () => console.log('onClick'),
conversationOpenInternal: () => console.log('onClick'),
},
];
<util.LeftPaneContext theme={util.theme}>
const messageLookup = util._.fromPairs(
util._.map(messages, message => [message.id, message])
);
messages.forEach(message => {
items.push({
type: 'message',
data: message.id,
});
});
<util.LeftPaneContext
theme={util.theme}
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
>
<SearchResults
conversations={window.searchResults.conversations}
contacts={window.searchResults.contacts}
messages={window.searchResults.messages}
items={items}
i18n={util.i18n}
onClickMessage={id => console.log('onClickMessage', id)}
onClickConversation={id => console.log('onClickConversation', id)}
onStartNewConversation={(query, options) =>
console.log('onStartNewConversation', query, options)
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
startNewConversation={(...args) =>
console.log('startNewConversation', args)
}
onStartNewConversation={(...args) =>
console.log('onStartNewConversation', args)
}
renderMessageSearchResult={id => (
<MessageSearchResult
{...messageLookup[id]}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
)}
/>
</util.LeftPaneContext>;
```
@ -123,82 +167,562 @@ window.searchResults.messages = [
#### With 'start new conversation'
```jsx
<util.LeftPaneContext theme={util.theme}>
const items = [
{
type: 'start-new-conversation',
data: undefined,
},
{
type: 'conversations-header',
data: undefined,
},
{
type: 'conversation',
data: {
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
},
},
},
{
type: 'conversation',
data: {
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'sent',
},
},
},
{
type: 'contacts-header',
data: undefined,
},
{
type: 'contact',
data: {
name: 'The one Everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0013',
avatarPath: util.gifObjectUrl,
},
},
{
type: 'contact',
data: {
name: 'No likey everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0014',
color: 'red',
},
},
{
type: 'messages-header',
data: undefined,
},
];
const messages = [
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: 'Mr. Fire 🔥',
phoneNumber: '(202) 555-0015',
},
id: '1-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0015',
receivedAt: Date.now() - 5 * 60 * 1000,
snippet: '<<left>>Everyone<<right>>! Get in!',
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
name: 'Jon ❄️',
phoneNumber: '(202) 555-0016',
color: 'green',
},
to: {
isMe: true,
},
id: '2-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0016',
snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
receivedAt: Date.now() - 20 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
name: 'Someone',
phoneNumber: '(202) 555-0011',
color: 'green',
avatarPath: util.pngObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '3-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
receivedAt: Date.now() - 24 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '4-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
receivedAt: Date.now() - 24 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
];
const messageLookup = util._.fromPairs(
util._.map(messages, message => [message.id, message])
);
messages.forEach(message => {
items.push({
type: 'message',
data: message.id,
});
});
<util.LeftPaneContext
theme={util.theme}
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
>
<SearchResults
conversations={window.searchResults.conversations}
contacts={window.searchResults.contacts}
messages={window.searchResults.messages}
showStartNewConversation={true}
searchTerm="(555) 100-2000"
items={items}
i18n={util.i18n}
onClickMessage={id => console.log('onClickMessage', id)}
onClickConversation={id => console.log('onClickConversation', id)}
onStartNewConversation={(query, options) =>
console.log('onStartNewConversation', query, options)
searchTerm="(202) 555-0015"
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
startNewConversation={(...args) =>
console.log('startNewConversation', args)
}
onStartNewConversation={(...args) =>
console.log('onStartNewConversation', args)
}
renderMessageSearchResult={id => (
<MessageSearchResult
{...messageLookup[id]}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
)}
/>
</util.LeftPaneContext>
</util.LeftPaneContext>;
```
#### With no conversations
```jsx
<util.LeftPaneContext theme={util.theme}>
const items = [
{
type: 'contacts-header',
data: undefined,
},
{
type: 'contact',
data: {
name: 'The one Everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0013',
avatarPath: util.gifObjectUrl,
},
},
{
type: 'contact',
data: {
name: 'No likey everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0014',
color: 'red',
},
},
{
type: 'messages-header',
data: undefined,
},
];
const messages = [
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: 'Mr. Fire 🔥',
phoneNumber: '(202) 555-0015',
},
id: '1-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0015',
receivedAt: Date.now() - 5 * 60 * 1000,
snippet: '<<left>>Everyone<<right>>! Get in!',
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
name: 'Jon ❄️',
phoneNumber: '(202) 555-0016',
color: 'green',
},
to: {
isMe: true,
},
id: '2-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0016',
snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
receivedAt: Date.now() - 20 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
name: 'Someone',
phoneNumber: '(202) 555-0011',
color: 'green',
avatarPath: util.pngObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '3-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
receivedAt: Date.now() - 24 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '4-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
receivedAt: Date.now() - 24 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
];
const messageLookup = util._.fromPairs(
util._.map(messages, message => [message.id, message])
);
messages.forEach(message => {
items.push({
type: 'message',
data: message.id,
});
});
<util.LeftPaneContext
theme={util.theme}
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
>
<SearchResults
conversations={null}
contacts={window.searchResults.contacts}
messages={window.searchResults.messages}
items={items}
i18n={util.i18n}
onClickMessage={id => console.log('onClickMessage', id)}
onClickConversation={id => console.log('onClickConversation', id)}
onStartNewConversation={(query, options) =>
console.log('onStartNewConversation', query, options)
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
startNewConversation={(...args) =>
console.log('startNewConversation', args)
}
onStartNewConversation={(...args) =>
console.log('onStartNewConversation', args)
}
renderMessageSearchResult={id => (
<MessageSearchResult
{...messageLookup[id]}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
)}
/>
</util.LeftPaneContext>
</util.LeftPaneContext>;
```
#### With no contacts
```jsx
<util.LeftPaneContext theme={util.theme}>
const items = [
{
type: 'conversations-header',
data: undefined,
},
{
type: 'conversation',
data: {
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
},
},
},
{
type: 'conversation',
data: {
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'sent',
},
},
},
{
type: 'messages-header',
data: undefined,
},
];
const messages = [
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: 'Mr. Fire 🔥',
phoneNumber: '(202) 555-0015',
},
id: '1-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0015',
receivedAt: Date.now() - 5 * 60 * 1000,
snippet: '<<left>>Everyone<<right>>! Get in!',
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
name: 'Jon ❄️',
phoneNumber: '(202) 555-0016',
color: 'green',
},
to: {
isMe: true,
},
id: '2-guid-guid-guid-guid-guid',
conversationId: '(202) 555-0016',
snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
receivedAt: Date.now() - 20 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
name: 'Someone',
phoneNumber: '(202) 555-0011',
color: 'green',
avatarPath: util.pngObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '3-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
receivedAt: Date.now() - 24 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
{
from: {
isMe: true,
avatarPath: util.gifObjectUrl,
},
to: {
name: "Y'all 🌆",
},
id: '4-guid-guid-guid-guid-guid',
conversationId: 'EveryoneGroupID',
snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
receivedAt: Date.now() - 24 * 60 * 1000,
conversationOpenInternal: () => console.log('onClick'),
},
];
const messageLookup = util._.fromPairs(
util._.map(messages, message => [message.id, message])
);
messages.forEach(message => {
items.push({
type: 'message',
data: message.id,
});
});
<util.LeftPaneContext
theme={util.theme}
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
>
<SearchResults
conversations={window.searchResults.conversations}
contacts={null}
messages={window.searchResults.messages}
items={items}
i18n={util.i18n}
onClickMessage={id => console.log('onClickMessage', id)}
onClickConversation={id => console.log('onClickConversation', id)}
onStartNewConversation={(query, options) =>
console.log('onStartNewConversation', query, options)
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
startNewConversation={(...args) =>
console.log('startNewConversation', args)
}
onStartNewConversation={(...args) =>
console.log('onStartNewConversation', args)
}
renderMessageSearchResult={id => (
<MessageSearchResult
{...messageLookup[id]}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
)}
/>
</util.LeftPaneContext>
</util.LeftPaneContext>;
```
#### With no messages
```jsx
<util.LeftPaneContext theme={util.theme}>
const items = [
{
type: 'conversations-header',
data: undefined,
},
{
type: 'conversation',
data: {
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
},
},
},
{
type: 'conversation',
data: {
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'sent',
},
},
},
{
type: 'contacts-header',
data: undefined,
},
{
type: 'contact',
data: {
name: 'The one Everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0013',
avatarPath: util.gifObjectUrl,
},
},
{
type: 'contact',
data: {
name: 'No likey everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0014',
color: 'red',
},
},
];
<util.LeftPaneContext
theme={util.theme}
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
>
<SearchResults
conversations={window.searchResults.conversations}
contacts={window.searchResults.contacts}
messages={null}
items={items}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
startNewConversation={(...args) =>
console.log('startNewConversation', args)
}
onStartNewConversation={(...args) =>
console.log('onStartNewConversation', args)
}
/>
</util.LeftPaneContext>
</util.LeftPaneContext>;
```
#### With no results at all
```jsx
<util.LeftPaneContext theme={util.theme}>
<util.LeftPaneContext
theme={util.theme}
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
>
<SearchResults
conversations={null}
contacts={null}
messages={null}
searchTerm="dinner plans"
items={[]}
noResults={true}
searchTerm="something"
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
startNewConversation={(...args) =>
console.log('startNewConversation', args)
}
onStartNewConversation={(...args) =>
console.log('onStartNewConversation', args)
}
renderMessageSearchResult={id => (
<MessageSearchResult
{...messageLookup[id]}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
)}
/>
</util.LeftPaneContext>
```
@ -206,6 +730,67 @@ window.searchResults.messages = [
#### With a lot of results
```jsx
const items = [
{
type: 'conversations-header',
data: undefined,
},
{
type: 'conversation',
data: {
name: 'Everyone 🌆',
conversationType: 'group',
phoneNumber: '(202) 555-0011',
avatarPath: util.landscapeGreenObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: 'The rabbit hopped silently in the night.',
status: 'sent',
},
},
},
{
type: 'conversation',
data: {
name: 'Everyone Else 🔥',
conversationType: 'direct',
phoneNumber: '(202) 555-0012',
avatarPath: util.landscapePurpleObjectUrl,
lastUpdated: Date.now() - 5 * 60 * 1000,
lastMessage: {
text: "What's going on?",
status: 'sent',
},
},
},
{
type: 'contacts-header',
data: undefined,
},
{
type: 'contact',
data: {
name: 'The one Everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0013',
avatarPath: util.gifObjectUrl,
},
},
{
type: 'contact',
data: {
name: 'No likey everyone',
conversationType: 'direct',
phoneNumber: '(202) 555-0014',
color: 'red',
},
},
{
type: 'messages-header',
data: undefined,
},
];
const messages = [];
for (let i = 0; i < 100; i += 1) {
messages.push({
@ -221,16 +806,45 @@ for (let i = 0; i < 100; i += 1) {
conversationId: '(202) 555-0015',
receivedAt: Date.now() - 5 * 60 * 1000,
snippet: `${i} <<left>>Everyone<<right>>! Get in!`,
onClick: data => console.log('onClick', data),
conversationOpenInternal: data => console.log('onClick', data),
});
}
<util.LeftPaneContext style={{ height: '500px' }} theme={util.theme}>
const messageLookup = util._.fromPairs(
util._.map(messages, message => [message.id, message])
);
messages.forEach(message => {
items.push({
type: 'message',
data: message.id,
});
});
<util.LeftPaneContext
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
theme={util.theme}
>
<SearchResults
conversations={null}
contacts={null}
messages={messages}
items={items}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
startNewConversation={(...args) =>
console.log('startNewConversation', args)
}
onStartNewConversation={(...args) =>
console.log('onStartNewConversation', args)
}
renderMessageSearchResult={id => (
<MessageSearchResult
{...messageLookup[id]}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
)}
/>
</util.LeftPaneContext>;
```
@ -238,6 +852,8 @@ for (let i = 0; i < 100; i += 1) {
#### With just messages and no header
```jsx
const items = [];
const messages = [];
for (let i = 0; i < 10; i += 1) {
messages.push({
@ -253,15 +869,45 @@ for (let i = 0; i < 10; i += 1) {
conversationId: '(202) 555-0015',
receivedAt: Date.now() - 5 * 60 * 1000,
snippet: `${i} <<left>>Everyone<<right>>! Get in!`,
onClick: data => console.log('onClick', data),
conversationOpenInternal: data => console.log('onClick', data),
});
}
<util.LeftPaneContext style={{ height: '500px' }} theme={util.theme}>
const messageLookup = util._.fromPairs(
util._.map(messages, message => [message.id, message])
);
messages.forEach(message => {
items.push({
type: 'message',
data: message.id,
});
});
<util.LeftPaneContext
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
theme={util.theme}
>
<SearchResults
hideMessagesHeader={true}
messages={messages}
items={items}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
startNewConversation={(...args) =>
console.log('startNewConversation', args)
}
onStartNewConversation={(...args) =>
console.log('onStartNewConversation', args)
}
renderMessageSearchResult={id => (
<MessageSearchResult
{...messageLookup[id]}
i18n={util.i18n}
openConversationInternal={(...args) =>
console.log('openConversationInternal', args)
}
/>
)}
/>
</util.LeftPaneContext>;
```

View file

@ -1,123 +1,277 @@
import React from 'react';
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from 'react-virtualized';
import {
ConversationListItem,
PropsData as ConversationListItemPropsType,
} from './ConversationListItem';
import { MessageSearchResult } from './MessageSearchResult';
import { StartNewConversation } from './StartNewConversation';
import { LocalizerType } from '../types/Util';
export type PropsData = {
contacts: Array<ConversationListItemPropsType>;
conversations: Array<ConversationListItemPropsType>;
hideMessagesHeader: boolean;
messages: Array<MessageSearchResultPropsType>;
export type PropsDataType = {
items: Array<SearchResultRowType>;
noResults: boolean;
regionCode: string;
searchTerm: string;
showStartNewConversation: boolean;
};
type PropsHousekeeping = {
type StartNewConversationType = {
type: 'start-new-conversation';
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;
};
export type SearchResultRowType =
| StartNewConversationType
| ConversationHeaderType
| ContactsHeaderType
| MessagesHeaderType
| ConversationType
| ContactsType
| MessageType;
type PropsHousekeepingType = {
i18n: LocalizerType;
openConversation: (id: string, messageId?: string) => void;
openConversationInternal: (id: string, messageId?: string) => void;
startNewConversation: (
query: string,
options: { regionCode: string }
) => void;
renderMessageSearchResult: (id: string) => JSX.Element;
};
type Props = PropsData & PropsHousekeeping;
type PropsType = PropsDataType & PropsHousekeepingType;
// 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: Object;
style: Object;
};
export class SearchResults extends React.Component<PropsType> {
public mostRecentWidth = 0;
public mostRecentHeight = 0;
public cellSizeCache = new CellMeasurerCache({
defaultHeight: 36,
fixedWidth: true,
});
public listRef = React.createRef<any>();
export class SearchResults extends React.Component<Props> {
public handleStartNewConversation = () => {
const { regionCode, searchTerm, startNewConversation } = this.props;
startNewConversation(searchTerm, { regionCode });
};
public render() {
public renderRowContents(row: SearchResultRowType) {
const {
conversations,
contacts,
hideMessagesHeader,
i18n,
messages,
openConversation,
searchTerm,
showStartNewConversation,
i18n,
openConversationInternal,
renderMessageSearchResult,
} = this.props;
const haveConversations = conversations && conversations.length;
const haveContacts = contacts && contacts.length;
const haveMessages = messages && messages.length;
const noResults =
!showStartNewConversation &&
!haveConversations &&
!haveContacts &&
!haveMessages;
if (row.type === 'start-new-conversation') {
return (
<StartNewConversation
phoneNumber={searchTerm}
i18n={i18n}
onClick={this.handleStartNewConversation}
/>
);
} else if (row.type === 'conversations-header') {
return (
<div className="module-search-results__conversations-header">
{i18n('conversationsHeader')}
</div>
);
} else if (row.type === 'conversation') {
const { data } = row;
return (
<ConversationListItem
key={data.phoneNumber}
{...data}
onClick={openConversationInternal}
i18n={i18n}
/>
);
} else if (row.type === 'contacts-header') {
return (
<div className="module-search-results__contacts-header">
{i18n('contactsHeader')}
</div>
);
} else if (row.type === 'contact') {
const { data } = row;
return (
<ConversationListItem
key={data.phoneNumber}
{...data}
onClick={openConversationInternal}
i18n={i18n}
/>
);
} else if (row.type === 'messages-header') {
return (
<div className="module-search-results__messages-header">
{i18n('messagesHeader')}
</div>
);
} else if (row.type === 'message') {
const { data } = row;
return renderMessageSearchResult(data);
} else {
throw new Error(
'SearchResults.renderRowContents: Encountered unknown row type'
);
}
}
public renderRow = ({
index,
key,
parent,
style,
}: RowRendererParamsType): JSX.Element => {
const { items } = this.props;
const row = items[index];
return (
<div className="module-search-results">
{noResults ? (
<div key={key} style={style}>
<CellMeasurer
cache={this.cellSizeCache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
width={this.mostRecentWidth}
>
{this.renderRowContents(row)}
</CellMeasurer>
</div>
);
};
public componentDidUpdate(prevProps: PropsType) {
const { items } = this.props;
if (
items &&
items.length > 0 &&
prevProps.items &&
prevProps.items.length > 0 &&
items !== prevProps.items
) {
this.resizeAll();
}
}
public getList = () => {
if (!this.listRef) {
return;
}
const { current } = this.listRef;
return current;
};
public recomputeRowHeights = (row?: number) => {
const list = this.getList();
if (!list) {
return;
}
list.recomputeRowHeights(row);
};
public resizeAll = () => {
this.cellSizeCache.clearAll();
const rowCount = this.getRowCount();
this.recomputeRowHeights(rowCount - 1);
};
public getRowCount() {
const { items } = this.props;
return items ? items.length : 0;
}
public render() {
const { items, i18n, noResults, searchTerm } = this.props;
if (noResults) {
return (
<div className="module-search-results">
<div className="module-search-results__no-results">
{i18n('noSearchResults', [searchTerm])}
</div>
) : null}
{showStartNewConversation ? (
<StartNewConversation
phoneNumber={searchTerm}
i18n={i18n}
onClick={this.handleStartNewConversation}
/>
) : null}
{haveConversations ? (
<div className="module-search-results__conversations">
<div className="module-search-results__conversations-header">
{i18n('conversationsHeader')}
</div>
{conversations.map(conversation => (
<ConversationListItem
key={conversation.phoneNumber}
{...conversation}
onClick={openConversation}
i18n={i18n}
</div>
);
}
return (
<div className="module-search-results" key={searchTerm}>
<AutoSizer>
{({ height, width }) => {
this.mostRecentWidth = width;
this.mostRecentHeight = height;
return (
<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}
width={width}
/>
))}
</div>
) : null}
{haveContacts ? (
<div className="module-search-results__contacts">
<div className="module-search-results__contacts-header">
{i18n('contactsHeader')}
</div>
{contacts.map(contact => (
<ConversationListItem
key={contact.phoneNumber}
{...contact}
onClick={openConversation}
i18n={i18n}
/>
))}
</div>
) : null}
{haveMessages ? (
<div className="module-search-results__messages">
{hideMessagesHeader ? null : (
<div className="module-search-results__messages-header">
{i18n('messagesHeader')}
</div>
)}
{messages.map(message => (
<MessageSearchResult
key={message.id}
{...message}
onClick={openConversation}
i18n={i18n}
/>
))}
</div>
) : null}
);
}}
</AutoSizer>
</div>
);
}

View file

@ -14,32 +14,6 @@ import { NoopActionType } from './noop';
// State
export type MessageSearchResultType = {
id: string;
conversationId: string;
receivedAt: number;
snippet: string;
from: {
phoneNumber: string;
isMe?: boolean;
name?: string;
color?: string;
profileName?: string;
avatarPath?: string;
};
to: {
groupName?: string;
phoneNumber: string;
isMe?: boolean;
name?: string;
profileName?: string;
};
isSelected?: boolean;
};
export type ConversationType = {
id: string;
name?: string;

View file

@ -9,25 +9,31 @@ import { makeLookup } from '../../util/makeLookup';
import {
ConversationType,
MessageDeletedActionType,
MessageSearchResultType,
MessageType,
RemoveAllConversationsActionType,
SelectedConversationChangedActionType,
} from './conversations';
// State
export type MessageSearchResultType = MessageType & {
snippet: string;
};
export type MessageSearchResultLookupType = {
[id: string]: MessageSearchResultType;
};
export type SearchStateType = {
// We store just ids of conversations, since that data is always cached in memory
contacts: Array<string>;
conversations: Array<string>;
query: string;
normalizedPhoneNumber?: string;
// We need to store messages here, because they aren't anywhere else in state
messages: Array<MessageSearchResultType>;
messageIds: Array<string>;
// We do store message data to pass through the selector
messageLookup: MessageSearchResultLookupType;
selectedMessage?: string;
messageLookup: {
[key: string]: MessageSearchResultType;
};
// For conversations we store just the id, and pull conversation props in the selector
conversations: Array<string>;
contacts: Array<string>;
};
// Actions
@ -193,7 +199,7 @@ async function queryConversationsAndContacts(
function getEmptyState(): SearchStateType {
return {
query: '',
messages: [],
messageIds: [],
messageLookup: {},
conversations: [],
contacts: [],
@ -220,16 +226,28 @@ export function reducer(
if (action.type === 'SEARCH_RESULTS_FULFILLED') {
const { payload } = action;
const { query, messages } = payload;
const {
contacts,
conversations,
messages,
normalizedPhoneNumber,
query,
} = payload;
// Reject if the associated query is not the most recent user-provided query
if (state.query !== query) {
return state;
}
const messageIds = messages.map(message => message.id);
return {
...state,
...payload,
contacts,
conversations,
normalizedPhoneNumber,
query,
messageIds,
messageLookup: makeLookup(messages, 'id'),
};
}
@ -253,8 +271,8 @@ export function reducer(
}
if (action.type === 'MESSAGE_DELETED') {
const { messages, messageLookup } = state;
if (!messages.length) {
const { messageIds, messageLookup } = state;
if (!messageIds || messageIds.length < 1) {
return state;
}
@ -263,7 +281,7 @@ export function reducer(
return {
...state,
messages: reject(messages, message => id === message.id),
messageIds: reject(messageIds, messageId => id === messageId),
messageLookup: omit(messageLookup, ['id']),
};
}

View file

@ -207,11 +207,13 @@ export const getCachedSelectorForConversation = createSelector(
(): CachedConversationSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(_conversationSelector, { max: 100 });
return memoizee(_conversationSelector, { max: 2000 });
}
);
type GetConversationByIdType = (id: string) => ConversationType | undefined;
export type GetConversationByIdType = (
id: string
) => ConversationType | undefined;
export const getConversationSelector = createSelector(
getCachedSelectorForConversation,
getConversationLookup,
@ -287,7 +289,7 @@ export const getCachedSelectorForMessage = createSelector(
(): CachedMessageSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(_messageSelector, { max: 500 });
return memoizee(_messageSelector, { max: 2000 });
}
);

View file

@ -1,17 +1,32 @@
import { compact } from 'lodash';
import memoizee from 'memoizee';
import { createSelector } from 'reselect';
import { getSearchResultsProps } from '../../shims/Whisper';
import { StateType } from '../reducer';
import { SearchStateType } from '../ducks/search';
import {
MessageSearchResultLookupType,
MessageSearchResultType,
SearchStateType,
} from '../ducks/search';
import {
ConversationLookupType,
ConversationType,
} from '../ducks/conversations';
import {
PropsDataType as SearchResultsPropsType,
SearchResultRowType,
} from '../../components/SearchResults';
import { PropsDataType as MessageSearchResultPropsDataType } from '../../components/MessageSearchResult';
import { getRegionCode, getUserNumber } from './user';
import {
GetConversationByIdType,
getConversationLookup,
getConversationSelector,
getSelectedConversation,
} from './conversations';
import { ConversationLookupType } from '../ducks/conversations';
import { getRegionCode } from './user';
export const getSearch = (state: StateType): SearchStateType => state.search;
@ -34,68 +49,182 @@ export const isSearching = createSelector(
}
);
export const getMessageSearchResultLookup = createSelector(
getSearch,
(state: SearchStateType) => state.messageLookup
);
export const getSearchResults = createSelector(
[
getSearch,
getRegionCode,
getConversationLookup,
getSelectedConversation,
getSelectedMessage,
],
[getSearch, getRegionCode, getConversationLookup, getSelectedConversation],
(
state: SearchStateType,
regionCode: string,
lookup: ConversationLookupType,
selectedConversation?: string,
selectedMessage?: string
) => {
selectedConversation?: string
): SearchResultsPropsType => {
const { conversations, contacts, messageIds } = state;
const showStartNewConversation = Boolean(
state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber]
);
const haveConversations = conversations && conversations.length;
const haveContacts = contacts && contacts.length;
const haveMessages = messageIds && messageIds.length;
const noResults =
!showStartNewConversation &&
!haveConversations &&
!haveContacts &&
!haveMessages;
const items: Array<SearchResultRowType> = [];
if (showStartNewConversation) {
items.push({
type: 'start-new-conversation',
data: undefined,
});
}
if (haveConversations) {
items.push({
type: 'conversations-header',
data: undefined,
});
conversations.forEach(id => {
const data = lookup[id];
items.push({
type: 'conversation',
data: {
...data,
isSelected: Boolean(data && id === selectedConversation),
},
});
});
}
if (haveContacts) {
items.push({
type: 'contacts-header',
data: undefined,
});
contacts.forEach(id => {
const data = lookup[id];
items.push({
type: 'contact',
data: {
...data,
isSelected: Boolean(data && id === selectedConversation),
},
});
});
}
if (haveMessages) {
items.push({
type: 'messages-header',
data: undefined,
});
messageIds.forEach(messageId => {
items.push({
type: 'message',
data: messageId,
});
});
}
return {
contacts: compact(
state.contacts.map(id => {
const value = lookup[id];
if (value && id === selectedConversation) {
return {
...value,
isSelected: true,
};
}
return value;
})
),
conversations: compact(
state.conversations.map(id => {
const value = lookup[id];
if (value && id === selectedConversation) {
return {
...value,
isSelected: true,
};
}
return value;
})
),
hideMessagesHeader: false,
messages: state.messages.map(message => {
const props = getSearchResultsProps(message);
if (message.id === selectedMessage) {
return {
...props,
isSelected: true,
};
}
return props;
}),
items,
noResults,
regionCode: regionCode,
searchTerm: state.query,
showStartNewConversation: Boolean(
state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber]
),
};
}
);
export function _messageSearchResultSelector(
message: MessageSearchResultType,
// @ts-ignore
ourNumber: string,
// @ts-ignore
regionCode: string,
// @ts-ignore
sender?: ConversationType,
// @ts-ignore
recipient?: ConversationType,
selectedMessageId?: string
): MessageSearchResultPropsDataType {
// Note: We don't use all of those parameters here, but the shim we call does.
// We want to call this function again if any of those parameters change.
return {
...getSearchResultsProps(message),
isSelected: message.id === selectedMessageId,
};
}
// A little optimization to reset our selector cache whenever high-level application data
// changes: regionCode and userNumber.
type CachedMessageSearchResultSelectorType = (
message: MessageSearchResultType,
ourNumber: string,
regionCode: string,
sender?: ConversationType,
recipient?: ConversationType,
selectedMessageId?: string
) => MessageSearchResultPropsDataType;
export const getCachedSelectorForMessageSearchResult = createSelector(
getRegionCode,
getUserNumber,
(): CachedMessageSearchResultSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(_messageSearchResultSelector, { max: 500 });
}
);
type GetMessageSearchResultByIdType = (
id: string
) => MessageSearchResultPropsDataType | undefined;
export const getMessageSearchResultSelector = createSelector(
getCachedSelectorForMessageSearchResult,
getMessageSearchResultLookup,
getSelectedMessage,
getConversationSelector,
getRegionCode,
getUserNumber,
(
messageSearchResultSelector: CachedMessageSearchResultSelectorType,
messageSearchResultLookup: MessageSearchResultLookupType,
selectedMessage: string | undefined,
conversationSelector: GetConversationByIdType,
regionCode: string,
ourNumber: string
): GetMessageSearchResultByIdType => {
return (id: string) => {
const message = messageSearchResultLookup[id];
if (!message) {
return;
}
const { conversationId, source, type } = message;
let sender: ConversationType | undefined;
let recipient: ConversationType | undefined;
if (type === 'incoming') {
sender = conversationSelector(source);
recipient = conversationSelector(ourNumber);
} else if (type === 'outgoing') {
sender = conversationSelector(ourNumber);
recipient = conversationSelector(conversationId);
}
return messageSearchResultSelector(
message,
ourNumber,
regionCode,
sender,
recipient,
selectedMessage
);
};
}
);

View file

@ -9,14 +9,19 @@ import { getIntl } from '../selectors/user';
import { getLeftPaneLists, getShowArchived } from '../selectors/conversations';
import { SmartMainHeader } from './MainHeader';
import { SmartMessageSearchResult } from './MessageSearchResult';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredSmartMainHeader = SmartMainHeader as any;
const FilteredSmartMessageSearchResult = SmartMessageSearchResult as any;
function renderMainHeader(): JSX.Element {
return <FilteredSmartMainHeader />;
}
function renderMessageSearchResult(id: string): JSX.Element {
return <FilteredSmartMessageSearchResult id={id} />;
}
const mapStateToProps = (state: StateType) => {
const showSearch = isSearching(state);
@ -30,6 +35,7 @@ const mapStateToProps = (state: StateType) => {
showArchived: getShowArchived(state),
i18n: getIntl(state),
renderMainHeader,
renderMessageSearchResult,
};
};

View file

@ -1,9 +1,11 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { mapDispatchToProps } from '../actions';
import { StateType } from '../reducer';
import { MessageSearchResult } from '../../components/MessageSearchResult';
import { getIntl } from '../selectors/user';
import { getMessageSearchResultSelector } from '../selectors/search';
type SmartProps = {
id: string;
@ -11,12 +13,13 @@ type SmartProps = {
function mapStateToProps(state: StateType, ourProps: SmartProps) {
const { id } = ourProps;
const lookup = state.search && state.search.messageLookup;
if (!lookup) {
return null;
}
return lookup[id];
const props = getMessageSearchResultSelector(state)(id);
return {
...props,
i18n: getIntl(state),
};
}
const smart = connect(mapStateToProps, mapDispatchToProps);

View file

@ -1,8 +1,9 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { TimelineItem } from '../../components/conversation/TimelineItem';
import { StateType } from '../reducer';
import { TimelineItem } from '../../components/conversation/TimelineItem';
import { getIntl } from '../selectors/user';
import { getMessageSelector } from '../selectors/conversations';

View file

@ -7,6 +7,7 @@ interface Props {
*/
theme: 'light-theme' | 'dark-theme';
style: any;
gutterStyle: any;
}
/**
@ -15,11 +16,13 @@ interface Props {
*/
export class LeftPaneContext extends React.Component<Props> {
public render() {
const { style, theme } = this.props;
const { gutterStyle, style, theme } = this.props;
return (
<div style={style} className={classNames(theme || 'light-theme')}>
<div className="gutter">{this.props.children}</div>
<div className="gutter" style={gutterStyle}>
{this.props.children}
</div>
</div>
);
}

View file

@ -26,6 +26,7 @@ export const format = memoizee(_format, {
primitive: true,
// Convert the arguments to a unique string, required for primitive mode.
normalizer: (...args) => JSON.stringify(args),
max: 5000,
});
export function parse(

View file

@ -7770,6 +7770,24 @@
"updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to set focus"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.js",
"line": " el.innerHTML = '';",
"lineNumber": 22,
"reasonCategory": "usageTrusted",
"updated": "2019-08-01T14:10:37.481Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';",
"lineNumber": 65,
"reasonCategory": "usageTrusted",
"updated": "2019-08-01T14:10:37.481Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
"rule": "React-createRef",
"path": "ts/components/Lightbox.js",
@ -7806,6 +7824,15 @@
"updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to set focus"
},
{
"rule": "React-createRef",
"path": "ts/components/SearchResults.js",
"line": " this.listRef = react_1.default.createRef();",
"lineNumber": 19,
"reasonCategory": "usageTrusted",
"updated": "2019-08-09T00:44:31.008Z",
"reasonDetail": "SearchResults needs to interact with its child List directly"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.js",
@ -7848,23 +7875,5 @@
"lineNumber": 60,
"reasonCategory": "falseMatch",
"updated": "2019-05-02T20:44:56.470Z"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.js",
"line": " el.innerHTML = '';",
"lineNumber": 22,
"reasonCategory": "usageTrusted",
"updated": "2019-08-01T14:10:37.481Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';",
"lineNumber": 65,
"reasonCategory": "usageTrusted",
"updated": "2019-08-01T14:10:37.481Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
}
]
]