session-desktop/ts/state/ducks/conversations.ts

752 lines
19 KiB
TypeScript
Raw Normal View History

2020-11-16 04:45:13 +01:00
import _, { omit } from 'lodash';
2019-01-14 22:49:58 +01:00
2020-11-16 04:45:13 +01:00
import { Constants } from '../../session';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getConversationController } from '../../session/conversations';
import { MessageModel } from '../../models/message';
2021-02-15 05:16:38 +01:00
import { getMessagesByConversation } from '../../data/data';
import { ConversationTypeEnum } from '../../models/conversation';
2021-06-15 02:12:43 +02:00
import { MessageDeliveryStatus } from '../../models/messageType';
2019-01-14 22:49:58 +01:00
// State
export type MessageType = {
id: string;
conversationId: string;
receivedAt: number;
snippet: string;
from: {
phoneNumber: string;
isMe?: boolean;
name?: string;
color?: string;
profileName?: string;
avatarPath?: string;
};
to: {
groupName?: string;
phoneNumber: string;
isMe?: boolean;
name?: string;
profileName?: string;
};
isSelected?: boolean;
};
2020-11-16 04:45:13 +01:00
export type MessageTypeInConvo = {
2020-11-16 04:45:13 +01:00
id: string;
conversationId: string;
attributes: any;
propsForMessage: Object;
propsForSearchResult: Object;
propsForGroupInvitation: Object;
propsForTimerNotification: Object;
propsForGroupNotification: Object;
firstMessageOfSeries: boolean;
receivedAt: number;
getPropsForMessageDetail(): Promise<any>;
2020-11-16 04:45:13 +01:00
};
2021-06-15 02:12:43 +02:00
export type LastMessageStatusType = MessageDeliveryStatus | null;
2021-07-05 03:23:47 +02:00
export type FindAndFormatContactType = {
phoneNumber: string;
avatarPath: string | null;
name: string | null;
profileName: string | null;
title: string | null;
};
export type PropsForExpirationTimer = {
timespan: string | null;
disabled: boolean;
phoneNumber: string;
avatarPath: string | null;
name: string | null;
profileName: string | null;
title: string | null;
type: 'fromMe' | 'fromSync' | 'fromOther';
} | null;
export type PropsForGroupUpdateGeneral = {
type: 'general';
};
export type PropsForGroupUpdateAdd = {
type: 'add';
contacts?: Array<FindAndFormatContactType>;
};
export type PropsForGroupUpdateKicked = {
type: 'kicked';
isMe: boolean;
contacts?: Array<FindAndFormatContactType>;
};
export type PropsForGroupUpdateRemove = {
type: 'remove';
isMe: boolean;
contacts?: Array<FindAndFormatContactType>;
};
export type PropsForGroupUpdateName = {
type: 'name';
newName: string;
};
export type PropsForGroupUpdateArray = Array<
| PropsForGroupUpdateGeneral
| PropsForGroupUpdateAdd
| PropsForGroupUpdateKicked
| PropsForGroupUpdateName
| PropsForGroupUpdateRemove
>;
export type PropsForGroupUpdate = {
changes: PropsForGroupUpdateArray;
} | null;
export type LastMessageType = {
status: LastMessageStatusType;
text: string | null;
};
2021-01-29 01:29:24 +01:00
export interface ConversationType {
2019-01-14 22:49:58 +01:00
id: string;
name?: string;
2021-01-29 01:29:24 +01:00
profileName?: string;
hasNickname?: boolean;
index?: number;
2019-01-14 22:49:58 +01:00
activeAt?: number;
lastMessage?: LastMessageType;
2019-01-14 22:49:58 +01:00
phoneNumber: string;
type: ConversationTypeEnum;
2019-01-14 22:49:58 +01:00
isMe: boolean;
isPublic?: boolean;
2019-01-14 22:49:58 +01:00
unreadCount: number;
mentionedUs: boolean;
2019-01-14 22:49:58 +01:00
isSelected: boolean;
2021-01-29 01:29:24 +01:00
2019-01-14 22:49:58 +01:00
isTyping: boolean;
isBlocked: boolean;
isKickedFromGroup: boolean;
left: boolean;
avatarPath?: string; // absolute filepath to the avatar
2021-01-19 01:25:03 +01:00
groupAdmins?: Array<string>; // admins for closed groups and moderators for open groups
members?: Array<string>; // members for closed groups only
2021-01-29 01:29:24 +01:00
}
2019-01-14 22:49:58 +01:00
export type ConversationLookupType = {
[key: string]: ConversationType;
};
export type ConversationsStateType = {
conversationLookup: ConversationLookupType;
selectedConversation?: string;
2020-11-16 04:45:13 +01:00
messages: Array<MessageTypeInConvo>;
2019-01-14 22:49:58 +01:00
};
2020-11-16 04:45:13 +01:00
async function getMessages(
conversationKey: string,
numMessages: number
): Promise<Array<MessageTypeInConvo>> {
const conversation = getConversationController().get(conversationKey);
2020-11-16 04:45:13 +01:00
if (!conversation) {
// no valid conversation, early return
window?.log?.error('Failed to get convo on reducer.');
2020-11-16 04:45:13 +01:00
return [];
}
2021-02-15 05:16:38 +01:00
const unreadCount = await conversation.getUnreadCount();
2020-11-16 04:45:13 +01:00
let msgCount =
2021-04-22 10:03:58 +02:00
numMessages || Number(Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT) + unreadCount;
2020-11-16 04:45:13 +01:00
msgCount =
msgCount > Constants.CONVERSATION.MAX_MESSAGE_FETCH_COUNT
? Constants.CONVERSATION.MAX_MESSAGE_FETCH_COUNT
: msgCount;
if (msgCount < Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT) {
msgCount = Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT;
}
const messageSet = await getMessagesByConversation(conversationKey, {
limit: msgCount,
});
2020-11-16 04:45:13 +01:00
// Set first member of series here.
const messageModels = messageSet.models;
const isPublic = conversation.isPublic();
2021-02-15 05:16:38 +01:00
const messagesPickedUp = messageModels.map(makeMessageTypeFromMessageModel);
2021-02-15 05:16:38 +01:00
const sortedMessage = sortMessages(messagesPickedUp, isPublic);
2020-11-16 04:45:13 +01:00
// no need to do that `firstMessageOfSeries` on a private chat
if (conversation.isPrivate()) {
return sortedMessage;
2020-11-16 04:45:13 +01:00
}
return updateFirstMessageOfSeries(sortedMessage);
}
2020-11-16 04:45:13 +01:00
const updateFirstMessageOfSeries = (messageModels: Array<any>) => {
2020-11-16 04:45:13 +01:00
// messages are got from the more recent to the oldest, so we need to check if
// the next messages in the list is still the same author.
// The message is the first of the series if the next message is not from the same author
for (let i = 0; i < messageModels.length; i++) {
// Handle firstMessageOfSeries for conditional avatar rendering
let firstMessageOfSeries = true;
const currentSender = messageModels[i].propsForMessage?.authorPhoneNumber;
const nextSender =
i < messageModels.length - 1
? messageModels[i + 1].propsForMessage?.authorPhoneNumber
: undefined;
if (i >= 0 && currentSender === nextSender) {
2020-11-16 04:45:13 +01:00
firstMessageOfSeries = false;
}
2021-01-29 01:29:24 +01:00
if (messageModels[i].propsForMessage) {
2021-04-22 10:03:58 +02:00
messageModels[i].propsForMessage.firstMessageOfSeries = firstMessageOfSeries;
2021-01-29 01:29:24 +01:00
}
2020-11-16 04:45:13 +01:00
}
return messageModels;
};
2020-11-16 04:45:13 +01:00
const fetchMessagesForConversation = createAsyncThunk(
'messages/fetchByConversationKey',
2021-04-22 10:03:58 +02:00
async ({ conversationKey, count }: { conversationKey: string; count: number }) => {
const beforeTimestamp = Date.now();
2020-11-16 04:45:13 +01:00
const messages = await getMessages(conversationKey, count);
const afterTimestamp = Date.now();
const time = afterTimestamp - beforeTimestamp;
window?.log?.info(`Loading ${messages.length} messages took ${time}ms to load.`);
2020-11-16 04:45:13 +01:00
return {
conversationKey,
messages,
};
}
);
2019-01-14 22:49:58 +01:00
// Actions
type ConversationAddedActionType = {
type: 'CONVERSATION_ADDED';
payload: {
id: string;
data: ConversationType;
};
};
type ConversationChangedActionType = {
type: 'CONVERSATION_CHANGED';
payload: {
id: string;
data: ConversationType;
};
};
type ConversationRemovedActionType = {
type: 'CONVERSATION_REMOVED';
payload: {
id: string;
};
};
export type RemoveAllConversationsActionType = {
type: 'CONVERSATIONS_REMOVE_ALL';
payload: null;
};
export type MessageExpiredActionType = {
type: 'MESSAGE_EXPIRED';
payload: {
messageId: string;
conversationKey: string;
2019-01-14 22:49:58 +01:00
};
};
2020-11-16 04:45:13 +01:00
export type MessageChangedActionType = {
type: 'MESSAGE_CHANGED';
payload: MessageModel;
};
export type MessagesChangedActionType = {
type: 'MESSAGES_CHANGED';
payload: Array<MessageModel>;
};
2020-11-16 04:45:13 +01:00
export type MessageAddedActionType = {
type: 'MESSAGE_ADDED';
payload: {
conversationKey: string;
messageModel: MessageModel;
};
};
2020-11-17 03:30:24 +01:00
export type MessageDeletedActionType = {
type: 'MESSAGE_DELETED';
payload: {
conversationKey: string;
messageId: string;
};
};
export type ConversationResetActionType = {
type: 'CONVERSATION_RESET';
payload: {
conversationKey: string;
};
};
2019-01-14 22:49:58 +01:00
export type SelectedConversationChangedActionType = {
type: 'SELECTED_CONVERSATION_CHANGED';
payload: {
id: string;
messageId?: string;
};
};
2020-11-16 04:45:13 +01:00
export type FetchMessagesForConversationType = {
type: 'messages/fetchByConversationKey/fulfilled';
payload: {
conversationKey: string;
messages: Array<MessageModel>;
};
};
2019-01-14 22:49:58 +01:00
export type ConversationActionType =
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationResetActionType
2019-01-14 22:49:58 +01:00
| RemoveAllConversationsActionType
| MessageExpiredActionType
2020-11-16 04:45:13 +01:00
| MessageAddedActionType
2020-11-17 03:30:24 +01:00
| MessageDeletedActionType
2020-11-16 04:45:13 +01:00
| MessageChangedActionType
| MessagesChangedActionType
2019-03-12 01:20:16 +01:00
| SelectedConversationChangedActionType
2020-11-16 04:45:13 +01:00
| SelectedConversationChangedActionType
| FetchMessagesForConversationType;
2019-01-14 22:49:58 +01:00
// Action Creators
export const actions = {
conversationAdded,
conversationChanged,
conversationRemoved,
removeAllConversations,
messageExpired,
2020-11-16 04:45:13 +01:00
messageAdded,
2020-11-17 03:30:24 +01:00
messageDeleted,
conversationReset,
2020-11-16 04:45:13 +01:00
messageChanged,
messagesChanged,
2020-11-16 04:45:13 +01:00
fetchMessagesForConversation,
2019-01-14 22:49:58 +01:00
openConversationExternal,
};
2021-04-22 10:03:58 +02:00
function conversationAdded(id: string, data: ConversationType): ConversationAddedActionType {
2019-01-14 22:49:58 +01:00
return {
type: 'CONVERSATION_ADDED',
payload: {
id,
data,
},
};
}
2021-04-22 10:03:58 +02:00
function conversationChanged(id: string, data: ConversationType): ConversationChangedActionType {
2019-01-14 22:49:58 +01:00
return {
type: 'CONVERSATION_CHANGED',
payload: {
id,
data,
},
};
}
function conversationRemoved(id: string): ConversationRemovedActionType {
return {
type: 'CONVERSATION_REMOVED',
payload: {
id,
},
};
}
function removeAllConversations(): RemoveAllConversationsActionType {
return {
type: 'CONVERSATIONS_REMOVE_ALL',
payload: null,
};
}
2019-03-12 01:20:16 +01:00
function messageExpired({
conversationKey,
messageId,
}: {
conversationKey: string;
messageId: string;
}): MessageExpiredActionType {
2019-01-14 22:49:58 +01:00
return {
type: 'MESSAGE_EXPIRED',
payload: {
conversationKey,
messageId,
2019-01-14 22:49:58 +01:00
},
};
}
2020-11-16 04:45:13 +01:00
function messageChanged(messageModel: MessageModel): MessageChangedActionType {
return {
type: 'MESSAGE_CHANGED',
payload: messageModel,
};
}
2021-04-22 10:03:58 +02:00
function messagesChanged(messageModels: Array<MessageModel>): MessagesChangedActionType {
return {
type: 'MESSAGES_CHANGED',
payload: messageModels,
};
}
2020-11-16 04:45:13 +01:00
function messageAdded({
conversationKey,
messageModel,
}: {
conversationKey: string;
messageModel: MessageModel;
}): MessageAddedActionType {
return {
type: 'MESSAGE_ADDED',
payload: {
conversationKey,
messageModel,
},
};
}
2020-11-17 03:30:24 +01:00
function messageDeleted({
conversationKey,
messageId,
}: {
conversationKey: string;
messageId: string;
}): MessageDeletedActionType {
return {
type: 'MESSAGE_DELETED',
payload: {
conversationKey,
messageId,
},
};
}
export function conversationReset({
conversationKey,
}: {
conversationKey: string;
}): ConversationResetActionType {
return {
type: 'CONVERSATION_RESET',
payload: {
conversationKey,
},
};
}
2021-03-16 07:22:46 +01:00
export function openConversationExternal(
2019-01-14 22:49:58 +01:00
id: string,
messageId?: string
): SelectedConversationChangedActionType {
window?.log?.info(`openConversationExternal with convoId: ${id}; messageId: ${messageId}`);
2019-01-14 22:49:58 +01:00
return {
type: 'SELECTED_CONVERSATION_CHANGED',
payload: {
id,
messageId,
},
};
}
// Reducer
2020-11-16 04:45:13 +01:00
const toPickFromMessageModel = [
'attributes',
'id',
'propsForSearchResult',
'propsForMessage',
'receivedAt',
'conversationId',
'firstMessageOfSeries',
'propsForGroupInvitation',
'propsForTimerNotification',
'propsForGroupNotification',
'propsForDataExtractionNotification',
// FIXME below are what is needed to fetch on the fly messageDetails. This is not the react way
'getPropsForMessageDetail',
'get',
'getConversation',
'isIncoming',
'findAndFormatContact',
'findContact',
'getStatus',
'getMessagePropStatus',
'hasErrors',
'isOutgoing',
2020-11-16 04:45:13 +01:00
];
2019-01-14 22:49:58 +01:00
function getEmptyState(): ConversationsStateType {
return {
conversationLookup: {},
2020-11-16 04:45:13 +01:00
messages: [],
2019-01-14 22:49:58 +01:00
};
}
2021-02-15 05:16:38 +01:00
const makeMessageTypeFromMessageModel = (message: MessageModel) => {
return _.pick(message as any, toPickFromMessageModel) as MessageTypeInConvo;
};
function sortMessages(
messages: Array<MessageTypeInConvo>,
isPublic: boolean
): Array<MessageTypeInConvo> {
// we order by serverTimestamp for public convos
// be sure to update the sorting order to fetch messages from the DB too at getMessagesByConversation
if (isPublic) {
return messages.sort(
2021-04-22 10:03:58 +02:00
(a: any, b: any) => b.attributes.serverTimestamp - a.attributes.serverTimestamp
);
}
if (messages.some(n => !n.attributes.sent_at && !n.attributes.received_at)) {
throw new Error('Found some messages without any timestamp set');
}
// for non public convos, we order by sent_at or received_at timestamp.
// we assume that a message has either a sent_at or a received_at field set.
const messagesSorted = messages.sort(
(a: any, b: any) =>
(b.attributes.sent_at || b.attributes.received_at) -
(a.attributes.sent_at || a.attributes.received_at)
);
return messagesSorted;
}
2021-04-22 10:03:58 +02:00
function handleMessageAdded(state: ConversationsStateType, action: MessageAddedActionType) {
const { messages } = state;
const { conversationKey, messageModel } = action.payload;
if (conversationKey === state.selectedConversation) {
2021-02-15 05:16:38 +01:00
const addedMessage = makeMessageTypeFromMessageModel(messageModel);
const messagesWithNewMessage = [...messages, addedMessage];
const convo = state.conversationLookup[state.selectedConversation];
const isPublic = convo?.isPublic || false;
if (convo) {
const sortedMessage = sortMessages(messagesWithNewMessage, isPublic);
2021-04-22 10:03:58 +02:00
const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeries(sortedMessage);
return {
...state,
messages: updatedWithFirstMessageOfSeries,
};
}
}
return state;
}
2021-04-22 10:03:58 +02:00
function handleMessageChanged(state: ConversationsStateType, action: MessageChangedActionType) {
const { payload } = action;
2021-04-22 10:03:58 +02:00
const messageInStoreIndex = state?.messages?.findIndex(m => m.id === payload.id);
if (messageInStoreIndex >= 0) {
2021-04-22 10:03:58 +02:00
const changedMessage = _.pick(payload as any, toPickFromMessageModel) as MessageTypeInConvo;
// we cannot edit the array directly, so slice the first part, insert our edited message, and slice the second part
const editedMessages = [
...state.messages.slice(0, messageInStoreIndex),
changedMessage,
...state.messages.slice(messageInStoreIndex + 1),
];
const convo = state.conversationLookup[payload.get('conversationId')];
const isPublic = convo?.isPublic || false;
// reorder the messages depending on the timestamp (we might have an updated serverTimestamp now)
const sortedMessage = sortMessages(editedMessages, isPublic);
2021-04-22 10:03:58 +02:00
const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeries(sortedMessage);
return {
...state,
messages: updatedWithFirstMessageOfSeries,
};
}
return state;
}
2021-04-22 10:03:58 +02:00
function handleMessagesChanged(state: ConversationsStateType, action: MessagesChangedActionType) {
const { payload } = action;
payload.forEach(element => {
// tslint:disable-next-line: no-parameter-reassignment
state = handleMessageChanged(state, {
payload: element,
type: 'MESSAGE_CHANGED',
});
});
return state;
}
function handleMessageExpiredOrDeleted(
state: ConversationsStateType,
action: MessageDeletedActionType | MessageExpiredActionType
) {
const { conversationKey, messageId } = action.payload;
if (conversationKey === state.selectedConversation) {
// search if we find this message id.
// we might have not loaded yet, so this case might not happen
2021-04-22 10:03:58 +02:00
const messageInStoreIndex = state?.messages.findIndex(m => m.id === messageId);
if (messageInStoreIndex >= 0) {
// we cannot edit the array directly, so slice the first part, and slice the second part,
// keeping the index removed out
const editedMessages = [
...state.messages.slice(0, messageInStoreIndex),
...state.messages.slice(messageInStoreIndex + 1),
];
2021-04-22 10:03:58 +02:00
const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeries(editedMessages);
// FIXME two other thing we have to do:
// * update the last message text if the message deleted was the last one
// * update the unread count of the convo if the message was the one counted as an unread
return {
...state,
messages: updatedWithFirstMessageOfSeries,
};
}
return state;
}
return state;
}
function handleConversationReset(
state: ConversationsStateType,
action: ConversationResetActionType
) {
const { conversationKey } = action.payload;
if (conversationKey === state.selectedConversation) {
// just empty the list of messages
return {
...state,
messages: [],
};
}
return state;
}
2020-11-17 03:30:24 +01:00
// tslint:disable: cyclomatic-complexity
// tslint:disable: max-func-body-length
2019-01-14 22:49:58 +01:00
export function reducer(
state: ConversationsStateType = getEmptyState(),
2019-01-14 22:49:58 +01:00
action: ConversationActionType
): ConversationsStateType {
if (action.type === 'CONVERSATION_ADDED') {
const { payload } = action;
const { id, data } = payload;
const { conversationLookup } = state;
return {
...state,
conversationLookup: {
...conversationLookup,
[id]: data,
},
};
}
if (action.type === 'CONVERSATION_CHANGED') {
const { payload } = action;
const { id, data } = payload;
const { conversationLookup, selectedConversation } = state;
2019-03-12 01:20:16 +01:00
const existing = conversationLookup[id];
2019-01-14 22:49:58 +01:00
// In the change case we only modify the lookup if we already had that conversation
2019-03-12 01:20:16 +01:00
if (!existing) {
2019-01-14 22:49:58 +01:00
return state;
}
return {
...state,
2019-03-12 01:20:16 +01:00
selectedConversation,
2019-01-14 22:49:58 +01:00
conversationLookup: {
...conversationLookup,
[id]: data,
},
};
}
if (action.type === 'CONVERSATION_REMOVED') {
const { payload } = action;
const { id } = payload;
const { conversationLookup, selectedConversation } = state;
2019-01-14 22:49:58 +01:00
return {
...state,
conversationLookup: omit(conversationLookup, [id]),
2021-04-22 10:03:58 +02:00
selectedConversation: selectedConversation === id ? undefined : selectedConversation,
2019-01-14 22:49:58 +01:00
};
}
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
return getEmptyState();
}
2019-03-12 01:20:16 +01:00
if (action.type === 'SELECTED_CONVERSATION_CHANGED') {
const { payload } = action;
const { id } = payload;
const oldSelectedConversation = state.selectedConversation;
const newSelectedConversation = id;
if (newSelectedConversation !== oldSelectedConversation) {
// empty the message list
return {
...state,
messages: [],
selectedConversation: id,
};
}
2019-03-12 01:20:16 +01:00
return {
...state,
selectedConversation: id,
};
}
// this is called once the messages are loaded from the db for the currently selected conversation
2020-11-16 04:45:13 +01:00
if (action.type === fetchMessagesForConversation.fulfilled.type) {
const { messages, conversationKey } = action.payload as any;
// double check that this update is for the shown convo
if (conversationKey === state.selectedConversation) {
2021-04-22 10:03:58 +02:00
const lightMessages = messages.map((m: any) => _.pick(m, toPickFromMessageModel)) as Array<
MessageTypeInConvo
>;
2020-11-16 04:45:13 +01:00
return {
...state,
messages: lightMessages,
};
}
return state;
}
if (action.type === 'MESSAGE_CHANGED') {
return handleMessageChanged(state, action);
2020-11-16 04:45:13 +01:00
}
if (action.type === 'MESSAGES_CHANGED') {
return handleMessagesChanged(state, action);
}
2020-11-16 04:45:13 +01:00
if (action.type === 'MESSAGE_ADDED') {
return handleMessageAdded(state, action);
2020-11-16 04:45:13 +01:00
}
if (action.type === 'MESSAGE_EXPIRED' || action.type === 'MESSAGE_DELETED') {
return handleMessageExpiredOrDeleted(state, action);
2020-11-17 03:30:24 +01:00
}
if (action.type === 'CONVERSATION_RESET') {
return handleConversationReset(state, action);
}
2019-01-14 22:49:58 +01:00
return state;
}