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

490 lines
12 KiB
TypeScript
Raw Normal View History

2019-01-14 21:49:58 +00:00
import { omit, reject } from 'lodash';
import { normalize } from '../../types/PhoneNumber';
import { trigger } from '../../shims/events';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import dataInterface from '../../sql/Client';
2019-01-14 21:49:58 +00:00
import { makeLookup } from '../../util/makeLookup';
import {
2019-11-07 21:36:16 +00:00
ConversationUnloadedActionType,
DBConversationType,
MessageDeletedActionType,
MessageType,
2019-01-14 21:49:58 +00:00
RemoveAllConversationsActionType,
SelectedConversationChangedActionType,
2019-11-07 21:36:16 +00:00
ShowArchivedConversationsActionType,
2019-01-14 21:49:58 +00:00
} from './conversations';
const {
searchConversations: dataSearchConversations,
searchMessages: dataSearchMessages,
searchMessagesInConversation,
} = dataInterface;
2019-01-14 21:49:58 +00:00
// State
export type MessageSearchResultType = MessageType & {
snippet: string;
};
export type MessageSearchResultLookupType = {
[id: string]: MessageSearchResultType;
};
2019-01-14 21:49:58 +00:00
export type SearchStateType = {
2019-11-07 21:36:16 +00:00
startSearchCounter: number;
2019-08-09 23:12:29 +00:00
searchConversationId?: string;
searchConversationName?: string;
// We store just ids of conversations, since that data is always cached in memory
contacts: Array<string>;
conversations: Array<string>;
2019-01-14 21:49:58 +00:00
query: string;
normalizedPhoneNumber?: string;
messageIds: Array<string>;
// We do store message data to pass through the selector
messageLookup: MessageSearchResultLookupType;
2019-01-14 21:49:58 +00:00
selectedMessage?: string;
// Loading state
discussionsLoading: boolean;
messagesLoading: boolean;
2019-01-14 21:49:58 +00:00
};
// Actions
type SearchResultsBaseType = {
2019-01-14 21:49:58 +00:00
query: string;
normalizedPhoneNumber?: string;
};
type SearchMessagesResultsPayloadType = SearchResultsBaseType & {
messages: Array<MessageSearchResultType>;
};
type SearchDiscussionsResultsPayloadType = SearchResultsBaseType & {
2019-01-14 21:49:58 +00:00
conversations: Array<string>;
contacts: Array<string>;
};
type SearchMessagesResultsKickoffActionType = {
type: 'SEARCH_MESSAGES_RESULTS';
payload: Promise<SearchMessagesResultsPayloadType>;
};
type SearchDiscussionsResultsKickoffActionType = {
type: 'SEARCH_DISCUSSIONS_RESULTS';
payload: Promise<SearchDiscussionsResultsPayloadType>;
};
2019-01-14 21:49:58 +00:00
type SearchMessagesResultsFulfilledActionType = {
type: 'SEARCH_MESSAGES_RESULTS_FULFILLED';
payload: SearchMessagesResultsPayloadType;
};
type SearchDiscussionsResultsFulfilledActionType = {
type: 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED';
payload: SearchDiscussionsResultsPayloadType;
2019-01-14 21:49:58 +00:00
};
type UpdateSearchTermActionType = {
type: 'SEARCH_UPDATE';
payload: {
query: string;
};
};
2019-11-07 21:36:16 +00:00
type StartSearchActionType = {
type: 'SEARCH_START';
payload: null;
};
2019-01-14 21:49:58 +00:00
type ClearSearchActionType = {
type: 'SEARCH_CLEAR';
payload: null;
};
2019-08-09 23:12:29 +00:00
type ClearConversationSearchActionType = {
type: 'CLEAR_CONVERSATION_SEARCH';
payload: null;
};
type SearchInConversationActionType = {
type: 'SEARCH_IN_CONVERSATION';
payload: {
searchConversationId: string;
searchConversationName: string;
};
};
2019-01-14 21:49:58 +00:00
export type SearchActionType =
| SearchMessagesResultsKickoffActionType
| SearchDiscussionsResultsKickoffActionType
| 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
2019-11-07 21:36:16 +00:00
| SelectedConversationChangedActionType
| ShowArchivedConversationsActionType
| ConversationUnloadedActionType;
2019-01-14 21:49:58 +00:00
// Action Creators
export const actions = {
searchMessages,
searchDiscussions,
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,
startNewConversation,
};
function searchMessages(
2019-01-14 21:49:58 +00:00
query: string,
2019-08-09 23:12:29 +00:00
options: {
regionCode: string;
}
): SearchMessagesResultsKickoffActionType {
return {
type: 'SEARCH_MESSAGES_RESULTS',
payload: doSearchMessages(query, options),
};
}
function searchDiscussions(
query: string,
options: {
ourConversationId: string;
2019-08-09 23:12:29 +00:00
noteToSelf: string;
}
): SearchDiscussionsResultsKickoffActionType {
2019-01-14 21:49:58 +00:00
return {
type: 'SEARCH_DISCUSSIONS_RESULTS',
payload: doSearchDiscussions(query, options),
2019-01-14 21:49:58 +00:00
};
}
async function doSearchMessages(
2019-01-14 21:49:58 +00:00
query: string,
options: {
2019-08-09 23:12:29 +00:00
searchConversationId?: string;
2019-01-14 21:49:58 +00:00
regionCode: string;
}
): Promise<SearchMessagesResultsPayloadType> {
const { regionCode, searchConversationId } = options;
2019-08-09 23:12:29 +00:00
const normalizedPhoneNumber = normalize(query, { regionCode });
2019-01-14 21:49:58 +00:00
const messages = await queryMessages(query, searchConversationId);
2019-01-14 21:49:58 +00:00
return {
messages,
normalizedPhoneNumber,
query,
};
}
2019-08-09 23:12:29 +00:00
async function doSearchDiscussions(
query: string,
options: {
ourConversationId: string;
noteToSelf: string;
2019-08-09 23:12:29 +00:00
}
): Promise<SearchDiscussionsResultsPayloadType> {
const { ourConversationId, noteToSelf } = options;
const { conversations, contacts } = await queryConversationsAndContacts(
query,
{
ourConversationId,
noteToSelf,
}
);
return {
conversations,
contacts,
query,
};
2019-01-14 21:49:58 +00:00
}
2019-11-07 21:36:16 +00:00
function startSearch(): StartSearchActionType {
return {
type: 'SEARCH_START',
payload: null,
};
}
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(
searchConversationId: string,
searchConversationName: string
): SearchInConversationActionType {
return {
type: 'SEARCH_IN_CONVERSATION',
payload: {
searchConversationId,
searchConversationName,
},
};
}
2019-01-14 21:49:58 +00:00
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,
};
}
2019-08-09 23:12:29 +00:00
async function queryMessages(query: string, searchConversationId?: string) {
try {
const normalized = cleanSearchTerm(query);
2019-01-14 21:49:58 +00:00
2019-08-09 23:12:29 +00:00
if (searchConversationId) {
return searchMessagesInConversation(normalized, searchConversationId);
}
return dataSearchMessages(normalized);
} catch (e) {
return [];
}
}
2019-01-14 21:49:58 +00:00
async function queryConversationsAndContacts(
providedQuery: string,
options: {
ourConversationId: string;
noteToSelf: string;
}
2019-01-14 21:49:58 +00:00
) {
const { ourConversationId, noteToSelf } = options;
2019-01-14 21:49:58 +00:00
const query = providedQuery.replace(/[+-.()]*/g, '');
const searchResults: Array<DBConversationType> = await dataSearchConversations(
2019-01-14 21:49:58 +00:00
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];
if (conversation.type === 'private' && !conversation.lastMessage) {
2019-01-14 21:49:58 +00:00
contacts.push(conversation.id);
} else {
conversations.push(conversation.id);
}
}
// // @ts-ignore
// console._log(
// '%cqueryConversationsAndContacts',
// 'background:black;color:red;',
// { searchResults, conversations, ourNumber, ourUuid }
// );
2019-01-14 21:49:58 +00:00
// 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 !== ourConversationId);
conversations = conversations.filter(id => id !== ourConversationId);
2019-01-14 21:49:58 +00:00
contacts.unshift(ourConversationId);
2019-01-14 21:49:58 +00:00
}
return { conversations, contacts };
}
// Reducer
function getEmptyState(): SearchStateType {
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: {},
conversations: [],
contacts: [],
discussionsLoading: false,
messagesLoading: false,
2019-01-14 21:49:58 +00:00
};
}
export function reducer(
state: SearchStateType = getEmptyState(),
action: SearchActionType
2019-01-14 21:49:58 +00:00
): SearchStateType {
2019-11-07 21:36:16 +00:00
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
return getEmptyState();
}
if (action.type === 'SEARCH_START') {
return {
...state,
searchConversationId: undefined,
searchConversationName: undefined,
startSearchCounter: state.startSearchCounter + 1,
};
}
2019-01-14 21:49:58 +00:00
if (action.type === 'SEARCH_CLEAR') {
return getEmptyState();
}
if (action.type === 'SEARCH_UPDATE') {
const { payload } = action;
const { query } = payload;
const hasQuery = Boolean(query && query.length >= 2);
const isWithinConversation = Boolean(state.searchConversationId);
2019-01-14 21:49:58 +00:00
return {
...state,
query,
messagesLoading: hasQuery,
...(hasQuery
? {
messageIds: [],
messageLookup: {},
discussionsLoading: !isWithinConversation,
contacts: [],
conversations: [],
}
: {}),
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;
const { searchConversationId, searchConversationName } = payload;
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
}
return {
...getEmptyState(),
searchConversationId,
searchConversationName,
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') {
const { searchConversationId, searchConversationName } = state;
return {
...getEmptyState(),
searchConversationId,
searchConversationName,
};
}
if (action.type === 'SEARCH_MESSAGES_RESULTS_FULFILLED') {
2019-01-14 21:49:58 +00:00
const { payload } = action;
const { messages, normalizedPhoneNumber, 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) {
return state;
}
const messageIds = messages.map(message => message.id);
2019-01-14 21:49:58 +00:00
return {
...state,
normalizedPhoneNumber,
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;
const { contacts, conversations } = payload;
return {
...state,
contacts,
conversations,
discussionsLoading: false,
2019-01-14 21:49:58 +00:00
};
}
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
return getEmptyState();
}
if (action.type === 'SELECTED_CONVERSATION_CHANGED') {
const { payload } = action;
2019-08-09 23:12:29 +00:00
const { id, messageId } = payload;
const { searchConversationId } = state;
2019-01-14 21:49:58 +00:00
2019-08-09 23:12:29 +00:00
if (searchConversationId && searchConversationId !== id) {
return getEmptyState();
2019-01-14 21:49:58 +00:00
}
return {
...state,
selectedMessage: messageId,
};
}
2019-11-07 21:36:16 +00:00
if (action.type === 'CONVERSATION_UNLOADED') {
const { payload } = action;
const { id } = payload;
const { searchConversationId } = state;
if (searchConversationId && searchConversationId === id) {
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;
return {
...state,
messageIds: reject(messageIds, messageId => id === messageId),
2019-01-14 21:49:58 +00:00
messageLookup: omit(messageLookup, ['id']),
};
}
return state;
}