Full-text search within conversation

This commit is contained in:
Scott Nonnenberg
2019-08-09 16:12:29 -07:00
parent 6292019d30
commit c39d5a811a
26 changed files with 697 additions and 134 deletions

View File

@@ -418,3 +418,38 @@ const conversations = [
/>
</util.LeftPaneContext>;
```
#### Searching in conversation
```jsx
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
<LeftPane
searchResults={{
searchConversationName: "Y'all 🌆",
}}
conversations={[]}
archivedConversations={[]}
showArchived={false}
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=""
search={result => console.log('search', result)}
searchConversationName="Y'all 🌆"
searchConversationId="group-id-1"
updateSearch={result => console.log('updateSearch', result)}
clearSearch={result => console.log('clearSearch', result)}
i18n={util.i18n}
/>
)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
```

View File

@@ -63,3 +63,38 @@ if the parent of this component feeds the updated `searchTerm` back.
/>
</util.LeftPaneContext>
```
#### Searching within conversation
```jsx
<util.LeftPaneContext theme={util.theme}>
<MainHeader
name="John Smith"
color="purple"
searchConversationId="group-id-1"
searchConversationName="Everyone 🔥"
search={(...args) => console.log('search', args)}
updateSearchTerm={(...args) => console.log('updateSearchTerm', args)}
clearSearch={(...args) => console.log('clearSearch', args)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
```
#### Searching within conversation, with search term
```jsx
<util.LeftPaneContext theme={util.theme}>
<MainHeader
name="John Smith"
color="purple"
searchConversationId="group-id-1"
searchConversationName="Everyone 🔥"
searchTerm="address"
search={(...args) => console.log('search', args)}
updateSearchTerm={(...args) => console.log('updateSearchTerm', args)}
clearSearch={(...args) => console.log('clearSearch', args)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
```

View File

@@ -1,13 +1,14 @@
import React from 'react';
import classNames from 'classnames';
import { debounce } from 'lodash';
import { Avatar } from './Avatar';
import { cleanSearchTerm } from '../util/cleanSearchTerm';
import { LocalizerType } from '../types/Util';
export interface Props {
export interface PropsType {
searchTerm: string;
searchConversationName?: string;
searchConversationId?: string;
// To be used as an ID
ourNumber: string;
@@ -27,55 +28,73 @@ export interface Props {
search: (
query: string,
options: {
searchConversationId?: string;
regionCode: string;
ourNumber: string;
noteToSelf: string;
}
) => void;
clearConversationSearch: () => void;
clearSearch: () => void;
}
export class MainHeader extends React.Component<Props> {
private readonly updateSearchBound: (
event: React.FormEvent<HTMLInputElement>
) => void;
private readonly clearSearchBound: () => void;
private readonly handleKeyUpBound: (
event: React.KeyboardEvent<HTMLInputElement>
) => void;
private readonly setFocusBound: () => void;
export class MainHeader extends React.Component<PropsType> {
private readonly inputRef: React.RefObject<HTMLInputElement>;
private readonly debouncedSearch: (searchTerm: string) => void;
constructor(props: Props) {
constructor(props: PropsType) {
super(props);
this.updateSearchBound = this.updateSearch.bind(this);
this.clearSearchBound = this.clearSearch.bind(this);
this.handleKeyUpBound = this.handleKeyUp.bind(this);
this.setFocusBound = this.setFocus.bind(this);
this.inputRef = React.createRef();
this.debouncedSearch = debounce(this.search.bind(this), 20);
}
public search() {
const { searchTerm, search, i18n, ourNumber, regionCode } = this.props;
public componentDidUpdate(prevProps: PropsType) {
const { searchConversationId } = this.props;
// When user chooses to search in a given conversation we focus the field for them
if (
searchConversationId &&
searchConversationId !== prevProps.searchConversationId
) {
this.setFocus();
}
}
// tslint:disable-next-line member-ordering
public search = debounce((searchTerm: string) => {
const {
i18n,
ourNumber,
regionCode,
search,
searchConversationId,
} = this.props;
if (search) {
search(searchTerm, {
searchConversationId,
noteToSelf: i18n('noteToSelf').toLowerCase(),
ourNumber,
regionCode,
});
}
}
}, 50);
public updateSearch(event: React.FormEvent<HTMLInputElement>) {
const { updateSearchTerm, clearSearch } = this.props;
public updateSearch = (event: React.FormEvent<HTMLInputElement>) => {
const {
updateSearchTerm,
clearConversationSearch,
clearSearch,
searchConversationId,
} = this.props;
const searchTerm = event.currentTarget.value;
if (!searchTerm) {
clearSearch();
if (searchConversationId) {
clearConversationSearch();
} else {
clearSearch();
}
return;
}
@@ -88,47 +107,82 @@ export class MainHeader extends React.Component<Props> {
return;
}
const cleanedTerm = cleanSearchTerm(searchTerm);
if (!cleanedTerm) {
return;
}
this.search(searchTerm);
};
this.debouncedSearch(cleanedTerm);
}
public clearSearch() {
public clearSearch = () => {
const { clearSearch } = this.props;
clearSearch();
this.setFocus();
}
};
public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
const { clearSearch } = this.props;
public clearConversationSearch = () => {
const { clearConversationSearch } = this.props;
if (event.key === 'Escape') {
clearConversationSearch();
this.setFocus();
};
public handleKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
const {
clearConversationSearch,
clearSearch,
searchConversationId,
searchTerm,
} = this.props;
if (event.key !== 'Escape') {
return;
}
if (searchConversationId && searchTerm) {
clearConversationSearch();
} else {
clearSearch();
}
}
};
public setFocus() {
public handleXButton = () => {
const {
searchConversationId,
clearConversationSearch,
clearSearch,
} = this.props;
if (searchConversationId) {
clearConversationSearch();
} else {
clearSearch();
}
this.setFocus();
};
public setFocus = () => {
if (this.inputRef.current) {
// @ts-ignore
this.inputRef.current.focus();
}
}
};
public render() {
const {
searchTerm,
avatarPath,
i18n,
color,
i18n,
name,
phoneNumber,
profileName,
searchConversationId,
searchConversationName,
searchTerm,
} = this.props;
const placeholder = searchConversationName
? i18n('searchIn', [searchConversationName])
: i18n('search');
return (
<div className="module-main-header">
<Avatar
@@ -142,26 +196,42 @@ export class MainHeader extends React.Component<Props> {
size={28}
/>
<div className="module-main-header__search">
<div
role="button"
className="module-main-header__search__icon"
onClick={this.setFocusBound}
/>
{searchConversationId ? (
<div className="module-main-header__search__in-conversation-pill">
<div className="module-main-header__search__in-conversation-pill__avatar-container">
<div className="module-main-header__search__in-conversation-pill__avatar" />
</div>
<button
className="module-main-header__search__in-conversation-pill__x-button"
onClick={this.clearSearch}
/>
</div>
) : (
<button
className="module-main-header__search__icon"
onClick={this.setFocus}
/>
)}
<input
type="text"
ref={this.inputRef}
className="module-main-header__search__input"
placeholder={i18n('search')}
className={classNames(
'module-main-header__search__input',
searchConversationId
? 'module-main-header__search__input--in-conversation'
: null
)}
placeholder={placeholder}
dir="auto"
onKeyUp={this.handleKeyUpBound}
onKeyUp={this.handleKeyUp}
value={searchTerm}
onChange={this.updateSearchBound}
onChange={this.updateSearch}
/>
{searchTerm ? (
<div
role="button"
className="module-main-header__search__cancel-icon"
onClick={this.clearSearchBound}
onClick={this.handleXButton}
/>
) : null}
</div>

View File

@@ -82,6 +82,47 @@
</util.LeftPaneContext>
```
#### Searching within conversation
```jsx
<util.LeftPaneContext theme={util.theme}>
<MessageSearchResult
isSearchingInConversation={true}
from={{
name: 'Someone 🔥',
phoneNumber: '(202) 555-0011',
avatarPath: util.gifObjectUrl,
}}
to={{
name: 'Everyone 🔥',
}}
snippet="What's <<left>>going<<right>> on?"
id="messageId1"
conversationId="conversationId1"
receivedAt={Date.now() - 3 * 60 * 1000}
onClick={result => console.log('onClick', result)}
i18n={util.i18n}
/>
<MessageSearchResult
isSearchingInConversation={true}
from={{
name: 'Someone 🔥',
phoneNumber: '(202) 555-0011',
avatarPath: util.gifObjectUrl,
}}
to={{
name: 'Everyone 🔥',
}}
snippet="How is everyone? <<left>>Going<<right>> well?"
id="messageId2"
conversationId="conversationId2"
receivedAt={Date.now() - 27 * 60 * 1000}
onClick={result => console.log('onClick', result)}
i18n={util.i18n}
/>
</util.LeftPaneContext>
```
#### From you and to you
```jsx

View File

@@ -10,6 +10,7 @@ import { LocalizerType } from '../types/Util';
export type PropsDataType = {
isSelected?: boolean;
isSearchingInConversation?: boolean;
id: string;
conversationId: string;
@@ -75,10 +76,10 @@ export class MessageSearchResult extends React.PureComponent<PropsType> {
}
public renderFrom() {
const { i18n, to } = this.props;
const { i18n, to, isSearchingInConversation } = this.props;
const fromName = this.renderFromName();
if (!to.isMe) {
if (!to.isMe && !isSearchingInConversation) {
return (
<div className="module-message-search-result__header__from">
{fromName} {i18n('to')}{' '}

View File

@@ -727,6 +727,76 @@ const items = [
</util.LeftPaneContext>
```
#### With no results at all, searching in conversation
```jsx
<util.LeftPaneContext
theme={util.theme}
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
>
<SearchResults
items={[]}
noResults={true}
searchTerm="something"
searchInConversationName="Everyone 🔥"
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>
```
#### Searching in conversation but no search term
```jsx
<util.LeftPaneContext
theme={util.theme}
gutterStyle={{ height: '500px', display: 'flex', flexDirection: 'row' }}
>
<SearchResults
items={[]}
noResults={true}
searchTerm=""
searchInConversationName="Everyone 🔥"
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>
```
#### With a lot of results
```jsx

View File

@@ -6,6 +6,8 @@ import {
List,
} from 'react-virtualized';
import { Intl } from './Intl';
import { Emojify } from './conversation/Emojify';
import {
ConversationListItem,
PropsData as ConversationListItemPropsType,
@@ -19,6 +21,7 @@ export type PropsDataType = {
noResults: boolean;
regionCode: string;
searchTerm: string;
searchConversationName?: string;
};
type StartNewConversationType = {
@@ -237,14 +240,33 @@ export class SearchResults extends React.Component<PropsType> {
}
public render() {
const { items, i18n, noResults, searchTerm } = this.props;
const {
i18n,
items,
noResults,
searchConversationName,
searchTerm,
} = this.props;
if (noResults) {
return (
<div className="module-search-results">
<div className="module-search-results__no-results">
{i18n('noSearchResults', [searchTerm])}
</div>
{!searchConversationName || searchTerm ? (
<div className="module-search-results__no-results" key={searchTerm}>
{searchConversationName ? (
<Intl
id="noSearchResultsInConversation"
i18n={i18n}
components={[
searchTerm,
<Emojify key="item-1" text={searchConversationName} />,
]}
/>
) : (
i18n('noSearchResults', [searchTerm])
)}
</div>
) : null}
</div>
);
}

View File

@@ -1,6 +1,6 @@
### Name variations, 1:1 conversation
Note the five items in gear menu, and the second-level menu with disappearing messages options. Disappearing message set to 'off'.
Note the five items in menu, and the second-level menu with disappearing messages options. Disappearing message set to 'off'.
#### With name and profile, verified
@@ -24,6 +24,7 @@ Note the five items in gear menu, and the second-level menu with disappearing me
onShowAllMedia={() => console.log('onShowAllMedia')}
onShowGroupMembers={() => console.log('onShowGroupMembers')}
onGoBack={() => console.log('onGoBack')}
onSearchInConversation={() => console.log('onSearchInConversation')}
/>
</util.ConversationContext>
```

View File

@@ -37,6 +37,7 @@ interface Props {
onSetDisappearingMessages: (seconds: number) => void;
onDeleteMessages: () => void;
onResetSession: () => void;
onSearchInConversation: () => void;
onShowSafetyNumber: () => void;
onShowAllMedia: () => void;
@@ -152,14 +153,21 @@ export class ConversationHeader extends React.Component<Props> {
}
public renderExpirationLength() {
const { expirationSettingName } = this.props;
const { expirationSettingName, showBackButton } = this.props;
if (!expirationSettingName) {
return null;
}
return (
<div className="module-conversation-header__expiration">
<div
className={classNames(
'module-conversation-header__expiration',
showBackButton
? 'module-conversation-header__expiration--hidden'
: null
)}
>
<div className="module-conversation-header__expiration__clock-icon" />
<div className="module-conversation-header__expiration__setting">
{expirationSettingName}
@@ -168,7 +176,7 @@ export class ConversationHeader extends React.Component<Props> {
);
}
public renderGear(triggerId: string) {
public renderMoreButton(triggerId: string) {
const { showBackButton } = this.props;
return (
@@ -176,10 +184,10 @@ export class ConversationHeader extends React.Component<Props> {
<button
onClick={this.showMenuBound}
className={classNames(
'module-conversation-header__gear-icon',
'module-conversation-header__more-button',
showBackButton
? null
: 'module-conversation-header__gear-icon--show'
: 'module-conversation-header__more-button--show'
)}
disabled={showBackButton}
/>
@@ -187,6 +195,23 @@ export class ConversationHeader extends React.Component<Props> {
);
}
public renderSearchButton() {
const { onSearchInConversation, showBackButton } = this.props;
return (
<button
onClick={onSearchInConversation}
className={classNames(
'module-conversation-header__search-button',
showBackButton
? null
: 'module-conversation-header__search-button--show'
)}
disabled={showBackButton}
/>
);
}
public renderMenu(triggerId: string) {
const {
i18n,
@@ -260,7 +285,8 @@ export class ConversationHeader extends React.Component<Props> {
</div>
</div>
{this.renderExpirationLength()}
{this.renderGear(triggerId)}
{this.renderSearchButton()}
{this.renderMoreButton(triggerId)}
{this.renderMenu(triggerId)}
</div>
);