signal-desktop/ts/state/ducks/search.ts

562 lines
14 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2019 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2021-11-01 18:43:02 +00:00
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { debounce, omit, reject } from 'lodash';
2019-01-14 21:49:58 +00:00
import type { ReadonlyDeep } from 'type-fest';
2021-11-01 18:43:02 +00:00
import type { StateType as RootStateType } from '../reducer';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
2022-04-07 18:47:12 +00:00
import { filterAndSortConversationsByRecent } from '../../util/filterAndSortConversations';
import type {
ClientSearchResultMessageType,
ClientInterface,
} from '../../sql/Interface';
import dataInterface from '../../sql/Client';
2019-01-14 21:49:58 +00:00
import { makeLookup } from '../../util/makeLookup';
2023-08-16 20:54:39 +00:00
import { isNotNil } from '../../util/isNotNil';
import type { ServiceIdString } from '../../types/ServiceId';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
2019-01-14 21:49:58 +00:00
import type {
2022-04-07 18:47:12 +00:00
ConversationType,
2019-11-07 21:36:16 +00:00
ConversationUnloadedActionType,
MessageDeletedActionType,
2019-01-14 21:49:58 +00:00
RemoveAllConversationsActionType,
2023-03-20 22:23:53 +00:00
TargetedConversationChangedActionType,
2019-11-07 21:36:16 +00:00
ShowArchivedConversationsActionType,
MessageType,
2019-01-14 21:49:58 +00:00
} from './conversations';
2021-11-01 18:43:02 +00:00
import { getQuery, getSearchConversation } from '../selectors/search';
2022-04-07 18:47:12 +00:00
import { getAllConversations } from '../selectors/conversations';
import {
getIntl,
getRegionCode,
getUserConversationId,
} from '../selectors/user';
import { strictAssert } from '../../util/assert';
import {
CONVERSATION_UNLOADED,
2023-03-20 22:23:53 +00:00
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';
2019-01-14 21:49:58 +00:00
const { searchMessages: dataSearchMessages }: ClientInterface = dataInterface;
2019-01-14 21:49:58 +00:00
// State
export type MessageSearchResultType = ReadonlyDeep<
MessageType & {
snippet?: string;
}
>;
export type MessageSearchResultLookupType = ReadonlyDeep<{
[id: string]: MessageSearchResultType;
}>;
export type SearchStateType = ReadonlyDeep<{
2019-11-07 21:36:16 +00:00
startSearchCounter: number;
2019-08-09 23:12:29 +00:00
searchConversationId?: string;
globalSearch?: boolean;
contactIds: Array<string>;
conversationIds: Array<string>;
2019-01-14 21:49:58 +00:00
query: string;
messageIds: Array<string>;
// We do store message data to pass through the selector
messageLookup: MessageSearchResultLookupType;
2023-03-20 22:23:53 +00:00
targetedMessage?: string;
// Loading state
discussionsLoading: boolean;
messagesLoading: boolean;
}>;
2019-01-14 21:49:58 +00:00
// Actions
type SearchMessagesResultsFulfilledActionType = ReadonlyDeep<{
type: 'SEARCH_MESSAGES_RESULTS_FULFILLED';
2021-11-01 18:43:02 +00:00
payload: {
messages: Array<MessageSearchResultType>;
query: string;
};
}>;
type SearchDiscussionsResultsFulfilledActionType = ReadonlyDeep<{
type: 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED';
2021-11-01 18:43:02 +00:00
payload: {
conversationIds: Array<string>;
contactIds: Array<string>;
query: string;
};
}>;
type UpdateSearchTermActionType = ReadonlyDeep<{
2019-01-14 21:49:58 +00:00
type: 'SEARCH_UPDATE';
payload: {
query: string;
};
}>;
type StartSearchActionType = ReadonlyDeep<{
2019-11-07 21:36:16 +00:00
type: 'SEARCH_START';
payload: { globalSearch: boolean };
}>;
type ClearSearchActionType = ReadonlyDeep<{
2019-01-14 21:49:58 +00:00
type: 'SEARCH_CLEAR';
payload: null;
}>;
type ClearConversationSearchActionType = ReadonlyDeep<{
2019-08-09 23:12:29 +00:00
type: 'CLEAR_CONVERSATION_SEARCH';
payload: null;
}>;
type SearchInConversationActionType = ReadonlyDeep<{
2019-08-09 23:12:29 +00:00
type: 'SEARCH_IN_CONVERSATION';
2021-11-01 18:43:02 +00:00
payload: { searchConversationId: string };
}>;
2019-01-14 21:49:58 +00:00
export type SearchActionType = ReadonlyDeep<
| SearchMessagesResultsFulfilledActionType
| SearchDiscussionsResultsFulfilledActionType
2019-01-14 21:49:58 +00:00
| UpdateSearchTermActionType
2019-11-07 21:36:16 +00:00
| StartSearchActionType
2019-01-14 21:49:58 +00:00
| ClearSearchActionType
2019-08-09 23:12:29 +00:00
| ClearConversationSearchActionType
| SearchInConversationActionType
| MessageDeletedActionType
2019-01-14 21:49:58 +00:00
| RemoveAllConversationsActionType
2023-03-20 22:23:53 +00:00
| TargetedConversationChangedActionType
2019-11-07 21:36:16 +00:00
| ShowArchivedConversationsActionType
| ConversationUnloadedActionType
>;
2019-01-14 21:49:58 +00:00
// Action Creators
export const actions = {
2019-11-07 21:36:16 +00:00
startSearch,
2019-01-14 21:49:58 +00:00
clearSearch,
2019-08-09 23:12:29 +00:00
clearConversationSearch,
searchInConversation,
2019-01-14 21:49:58 +00:00
updateSearchTerm,
};
export const useSearchActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
2019-11-07 21:36:16 +00:00
function startSearch(): StartSearchActionType {
return {
type: 'SEARCH_START',
payload: { globalSearch: true },
2019-11-07 21:36:16 +00:00
};
}
2019-01-14 21:49:58 +00:00
function clearSearch(): ClearSearchActionType {
return {
type: 'SEARCH_CLEAR',
payload: null,
};
}
2019-08-09 23:12:29 +00:00
function clearConversationSearch(): ClearConversationSearchActionType {
return {
type: 'CLEAR_CONVERSATION_SEARCH',
payload: null,
};
}
function searchInConversation(
2021-11-01 18:43:02 +00:00
searchConversationId: string
2019-08-09 23:12:29 +00:00
): SearchInConversationActionType {
return {
type: 'SEARCH_IN_CONVERSATION',
2021-11-01 18:43:02 +00:00
payload: { searchConversationId },
2019-08-09 23:12:29 +00:00
};
}
2021-11-01 18:43:02 +00:00
function updateSearchTerm(
query: string
): ThunkAction<void, RootStateType, unknown, UpdateSearchTermActionType> {
return (dispatch, getState) => {
dispatch({
type: 'SEARCH_UPDATE',
payload: { query },
});
const state = getState();
const ourConversationId = getUserConversationId(state);
strictAssert(
ourConversationId,
'updateSearchTerm our conversation is missing'
);
2021-11-01 18:43:02 +00:00
2023-06-15 00:57:27 +00:00
const i18n = getIntl(state);
2021-11-01 18:43:02 +00:00
doSearch({
dispatch,
2022-04-07 18:47:12 +00:00
allConversations: getAllConversations(state),
regionCode: getRegionCode(state),
2023-06-15 00:57:27 +00:00
noteToSelf: i18n('icu:noteToSelf').toLowerCase(),
ourConversationId,
2021-11-01 18:43:02 +00:00
query: getQuery(state),
searchConversationId: getSearchConversation(state)?.id,
});
2019-01-14 21:49:58 +00:00
};
}
2021-11-01 18:43:02 +00:00
const doSearch = debounce(
({
dispatch,
2022-04-07 18:47:12 +00:00
allConversations,
regionCode,
2021-11-01 18:43:02 +00:00
noteToSelf,
ourConversationId,
query,
searchConversationId,
}: Readonly<{
dispatch: ThunkDispatch<
RootStateType,
unknown,
| SearchMessagesResultsFulfilledActionType
| SearchDiscussionsResultsFulfilledActionType
>;
2022-04-07 18:47:12 +00:00
allConversations: ReadonlyArray<ConversationType>;
2021-11-01 18:43:02 +00:00
noteToSelf: string;
2022-04-07 18:47:12 +00:00
regionCode: string | undefined;
2021-11-01 18:43:02 +00:00
ourConversationId: string;
query: string;
searchConversationId: undefined | string;
}>) => {
if (!query) {
return;
}
// Limit the number of contacts to something reasonable
const MAX_MATCHING_CONTACTS = 100;
void (async () => {
const segmenter = new Intl.Segmenter([], { granularity: 'word' });
const queryWords = [...segmenter.segment(query)]
.filter(word => word.isWordLike)
.map(word => word.segment);
2023-08-16 20:54:39 +00:00
const contactServiceIdsMatchingQuery = searchConversationTitles(
allConversations,
queryWords
)
2023-08-16 20:54:39 +00:00
.filter(conversation => isDirectConversation(conversation))
.map(conversation => conversation.serviceId)
.filter(isNotNil)
.slice(0, MAX_MATCHING_CONTACTS);
const messages = await queryMessages({
query,
searchConversationId,
2023-08-16 20:54:39 +00:00
contactServiceIdsMatchingQuery,
});
2021-11-01 18:43:02 +00:00
dispatch({
type: 'SEARCH_MESSAGES_RESULTS_FULFILLED',
payload: {
messages,
2021-11-01 18:43:02 +00:00
query,
},
});
})();
if (!searchConversationId) {
void (async () => {
2021-11-11 22:43:05 +00:00
const { conversationIds, contactIds } =
await queryConversationsAndContacts(query, {
ourConversationId,
noteToSelf,
2022-04-07 18:47:12 +00:00
regionCode,
allConversations,
2021-11-11 22:43:05 +00:00
});
2021-11-01 18:43:02 +00:00
dispatch({
type: 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED',
payload: {
conversationIds,
contactIds,
query,
},
});
})();
}
},
200
);
async function queryMessages({
query,
searchConversationId,
2023-08-16 20:54:39 +00:00
contactServiceIdsMatchingQuery,
}: {
query: string;
searchConversationId?: string;
2023-08-16 20:54:39 +00:00
contactServiceIdsMatchingQuery?: Array<ServiceIdString>;
}): Promise<Array<ClientSearchResultMessageType>> {
try {
const normalized = cleanSearchTerm(query);
if (normalized.length === 0) {
return [];
}
2019-01-14 21:49:58 +00:00
2019-08-09 23:12:29 +00:00
if (searchConversationId) {
return dataSearchMessages({
query: normalized,
conversationId: searchConversationId,
2023-08-16 20:54:39 +00:00
contactServiceIdsMatchingQuery,
});
2019-08-09 23:12:29 +00:00
}
return dataSearchMessages({
query: normalized,
2023-08-16 20:54:39 +00:00
contactServiceIdsMatchingQuery,
});
} catch (e) {
return [];
}
}
2019-01-14 21:49:58 +00:00
async function queryConversationsAndContacts(
2022-04-07 18:47:12 +00:00
query: string,
options: {
ourConversationId: string;
noteToSelf: string;
2022-04-07 18:47:12 +00:00
regionCode: string | undefined;
allConversations: ReadonlyArray<ConversationType>;
}
): Promise<{
contactIds: Array<string>;
conversationIds: Array<string>;
}> {
2022-04-07 18:47:12 +00:00
const { ourConversationId, noteToSelf, regionCode, allConversations } =
options;
2019-01-14 21:49:58 +00:00
const normalizedQuery = removeDiacritics(query);
2022-04-07 18:47:12 +00:00
const searchResults: Array<ConversationType> =
filterAndSortConversationsByRecent(
allConversations,
normalizedQuery,
regionCode
);
2019-01-14 21:49:58 +00:00
// Split into two groups - active conversations and items just from address book
let conversationIds: Array<string> = [];
let contactIds: Array<string> = [];
2019-01-14 21:49:58 +00:00
const max = searchResults.length;
for (let i = 0; i < max; i += 1) {
const conversation = searchResults[i];
2022-04-07 18:47:12 +00:00
if (conversation.type === 'direct' && !conversation.lastMessage) {
contactIds.push(conversation.id);
2019-01-14 21:49:58 +00:00
} else {
conversationIds.push(conversation.id);
2019-01-14 21:49:58 +00:00
}
}
// Inject synthetic Note to Self entry if query matches localized 'Note to Self'
2022-04-07 18:47:12 +00:00
if (noteToSelf.indexOf(query.toLowerCase()) !== -1) {
2019-01-14 21:49:58 +00:00
// ensure that we don't have duplicates in our results
contactIds = contactIds.filter(id => id !== ourConversationId);
conversationIds = conversationIds.filter(id => id !== ourConversationId);
2019-01-14 21:49:58 +00:00
contactIds.unshift(ourConversationId);
2019-01-14 21:49:58 +00:00
}
return { conversationIds, contactIds };
2019-01-14 21:49:58 +00:00
}
// Reducer
export function getEmptyState(): SearchStateType {
2019-01-14 21:49:58 +00:00
return {
2019-11-07 21:36:16 +00:00
startSearchCounter: 0,
2019-01-14 21:49:58 +00:00
query: '',
messageIds: [],
2019-01-14 21:49:58 +00:00
messageLookup: {},
conversationIds: [],
contactIds: [],
discussionsLoading: false,
messagesLoading: false,
2019-01-14 21:49:58 +00:00
};
}
export function reducer(
state: Readonly<SearchStateType> = getEmptyState(),
action: Readonly<SearchActionType>
2019-01-14 21:49:58 +00:00
): SearchStateType {
2019-11-07 21:36:16 +00:00
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
log.info('search: show archived conversations, clearing message lookup');
2019-11-07 21:36:16 +00:00
return getEmptyState();
}
if (action.type === 'SEARCH_START') {
return {
...state,
searchConversationId: undefined,
globalSearch: true,
2019-11-07 21:36:16 +00:00
startSearchCounter: state.startSearchCounter + 1,
};
}
2019-01-14 21:49:58 +00:00
if (action.type === 'SEARCH_CLEAR') {
log.info('search: cleared, clearing message lookup');
2022-12-09 18:03:32 +00:00
return {
...getEmptyState(),
startSearchCounter: state.startSearchCounter,
};
2019-01-14 21:49:58 +00:00
}
if (action.type === 'SEARCH_UPDATE') {
const { payload } = action;
const { query } = payload;
const hasQuery = Boolean(query);
const isWithinConversation = Boolean(state.searchConversationId);
2019-01-14 21:49:58 +00:00
return {
...state,
query,
messagesLoading: hasQuery,
...(hasQuery
? {
messageIds: [],
messageLookup: {},
discussionsLoading: !isWithinConversation,
contactIds: [],
conversationIds: [],
}
: {}),
2019-01-14 21:49:58 +00:00
};
}
2019-08-09 23:12:29 +00:00
if (action.type === 'SEARCH_IN_CONVERSATION') {
const { payload } = action;
2021-11-01 18:43:02 +00:00
const { searchConversationId } = payload;
2019-08-09 23:12:29 +00:00
if (searchConversationId === state.searchConversationId) {
2019-11-07 21:36:16 +00:00
return {
...state,
startSearchCounter: state.startSearchCounter + 1,
};
2019-08-09 23:12:29 +00:00
}
log.info('search: searching in new conversation, clearing message lookup');
2019-08-09 23:12:29 +00:00
return {
...getEmptyState(),
searchConversationId,
2019-11-07 21:36:16 +00:00
startSearchCounter: state.startSearchCounter + 1,
2019-08-09 23:12:29 +00:00
};
}
if (action.type === 'CLEAR_CONVERSATION_SEARCH') {
2021-11-01 18:43:02 +00:00
const { searchConversationId } = state;
2019-08-09 23:12:29 +00:00
log.info('search: cleared conversation search, clearing message lookup');
2019-08-09 23:12:29 +00:00
return {
...getEmptyState(),
searchConversationId,
};
}
if (action.type === 'SEARCH_MESSAGES_RESULTS_FULFILLED') {
2019-01-14 21:49:58 +00:00
const { payload } = action;
2021-11-01 18:43:02 +00:00
const { messages, query } = payload;
2019-01-14 21:49:58 +00:00
// 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');
2019-01-14 21:49:58 +00:00
return state;
}
log.info('search: got new messages, updating message lookup');
const messageIds = messages.map(message => message.id);
2019-01-14 21:49:58 +00:00
return {
...state,
query,
messageIds,
2019-01-14 21:49:58 +00:00
messageLookup: makeLookup(messages, 'id'),
messagesLoading: false,
};
}
if (action.type === 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED') {
const { payload } = action;
2021-11-01 18:43:02 +00:00
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');
2021-11-01 18:43:02 +00:00
return state;
}
return {
...state,
contactIds,
conversationIds,
discussionsLoading: false,
2019-01-14 21:49:58 +00:00
};
}
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
return getEmptyState();
}
2023-03-20 22:23:53 +00:00
if (action.type === TARGETED_CONVERSATION_CHANGED) {
2019-01-14 21:49:58 +00:00
const { payload } = action;
const { conversationId, messageId } = payload;
2019-08-09 23:12:29 +00:00
const { searchConversationId } = state;
2019-01-14 21:49:58 +00:00
if (searchConversationId && searchConversationId !== conversationId) {
log.info(
'search: targeted conversation changed, clearing message lookup'
);
2019-08-09 23:12:29 +00:00
return getEmptyState();
2019-01-14 21:49:58 +00:00
}
return {
...state,
2023-03-20 22:23:53 +00:00
targetedMessage: messageId,
2019-01-14 21:49:58 +00:00
};
}
if (action.type === CONVERSATION_UNLOADED) {
2019-11-07 21:36:16 +00:00
const { payload } = action;
const { conversationId } = payload;
2019-11-07 21:36:16 +00:00
const { searchConversationId } = state;
if (searchConversationId && searchConversationId === conversationId) {
log.info(
'search: searched conversation unloaded, clearing message lookup'
);
2019-11-07 21:36:16 +00:00
return getEmptyState();
}
return state;
}
if (action.type === 'MESSAGE_DELETED') {
const { messageIds, messageLookup } = state;
if (!messageIds || messageIds.length < 1) {
2019-01-14 21:49:58 +00:00
return state;
}
const { payload } = action;
const { id } = payload;
log.info('search: message deleted, removing from message lookup');
2019-01-14 21:49:58 +00:00
return {
...state,
messageIds: reject(messageIds, messageId => id === messageId),
messageLookup: omit(messageLookup, id),
2019-01-14 21:49:58 +00:00
};
}
return state;
}