Add "new conversation" composer for direct messages

This commit is contained in:
Evan Hahn 2021-02-23 14:34:28 -06:00 committed by Josh Perez
parent 84dc166b63
commit 06fb4fd0bc
61 changed files with 5960 additions and 3887 deletions

View file

@ -2,8 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import memoizee from 'memoizee';
import { fromPairs, isNumber } from 'lodash';
import { fromPairs, isNumber, isString } from 'lodash';
import { createSelector } from 'reselect';
import Fuse, { FuseOptions } from 'fuse.js';
import { StateType } from '../reducer';
import {
@ -16,6 +17,7 @@ import {
MessageType,
PreJoinConversationType,
} from '../ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { getOwn } from '../../util/getOwn';
import type { CallsByConversationType } from '../ducks/calling';
import { getCallsByConversation } from './calling';
@ -23,6 +25,7 @@ import { getBubbleProps } from '../../shims/Whisper';
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import { TimelineItemType } from '../../components/conversation/TimelineItem';
import { assert } from '../../util/assert';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import {
getInteractionMode,
@ -135,6 +138,16 @@ export const getShowArchived = createSelector(
}
);
const getComposerState = createSelector(
getConversations,
(state: ConversationsStateType) => state.composer
);
export const isComposing = createSelector(
getComposerState,
(composerState): boolean => Boolean(composerState)
);
export const getMessages = createSelector(
getConversations,
(state: ConversationsStateType): MessageLookupType => {
@ -148,6 +161,20 @@ export const getMessagesByConversation = createSelector(
}
);
export const getIsConversationEmptySelector = createSelector(
getMessagesByConversation,
(messagesByConversation: MessagesByConversationType) => (
conversationId: string
): boolean => {
const messages = getOwn(messagesByConversation, conversationId);
if (!messages) {
assert(false, 'Could not find conversation with this ID');
return true;
}
return messages.messageIds.length === 0;
}
);
const collator = new Intl.Collator();
// Note: we will probably want to put i18n and regionCode back when we are formatting
@ -256,6 +283,86 @@ export const getMe = createSelector(
}
);
export const getComposerContactSearchTerm = createSelector(
getComposerState,
(composer): string => {
if (!composer) {
assert(false, 'getComposerContactSearchTerm: composer is not open');
return '';
}
return composer.contactSearchTerm;
}
);
/**
* This returns contacts for the composer, which isn't just your primary's system
* contacts. It may include false positives, which is better than missing contacts.
*
* Because it filters unregistered contacts and that's (partially) determined by the
* current time, it's possible for this to return stale contacts that have unregistered
* if no other conversations change. This should be a rare false positive.
*/
const getContacts = createSelector(
getConversationLookup,
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
Object.values(conversationLookup).filter(
contact =>
contact.type === 'direct' &&
!contact.isMe &&
!contact.isBlocked &&
!isConversationUnregistered(contact) &&
(isString(contact.name) || contact.profileSharing)
)
);
const getNormalizedComposerContactSearchTerm = createSelector(
getComposerContactSearchTerm,
(searchTerm: string): string => searchTerm.trim()
);
const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) =>
i18n('noteToSelf').toLowerCase()
);
const COMPOSE_CONTACTS_FUSE_OPTIONS: FuseOptions<ConversationType> = {
// A small-but-nonzero threshold lets us match parts of E164s better, and makes the
// search a little more forgiving.
threshold: 0.05,
keys: ['title', 'name', 'e164'],
};
export const getComposeContacts = createSelector(
getNormalizedComposerContactSearchTerm,
getContacts,
getMe,
getNoteToSelfTitle,
(
searchTerm: string,
contacts: Array<ConversationType>,
noteToSelf: ConversationType,
noteToSelfTitle: string
): Array<ConversationType> => {
let result: Array<ConversationType>;
if (searchTerm.length) {
const fuse = new Fuse<ConversationType>(
contacts,
COMPOSE_CONTACTS_FUSE_OPTIONS
);
result = fuse.search(searchTerm);
if (noteToSelfTitle.includes(searchTerm)) {
result.push(noteToSelf);
}
} else {
result = contacts.concat();
result.sort((a, b) => collator.compare(a.title, b.title));
result.push(noteToSelf);
}
return result;
}
);
// This is where we will put Conversation selector logic, replicating what
// is currently in models/conversation.getProps()
// What needs to happen to pull that selector logic here?

View file

@ -1,9 +1,10 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import memoizee from 'memoizee';
import { createSelector } from 'reselect';
import { instance } from '../../util/libphonenumberInstance';
import { deconstructLookup } from '../../util/deconstructLookup';
import { StateType } from '../reducer';
@ -17,19 +18,14 @@ import {
ConversationType,
} from '../ducks/conversations';
import {
PropsDataType as SearchResultsPropsType,
SearchResultRowType,
} from '../../components/SearchResults';
import { PropsDataType as MessageSearchResultPropsDataType } from '../../components/MessageSearchResult';
import { LeftPaneSearchPropsType } from '../../components/leftPane/LeftPaneSearchHelper';
import { PropsDataType as MessageSearchResultPropsDataType } from '../../components/conversationList/MessageSearchResult';
import { getRegionCode, getUserConversationId } from './user';
import { getUserAgent } from './items';
import { getUserConversationId } from './user';
import {
GetConversationByIdType,
getConversationLookup,
getConversationSelector,
getSelectedConversationId,
} from './conversations';
export const getSearch = (state: StateType): SearchStateType => state.search;
@ -72,148 +68,44 @@ export const getMessageSearchResultLookup = createSelector(
getSearch,
(state: SearchStateType) => state.messageLookup
);
export const getSearchResults = createSelector(
[
getSearch,
getRegionCode,
getUserAgent,
getConversationLookup,
getSelectedConversationId,
getSelectedMessage,
],
[getSearch, getConversationLookup],
(
state: SearchStateType,
regionCode: string,
userAgent: string,
lookup: ConversationLookupType,
selectedConversationId?: string,
selectedMessageId?: string
): SearchResultsPropsType | undefined => {
conversationLookup: ConversationLookupType
): LeftPaneSearchPropsType => {
const {
contacts,
conversations,
contactIds,
conversationIds,
discussionsLoading,
messageIds,
messageLookup,
messagesLoading,
searchConversationName,
} = state;
const showStartNewConversation = Boolean(
state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber]
);
const haveConversations = conversations && conversations.length;
const haveContacts = contacts && contacts.length;
const haveMessages = messageIds && messageIds.length;
const noResults =
!discussionsLoading &&
!messagesLoading &&
!showStartNewConversation &&
!haveConversations &&
!haveContacts &&
!haveMessages;
const items: Array<SearchResultRowType> = [];
if (showStartNewConversation) {
items.push({
type: 'start-new-conversation',
data: undefined,
});
const isIOS = userAgent === 'OWI';
let isValidNumber = false;
try {
// Sometimes parse() throws, like for invalid country codes
const parsedNumber = instance.parse(state.query, regionCode);
isValidNumber = instance.isValidNumber(parsedNumber);
} catch (_) {
// no-op
}
if (!isIOS && isValidNumber) {
items.push({
type: 'sms-mms-not-supported-text',
data: undefined,
});
}
}
if (haveConversations) {
items.push({
type: 'conversations-header',
data: undefined,
});
conversations.forEach(id => {
const data = lookup[id];
items.push({
type: 'conversation',
data: {
...data,
isSelected: Boolean(data && id === selectedConversationId),
},
});
});
} else if (discussionsLoading) {
items.push({
type: 'conversations-header',
data: undefined,
});
items.push({
type: 'spinner',
data: undefined,
});
}
if (haveContacts) {
items.push({
type: 'contacts-header',
data: undefined,
});
contacts.forEach(id => {
const data = lookup[id];
items.push({
type: 'contact',
data: {
...data,
isSelected: Boolean(data && id === selectedConversationId),
},
});
});
}
if (haveMessages) {
items.push({
type: 'messages-header',
data: undefined,
});
messageIds.forEach(messageId => {
items.push({
type: 'message',
data: messageId,
});
});
} else if (messagesLoading) {
items.push({
type: 'messages-header',
data: undefined,
});
items.push({
type: 'spinner',
data: undefined,
});
}
return {
discussionsLoading,
items,
messagesLoading,
noResults,
regionCode,
conversationResults: discussionsLoading
? { isLoading: true }
: {
isLoading: false,
results: deconstructLookup(conversationLookup, conversationIds),
},
contactResults: discussionsLoading
? { isLoading: true }
: {
isLoading: false,
results: deconstructLookup(conversationLookup, contactIds),
},
messageResults: messagesLoading
? { isLoading: true }
: {
isLoading: false,
results: deconstructLookup(messageLookup, messageIds),
},
searchConversationName,
searchTerm: state.query,
selectedConversationId,
selectedMessageId,
};
}
);