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 = [ const SCHEMA_VERSIONS = [
updateToSchemaVersion1, updateToSchemaVersion1,
updateToSchemaVersion2, updateToSchemaVersion2,
@ -1035,6 +1098,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion15, updateToSchemaVersion15,
updateToSchemaVersion16, updateToSchemaVersion16,
updateToSchemaVersion17, updateToSchemaVersion17,
updateToSchemaVersion18,
]; ];
async function updateSchema(instance) { async function updateSchema(instance) {
@ -1552,7 +1616,7 @@ async function searchConversations(query, { limit } = {}) {
$id: `%${query}%`, $id: `%${query}%`,
$name: `%${query}%`, $name: `%${query}%`,
$profileName: `%${query}%`, $profileName: `%${query}%`,
$limit: limit || 50, $limit: limit || 100,
} }
); );
@ -1572,7 +1636,7 @@ async function searchMessages(query, { limit } = {}) {
LIMIT $limit;`, LIMIT $limit;`,
{ {
$query: query, $query: query,
$limit: limit || 100, $limit: limit || 500,
} }
); );

View file

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

View file

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

View file

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

View file

@ -1,8 +1,196 @@
#### With search results #### With search results
```jsx ```jsx
window.searchResults = {}; const items = [
window.searchResults.conversations = [ {
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', id: 'convo1',
name: 'Everyone 🌆', 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' }}> <util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane <LeftPane
searchResults={window.searchResults} conversations={conversations}
archivedConversations={[]}
startNewConversation={(query, options) => startNewConversation={(query, options) =>
console.log('startNewConversation', query, options) console.log('startNewConversation', query, options)
} }
@ -151,42 +264,61 @@ window.searchResults.messages = [
</util.LeftPaneContext>; </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 #### Showing inbox, with some archived
```jsx ```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' }}> <util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane <LeftPane
conversations={window.searchResults.conversations.slice(0, 2)} conversations={conversations.slice(0, 2)}
archivedConversations={window.searchResults.conversations.slice(2)} archivedConversations={conversations.slice(2)}
startNewConversation={(query, options) => startNewConversation={(query, options) =>
console.log('startNewConversation', query, options) console.log('startNewConversation', query, options)
} }
@ -206,16 +338,64 @@ window.searchResults.messages = [
)} )}
i18n={util.i18n} i18n={util.i18n}
/> />
</util.LeftPaneContext> </util.LeftPaneContext>;
``` ```
#### Showing archived conversations #### Showing archived conversations
```jsx ```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' }}> <util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane <LeftPane
conversations={window.searchResults.conversations.slice(0, 2)} conversations={conversations.slice(0, 2)}
archivedConversations={window.searchResults.conversations.slice(2)} archivedConversations={conversations.slice(2)}
showArchived={true} showArchived={true}
startNewConversation={(query, options) => startNewConversation={(query, options) =>
console.log('startNewConversation', query, options) console.log('startNewConversation', query, options)
@ -236,5 +416,5 @@ window.searchResults.messages = [
)} )}
i18n={util.i18n} i18n={util.i18n}
/> />
</util.LeftPaneContext> </util.LeftPaneContext>;
``` ```

View file

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

View file

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

View file

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

View file

@ -1,123 +1,277 @@
import React from 'react'; import React from 'react';
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from 'react-virtualized';
import { import {
ConversationListItem, ConversationListItem,
PropsData as ConversationListItemPropsType, PropsData as ConversationListItemPropsType,
} from './ConversationListItem'; } from './ConversationListItem';
import { MessageSearchResult } from './MessageSearchResult';
import { StartNewConversation } from './StartNewConversation'; import { StartNewConversation } from './StartNewConversation';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
export type PropsData = { export type PropsDataType = {
contacts: Array<ConversationListItemPropsType>; items: Array<SearchResultRowType>;
conversations: Array<ConversationListItemPropsType>; noResults: boolean;
hideMessagesHeader: boolean;
messages: Array<MessageSearchResultPropsType>;
regionCode: string; regionCode: string;
searchTerm: 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; i18n: LocalizerType;
openConversation: (id: string, messageId?: string) => void; openConversationInternal: (id: string, messageId?: string) => void;
startNewConversation: ( startNewConversation: (
query: string, query: string,
options: { regionCode: string } options: { regionCode: string }
) => void; ) => 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 = () => { public handleStartNewConversation = () => {
const { regionCode, searchTerm, startNewConversation } = this.props; const { regionCode, searchTerm, startNewConversation } = this.props;
startNewConversation(searchTerm, { regionCode }); startNewConversation(searchTerm, { regionCode });
}; };
public render() { public renderRowContents(row: SearchResultRowType) {
const { const {
conversations,
contacts,
hideMessagesHeader,
i18n,
messages,
openConversation,
searchTerm, searchTerm,
showStartNewConversation, i18n,
openConversationInternal,
renderMessageSearchResult,
} = this.props; } = this.props;
const haveConversations = conversations && conversations.length; if (row.type === 'start-new-conversation') {
const haveContacts = contacts && contacts.length; return (
const haveMessages = messages && messages.length; <StartNewConversation
const noResults = phoneNumber={searchTerm}
!showStartNewConversation && i18n={i18n}
!haveConversations && onClick={this.handleStartNewConversation}
!haveContacts && />
!haveMessages; );
} 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 ( return (
<div className="module-search-results"> <div key={key} style={style}>
{noResults ? ( <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"> <div className="module-search-results__no-results">
{i18n('noSearchResults', [searchTerm])} {i18n('noSearchResults', [searchTerm])}
</div> </div>
) : null} </div>
{showStartNewConversation ? ( );
<StartNewConversation }
phoneNumber={searchTerm}
i18n={i18n} return (
onClick={this.handleStartNewConversation} <div className="module-search-results" key={searchTerm}>
/> <AutoSizer>
) : null} {({ height, width }) => {
{haveConversations ? ( this.mostRecentWidth = width;
<div className="module-search-results__conversations"> this.mostRecentHeight = height;
<div className="module-search-results__conversations-header">
{i18n('conversationsHeader')} return (
</div> <List
{conversations.map(conversation => ( className="module-search-results__virtual-list"
<ConversationListItem deferredMeasurementCache={this.cellSizeCache}
key={conversation.phoneNumber} height={height}
{...conversation} items={items}
onClick={openConversation} overscanRowCount={5}
i18n={i18n} ref={this.listRef}
rowCount={this.getRowCount()}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.renderRow}
width={width}
/> />
))} );
</div> }}
) : null} </AutoSizer>
{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}
</div> </div>
); );
} }

View file

@ -14,32 +14,6 @@ import { NoopActionType } from './noop';
// State // 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 = { export type ConversationType = {
id: string; id: string;
name?: string; name?: string;

View file

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

View file

@ -207,11 +207,13 @@ export const getCachedSelectorForConversation = createSelector(
(): CachedConversationSelectorType => { (): CachedConversationSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector // Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed. // 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( export const getConversationSelector = createSelector(
getCachedSelectorForConversation, getCachedSelectorForConversation,
getConversationLookup, getConversationLookup,
@ -287,7 +289,7 @@ export const getCachedSelectorForMessage = createSelector(
(): CachedMessageSelectorType => { (): CachedMessageSelectorType => {
// Note: memoizee will check all parameters provided, and only run our selector // Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed. // 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 { createSelector } from 'reselect';
import { getSearchResultsProps } from '../../shims/Whisper'; import { getSearchResultsProps } from '../../shims/Whisper';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { SearchStateType } from '../ducks/search';
import { 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, getConversationLookup,
getConversationSelector,
getSelectedConversation, getSelectedConversation,
} from './conversations'; } from './conversations';
import { ConversationLookupType } from '../ducks/conversations';
import { getRegionCode } from './user';
export const getSearch = (state: StateType): SearchStateType => state.search; 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( export const getSearchResults = createSelector(
[ [getSearch, getRegionCode, getConversationLookup, getSelectedConversation],
getSearch,
getRegionCode,
getConversationLookup,
getSelectedConversation,
getSelectedMessage,
],
( (
state: SearchStateType, state: SearchStateType,
regionCode: string, regionCode: string,
lookup: ConversationLookupType, lookup: ConversationLookupType,
selectedConversation?: string, selectedConversation?: string
selectedMessage?: 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 { return {
contacts: compact( items,
state.contacts.map(id => { noResults,
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;
}),
regionCode: regionCode, regionCode: regionCode,
searchTerm: state.query, 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 { getLeftPaneLists, getShowArchived } from '../selectors/conversations';
import { SmartMainHeader } from './MainHeader'; import { SmartMainHeader } from './MainHeader';
import { SmartMessageSearchResult } from './MessageSearchResult';
// Workaround: A react component's required properties are filtering up through connect() // Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredSmartMainHeader = SmartMainHeader as any; const FilteredSmartMainHeader = SmartMainHeader as any;
const FilteredSmartMessageSearchResult = SmartMessageSearchResult as any;
function renderMainHeader(): JSX.Element { function renderMainHeader(): JSX.Element {
return <FilteredSmartMainHeader />; return <FilteredSmartMainHeader />;
} }
function renderMessageSearchResult(id: string): JSX.Element {
return <FilteredSmartMessageSearchResult id={id} />;
}
const mapStateToProps = (state: StateType) => { const mapStateToProps = (state: StateType) => {
const showSearch = isSearching(state); const showSearch = isSearching(state);
@ -30,6 +35,7 @@ const mapStateToProps = (state: StateType) => {
showArchived: getShowArchived(state), showArchived: getShowArchived(state),
i18n: getIntl(state), i18n: getIntl(state),
renderMainHeader, renderMainHeader,
renderMessageSearchResult,
}; };
}; };

View file

@ -1,9 +1,11 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { mapDispatchToProps } from '../actions';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { MessageSearchResult } from '../../components/MessageSearchResult'; import { MessageSearchResult } from '../../components/MessageSearchResult';
import { getIntl } from '../selectors/user';
import { getMessageSearchResultSelector } from '../selectors/search';
type SmartProps = { type SmartProps = {
id: string; id: string;
@ -11,12 +13,13 @@ type SmartProps = {
function mapStateToProps(state: StateType, ourProps: SmartProps) { function mapStateToProps(state: StateType, ourProps: SmartProps) {
const { id } = ourProps; 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); const smart = connect(mapStateToProps, mapDispatchToProps);

View file

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

View file

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

View file

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

View file

@ -7770,6 +7770,24 @@
"updated": "2019-03-09T00:08:44.242Z", "updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to set focus" "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", "rule": "React-createRef",
"path": "ts/components/Lightbox.js", "path": "ts/components/Lightbox.js",
@ -7806,6 +7824,15 @@
"updated": "2019-03-09T00:08:44.242Z", "updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to set focus" "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", "rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.js", "path": "ts/components/conversation/ConversationHeader.js",
@ -7848,23 +7875,5 @@
"lineNumber": 60, "lineNumber": 60,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2019-05-02T20:44:56.470Z" "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"
} }
] ]