// Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import { debounce, omit, reject } from 'lodash'; import type { ReadonlyDeep } from 'type-fest'; import type { StateType as RootStateType } from '../reducer'; import { filterAndSortConversations } from '../../util/filterAndSortConversations'; import type { ClientSearchResultMessageType } from '../../sql/Interface'; import { DataReader } from '../../sql/Client'; import { makeLookup } from '../../util/makeLookup'; import { isNotNil } from '../../util/isNotNil'; import type { ServiceIdString } from '../../types/ServiceId'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions'; import type { ConversationType, ConversationUnloadedActionType, MessageDeletedActionType, RemoveAllConversationsActionType, TargetedConversationChangedActionType, ShowArchivedConversationsActionType, MessageType, } from './conversations'; import { getFilterByUnread, getIsActivelySearching, getQuery, getSearchConversation, } from '../selectors/search'; import { getAllConversations } from '../selectors/conversations'; import { getIntl, getRegionCode, getUserConversationId, } from '../selectors/user'; import { strictAssert } from '../../util/assert'; import { CONVERSATION_UNLOADED, TARGETED_CONVERSATION_CHANGED, } from './conversations'; import { removeDiacritics } from '../../util/removeDiacritics'; import * as log from '../../logging/log'; import { searchConversationTitles } from '../../util/searchConversationTitles'; import { isDirectConversation } from '../../util/whatTypeOfConversation'; const { searchMessages: dataSearchMessages } = DataReader; // State export type MessageSearchResultType = ReadonlyDeep< MessageType & { snippet?: string; } >; export type MessageSearchResultLookupType = ReadonlyDeep<{ [id: string]: MessageSearchResultType; }>; export type SearchStateType = ReadonlyDeep<{ startSearchCounter: number; searchConversationId?: string; globalSearch?: boolean; contactIds: Array; conversationIds: Array; query: string; filterByUnread: boolean; messageIds: Array; // We do store message data to pass through the selector messageLookup: MessageSearchResultLookupType; targetedMessage?: string; // Loading state discussionsLoading: boolean; messagesLoading: boolean; }>; // Actions type SearchMessagesResultsFulfilledActionType = ReadonlyDeep<{ type: 'SEARCH_MESSAGES_RESULTS_FULFILLED'; payload: { messages: Array; query: string; }; }>; type SearchDiscussionsResultsFulfilledActionType = ReadonlyDeep<{ type: 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED'; payload: { conversationIds: Array; contactIds: Array; query: string; }; }>; type UpdateSearchTermActionType = ReadonlyDeep<{ type: 'SEARCH_UPDATE'; payload: { query: string; }; }>; type StartSearchActionType = ReadonlyDeep<{ type: 'SEARCH_START'; payload: null; }>; type ClearSearchQueryActionType = ReadonlyDeep<{ type: 'SEARCH_QUERY_CLEAR'; payload: null; }>; type ClearConversationSearchActionType = ReadonlyDeep<{ type: 'CLEAR_CONVERSATION_SEARCH'; payload: null; }>; type EndSearchActionType = ReadonlyDeep<{ type: 'SEARCH_END'; payload: null; }>; type EndConversationSearchActionType = ReadonlyDeep<{ type: 'END_CONVERSATION_SEARCH'; payload: null; }>; type SearchInConversationActionType = ReadonlyDeep<{ type: 'SEARCH_IN_CONVERSATION'; payload: { searchConversationId: string }; }>; type UpdateFilterByUnreadActionType = ReadonlyDeep<{ type: 'FILTER_BY_UNREAD_UPDATE'; payload: { enabled: boolean }; }>; type RefreshSearchActionType = ReadonlyDeep<{ type: 'SEARCH_REFRESH'; payload: null; }>; export type SearchActionType = ReadonlyDeep< | SearchMessagesResultsFulfilledActionType | SearchDiscussionsResultsFulfilledActionType | UpdateSearchTermActionType | StartSearchActionType | ClearSearchQueryActionType | ClearConversationSearchActionType | EndSearchActionType | EndConversationSearchActionType | SearchInConversationActionType | MessageDeletedActionType | RemoveAllConversationsActionType | TargetedConversationChangedActionType | ShowArchivedConversationsActionType | ConversationUnloadedActionType | UpdateFilterByUnreadActionType | RefreshSearchActionType >; // Action Creators export const actions = { startSearch, clearSearchQuery, clearConversationSearch, endSearch, endConversationSearch, searchInConversation, updateSearchTerm, updateFilterByUnread, refreshSearch, }; export const useSearchActions = (): BoundActionCreatorsMapObject< typeof actions > => useBoundActions(actions); function startSearch(): StartSearchActionType { return { type: 'SEARCH_START', payload: null, }; } function clearSearchQuery(): ThunkAction< void, RootStateType, unknown, ClearSearchQueryActionType > { return async (dispatch, getState) => { dispatch({ type: 'SEARCH_QUERY_CLEAR', payload: null, }); doSearch({ dispatch, state: getState(), }); }; } function clearConversationSearch(): ClearConversationSearchActionType { return { type: 'CLEAR_CONVERSATION_SEARCH', payload: null, }; } function endSearch(): EndSearchActionType { return { type: 'SEARCH_END', payload: null, }; } function endConversationSearch(): EndConversationSearchActionType { return { type: 'END_CONVERSATION_SEARCH', payload: null, }; } function searchInConversation( searchConversationId: string ): SearchInConversationActionType { return { type: 'SEARCH_IN_CONVERSATION', payload: { searchConversationId }, }; } function refreshSearch(): ThunkAction< void, RootStateType, unknown, RefreshSearchActionType > { return (dispatch, getState) => { const state = getState(); if (!getIsActivelySearching(state)) { return; } dispatch({ type: 'SEARCH_REFRESH', payload: null, }); doSearch({ dispatch, state, }); }; } function updateFilterByUnread( filterByUnread: boolean ): ThunkAction { return (dispatch, getState) => { dispatch({ type: 'FILTER_BY_UNREAD_UPDATE', payload: { enabled: filterByUnread, }, }); doSearch({ dispatch, state: getState(), }); }; } function updateSearchTerm( query: string ): ThunkAction { return (dispatch, getState) => { dispatch({ type: 'SEARCH_UPDATE', payload: { query }, }); doSearch({ dispatch, state: getState(), }); }; } const doSearch = debounce( ({ dispatch, state, }: Readonly<{ dispatch: ThunkDispatch< RootStateType, unknown, | SearchMessagesResultsFulfilledActionType | SearchDiscussionsResultsFulfilledActionType >; state: RootStateType; }>) => { if (!getIsActivelySearching(state)) { return; } const query = getQuery(state); const filterByUnread = getFilterByUnread(state); const i18n = getIntl(state); const allConversations = getAllConversations(state); const regionCode = getRegionCode(state); const noteToSelf = i18n('icu:noteToSelf').toLowerCase(); const ourConversationId = getUserConversationId(state); const searchConversationId = getSearchConversation(state)?.id; strictAssert(ourConversationId, 'doSearch our conversation is missing'); // Limit the number of contacts to something reasonable const MAX_MATCHING_CONTACTS = 100; void (async () => { if (filterByUnread) { dispatch({ type: 'SEARCH_MESSAGES_RESULTS_FULFILLED', payload: { messages: [], query, }, }); return; } const segmenter = new Intl.Segmenter([], { granularity: 'word' }); const queryWords = [...segmenter.segment(query)] .filter(word => word.isWordLike) .map(word => word.segment); const contactServiceIdsMatchingQuery = searchConversationTitles( allConversations, queryWords ) .filter(conversation => isDirectConversation(conversation)) .map(conversation => conversation.serviceId) .filter(isNotNil) .slice(0, MAX_MATCHING_CONTACTS); const messages = await queryMessages({ query, searchConversationId, contactServiceIdsMatchingQuery, }); dispatch({ type: 'SEARCH_MESSAGES_RESULTS_FULFILLED', payload: { messages, query, }, }); })(); if (!searchConversationId) { void (async () => { const { conversationIds, contactIds } = await queryConversationsAndContacts(query, { filterByUnread, ourConversationId, noteToSelf, regionCode, allConversations, }); dispatch({ type: 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED', payload: { conversationIds, contactIds, query, }, }); })(); } }, 200 ); async function queryMessages({ query, searchConversationId, contactServiceIdsMatchingQuery, }: { query: string; searchConversationId?: string; contactServiceIdsMatchingQuery?: Array; }): Promise> { try { if (query.trim().length === 0) { return []; } if (searchConversationId) { return dataSearchMessages({ query, conversationId: searchConversationId, contactServiceIdsMatchingQuery, }); } return dataSearchMessages({ query, contactServiceIdsMatchingQuery, }); } catch (e) { return []; } } async function queryConversationsAndContacts( query: string, options: { filterByUnread: boolean; ourConversationId: string; noteToSelf: string; regionCode: string | undefined; allConversations: ReadonlyArray; } ): Promise<{ contactIds: Array; conversationIds: Array; }> { const { filterByUnread, ourConversationId, noteToSelf, regionCode, allConversations, } = options; const normalizedQuery = removeDiacritics(query); const visibleConversations = allConversations.filter(conversation => { const { activeAt, removalStage, isBlocked } = conversation; if (isDirectConversation(conversation)) { // if a conversation has messages (i.e. is not "deleted"), always show it if (activeAt != null) { return true; } // Don't show if conversation is empty and the contact is blocked if (isBlocked) { return false; } // Don't show if conversation is empty and the contact is removed if (removalStage != null) { return false; } // Otherwise, show it return true; } // We don't show groups in search results that don't have any messages return activeAt != null; }); const searchResults: Array = filterAndSortConversations( visibleConversations, normalizedQuery, regionCode, filterByUnread ); // Split into two groups - active conversations and items just from address book let conversationIds: Array = []; let contactIds: Array = []; const max = searchResults.length; for (let i = 0; i < max; i += 1) { const conversation = searchResults[i]; if (conversation.type === 'direct' && !conversation.lastMessage) { contactIds.push(conversation.id); } else { conversationIds.push(conversation.id); } } // Inject synthetic Note to Self entry if query matches localized 'Note to Self' if (noteToSelf.indexOf(query.toLowerCase()) !== -1) { // ensure that we don't have duplicates in our results contactIds = contactIds.filter(id => id !== ourConversationId); conversationIds = conversationIds.filter(id => id !== ourConversationId); contactIds.unshift(ourConversationId); } // Don't show contacts in the left pane if we're filtering by unread if (filterByUnread) { contactIds = []; } return { conversationIds, contactIds }; } // Reducer export function getEmptyState(): SearchStateType { return { startSearchCounter: 0, query: '', filterByUnread: false, messageIds: [], messageLookup: {}, conversationIds: [], contactIds: [], discussionsLoading: false, messagesLoading: false, }; } function handleSearchUpdate( state: SearchStateType, params: { query?: string; filterByUnread?: boolean } ): SearchStateType { const { query, filterByUnread } = params; // Determine the new state values, falling back to existing state if not provided const newQuery = query ?? state.query; const newFilterByUnread = filterByUnread ?? state.filterByUnread; const isValidSearch = newQuery.length > 0 || newFilterByUnread; const isWithinConversation = Boolean(state.searchConversationId); if (isValidSearch) { return { ...state, query: newQuery, filterByUnread: newFilterByUnread, messagesLoading: true, messageIds: [], messageLookup: {}, discussionsLoading: !isWithinConversation, contactIds: [], conversationIds: [], }; } return { ...getEmptyState(), startSearchCounter: state.startSearchCounter, searchConversationId: state.searchConversationId, globalSearch: state.globalSearch, }; } export function reducer( state: Readonly = getEmptyState(), action: Readonly ): SearchStateType { if (action.type === 'FILTER_BY_UNREAD_UPDATE') { return handleSearchUpdate(state, { filterByUnread: action.payload.enabled, }); } if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') { log.info('search: show archived conversations, clearing message lookup'); return getEmptyState(); } if (action.type === 'SEARCH_START') { return { ...state, searchConversationId: undefined, globalSearch: true, startSearchCounter: state.startSearchCounter + 1, }; } if (action.type === 'SEARCH_QUERY_CLEAR') { return handleSearchUpdate(state, { query: '' }); } if (action.type === 'SEARCH_END') { return { ...state, globalSearch: Boolean(state.query) && !state.searchConversationId, }; } if (action.type === 'SEARCH_UPDATE') { return handleSearchUpdate(state, { query: action.payload.query }); } if (action.type === 'SEARCH_IN_CONVERSATION') { const { payload } = action; const { searchConversationId } = payload; if (searchConversationId === state.searchConversationId) { return { ...state, startSearchCounter: state.startSearchCounter + 1, }; } log.info('search: searching in new conversation, clearing message lookup'); return { ...getEmptyState(), searchConversationId, startSearchCounter: state.startSearchCounter + 1, }; } if (action.type === 'CLEAR_CONVERSATION_SEARCH') { const { searchConversationId } = state; log.info('search: cleared conversation search, clearing message lookup'); return { ...getEmptyState(), searchConversationId, }; } if (action.type === 'END_CONVERSATION_SEARCH') { return { ...getEmptyState(), startSearchCounter: state.startSearchCounter + 1, globalSearch: true, }; } if (action.type === 'SEARCH_MESSAGES_RESULTS_FULFILLED') { const { payload } = action; const { messages, query } = payload; // Reject if the associated query is not the most recent user-provided query if (state.query !== query) { log.info('search: query mismatch, ignoring message results'); return state; } log.info('search: got new messages, updating message lookup'); const messageIds = messages.map(message => message.id); return { ...state, query, messageIds, messageLookup: makeLookup(messages, 'id'), messagesLoading: false, }; } if (action.type === 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED') { const { payload } = action; const { contactIds, conversationIds, query } = payload; // Reject if the associated query is not the most recent user-provided query if (state.query !== query) { log.info('search: query mismatch, ignoring message results'); return state; } return { ...state, contactIds, conversationIds, discussionsLoading: false, }; } if (action.type === 'CONVERSATIONS_REMOVE_ALL') { return getEmptyState(); } if (action.type === TARGETED_CONVERSATION_CHANGED) { const { payload } = action; const { conversationId, messageId } = payload; const { searchConversationId } = state; if (searchConversationId && searchConversationId !== conversationId) { log.info( 'search: targeted conversation changed, clearing message lookup' ); return getEmptyState(); } return { ...state, targetedMessage: messageId, }; } if (action.type === CONVERSATION_UNLOADED) { const { payload } = action; const { conversationId } = payload; const { searchConversationId } = state; if (searchConversationId && searchConversationId === conversationId) { log.info( 'search: searched conversation unloaded, clearing message lookup' ); return getEmptyState(); } return state; } if (action.type === 'MESSAGE_DELETED') { const { messageIds, messageLookup } = state; if (!messageIds || messageIds.length < 1) { return state; } const { payload } = action; const { id } = payload; log.info('search: message deleted, removing from message lookup'); return { ...state, messageIds: reject(messageIds, messageId => id === messageId), messageLookup: omit(messageLookup, id), }; } return state; }