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", "message": "Search",
"description": "Placeholder text in the search input" "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": { "noSearchResults": {
"message": "No results for \"$searchTerm$\"", "message": "No results for \"$searchTerm$\"",
"description": "Shown in the search left pane when no results were found", "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": { "conversationsHeader": {
"message": "Conversations", "message": "Conversations",
"description": "Shown to separate the types of search results" "description": "Shown to separate the types of search results"

View file

@ -1641,7 +1641,7 @@ async function searchMessages(query, { limit } = {}) {
); );
return map(rows, row => ({ return map(rows, row => ({
...jsonToObject(row.json), json: row.json,
snippet: row.snippet, snippet: row.snippet,
})); }));
} }
@ -1670,7 +1670,7 @@ async function searchMessagesInConversation(
); );
return map(rows, row => ({ return map(rows, row => ({
...jsonToObject(row.json), json: row.json,
snippet: row.snippet, snippet: row.snippet,
})); }));
} }
@ -1925,7 +1925,7 @@ async function getOlderMessagesByConversation(
} }
); );
return map(rows.reverse(), row => jsonToObject(row.json)); return rows.reverse();
} }
async function getNewerMessagesByConversation( async function getNewerMessagesByConversation(
@ -1945,7 +1945,7 @@ async function getNewerMessagesByConversation(
} }
); );
return map(rows, row => jsonToObject(row.json)); return rows;
} }
async function getOldestMessageForConversation(conversationId) { async function getOldestMessageForConversation(conversationId) {
const row = await db.get( 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"> <?xml version="1.0" encoding="utf-8"?>
<defs> <!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<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"/> <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"
<path id="c" d="M0 0h40v40H0z"/> viewBox="0 0 20 20" style="enable-background:new 0 0 20 20;" xml:space="preserve">
</defs> <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
<g fill="none" fill-rule="evenodd"> 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
<mask id="b" fill="#fff"> 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
<use xlink:href="#a"/> 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"/>
</mask>
<g mask="url(#b)">
<use fill="#62656A" xlink:href="#c"/>
</g>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 820 B

View file

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

View file

@ -1,5 +1,9 @@
export function searchMessages(query: string): Promise<Array<any>>; export function searchMessages(query: string): Promise<Array<any>>;
export function searchConversations(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( export function updateStickerLastUsed(
packId: string, packId: string,

View file

@ -655,9 +655,16 @@ async function searchConversations(query) {
return conversations; return conversations;
} }
function handleSearchMessageJSON(messages) {
return messages.map(message => ({
...JSON.parse(message.json),
snippet: message.snippet,
}));
}
async function searchMessages(query, { limit } = {}) { async function searchMessages(query, { limit } = {}) {
const messages = await channels.searchMessages(query, { limit }); const messages = await channels.searchMessages(query, { limit });
return messages; return handleSearchMessageJSON(messages);
} }
async function searchMessagesInConversation( async function searchMessagesInConversation(
@ -670,7 +677,7 @@ async function searchMessagesInConversation(
conversationId, conversationId,
{ limit } { limit }
); );
return messages; return handleSearchMessageJSON(messages);
} }
// Message // Message
@ -784,6 +791,10 @@ async function getUnreadByConversation(conversationId, { MessageCollection }) {
return new MessageCollection(messages); return new MessageCollection(messages);
} }
function handleMessageJSON(messages) {
return messages.map(message => JSON.parse(message.json));
}
async function getOlderMessagesByConversation( async function getOlderMessagesByConversation(
conversationId, conversationId,
{ limit = 100, receivedAt = Number.MAX_VALUE, MessageCollection } { 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( async function getNewerMessagesByConversation(
conversationId, conversationId,
@ -810,7 +821,7 @@ async function getNewerMessagesByConversation(
} }
); );
return new MessageCollection(messages); return new MessageCollection(handleMessageJSON(messages));
} }
async function getMessageMetricsForConversation(conversationId) { async function getMessageMetricsForConversation(conversationId) {
const result = await channels.getMessageMetricsForConversation( 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 conversationsDuck = require('../../ts/state/ducks/conversations');
const emojisDuck = require('../../ts/state/ducks/emojis'); const emojisDuck = require('../../ts/state/ducks/emojis');
const itemsDuck = require('../../ts/state/ducks/items'); const itemsDuck = require('../../ts/state/ducks/items');
const searchDuck = require('../../ts/state/ducks/search');
const stickersDuck = require('../../ts/state/ducks/stickers'); const stickersDuck = require('../../ts/state/ducks/stickers');
const userDuck = require('../../ts/state/ducks/user'); const userDuck = require('../../ts/state/ducks/user');
@ -274,6 +275,7 @@ exports.setup = (options = {}) => {
emojis: emojisDuck, emojis: emojisDuck,
items: itemsDuck, items: itemsDuck,
user: userDuck, user: userDuck,
search: searchDuck,
stickers: stickersDuck, stickers: stickersDuck,
}; };
const State = { const State = {

View file

@ -257,6 +257,13 @@
this.setDisappearingMessages(seconds), this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(), onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(), 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 // These are view only and don't update the Conversation model, so they
// need a manual update call. // need a manual update call.
@ -1490,8 +1497,6 @@
this.focusMessageField(); this.focusMessageField();
this.model.updateLastMessage();
const statusPromise = this.throttledGetProfiles(); const statusPromise = this.throttledGetProfiles();
// eslint-disable-next-line more/no-then // eslint-disable-next-line more/no-then
this.statusFetch = statusPromise.then(() => this.statusFetch = statusPromise.then(() =>
@ -1522,6 +1527,8 @@
if (quotedMessageId) { if (quotedMessageId) {
this.setQuoteMessage(quotedMessageId); this.setQuoteMessage(quotedMessageId);
} }
this.model.updateLastMessage();
}, },
async retrySend(messageId) { async retrySend(messageId) {

View file

@ -1644,12 +1644,17 @@
align-items: center; align-items: center;
padding-left: 8px; padding-left: 8px;
padding-right: 8px; padding-right: 8px;
transition: opacity 250ms ease-out;
&--hidden {
opacity: 0;
}
} }
.module-conversation-header__expiration__clock-icon { .module-conversation-header__expiration__clock-icon {
@include color-svg('../images/timer.svg', $color-gray-60); @include color-svg('../images/timer.svg', $color-gray-60);
height: 20px; height: 24px;
width: 20px; width: 24px;
display: inline-block; display: inline-block;
} }
@ -1658,11 +1663,29 @@
text-align: center; text-align: center;
} }
.module-conversation-header__gear-icon { .module-conversation-header__more-button {
@include color-svg('../images/gear.svg', $color-gray-60); @include color-svg('../images/more-h-24.svg', $color-gray-75);
height: 20px; height: 24px;
width: 20px; width: 24px;
margin-left: 4px; 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; border: none;
opacity: 0; opacity: 0;
transition: opacity 250ms ease-out; transition: opacity 250ms ease-out;
@ -2402,6 +2425,10 @@
} }
} }
.module-main-header__search__input--in-conversation {
padding-left: 50px;
}
.module-main-header__search__icon { .module-main-header__search__icon {
position: absolute; position: absolute;
left: 8px; left: 8px;
@ -2413,6 +2440,41 @@
@include color-svg('../images/search.svg', $color-gray-60); @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 { .module-main-header__search__cancel-icon {
position: absolute; position: absolute;
right: 8px; right: 8px;
@ -3142,8 +3204,24 @@
.module-search-results__no-results { .module-search-results__no-results {
margin-top: 27px; margin-top: 27px;
padding-left: 1em;
padding-right: 1em;
width: 100%; width: 100%;
text-align: center; 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 { .module-search-results__contacts-header {
@ -3872,10 +3950,10 @@
min-width: 24px; min-width: 24px;
min-height: 24px; min-height: 24px;
@include light-theme { @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 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 { .module-conversation-header__back-icon {
@include color-svg('../images/back.svg', $color-dark-05); background-color: $color-dark-05;
} }
.module-conversation-header__title { .module-conversation-header__title {
@ -1160,15 +1160,19 @@ body.dark-theme {
} }
.module-conversation-header__title__verified-icon { .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 { .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 { .module-conversation-header__more-button {
@include color-svg('../images/gear.svg', $color-dark-30); background-color: $color-gray-15;
}
.module-conversation-header__search-button {
background-color: $color-gray-15;
} }
// Module: Message Detail // Module: Message Detail
@ -1398,11 +1402,24 @@ body.dark-theme {
} }
.module-main-header__search__icon { .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 { .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 // Module: Image

View file

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

View file

@ -82,6 +82,47 @@
</util.LeftPaneContext> </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 #### From you and to you
```jsx ```jsx

View file

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

View file

@ -727,6 +727,76 @@ const items = [
</util.LeftPaneContext> </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 #### With a lot of results
```jsx ```jsx

View file

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

View file

@ -1,6 +1,6 @@
### Name variations, 1:1 conversation ### 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 #### 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')} onShowAllMedia={() => console.log('onShowAllMedia')}
onShowGroupMembers={() => console.log('onShowGroupMembers')} onShowGroupMembers={() => console.log('onShowGroupMembers')}
onGoBack={() => console.log('onGoBack')} onGoBack={() => console.log('onGoBack')}
onSearchInConversation={() => console.log('onSearchInConversation')}
/> />
</util.ConversationContext> </util.ConversationContext>
``` ```

View file

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

View file

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

View file

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

View file

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

View file

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