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

@ -735,6 +735,17 @@
"message": "Search",
"description": "Placeholder text in the search input"
},
"searchIn": {
"message": "Search in $conversationName$",
"description":
"Shown in the search box before text is entered when searching in a specific conversation",
"placeholders": {
"conversationName": {
"content": "$1",
"example": "Friends"
}
}
},
"noSearchResults": {
"message": "No results for \"$searchTerm$\"",
"description": "Shown in the search left pane when no results were found",
@ -745,6 +756,20 @@
}
}
},
"noSearchResultsInConversation": {
"message": "No results for \"$searchTerm$\" in $conversationName$",
"description": "Shown in the search left pane when no results were found",
"placeholders": {
"searchTerm": {
"content": "$1",
"example": "dog"
},
"searchTerm": {
"content": "$2",
"example": "Friends"
}
}
},
"conversationsHeader": {
"message": "Conversations",
"description": "Shown to separate the types of search results"

View file

@ -1641,7 +1641,7 @@ async function searchMessages(query, { limit } = {}) {
);
return map(rows, row => ({
...jsonToObject(row.json),
json: row.json,
snippet: row.snippet,
}));
}
@ -1670,7 +1670,7 @@ async function searchMessagesInConversation(
);
return map(rows, row => ({
...jsonToObject(row.json),
json: row.json,
snippet: row.snippet,
}));
}
@ -1925,7 +1925,7 @@ async function getOlderMessagesByConversation(
}
);
return map(rows.reverse(), row => jsonToObject(row.json));
return rows.reverse();
}
async function getNewerMessagesByConversation(
@ -1945,7 +1945,7 @@ async function getNewerMessagesByConversation(
}
);
return map(rows, row => jsonToObject(row.json));
return rows;
}
async function getOldestMessageForConversation(conversationId) {
const row = await db.get(

View file

Before

Width:  |  Height:  |  Size: 456 B

After

Width:  |  Height:  |  Size: 456 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><title>profile-solid-16</title><path d="M10.5,6.324C10.5,7.98,9.381,9.5,8,9.5S5.5,7.98,5.5,6.324A2.617,2.617,0,0,1,8,3.5,2.617,2.617,0,0,1,10.5,6.324Z"/><path d="M10,10.5H6a3.975,3.975,0,0,0-3.108,1.511,6.486,6.486,0,0,0,10.216,0A3.975,3.975,0,0,0,10,10.5Z"/></svg>

After

Width:  |  Height:  |  Size: 348 B

1
images/search-24.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>search-24</title><path d="M17.161,16.1a3.979,3.979,0,0,1-1.287-.683,8.02,8.02,0,1,0-.457.457,3.959,3.959,0,0,1,.684,1.286L20.47,21.53l1.06-1.06ZM10,16.5A6.5,6.5,0,1,1,16.5,10,6.508,6.508,0,0,1,10,16.5Z"/></svg>

After

Width:  |  Height:  |  Size: 300 B

View file

@ -1,14 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" viewBox="0 0 20 20">
<defs>
<path id="a" d="M10 1.5c-.338 0-.672.02-1 .058V.05C9.329.017 9.663 0 10 0c5.523 0 10 4.477 10 10s-4.477 10-10 10S0 15.523 0 10A10 10 0 0 1 5.658.99l.487.843.005-.002 4.5 7.794-1.3.75-4.233-7.333A8.5 8.5 0 1 0 10 1.5z"/>
<path id="c" d="M0 0h40v40H0z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="b" fill="#fff">
<use xlink:href="#a"/>
</mask>
<g mask="url(#b)">
<use fill="#62656A" xlink:href="#c"/>
</g>
</g>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Export" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 20 20" style="enable-background:new 0 0 20 20;" xml:space="preserve">
<path d="M16.1,7.2c0.6-0.1,1.1-0.6,1.1-1.2c0-0.7-0.6-1.2-1.2-1.2c-0.6,0-1.1,0.4-1.2,1c-1.1-1-2.6-1.6-4.1-1.8l0.8-3h-3l0.7,3l0,0
c-1.5,0.2-2.9,0.7-4,1.7c-0.1-0.6-0.6-1-1.2-1c-0.7,0-1.3,0.5-1.3,1.2c0,0,0,0.1,0,0.1c0,0.6,0.5,1.2,1.1,1.2
c-2.4,3.4-1.5,8.1,1.9,10.4s8.1,1.5,10.4-1.9c0.9-1.3,1.4-2.8,1.3-4.3C17.5,10,17,8.5,16.1,7.2z M10,17.5c-3.3,0-6-2.7-6-6
s2.7-6,6-6s6,2.7,6,6S13.3,17.5,10,17.5z M10.9,11.5c0,0.5-0.4,0.9-0.9,0.9S9.1,12,9.1,11.5l0.5-5h0.8L10.9,11.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 820 B

View file

@ -520,6 +520,10 @@
Signal.State.Ducks.user.actions,
store.dispatch
);
actions.search = Signal.State.bindActionCreators(
Signal.State.Ducks.search.actions,
store.dispatch
);
actions.stickers = Signal.State.bindActionCreators(
Signal.State.Ducks.stickers.actions,
store.dispatch

View file

@ -1,5 +1,9 @@
export function searchMessages(query: string): Promise<Array<any>>;
export function searchConversations(query: string): Promise<Array<any>>;
export function searchMessagesInConversation(
query: string,
conversationId: string
): Promise<Array<any>>;
export function updateStickerLastUsed(
packId: string,

View file

@ -655,9 +655,16 @@ async function searchConversations(query) {
return conversations;
}
function handleSearchMessageJSON(messages) {
return messages.map(message => ({
...JSON.parse(message.json),
snippet: message.snippet,
}));
}
async function searchMessages(query, { limit } = {}) {
const messages = await channels.searchMessages(query, { limit });
return messages;
return handleSearchMessageJSON(messages);
}
async function searchMessagesInConversation(
@ -670,7 +677,7 @@ async function searchMessagesInConversation(
conversationId,
{ limit }
);
return messages;
return handleSearchMessageJSON(messages);
}
// Message
@ -784,6 +791,10 @@ async function getUnreadByConversation(conversationId, { MessageCollection }) {
return new MessageCollection(messages);
}
function handleMessageJSON(messages) {
return messages.map(message => JSON.parse(message.json));
}
async function getOlderMessagesByConversation(
conversationId,
{ limit = 100, receivedAt = Number.MAX_VALUE, MessageCollection }
@ -796,7 +807,7 @@ async function getOlderMessagesByConversation(
}
);
return new MessageCollection(messages);
return new MessageCollection(handleMessageJSON(messages));
}
async function getNewerMessagesByConversation(
conversationId,
@ -810,7 +821,7 @@ async function getNewerMessagesByConversation(
}
);
return new MessageCollection(messages);
return new MessageCollection(handleMessageJSON(messages));
}
async function getMessageMetricsForConversation(conversationId) {
const result = await channels.getMessageMetricsForConversation(

View file

@ -62,6 +62,7 @@ const { createStore } = require('../../ts/state/createStore');
const conversationsDuck = require('../../ts/state/ducks/conversations');
const emojisDuck = require('../../ts/state/ducks/emojis');
const itemsDuck = require('../../ts/state/ducks/items');
const searchDuck = require('../../ts/state/ducks/search');
const stickersDuck = require('../../ts/state/ducks/stickers');
const userDuck = require('../../ts/state/ducks/user');
@ -274,6 +275,7 @@ exports.setup = (options = {}) => {
emojis: emojisDuck,
items: itemsDuck,
user: userDuck,
search: searchDuck,
stickers: stickersDuck,
};
const State = {

View file

@ -257,6 +257,13 @@
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(),
onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search;
const name = this.model.isMe()
? i18n('noteToSelf')
: this.model.getTitle();
searchInConversation(this.model.id, name);
},
// These are view only and don't update the Conversation model, so they
// need a manual update call.
@ -1490,8 +1497,6 @@
this.focusMessageField();
this.model.updateLastMessage();
const statusPromise = this.throttledGetProfiles();
// eslint-disable-next-line more/no-then
this.statusFetch = statusPromise.then(() =>
@ -1522,6 +1527,8 @@
if (quotedMessageId) {
this.setQuoteMessage(quotedMessageId);
}
this.model.updateLastMessage();
},
async retrySend(messageId) {

View file

@ -1644,12 +1644,17 @@
align-items: center;
padding-left: 8px;
padding-right: 8px;
transition: opacity 250ms ease-out;
&--hidden {
opacity: 0;
}
}
.module-conversation-header__expiration__clock-icon {
@include color-svg('../images/timer.svg', $color-gray-60);
height: 20px;
width: 20px;
height: 24px;
width: 24px;
display: inline-block;
}
@ -1658,11 +1663,29 @@
text-align: center;
}
.module-conversation-header__gear-icon {
@include color-svg('../images/gear.svg', $color-gray-60);
height: 20px;
width: 20px;
margin-left: 4px;
.module-conversation-header__more-button {
@include color-svg('../images/more-h-24.svg', $color-gray-75);
height: 24px;
width: 24px;
margin-left: 12px;
border: none;
opacity: 0;
transition: opacity 250ms ease-out;
&:disabled {
cursor: default;
}
&--show {
opacity: 1;
}
}
.module-conversation-header__search-button {
@include color-svg('../images/search-24.svg', $color-gray-75);
height: 24px;
width: 24px;
margin-left: 12px;
border: none;
opacity: 0;
transition: opacity 250ms ease-out;
@ -2402,6 +2425,10 @@
}
}
.module-main-header__search__input--in-conversation {
padding-left: 50px;
}
.module-main-header__search__icon {
position: absolute;
left: 8px;
@ -2413,6 +2440,41 @@
@include color-svg('../images/search.svg', $color-gray-60);
}
.module-main-header__search__in-conversation-pill {
position: absolute;
left: 3px;
top: 3px;
bottom: 3px;
border-radius: 14px;
width: 42px;
background-color: $color-gray-05;
display: flex;
flex-direction: row;
align-items: center;
}
.module-main-header__search__in-conversation-pill__avatar-container {
margin-left: 4px;
height: 16px;
width: 16px;
border-radius: 8px;
background-color: $color-signal-blue;
}
.module-main-header__search__in-conversation-pill__avatar {
height: 16px;
width: 16px;
@include color-svg('../images/profile-solid-16.svg', $color-white);
}
.module-main-header__search__in-conversation-pill__x-button {
margin-left: 2px;
@include color-svg('../images/x.svg', $color-gray-60);
height: 16px;
width: 16px;
}
.module-main-header__search__cancel-icon {
position: absolute;
right: 8px;
@ -3142,8 +3204,24 @@
.module-search-results__no-results {
margin-top: 27px;
padding-left: 1em;
padding-right: 1em;
width: 100%;
text-align: center;
animation: delayed-fade-in 2s;
}
@keyframes delayed-fade-in {
0% {
opacity: 0;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.module-search-results__contacts-header {
@ -3872,10 +3950,10 @@
min-width: 24px;
min-height: 24px;
@include light-theme {
@include color-svg('../images/more-h.svg', $color-gray-60);
@include color-svg('../images/more-h-24.svg', $color-gray-60);
}
@include dark-theme {
@include color-svg('../images/more-h.svg', $color-gray-25);
@include color-svg('../images/more-h-24.svg', $color-gray-25);
}
}
}

View file

@ -1148,7 +1148,7 @@ body.dark-theme {
}
.module-conversation-header__back-icon {
@include color-svg('../images/back.svg', $color-dark-05);
background-color: $color-dark-05;
}
.module-conversation-header__title {
@ -1160,15 +1160,19 @@ body.dark-theme {
}
.module-conversation-header__title__verified-icon {
@include color-svg('../images/verified-check.svg', $color-dark-05);
background-color: $color-dark-05;
}
.module-conversation-header__expiration__clock-icon {
@include color-svg('../images/timer.svg', $color-dark-30);
background-color: $color-gray-25;
}
.module-conversation-header__gear-icon {
@include color-svg('../images/gear.svg', $color-dark-30);
.module-conversation-header__more-button {
background-color: $color-gray-15;
}
.module-conversation-header__search-button {
background-color: $color-gray-15;
}
// Module: Message Detail
@ -1398,11 +1402,24 @@ body.dark-theme {
}
.module-main-header__search__icon {
@include color-svg('../images/search.svg', $color-gray-25);
background-color: $color-gray-25;
}
.module-main-header__search__cancel-icon {
@include color-svg('../images/x-16.svg', $color-gray-25);
background-color: $color-gray-25;
}
.module-main-header__search__in-conversation-pill {
background-color: $color-gray-75;
}
.module-main-header__search__in-conversation-pill__avatar-container {
background-color: $color-signal-blue;
}
.module-main-header__search__in-conversation-pill__avatar {
background-color: $color-gray-05;
}
.module-main-header__search__in-conversation-pill__x-button {
background-color: $color-gray-25;
}
// Module: Image

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

View file

@ -3,7 +3,11 @@ import { omit, reject } from 'lodash';
import { normalize } from '../../types/PhoneNumber';
import { trigger } from '../../shims/events';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import { searchConversations, searchMessages } from '../../../js/modules/data';
import {
searchConversations,
searchMessages,
searchMessagesInConversation,
} from '../../../js/modules/data';
import { makeLookup } from '../../util/makeLookup';
import {
@ -25,6 +29,8 @@ export type MessageSearchResultLookupType = {
};
export type SearchStateType = {
searchConversationId?: string;
searchConversationName?: string;
// We store just ids of conversations, since that data is always cached in memory
contacts: Array<string>;
conversations: Array<string>;
@ -64,11 +70,24 @@ type ClearSearchActionType = {
type: 'SEARCH_CLEAR';
payload: null;
};
type ClearConversationSearchActionType = {
type: 'CLEAR_CONVERSATION_SEARCH';
payload: null;
};
type SearchInConversationActionType = {
type: 'SEARCH_IN_CONVERSATION';
payload: {
searchConversationId: string;
searchConversationName: string;
};
};
export type SEARCH_TYPES =
| SearchResultsFulfilledActionType
| UpdateSearchTermActionType
| ClearSearchActionType
| ClearConversationSearchActionType
| SearchInConversationActionType
| MessageDeletedActionType
| RemoveAllConversationsActionType
| SelectedConversationChangedActionType;
@ -78,13 +97,20 @@ export type SEARCH_TYPES =
export const actions = {
search,
clearSearch,
clearConversationSearch,
searchInConversation,
updateSearchTerm,
startNewConversation,
};
function search(
query: string,
options: { regionCode: string; ourNumber: string; noteToSelf: string }
options: {
searchConversationId?: string;
regionCode: string;
ourNumber: string;
noteToSelf: string;
}
): SearchResultsKickoffActionType {
return {
type: 'SEARCH_RESULTS',
@ -95,26 +121,40 @@ function search(
async function doSearch(
query: string,
options: {
searchConversationId?: string;
regionCode: string;
ourNumber: string;
noteToSelf: string;
}
): Promise<SearchResultsPayloadType> {
const { regionCode, ourNumber, noteToSelf } = options;
const { regionCode, ourNumber, noteToSelf, searchConversationId } = options;
const normalizedPhoneNumber = normalize(query, { regionCode });
const [discussions, messages] = await Promise.all([
queryConversationsAndContacts(query, { ourNumber, noteToSelf }),
queryMessages(query),
]);
const { conversations, contacts } = discussions;
if (searchConversationId) {
const messages = await queryMessages(query, searchConversationId);
return {
query,
normalizedPhoneNumber: normalize(query, { regionCode }),
conversations,
contacts,
messages,
};
return {
contacts: [],
conversations: [],
messages,
normalizedPhoneNumber,
query,
};
} else {
const [discussions, messages] = await Promise.all([
queryConversationsAndContacts(query, { ourNumber, noteToSelf }),
queryMessages(query),
]);
const { conversations, contacts } = discussions;
return {
contacts,
conversations,
messages,
normalizedPhoneNumber,
query,
};
}
}
function clearSearch(): ClearSearchActionType {
return {
@ -122,6 +162,25 @@ function clearSearch(): ClearSearchActionType {
payload: null,
};
}
function clearConversationSearch(): ClearConversationSearchActionType {
return {
type: 'CLEAR_CONVERSATION_SEARCH',
payload: null,
};
}
function searchInConversation(
searchConversationId: string,
searchConversationName: string
): SearchInConversationActionType {
return {
type: 'SEARCH_IN_CONVERSATION',
payload: {
searchConversationId,
searchConversationName,
},
};
}
function updateSearchTerm(query: string): UpdateSearchTermActionType {
return {
type: 'SEARCH_UPDATE',
@ -147,10 +206,14 @@ function startNewConversation(
};
}
async function queryMessages(query: string) {
async function queryMessages(query: string, searchConversationId?: string) {
try {
const normalized = cleanSearchTerm(query);
if (searchConversationId) {
return searchMessagesInConversation(normalized, searchConversationId);
}
return searchMessages(normalized);
} catch (e) {
return [];
@ -206,6 +269,7 @@ function getEmptyState(): SearchStateType {
};
}
// tslint:disable-next-line max-func-body-length
export function reducer(
state: SearchStateType = getEmptyState(),
action: SEARCH_TYPES
@ -224,6 +288,30 @@ export function reducer(
};
}
if (action.type === 'SEARCH_IN_CONVERSATION') {
const { payload } = action;
const { searchConversationId, searchConversationName } = payload;
if (searchConversationId === state.searchConversationId) {
return state;
}
return {
...getEmptyState(),
searchConversationId,
searchConversationName,
};
}
if (action.type === 'CLEAR_CONVERSATION_SEARCH') {
const { searchConversationId, searchConversationName } = state;
return {
...getEmptyState(),
searchConversationId,
searchConversationName,
};
}
if (action.type === 'SEARCH_RESULTS_FULFILLED') {
const { payload } = action;
const {
@ -258,10 +346,11 @@ export function reducer(
if (action.type === 'SELECTED_CONVERSATION_CHANGED') {
const { payload } = action;
const { messageId } = payload;
const { id, messageId } = payload;
const { searchConversationId } = state;
if (!messageId) {
return state;
if (searchConversationId && searchConversationId !== id) {
return getEmptyState();
}
return {

View file

@ -40,12 +40,22 @@ export const getSelectedMessage = createSelector(
(state: SearchStateType): string | undefined => state.selectedMessage
);
export const getSearchConversationId = createSelector(
getSearch,
(state: SearchStateType): string | undefined => state.searchConversationId
);
export const getSearchConversationName = createSelector(
getSearch,
(state: SearchStateType): string | undefined => state.searchConversationName
);
export const isSearching = createSelector(
getSearch,
(state: SearchStateType) => {
const { query } = state;
const { query, searchConversationId } = state;
return query && query.trim().length > 1;
return (query && query.trim().length > 1) || searchConversationId;
}
);
@ -62,7 +72,12 @@ export const getSearchResults = createSelector(
lookup: ConversationLookupType,
selectedConversation?: string
): SearchResultsPropsType => {
const { conversations, contacts, messageIds } = state;
const {
contacts,
conversations,
messageIds,
searchConversationName,
} = state;
const showStartNewConversation = Boolean(
state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber]
@ -136,6 +151,7 @@ export const getSearchResults = createSelector(
items,
noResults,
regionCode: regionCode,
searchConversationName,
searchTerm: state.query,
};
}
@ -151,6 +167,7 @@ export function _messageSearchResultSelector(
sender?: ConversationType,
// @ts-ignore
recipient?: ConversationType,
searchConversationId?: string,
selectedMessageId?: string
): MessageSearchResultPropsDataType {
// Note: We don't use all of those parameters here, but the shim we call does.
@ -158,6 +175,7 @@ export function _messageSearchResultSelector(
return {
...getSearchResultsProps(message),
isSelected: message.id === selectedMessageId,
isSearchingInConversation: Boolean(searchConversationId),
};
}
@ -169,6 +187,7 @@ type CachedMessageSearchResultSelectorType = (
regionCode: string,
sender?: ConversationType,
recipient?: ConversationType,
searchConversationId?: string,
selectedMessageId?: string
) => MessageSearchResultPropsDataType;
export const getCachedSelectorForMessageSearchResult = createSelector(
@ -189,6 +208,7 @@ export const getMessageSearchResultSelector = createSelector(
getMessageSearchResultLookup,
getSelectedMessage,
getConversationSelector,
getSearchConversationId,
getRegionCode,
getUserNumber,
(
@ -196,6 +216,7 @@ export const getMessageSearchResultSelector = createSelector(
messageSearchResultLookup: MessageSearchResultLookupType,
selectedMessage: string | undefined,
conversationSelector: GetConversationByIdType,
searchConversationId: string | undefined,
regionCode: string,
ourNumber: string
): GetMessageSearchResultByIdType => {
@ -223,6 +244,7 @@ export const getMessageSearchResultSelector = createSelector(
regionCode,
sender,
recipient,
searchConversationId,
selectedMessage
);
};

View file

@ -4,13 +4,19 @@ import { mapDispatchToProps } from '../actions';
import { MainHeader } from '../../components/MainHeader';
import { StateType } from '../reducer';
import { getQuery } from '../selectors/search';
import {
getQuery,
getSearchConversationId,
getSearchConversationName,
} from '../selectors/search';
import { getIntl, getRegionCode, getUserNumber } from '../selectors/user';
import { getMe } from '../selectors/conversations';
const mapStateToProps = (state: StateType) => {
return {
searchTerm: getQuery(state),
searchConversationId: getSearchConversationId(state),
searchConversationName: getSearchConversationName(state),
regionCode: getRegionCode(state),
ourNumber: getUserNumber(state),
...getMe(state),

View file

@ -7810,25 +7810,25 @@
"rule": "React-createRef",
"path": "ts/components/MainHeader.js",
"line": " this.inputRef = react_1.default.createRef();",
"lineNumber": 17,
"lineNumber": 83,
"reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z",
"updated": "2019-08-09T21:17:57.798Z",
"reasonDetail": "Used only to set focus"
},
{
"rule": "React-createRef",
"path": "ts/components/MainHeader.tsx",
"line": " this.inputRef = React.createRef();",
"lineNumber": 57,
"lineNumber": 48,
"reasonCategory": "usageTrusted",
"updated": "2019-03-09T00:08:44.242Z",
"updated": "2019-08-09T21:17:57.798Z",
"reasonDetail": "Used only to set focus"
},
{
"rule": "React-createRef",
"path": "ts/components/SearchResults.js",
"line": " this.listRef = react_1.default.createRef();",
"lineNumber": 19,
"lineNumber": 21,
"reasonCategory": "usageTrusted",
"updated": "2019-08-09T00:44:31.008Z",
"reasonDetail": "SearchResults needs to interact with its child List directly"
@ -7846,7 +7846,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 59,
"lineNumber": 60,
"reasonCategory": "usageTrusted",
"updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Used to reference popup menu"