You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			329 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			329 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			TypeScript
		
	
| import { AdvancedSearchOptions, SearchOptions } from '../../types/Search';
 | |
| import { cleanSearchTerm } from '../../util/cleanSearchTerm';
 | |
| import { searchConversations, searchMessages } from '../../../ts/data/data';
 | |
| 
 | |
| import { ReduxConversationType } from './conversations';
 | |
| import { PubKey } from '../../session/types';
 | |
| import { ConversationTypeEnum } from '../../models/conversation';
 | |
| 
 | |
| // State
 | |
| 
 | |
| export type SearchStateType = {
 | |
|   query: string;
 | |
|   normalizedPhoneNumber?: string;
 | |
|   // We need to store messages here, because they aren't anywhere else in state
 | |
|   selectedMessage?: string;
 | |
|   // For conversations we store just the id, and pull conversation props in the selector
 | |
|   conversations: Array<string>;
 | |
|   contacts: Array<string>;
 | |
| };
 | |
| 
 | |
| // Actions
 | |
| type SearchResultsPayloadType = {
 | |
|   query: string;
 | |
|   normalizedPhoneNumber?: string;
 | |
|   conversations: Array<string>;
 | |
|   contacts: Array<string>;
 | |
| };
 | |
| 
 | |
| type SearchResultsKickoffActionType = {
 | |
|   type: 'SEARCH_RESULTS';
 | |
|   payload: Promise<SearchResultsPayloadType>;
 | |
| };
 | |
| type SearchResultsFulfilledActionType = {
 | |
|   type: 'SEARCH_RESULTS_FULFILLED';
 | |
|   payload: SearchResultsPayloadType;
 | |
| };
 | |
| type UpdateSearchTermActionType = {
 | |
|   type: 'SEARCH_UPDATE';
 | |
|   payload: {
 | |
|     query: string;
 | |
|   };
 | |
| };
 | |
| type ClearSearchActionType = {
 | |
|   type: 'SEARCH_CLEAR';
 | |
|   payload: null;
 | |
| };
 | |
| 
 | |
| export type SEARCH_TYPES =
 | |
|   | SearchResultsFulfilledActionType
 | |
|   | UpdateSearchTermActionType
 | |
|   | ClearSearchActionType;
 | |
| 
 | |
| // Action Creators
 | |
| 
 | |
| export const actions = {
 | |
|   search,
 | |
|   clearSearch,
 | |
|   updateSearchTerm,
 | |
| };
 | |
| 
 | |
| export function search(query: string, options: SearchOptions): SearchResultsKickoffActionType {
 | |
|   return {
 | |
|     type: 'SEARCH_RESULTS',
 | |
|     payload: doSearch(query, options), // this uses redux-promise-middleware
 | |
|   };
 | |
| }
 | |
| 
 | |
| async function doSearch(query: string, options: SearchOptions): Promise<SearchResultsPayloadType> {
 | |
|   const advancedSearchOptions = getAdvancedSearchOptionsFromQuery(query);
 | |
|   const processedQuery = advancedSearchOptions.query;
 | |
|   const isAdvancedQuery = query !== processedQuery;
 | |
| 
 | |
|   const [discussions, messages] = await Promise.all([
 | |
|     queryConversationsAndContacts(processedQuery, options),
 | |
|     queryMessages(processedQuery),
 | |
|   ]);
 | |
|   const { conversations, contacts } = discussions;
 | |
|   let filteredMessages = messages.filter(message => message !== undefined);
 | |
| 
 | |
|   if (isAdvancedQuery) {
 | |
|     let senderFilter: Array<string> = [];
 | |
|     if (advancedSearchOptions.from && advancedSearchOptions.from.length > 0) {
 | |
|       const senderFilterQuery = await queryConversationsAndContacts(
 | |
|         advancedSearchOptions.from,
 | |
|         options
 | |
|       );
 | |
|       senderFilter = senderFilterQuery.contacts;
 | |
|     }
 | |
|     filteredMessages = filterMessages(filteredMessages, advancedSearchOptions, senderFilter);
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     query,
 | |
|     normalizedPhoneNumber: PubKey.normalize(query),
 | |
|     conversations,
 | |
|     contacts,
 | |
|   };
 | |
| }
 | |
| export function clearSearch(): ClearSearchActionType {
 | |
|   return {
 | |
|     type: 'SEARCH_CLEAR',
 | |
|     payload: null,
 | |
|   };
 | |
| }
 | |
| export function updateSearchTerm(query: string): UpdateSearchTermActionType {
 | |
|   return {
 | |
|     type: 'SEARCH_UPDATE',
 | |
|     payload: {
 | |
|       query,
 | |
|     },
 | |
|   };
 | |
| }
 | |
| 
 | |
| // Helper functions for search
 | |
| 
 | |
| function filterMessages(
 | |
|   messages: Array<any>,
 | |
|   filters: AdvancedSearchOptions,
 | |
|   contacts: Array<string>
 | |
| ) {
 | |
|   let filteredMessages = messages;
 | |
|   if (filters.from && filters.from.length > 0) {
 | |
|     if (filters.from === '@me') {
 | |
|       filteredMessages = filteredMessages.filter(message => message.sent);
 | |
|     } else {
 | |
|       filteredMessages = [];
 | |
|       for (const contact of contacts) {
 | |
|         for (const message of messages) {
 | |
|           if (message.source === contact) {
 | |
|             filteredMessages.push(message);
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   }
 | |
|   if (filters.before > 0) {
 | |
|     filteredMessages = filteredMessages.filter(message => message.received_at < filters.before);
 | |
|   }
 | |
|   if (filters.after > 0) {
 | |
|     filteredMessages = filteredMessages.filter(message => message.received_at > filters.after);
 | |
|   }
 | |
| 
 | |
|   return filteredMessages;
 | |
| }
 | |
| 
 | |
| function getUnixMillisecondsTimestamp(timestamp: string): number {
 | |
|   const timestampInt = parseInt(timestamp, 10);
 | |
|   if (!isNaN(timestampInt)) {
 | |
|     try {
 | |
|       if (timestampInt > 10000) {
 | |
|         return new Date(timestampInt).getTime();
 | |
|       }
 | |
| 
 | |
|       return new Date(timestamp).getTime();
 | |
|     } catch (error) {
 | |
|       window?.log?.warn('Advanced Search: ', error);
 | |
| 
 | |
|       return 0;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return 0;
 | |
| }
 | |
| 
 | |
| function getAdvancedSearchOptionsFromQuery(query: string): AdvancedSearchOptions {
 | |
|   const filterSeperator = ':';
 | |
|   const filters: any = {
 | |
|     query: null,
 | |
|     from: null,
 | |
|     before: null,
 | |
|     after: null,
 | |
|   };
 | |
| 
 | |
|   let newQuery = query;
 | |
|   const splitQuery = query.toLowerCase().split(' ');
 | |
|   const filtersList = Object.keys(filters);
 | |
|   for (const queryPart of splitQuery) {
 | |
|     for (const filter of filtersList) {
 | |
|       const filterMatcher = filter + filterSeperator;
 | |
|       if (queryPart.startsWith(filterMatcher)) {
 | |
|         filters[filter] = queryPart.replace(filterMatcher, '');
 | |
|         newQuery = newQuery.replace(queryPart, '').trim();
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   filters.before = getUnixMillisecondsTimestamp(filters.before);
 | |
|   filters.after = getUnixMillisecondsTimestamp(filters.after);
 | |
|   filters.query = newQuery;
 | |
| 
 | |
|   return filters;
 | |
| }
 | |
| 
 | |
| async function queryMessages(query: string) {
 | |
|   try {
 | |
|     const normalized = cleanSearchTerm(query);
 | |
| 
 | |
|     return searchMessages(normalized);
 | |
|   } catch (e) {
 | |
|     return [];
 | |
|   }
 | |
| }
 | |
| 
 | |
| async function queryConversationsAndContacts(providedQuery: string, options: SearchOptions) {
 | |
|   const { ourNumber, noteToSelf } = options;
 | |
|   const query = providedQuery.replace(/[+-.()]*/g, '');
 | |
| 
 | |
|   const searchResults: Array<ReduxConversationType> = await searchConversations(query);
 | |
| 
 | |
|   // Split into two groups - active conversations and items just from address book
 | |
|   let conversations: Array<string> = [];
 | |
|   let contacts: Array<string> = [];
 | |
|   const max = searchResults.length;
 | |
|   for (let i = 0; i < max; i += 1) {
 | |
|     const conversation = searchResults[i];
 | |
|     const primaryDevice = searchResults[i].id;
 | |
| 
 | |
|     if (primaryDevice) {
 | |
|       if (primaryDevice === ourNumber) {
 | |
|         conversations.push(ourNumber);
 | |
|       } else {
 | |
|         conversations.push(primaryDevice);
 | |
|       }
 | |
|     } else if (conversation.type === ConversationTypeEnum.PRIVATE) {
 | |
|       contacts.push(conversation.id);
 | |
|     } else if (conversation.type !== ConversationTypeEnum.GROUP) {
 | |
|       contacts.push(conversation.id);
 | |
|     } else {
 | |
|       conversations.push(conversation.id);
 | |
|     }
 | |
|   }
 | |
|   // Inject synthetic Note to Self entry if query matches localized 'Note to Self'
 | |
|   if (noteToSelf.indexOf(providedQuery.toLowerCase()) !== -1) {
 | |
|     // ensure that we don't have duplicates in our results
 | |
|     contacts = contacts.filter(id => id !== ourNumber);
 | |
|     conversations = conversations.filter(id => id !== ourNumber);
 | |
| 
 | |
|     contacts.unshift(ourNumber);
 | |
|   }
 | |
| 
 | |
|   return { conversations, contacts };
 | |
| }
 | |
| 
 | |
| // Reducer
 | |
| 
 | |
| export const initialSearchState: SearchStateType = {
 | |
|   query: '',
 | |
|   conversations: [],
 | |
|   contacts: [],
 | |
| };
 | |
| 
 | |
| function getEmptyState(): SearchStateType {
 | |
|   return initialSearchState;
 | |
| }
 | |
| 
 | |
| export function reducer(state: SearchStateType | undefined, action: SEARCH_TYPES): SearchStateType {
 | |
|   if (!state) {
 | |
|     return getEmptyState();
 | |
|   }
 | |
| 
 | |
|   if (action.type === 'SEARCH_CLEAR') {
 | |
|     return getEmptyState();
 | |
|   }
 | |
| 
 | |
|   if (action.type === 'SEARCH_UPDATE') {
 | |
|     const { payload } = action;
 | |
|     const { query } = payload;
 | |
| 
 | |
|     return {
 | |
|       ...state,
 | |
|       query,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   if (action.type === 'SEARCH_RESULTS_FULFILLED') {
 | |
|     const { payload } = action;
 | |
|     const { query, normalizedPhoneNumber, conversations, contacts } = payload;
 | |
| 
 | |
|     // Reject if the associated query is not the most recent user-provided query
 | |
|     if (state.query !== query) {
 | |
|       return state;
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       ...state,
 | |
|       query,
 | |
|       normalizedPhoneNumber,
 | |
|       conversations,
 | |
|       contacts,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   // if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
 | |
|   //   return getEmptyState();
 | |
|   // }
 | |
| 
 | |
|   // if (action.type === openConversationExternal.name) {
 | |
|   //   const { payload } = action;
 | |
|   //   const { messageId } = payload;
 | |
| 
 | |
|   //   if (!messageId) {
 | |
|   //     return state;
 | |
|   //   }
 | |
| 
 | |
|   //   return {
 | |
|   //     ...state,
 | |
|   //     selectedMessage: messageId,
 | |
|   //   };
 | |
|   // }
 | |
| 
 | |
|   // if (action.type === 'MESSAGE_EXPIRED') {
 | |
|   //   const { messages, messageLookup } = state;
 | |
|   //   if (!messages.length) {
 | |
|   //     return state;
 | |
|   //   }
 | |
| 
 | |
|   //   const { payload } = action;
 | |
|   //   const { messageId } = payload;
 | |
| 
 | |
|   //   return {
 | |
|   //     ...state,
 | |
|   //     messages: reject(messages, message => messageId === message.id),
 | |
|   //     messageLookup: omit(messageLookup, ['id']),
 | |
|   //   };
 | |
|   // }
 | |
| 
 | |
|   return state;
 | |
| }
 |