Full-text search within conversation
This commit is contained in:
		
					parent
					
						
							
								6292019d30
							
						
					
				
			
			
				commit
				
					
						c39d5a811a
					
				
			
		
					 26 changed files with 697 additions and 134 deletions
				
			
		|  | @ -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" | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
| Before Width: | Height: | Size: 456 B After Width: | Height: | Size: 456 B | 
							
								
								
									
										1
									
								
								images/profile-solid-16.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								images/profile-solid-16.svg
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										1
									
								
								images/search-24.svg
									
										
									
									
									
										Normal 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 | 
|  | @ -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 | 
|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										4
									
								
								js/modules/data.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								js/modules/data.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -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, | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
|  | @ -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 = { | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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> | ||||
| ``` | ||||
|  |  | |||
|  | @ -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> | ||||
| ``` | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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')}{' '} | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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> | ||||
|       ); | ||||
|     } | ||||
|  |  | |||
|  | @ -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> | ||||
| ``` | ||||
|  |  | |||
|  | @ -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> | ||||
|     ); | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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 | ||||
|       ); | ||||
|     }; | ||||
|  |  | |||
|  | @ -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), | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Scott Nonnenberg
				Scott Nonnenberg