import { omit, reject } from 'lodash'; import { normalize } from '../../types/PhoneNumber'; import { AdvancedSearchOptions, SearchOptions } from '../../types/Search'; import { trigger } from '../../shims/events'; import { getMessageModel } from '../../shims/Whisper'; import { cleanSearchTerm } from '../../util/cleanSearchTerm'; import { getPrimaryDeviceFor, searchConversations, searchMessages, } from '../../../js/modules/data'; import { makeLookup } from '../../util/makeLookup'; import { ConversationType, MessageExpiredActionType, MessageType, RemoveAllConversationsActionType, SelectedConversationChangedActionType, } from './conversations'; // State export type SearchStateType = { query: string; normalizedPhoneNumber?: string; // We need to store messages here, because they aren't anywhere else in state messages: Array; selectedMessage?: string; messageLookup: { [key: string]: MessageType; }; // For conversations we store just the id, and pull conversation props in the selector conversations: Array; contacts: Array; }; // Actions type SearchResultsPayloadType = { query: string; normalizedPhoneNumber?: string; messages: Array; conversations: Array; contacts: Array; }; type SearchResultsKickoffActionType = { type: 'SEARCH_RESULTS'; payload: Promise; }; 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 | MessageExpiredActionType | RemoveAllConversationsActionType | SelectedConversationChangedActionType; // Action Creators export const actions = { search, clearSearch, updateSearchTerm, startNewConversation, }; function search( query: string, options: SearchOptions ): SearchResultsKickoffActionType { return { type: 'SEARCH_RESULTS', payload: doSearch(query, options), }; } async function doSearch( query: string, options: SearchOptions ): Promise { const { regionCode } = options; 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 = []; 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: normalize(query, { regionCode }), conversations, contacts, messages: getMessageProps(filteredMessages) || [], }; } function clearSearch(): ClearSearchActionType { return { type: 'SEARCH_CLEAR', payload: null, }; } function updateSearchTerm(query: string): UpdateSearchTermActionType { return { type: 'SEARCH_UPDATE', payload: { query, }, }; } function startNewConversation( query: string, options: { regionCode: string } ): ClearSearchActionType { const { regionCode } = options; const normalized = normalize(query, { regionCode }); if (!normalized) { throw new Error('Attempted to start new conversation with invalid number'); } trigger('showConversation', normalized); return { type: 'SEARCH_CLEAR', payload: null, }; } // Helper functions for search function filterMessages( messages: Array, filters: AdvancedSearchOptions, contacts: Array ) { 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) { console.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; } const getMessageProps = (messages: Array) => { if (!messages || !messages.length) { return []; } return messages.map(message => { const model = getMessageModel(message); return model.propsForSearchResult; }); }; 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, isSecondaryDevice } = options; const query = providedQuery.replace(/[+-.()]*/g, ''); const searchResults: Array = await searchConversations( query ); const ourPrimaryDevice = isSecondaryDevice ? await getPrimaryDeviceFor(ourNumber) : ourNumber; const resultPrimaryDevices: Array = await Promise.all( searchResults.map( async conversation => conversation.id === ourPrimaryDevice ? Promise.resolve(ourPrimaryDevice) : getPrimaryDeviceFor(conversation.id) ) ); // Split into two groups - active conversations and items just from address book let conversations: Array = []; let contacts: Array = []; const max = searchResults.length; for (let i = 0; i < max; i += 1) { const conversation = searchResults[i]; const primaryDevice = resultPrimaryDevices[i]; if (primaryDevice) { if (isSecondaryDevice && primaryDevice === ourPrimaryDevice) { conversations.push(ourNumber); } else { conversations.push(primaryDevice); } } else if (conversation.type === 'direct') { contacts.push(conversation.id); } else if (conversation.type !== '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 function getEmptyState(): SearchStateType { return { query: '', messages: [], messageLookup: {}, conversations: [], contacts: [], }; } 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, messages, normalizedPhoneNumber, conversations, contacts, } = payload; // Reject if the associated query is not the most recent user-provided query if (state.query !== query) { return state; } const filteredMessage = messages.filter(message => message !== undefined); return { ...state, query, normalizedPhoneNumber, conversations, contacts, messages: filteredMessage, messageLookup: makeLookup(filteredMessage, 'id'), }; } if (action.type === 'CONVERSATIONS_REMOVE_ALL') { return getEmptyState(); } if (action.type === 'SELECTED_CONVERSATION_CHANGED') { 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 { id } = payload; return { ...state, messages: reject(messages, message => id === message.id), messageLookup: omit(messageLookup, ['id']), }; } return state; }