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

779 lines
22 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, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { getConversationController } from '../../session/conversations';
2021-02-15 05:16:38 +01:00
import { getMessagesByConversation } from '../../data/data';
import {
ConversationNotificationSettingType,
ConversationTypeEnum,
} from '../../models/conversation';
import {
MessageDeliveryStatus,
MessageModelType,
2021-07-13 09:00:20 +02:00
MessageRegularProps,
PropsForDataExtractionNotification,
} from '../../models/messageType';
import { NotificationForConvoOption } from '../../components/conversation/ConversationHeader';
2021-07-14 05:48:59 +02:00
import { LightBoxOptions } from '../../components/session/conversation/SessionConversation';
import { ReplyingToMessageProps } from '../../components/session/conversation/SessionCompositionBox';
export type MessageModelProps = {
propsForMessage: PropsForMessage;
propsForSearchResult: PropsForSearchResults | null;
propsForGroupInvitation: PropsForGroupInvitation | null;
propsForTimerNotification: PropsForExpirationTimer | null;
propsForDataExtractionNotification: PropsForDataExtractionNotification | null;
propsForGroupNotification: PropsForGroupUpdate | null;
2019-01-14 22:49:58 +01:00
};
2020-11-16 04:45:13 +01:00
2021-07-13 09:00:20 +02:00
export type ContactPropsMessageDetail = {
status: string | null;
phoneNumber: string;
name?: string | null;
profileName?: string | null;
avatarPath?: string | null;
isOutgoingKeyError: boolean;
errors?: Array<Error>;
};
export type MessagePropsDetails = {
sentAt: number;
receivedAt: number;
message: MessageRegularProps;
errors: Array<Error>;
contacts: Array<ContactPropsMessageDetail>;
};
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;
isMe: boolean;
2021-07-05 03:23:47 +02:00
};
export type PropsForExpirationTimer = {
timespan: string;
2021-07-05 03:23:47 +02:00
disabled: boolean;
phoneNumber: string;
avatarPath: string | null;
name: string | null;
profileName: string | null;
title: string | null;
type: 'fromMe' | 'fromSync' | 'fromOther';
};
2021-07-05 03:23:47 +02:00
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 PropsForGroupUpdateType =
2021-07-05 03:23:47 +02:00
| PropsForGroupUpdateGeneral
| PropsForGroupUpdateAdd
| PropsForGroupUpdateKicked
| PropsForGroupUpdateName
| PropsForGroupUpdateRemove;
export type PropsForGroupUpdateArray = Array<PropsForGroupUpdateType>;
2021-07-05 03:23:47 +02:00
export type PropsForGroupUpdate = {
changes: PropsForGroupUpdateArray;
};
export type PropsForGroupInvitation = {
serverName: string;
url: string;
direction: MessageModelType;
acceptUrl: string;
messageId: string;
};
export type PropsForSearchResults = {
from: FindAndFormatContactType;
to: FindAndFormatContactType;
id: string;
conversationId: string;
receivedAt: number | undefined;
snippet?: string; //not sure about the type of snippet
};
export type PropsForAttachment = {
id?: string;
contentType: string;
size: number;
width?: number;
height?: number;
url: string;
path?: string;
fileSize: string | null;
isVoiceMessage: boolean;
pending: boolean;
fileName: string;
screenshot: {
contentType: string;
width: number;
height: number;
url?: string;
path?: string;
} | null;
thumbnail: {
contentType: string;
width: number;
height: number;
url?: string;
path?: string;
} | null;
};
export type PropsForMessage = {
text: string | null;
id: string;
direction: MessageModelType;
2021-07-08 08:43:32 +02:00
timestamp: number;
receivedAt: number | undefined;
serverTimestamp: number | undefined;
2021-07-06 06:22:22 +02:00
serverId: number | undefined;
status: LastMessageStatusType;
authorName: string | null;
authorProfileName: string | null;
authorPhoneNumber: string;
conversationType: ConversationTypeEnum;
convoId: string;
attachments: Array<PropsForAttachment>;
previews: any;
quote: any;
authorAvatarPath: string | null;
isUnread: boolean;
expirationLength: number;
expirationTimestamp: number | null;
isPublic: boolean;
isOpenGroupV2: boolean;
isKickedFromGroup: boolean | undefined;
isTrustedForAttachmentDownload: boolean;
weAreAdmin: boolean;
isSenderAdmin: boolean;
isDeletable: boolean;
2021-07-08 08:43:32 +02:00
isExpired: boolean;
isBlocked: boolean;
};
2021-07-05 03:23:47 +02:00
export type LastMessageType = {
status: LastMessageStatusType;
text: string | null;
};
export interface ReduxConversationType {
2019-01-14 22:49:58 +01:00
id: string;
name?: string;
2021-01-29 01:29:24 +01:00
profileName?: string;
hasNickname: boolean;
2021-01-29 01:29:24 +01:00
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;
isGroup: boolean;
isPrivate: boolean;
weAreAdmin: boolean;
2019-01-14 22:49:58 +01:00
unreadCount: number;
mentionedUs: boolean;
2019-01-14 22:49:58 +01:00
isSelected: boolean;
expireTimer: number;
2021-01-29 01:29:24 +01:00
2019-01-14 22:49:58 +01:00
isTyping: boolean;
isBlocked: boolean;
isKickedFromGroup: boolean;
subscriberCount: number;
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
currentNotificationSetting: ConversationNotificationSettingType;
notificationForConvo: Array<NotificationForConvoOption>;
2021-07-06 09:16:05 +02:00
isPinned: boolean;
2021-01-29 01:29:24 +01:00
}
2019-01-14 22:49:58 +01:00
export type ConversationLookupType = {
[key: string]: ReduxConversationType;
2019-01-14 22:49:58 +01:00
};
export type ConversationsStateType = {
conversationLookup: ConversationLookupType;
selectedConversation?: string;
messages: Array<SortedMessageModelProps>;
messageDetailProps: MessagePropsDetails | undefined;
showRightPanel: boolean;
selectedMessageIds: Array<string>;
2021-07-14 05:48:59 +02:00
lightBox?: LightBoxOptions;
quotedMessage?: ReplyingToMessageProps;
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<SortedMessageModelProps>> {
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.
2021-07-05 09:06:34 +02:00
const messageModelsProps: Array<SortedMessageModelProps> = [];
messageSet.models.forEach(m => {
messageModelsProps.push({ ...m.getProps(), firstMessageOfSeries: true });
});
2020-11-16 04:45:13 +01:00
const isPublic = conversation.isPublic();
const sortedMessageProps = sortMessages(messageModelsProps, isPublic);
2020-11-16 04:45:13 +01:00
// no need to do that `firstMessageOfSeries` on a private chat
if (conversation.isPrivate()) {
return sortedMessageProps;
2020-11-16 04:45:13 +01:00
}
2021-07-14 03:30:31 +02:00
return updateFirstMessageOfSeriesAndUnread(sortedMessageProps);
}
2020-11-16 04:45:13 +01:00
export type SortedMessageModelProps = MessageModelProps & {
firstMessageOfSeries: boolean;
2021-07-14 03:30:31 +02:00
firstUnread?: boolean;
};
2021-07-14 03:30:31 +02:00
const updateFirstMessageOfSeriesAndUnread = (
messageModelsProps: Array<SortedMessageModelProps>
): Array<SortedMessageModelProps> => {
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
const sortedMessageProps: Array<SortedMessageModelProps> = [];
2021-07-14 03:30:31 +02:00
const firstUnreadIndex = getFirstMessageUnreadIndex(messageModelsProps);
for (let i = 0; i < messageModelsProps.length; i++) {
2020-11-16 04:45:13 +01:00
// Handle firstMessageOfSeries for conditional avatar rendering
let firstMessageOfSeries = true;
2021-07-14 03:30:31 +02:00
let firstUnread = false;
const currentSender = messageModelsProps[i].propsForMessage?.authorPhoneNumber;
2020-11-16 04:45:13 +01:00
const nextSender =
i < messageModelsProps.length - 1
? messageModelsProps[i + 1].propsForMessage?.authorPhoneNumber
2020-11-16 04:45:13 +01:00
: undefined;
if (i >= 0 && currentSender === nextSender) {
2020-11-16 04:45:13 +01:00
firstMessageOfSeries = false;
}
2021-07-14 03:30:31 +02:00
if (i === firstUnreadIndex) {
firstUnread = true;
}
2021-07-14 03:30:31 +02:00
sortedMessageProps.push({ ...messageModelsProps[i], firstMessageOfSeries, firstUnread });
2020-11-16 04:45:13 +01:00
}
return sortedMessageProps;
};
type FetchedMessageResults = {
conversationKey: string;
messagesProps: Array<SortedMessageModelProps>;
};
2020-11-16 04:45:13 +01:00
2021-07-14 03:30:31 +02:00
const getFirstMessageUnreadIndex = (messages: Array<SortedMessageModelProps>) => {
if (!messages || messages.length === 0) {
return -1;
}
// iterate over the incoming messages from the oldest one. the first one with isUnread !== undefined is our first unread
for (let index = messages.length - 1; index > 0; index--) {
const message = messages[index];
if (
message.propsForMessage.direction === 'incoming' &&
message.propsForMessage.isUnread === true
) {
console.warn('message.propsForMessage', message.propsForMessage);
return index;
}
}
return -1;
};
2021-07-08 08:43:32 +02:00
export const fetchMessagesForConversation = createAsyncThunk(
2020-11-16 04:45:13 +01:00
'messages/fetchByConversationKey',
async ({
conversationKey,
count,
}: {
conversationKey: string;
count: number;
}): Promise<FetchedMessageResults> => {
const beforeTimestamp = Date.now();
const messagesProps = await getMessages(conversationKey, count);
2021-07-14 03:30:31 +02:00
const firstUnreadIndex = getFirstMessageUnreadIndex(messagesProps);
const afterTimestamp = Date.now();
const time = afterTimestamp - beforeTimestamp;
window?.log?.info(`Loading ${messagesProps.length} messages took ${time}ms to load.`);
2021-07-14 03:30:31 +02:00
const mapped = messagesProps.map((m, index) => {
if (index === firstUnreadIndex) {
console.warn('fullfuled firstUnreadIndex', firstUnreadIndex);
return {
...m,
firstMessageOfSeries: true,
firstUnread: true,
};
}
2021-07-05 09:06:34 +02:00
return {
...m,
firstMessageOfSeries: true,
2021-07-14 03:30:31 +02:00
firstUnread: false,
2021-07-05 09:06:34 +02:00
};
});
2020-11-16 04:45:13 +01:00
return {
conversationKey,
2021-07-05 09:06:34 +02:00
messagesProps: mapped,
2020-11-16 04:45:13 +01:00
};
}
);
2019-01-14 22:49:58 +01:00
// Reducer
function getEmptyState(): ConversationsStateType {
return {
conversationLookup: {},
2020-11-16 04:45:13 +01:00
messages: [],
messageDetailProps: undefined,
showRightPanel: false,
selectedMessageIds: [],
2019-01-14 22:49:58 +01:00
};
}
function sortMessages(
messages: Array<SortedMessageModelProps>,
isPublic: boolean
): Array<SortedMessageModelProps> {
// 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) {
2021-07-05 09:06:34 +02:00
return messages.sort((a, b) => {
return (b.propsForMessage.serverTimestamp || 0) - (a.propsForMessage.serverTimestamp || 0);
});
}
if (messages.some(n => !n.propsForMessage.timestamp && !n.propsForMessage.receivedAt)) {
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(
2021-07-05 09:06:34 +02:00
(a, b) =>
(b.propsForMessage.timestamp || b.propsForMessage.receivedAt || 0) -
(a.propsForMessage.timestamp || a.propsForMessage.receivedAt || 0)
);
return messagesSorted;
}
function handleMessageAdded(
state: ConversationsStateType,
action: PayloadAction<{
conversationKey: string;
messageModelProps: MessageModelProps;
}>
) {
const { messages } = state;
const { conversationKey, messageModelProps: addedMessageProps } = action.payload;
if (conversationKey === state.selectedConversation) {
const messagesWithNewMessage = [
...messages,
{ ...addedMessageProps, firstMessageOfSeries: true },
];
const convo = state.conversationLookup[state.selectedConversation];
const isPublic = convo?.isPublic || false;
if (convo) {
const sortedMessage = sortMessages(messagesWithNewMessage, isPublic);
2021-07-14 03:30:31 +02:00
const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeriesAndUnread(sortedMessage);
return {
...state,
messages: updatedWithFirstMessageOfSeries,
};
}
}
return state;
}
function handleMessageChanged(state: ConversationsStateType, payload: MessageModelProps) {
const messageInStoreIndex = state?.messages?.findIndex(
m => m.propsForMessage.id === payload.propsForMessage.id
);
if (messageInStoreIndex >= 0) {
const changedMessage = { ...payload, firstMessageOfSeries: true };
// 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.propsForMessage.convoId];
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-07-14 03:30:31 +02:00
const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeriesAndUnread(sortedMessage);
return {
...state,
messages: updatedWithFirstMessageOfSeries,
};
}
return state;
}
function handleMessagesChanged(state: ConversationsStateType, payload: Array<MessageModelProps>) {
payload.forEach(element => {
// tslint:disable-next-line: no-parameter-reassignment
state = handleMessageChanged(state, element);
});
return state;
}
function handleMessageExpiredOrDeleted(
state: ConversationsStateType,
action: PayloadAction<{
messageId: string;
conversationKey: string;
}>
) {
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
const messageInStoreIndex = state?.messages.findIndex(m => m.propsForMessage.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-07-14 03:30:31 +02:00
const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeriesAndUnread(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: PayloadAction<string>) {
const conversationKey = action.payload;
if (conversationKey === state.selectedConversation) {
// just empty the list of messages
return {
...state,
messages: [],
};
}
return state;
}
const conversationsSlice = createSlice({
name: 'conversations',
initialState: getEmptyState(),
reducers: {
showMessageDetailsView(
state: ConversationsStateType,
action: PayloadAction<MessagePropsDetails>
) {
// force the right panel to be hidden when showing message detail view
return { ...state, messageDetailProps: action.payload, showRightPanel: false };
},
2019-01-14 22:49:58 +01:00
closeMessageDetailsView(state: ConversationsStateType) {
return { ...state, messageDetailProps: undefined };
},
2019-01-14 22:49:58 +01:00
openRightPanel(state: ConversationsStateType) {
return { ...state, showRightPanel: true };
},
closeRightPanel(state: ConversationsStateType) {
return { ...state, showRightPanel: false };
},
addMessageIdToSelection(state: ConversationsStateType, action: PayloadAction<string>) {
if (state.selectedMessageIds.some(id => id === action.payload)) {
return state;
}
return { ...state, selectedMessageIds: [...state.selectedMessageIds, action.payload] };
},
removeMessageIdFromSelection(state: ConversationsStateType, action: PayloadAction<string>) {
const index = state.selectedMessageIds.findIndex(id => id === action.payload);
if (index === -1) {
return state;
}
return { ...state, selectedMessageIds: state.selectedMessageIds.splice(index, 1) };
},
toggleSelectedMessageId(state: ConversationsStateType, action: PayloadAction<string>) {
const index = state.selectedMessageIds.findIndex(id => id === action.payload);
if (index === -1) {
2021-07-14 03:30:31 +02:00
state.selectedMessageIds = [...state.selectedMessageIds, action.payload];
} else {
state.selectedMessageIds.splice(index, 1);
}
2021-07-14 03:30:31 +02:00
return state;
},
resetSelectedMessageIds(state: ConversationsStateType) {
return { ...state, selectedMessageIds: [] };
},
conversationAdded(
state: ConversationsStateType,
action: PayloadAction<{
id: string;
data: ReduxConversationType;
}>
) {
const { conversationLookup } = state;
return {
...state,
conversationLookup: {
...conversationLookup,
[action.payload.id]: action.payload.data,
},
};
},
conversationChanged(
state: ConversationsStateType,
action: PayloadAction<{
id: string;
data: ReduxConversationType;
}>
) {
const { payload } = action;
const { id, data } = payload;
const { conversationLookup, selectedConversation } = state;
const existing = conversationLookup[id];
// In the change case we only modify the lookup if we already had that conversation
if (!existing) {
return state;
}
2020-11-16 04:45:13 +01:00
return {
...state,
selectedConversation,
conversationLookup: {
...conversationLookup,
[id]: data,
},
2020-11-16 04:45:13 +01:00
};
},
2020-11-16 04:45:13 +01:00
conversationRemoved(
state: ConversationsStateType,
action: PayloadAction<{
id: string;
}>
) {
const { payload } = action;
const { id } = payload;
const { conversationLookup, selectedConversation } = state;
return {
...state,
conversationLookup: omit(conversationLookup, [id]),
selectedConversation: selectedConversation === id ? undefined : selectedConversation,
};
},
2020-11-16 04:45:13 +01:00
removeAllConversations() {
return getEmptyState();
},
messageAdded(
state: ConversationsStateType,
action: PayloadAction<{
conversationKey: string;
messageModelProps: MessageModelProps;
}>
) {
return handleMessageAdded(state, action);
},
2020-11-17 03:30:24 +01:00
messageChanged(state: ConversationsStateType, action: PayloadAction<MessageModelProps>) {
return handleMessageChanged(state, action.payload);
},
messagesChanged(
state: ConversationsStateType,
action: PayloadAction<Array<MessageModelProps>>
) {
return handleMessagesChanged(state, action.payload);
},
messageExpired(
state: ConversationsStateType,
action: PayloadAction<{
messageId: string;
conversationKey: string;
}>
) {
return handleMessageExpiredOrDeleted(state, action);
},
messageDeleted(
state: ConversationsStateType,
action: PayloadAction<{
messageId: string;
conversationKey: string;
}>
) {
return handleMessageExpiredOrDeleted(state, action);
},
conversationReset(state: ConversationsStateType, action: PayloadAction<string>) {
return handleConversationReset(state, action);
},
openConversationExternal(
state: ConversationsStateType,
action: PayloadAction<{
id: string;
messageId?: string;
}>
) {
if (state.selectedConversation === action.payload.id) {
return state;
}
state.showRightPanel = false;
state.messageDetailProps = undefined;
state.selectedMessageIds = [];
state.selectedConversation = action.payload.id;
state.messages = [];
state.quotedMessage = undefined;
state.lightBox = undefined;
return state;
},
2021-07-14 05:48:59 +02:00
showLightBox(
state: ConversationsStateType,
action: PayloadAction<LightBoxOptions | undefined>
) {
state.lightBox = action.payload;
return state;
},
quoteMessage(
state: ConversationsStateType,
action: PayloadAction<ReplyingToMessageProps | undefined>
) {
state.quotedMessage = action.payload;
return state;
},
},
extraReducers: (builder: any) => {
// Add reducers for additional action types here, and handle loading state as needed
builder.addCase(
fetchMessagesForConversation.fulfilled,
(state: ConversationsStateType, action: any) => {
// this is called once the messages are loaded from the db for the currently selected conversation
const { messagesProps, conversationKey } = action.payload as FetchedMessageResults;
// double check that this update is for the shown convo
if (conversationKey === state.selectedConversation) {
return {
...state,
messages: messagesProps,
};
}
return state;
}
);
},
});
// destructures
export const { actions, reducer } = conversationsSlice;
export const {
// conversation and messages list
conversationAdded,
conversationChanged,
conversationRemoved,
removeAllConversations,
messageExpired,
messageAdded,
messageDeleted,
conversationReset,
messageChanged,
messagesChanged,
openConversationExternal,
// layout stuff
showMessageDetailsView,
closeMessageDetailsView,
openRightPanel,
closeRightPanel,
addMessageIdToSelection,
resetSelectedMessageIds,
2021-07-14 03:30:31 +02:00
toggleSelectedMessageId,
2021-07-14 05:48:59 +02:00
showLightBox,
quoteMessage,
} = actions;