split conversation reducer for convo logic

also
- fix ordering of messages for open groups, when they are added
- fix the way the firstMessageOfSeries for just created clsoed groups
This commit is contained in:
Audric Ackermann 2021-01-07 10:51:20 +11:00
parent 1aa165c261
commit 9048913332
No known key found for this signature in database
GPG key ID: 999F434D76324AD4
4 changed files with 458 additions and 396 deletions

View file

@ -325,6 +325,7 @@ export class SessionMessagesList extends React.Component<Props, State> {
messageProps.i18n = window.i18n;
messageProps.selected = selected;
messageProps.firstMessageOfSeries = firstMessageOfSeries;
messageProps.multiSelectMode = multiSelectMode;
messageProps.onSelectMessage = this.props.selectMessage;
messageProps.onDeleteMessage = this.props.deleteMessage;

View file

@ -1,336 +1,339 @@
import {
ConversationAttributes,
ConversationModel,
ConversationAttributes,
ConversationModel,
} from '../../../js/models/conversations';
import { BlockedNumberController } from '../../util';
// It's not only data from the db which is stored on the MessageController entries, we could fetch this again. What we cannot fetch from the db and which is stored here is all listeners a particular messages is linked to for instance. We will be able to get rid of this once we don't use backbone models at all
export class ConversationController {
private static instance: ConversationController | null;
private readonly conversations: any;
private _initialFetchComplete: boolean = false;
private _initialPromise?: Promise<any>;
private static instance: ConversationController | null;
private readonly conversations: any;
private _initialFetchComplete: boolean = false;
private _initialPromise?: Promise<any>;
private constructor() {
this.conversations = new window.Whisper.ConversationCollection();
private constructor() {
this.conversations = new window.Whisper.ConversationCollection();
}
public static getInstance() {
if (ConversationController.instance) {
return ConversationController.instance;
}
ConversationController.instance = new ConversationController();
return ConversationController.instance;
}
public get(id: string): ConversationModel {
if (!this._initialFetchComplete) {
throw new Error(
'ConversationController.get() needs complete initial fetch'
);
}
public static getInstance() {
if (ConversationController.instance) {
return ConversationController.instance;
}
ConversationController.instance = new ConversationController();
return ConversationController.instance;
return this.conversations.get(id);
}
public getOrThrow(id: string) {
if (!this._initialFetchComplete) {
throw new Error(
'ConversationController.get() needs complete initial fetch'
);
}
public get(id: string): ConversationModel {
if (!this._initialFetchComplete) {
throw new Error(
'ConversationController.get() needs complete initial fetch'
);
}
const convo = this.conversations.get(id);
return this.conversations.get(id);
if (convo) {
return convo;
}
throw new Error(
`Conversation ${id} does not exist on ConversationController.get()`
);
}
// Needed for some model setup which happens during the initial fetch() call below
public getUnsafe(id: string) {
return this.conversations.get(id);
}
public dangerouslyCreateAndAdd(attributes: ConversationAttributes) {
return this.conversations.add(attributes);
}
public getOrCreate(id: string, type: string) {
if (typeof id !== 'string') {
throw new TypeError("'id' must be a string");
}
public getOrThrow(id: string) {
if (!this._initialFetchComplete) {
throw new Error(
'ConversationController.get() needs complete initial fetch'
);
}
if (type !== 'private' && type !== 'group') {
throw new TypeError(
`'type' must be 'private' or 'group'; got: '${type}'`
);
}
const convo = this.conversations.get(id);
if (!this._initialFetchComplete) {
throw new Error(
'ConversationController.get() needs complete initial fetch'
);
}
if (convo) {
return convo;
}
throw new Error(
`Conversation ${id} does not exist on ConversationController.get()`
let conversation = this.conversations.get(id);
if (conversation) {
return conversation;
}
conversation = this.conversations.add({
id,
type,
version: 2,
} as any);
const create = async () => {
if (!conversation.isValid()) {
const validationError = conversation.validationError || {};
window.log.error(
'Contact is not valid. Not saving, but adding to collection:',
conversation.idForLogging(),
validationError.stack
);
}
// Needed for some model setup which happens during the initial fetch() call below
public getUnsafe(id: string) {
return this.conversations.get(id);
}
public dangerouslyCreateAndAdd(attributes: ConversationAttributes) {
return this.conversations.add(attributes);
}
public getOrCreate(id: string, type: string) {
if (typeof id !== 'string') {
throw new TypeError("'id' must be a string");
}
if (type !== 'private' && type !== 'group') {
throw new TypeError(
`'type' must be 'private' or 'group'; got: '${type}'`
);
}
if (!this._initialFetchComplete) {
throw new Error(
'ConversationController.get() needs complete initial fetch'
);
}
let conversation = this.conversations.get(id);
if (conversation) {
return conversation;
}
conversation = this.conversations.add({
id,
type,
version: 2,
} as any);
const create = async () => {
if (!conversation.isValid()) {
const validationError = conversation.validationError || {};
window.log.error(
'Contact is not valid. Not saving, but adding to collection:',
conversation.idForLogging(),
validationError.stack
);
return conversation;
}
try {
await window.Signal.Data.saveConversation(conversation.attributes, {
Conversation: window.Whisper.Conversation,
});
} catch (error) {
window.log.error(
'Conversation save failed! ',
id,
type,
'Error:',
error && error.stack ? error.stack : error
);
throw error;
}
return conversation;
};
conversation.initialPromise = create();
conversation.initialPromise.then(async () => {
if (!conversation.isPublic() && !conversation.isRss()) {
await Promise.all([
conversation.updateProfileAvatar(),
// NOTE: we request snodes updating the cache, but ignore the result
window.SnodePool.getSnodesFor(id),
]);
}
if (window.inboxStore) {
conversation.on('change', this.updateReduxConvoChanged);
window.inboxStore.dispatch(
window.actionsCreators.conversationAdded(
conversation.id,
conversation.getProps()
)
);
}
});
return conversation;
}
}
public getContactProfileNameOrShortenedPubKey(pubKey: string): string {
const conversation = ConversationController.getInstance().get(pubKey);
if (!conversation) {
return pubKey;
}
return conversation.getContactProfileNameOrShortenedPubKey();
}
public getContactProfileNameOrFullPubKey(pubKey: string): string {
const conversation = this.conversations.get(pubKey);
if (!conversation) {
return pubKey;
}
return conversation.getContactProfileNameOrFullPubKey();
}
public isMediumGroup(hexEncodedGroupPublicKey: string): boolean {
const convo = this.conversations.get(hexEncodedGroupPublicKey);
if (convo) {
return convo.isMediumGroup();
}
return false;
}
public async getOrCreateAndWait(id: any, type: string) {
const initialPromise = this._initialPromise !== undefined ? this._initialPromise : Promise.resolve();
return initialPromise.then(() => {
if (!id) {
return Promise.reject(
new Error('getOrCreateAndWait: invalid id passed.')
);
}
const pubkey = id && id.key ? id.key : id;
const conversation = this.getOrCreate(pubkey, type);
if (conversation) {
return conversation.initialPromise.then(() => conversation);
}
return Promise.reject(
new Error('getOrCreateAndWait: did not get conversation')
);
try {
await window.Signal.Data.saveConversation(conversation.attributes, {
Conversation: window.Whisper.Conversation,
});
} catch (error) {
window.log.error(
'Conversation save failed! ',
id,
type,
'Error:',
error && error.stack ? error.stack : error
);
throw error;
}
return conversation;
};
conversation.initialPromise = create();
conversation.initialPromise.then(async () => {
if (window.inboxStore) {
conversation.on('change', this.updateReduxConvoChanged);
window.inboxStore.dispatch(
window.actionsCreators.conversationAdded(
conversation.id,
conversation.getProps()
)
);
}
if (!conversation.isPublic() && !conversation.isRss()) {
await Promise.all([
conversation.updateProfileAvatar(),
// NOTE: we request snodes updating the cache, but ignore the result
window.SnodePool.getSnodesFor(id),
]);
}
});
return conversation;
}
public getContactProfileNameOrShortenedPubKey(pubKey: string): string {
const conversation = ConversationController.getInstance().get(pubKey);
if (!conversation) {
return pubKey;
}
return conversation.getContactProfileNameOrShortenedPubKey();
}
public getContactProfileNameOrFullPubKey(pubKey: string): string {
const conversation = this.conversations.get(pubKey);
if (!conversation) {
return pubKey;
}
return conversation.getContactProfileNameOrFullPubKey();
}
public isMediumGroup(hexEncodedGroupPublicKey: string): boolean {
const convo = this.conversations.get(hexEncodedGroupPublicKey);
if (convo) {
return convo.isMediumGroup();
}
return false;
}
public async getOrCreateAndWait(id: any, type: string) {
const initialPromise =
this._initialPromise !== undefined
? this._initialPromise
: Promise.resolve();
return initialPromise.then(() => {
if (!id) {
return Promise.reject(
new Error('getOrCreateAndWait: invalid id passed.')
);
}
const pubkey = id && id.key ? id.key : id;
const conversation = this.getOrCreate(pubkey, type);
if (conversation) {
return conversation.initialPromise.then(() => conversation);
}
return Promise.reject(
new Error('getOrCreateAndWait: did not get conversation')
);
});
}
public async getAllGroupsInvolvingId(id: String) {
const groups = await window.Signal.Data.getAllGroupsInvolvingId(id, {
ConversationCollection: window.Whisper.ConversationCollection,
});
return groups.map((group: any) => this.conversations.add(group));
}
public async deleteContact(id: string) {
if (typeof id !== 'string') {
throw new TypeError("'id' must be a string");
}
public async getAllGroupsInvolvingId(id: String) {
const groups = await window.Signal.Data.getAllGroupsInvolvingId(id, {
ConversationCollection: window.Whisper.ConversationCollection,
if (!this._initialFetchComplete) {
throw new Error(
'ConversationController.get() needs complete initial fetch'
);
}
const conversation = this.conversations.get(id);
if (!conversation) {
return;
}
// Close group leaving
if (conversation.isClosedGroup()) {
await conversation.leaveGroup();
} else if (conversation.isPublic()) {
const channelAPI = await conversation.getPublicSendData();
if (channelAPI === null) {
window.log.warn(`Could not get API for public conversation ${id}`);
} else {
channelAPI.serverAPI.partChannel(channelAPI.channelId);
}
} else if (conversation.isPrivate()) {
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds(
id
);
await Promise.all(
deviceIds.map((deviceId: string) => {
const address = new window.libsignal.SignalProtocolAddress(
id,
deviceId
);
const sessionCipher = new window.libsignal.SessionCipher(
window.textsecure.storage.protocol,
address
);
return sessionCipher.deleteAllSessionsForDevice();
})
);
}
await conversation.destroyMessages();
await window.Signal.Data.removeConversation(id, {
Conversation: window.Whisper.Conversation,
});
conversation.off('change', this.updateReduxConvoChanged);
this.conversations.remove(conversation);
if (window.inboxStore) {
window.inboxStore.dispatch(
window.actionsCreators.conversationRemoved(conversation.id)
);
}
}
public getConversations(): Array<ConversationModel> {
return Array.from(this.conversations.models.values());
}
public async load() {
window.log.info('ConversationController: starting initial fetch');
if (this.conversations.length) {
throw new Error('ConversationController: Already loaded!');
}
const load = async () => {
try {
const collection = await window.Signal.Data.getAllConversations({
ConversationCollection: window.Whisper.ConversationCollection,
});
return groups.map((group: any) => this.conversations.add(group));
}
public async deleteContact(id: string) {
if (typeof id !== 'string') {
throw new TypeError("'id' must be a string");
}
this.conversations.add(collection.models);
if (!this._initialFetchComplete) {
throw new Error(
'ConversationController.get() needs complete initial fetch'
);
}
this._initialFetchComplete = true;
const promises: any = [];
this.conversations.forEach((conversation: ConversationModel) => {
if (!conversation.get('lastMessage')) {
// tslint:disable-next-line: no-void-expression
promises.push(conversation.updateLastMessage());
}
const conversation = this.conversations.get(id);
if (!conversation) {
return;
}
// Close group leaving
if (conversation.isClosedGroup()) {
await conversation.leaveGroup();
} else if (conversation.isPublic()) {
const channelAPI = await conversation.getPublicSendData();
if (channelAPI === null) {
window.log.warn(`Could not get API for public conversation ${id}`);
} else {
channelAPI.serverAPI.partChannel(channelAPI.channelId);
}
} else if (conversation.isPrivate()) {
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds(
id
);
await Promise.all(
deviceIds.map((deviceId: string) => {
const address = new window.libsignal.SignalProtocolAddress(
id,
deviceId
);
const sessionCipher = new window.libsignal.SessionCipher(
window.textsecure.storage.protocol,
address
);
return sessionCipher.deleteAllSessionsForDevice();
})
);
}
await conversation.destroyMessages();
await window.Signal.Data.removeConversation(id, {
Conversation: window.Whisper.Conversation,
promises.concat([
conversation.updateProfileName(),
conversation.updateProfileAvatar(),
]);
});
conversation.off('change', this.updateReduxConvoChanged);
this.conversations.remove(conversation);
if (window.inboxStore) {
window.inboxStore.dispatch(
window.actionsCreators.conversationRemoved(conversation.id)
);
}
this.conversations.forEach((conversation: ConversationModel) => {
// register for change event on each conversation, and forward to redux
conversation.on('change', this.updateReduxConvoChanged);
});
await Promise.all(promises);
// Remove any unused images
window.profileImages.removeImagesNotInArray(
this.conversations.map((c: any) => c.id)
);
window.log.info('ConversationController: done with initial fetch');
} catch (error) {
window.log.error(
'ConversationController: initial fetch failed',
error && error.stack ? error.stack : error
);
throw error;
}
};
await BlockedNumberController.load();
this._initialPromise = load();
return this._initialPromise;
}
public loadPromise() {
return this._initialPromise;
}
public reset() {
this._initialPromise = Promise.resolve();
this._initialFetchComplete = false;
if (window.inboxStore) {
this.conversations.forEach((convo: ConversationModel) =>
convo.off('change', this.updateReduxConvoChanged)
);
window.inboxStore.dispatch(
window.actionsCreators.removeAllConversations()
);
}
this.conversations.reset([]);
}
public getConversations(): Array<ConversationModel> {
return Array.from(this.conversations.models.values());
}
public async load() {
window.log.info('ConversationController: starting initial fetch');
if (this.conversations.length) {
throw new Error('ConversationController: Already loaded!');
}
const load = async () => {
try {
const collection = await window.Signal.Data.getAllConversations({
ConversationCollection: window.Whisper.ConversationCollection,
});
this.conversations.add(collection.models);
this._initialFetchComplete = true;
const promises: any = [];
this.conversations.forEach((conversation: ConversationModel) => {
if (!conversation.get('lastMessage')) {
// tslint:disable-next-line: no-void-expression
promises.push(conversation.updateLastMessage());
}
promises.concat([
conversation.updateProfileName(),
conversation.updateProfileAvatar(),
]);
});
this.conversations.forEach((conversation: ConversationModel) => {
// register for change event on each conversation, and forward to redux
conversation.on('change', this.updateReduxConvoChanged);
});
await Promise.all(promises);
// Remove any unused images
window.profileImages.removeImagesNotInArray(
this.conversations.map((c: any) => c.id)
);
window.log.info('ConversationController: done with initial fetch');
} catch (error) {
window.log.error(
'ConversationController: initial fetch failed',
error && error.stack ? error.stack : error
);
throw error;
}
};
await BlockedNumberController.load();
this._initialPromise = load();
return this._initialPromise;
}
public loadPromise() {
return this._initialPromise;
}
public reset() {
this._initialPromise = Promise.resolve();
this._initialFetchComplete = false;
if (window.inboxStore) {
this.conversations.forEach((convo: ConversationModel) =>
convo.off('change', this.updateReduxConvoChanged)
);
window.inboxStore.dispatch(
window.actionsCreators.removeAllConversations()
);
}
this.conversations.reset([]);
}
private updateReduxConvoChanged(convo: ConversationModel) {
if (window.inboxStore) {
window.inboxStore.dispatch(
window.actionsCreators.conversationChanged(convo.id, convo.getProps())
);
}
private updateReduxConvoChanged(convo: ConversationModel) {
if (window.inboxStore) {
window.inboxStore.dispatch(
window.actionsCreators.conversationChanged(convo.id, convo.getProps())
);
}
}
}

View file

@ -4,6 +4,7 @@ import { Constants } from '../../session';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { MessageModel } from '../../../js/models/messages';
import { ConversationController } from '../../session/conversations';
import { StateType } from '../reducer';
// State
@ -122,11 +123,18 @@ async function getMessages(
// Set first member of series here.
const messageModels = messageSet.models;
const isPublic = conversation.isPublic();
const sortedMessage = sortMessages(messageModels, isPublic);
// no need to do that `firstMessageOfSeries` on a private chat
if (conversation.isPrivate()) {
return messageModels;
return sortedMessage;
}
return updateFirstMessageOfSeries(sortedMessage);
}
const updateFirstMessageOfSeries = (messageModels: Array<any>) => {
// 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
@ -138,13 +146,13 @@ async function getMessages(
i < messageModels.length - 1
? messageModels[i + 1].propsForMessage?.authorPhoneNumber
: undefined;
if (i > 0 && currentSender === nextSender) {
if (i >= 0 && currentSender === nextSender) {
firstMessageOfSeries = false;
}
messageModels[i].firstMessageOfSeries = firstMessageOfSeries;
}
return messageModels;
}
};
const fetchMessagesForConversation = createAsyncThunk(
'messages/fetchByConversationKey',
@ -422,6 +430,138 @@ function getEmptyState(): ConversationsStateType {
};
}
function sortMessages(
messages: Array<MessageTypeInConvo>,
isPublic: boolean
): Array<MessageTypeInConvo> {
// we order by serverTimestamp for public convos
if (isPublic) {
return messages.sort(
(a: any, b: any) =>
b.attributes.serverTimestamp - a.attributes.serverTimestamp
);
}
return messages.sort(
(a: any, b: any) => b.attributes.timestamp - a.attributes.timestamp
);
}
function handleMessageAdded(
state: ConversationsStateType,
action: MessageAddedActionType
) {
const { messages } = state;
const { conversationKey, messageModel } = action.payload;
if (conversationKey === state.selectedConversation) {
const addedMessage = _.pick(
messageModel as any,
toPickFromMessageModel
) as MessageTypeInConvo;
const messagesWithNewMessage = [...messages, addedMessage];
const convo = state.conversationLookup[state.selectedConversation];
const isPublic = convo?.isPublic || false;
if (convo) {
const sortedMessage = sortMessages(messagesWithNewMessage, isPublic);
const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeries(
sortedMessage
);
return {
...state,
messages: updatedWithFirstMessageOfSeries,
};
}
}
return state;
}
function handleMessageChanged(
state: ConversationsStateType,
action: MessageChangedActionType
) {
const messageInStoreIndex = state?.messages?.findIndex(
m => m.id === action.payload.id
);
if (messageInStoreIndex >= 0) {
const changedMessage = _.pick(
action.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),
];
// reorder the messages depending on the timestamp (we might have an updated serverTimestamp now)
const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeries(
editedMessages
);
return {
...state,
messages: updatedWithFirstMessageOfSeries,
};
}
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
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),
];
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;
}
// tslint:disable: cyclomatic-complexity
// tslint:disable: max-func-body-length
export function reducer(
@ -492,6 +632,7 @@ export function reducer(
const { id } = payload;
const oldSelectedConversation = state.selectedConversation;
const newSelectedConversation = id;
if (newSelectedConversation !== oldSelectedConversation) {
// empty the message list
return {
@ -505,6 +646,8 @@ export function reducer(
selectedConversation: id,
};
}
// this is called once the messages are loaded from the db for the currently selected conversation
if (action.type === fetchMessagesForConversation.fulfilled.type) {
const { messages, conversationKey } = action.payload as any;
// double check that this update is for the shown convo
@ -521,102 +664,18 @@ export function reducer(
}
if (action.type === 'MESSAGE_CHANGED') {
const messageInStoreIndex = state?.messages?.findIndex(
m => m.id === action.payload.id
);
if (messageInStoreIndex >= 0) {
const changedMessage = _.pick(
action.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),
];
return {
...state,
messages: editedMessages,
};
}
return state;
return handleMessageChanged(state, action);
}
if (action.type === 'MESSAGE_ADDED') {
const { conversationKey, messageModel } = action.payload;
if (conversationKey === state.selectedConversation) {
const { messages } = state;
const addedMessage = _.pick(
messageModel as any,
toPickFromMessageModel
) as MessageTypeInConvo;
const messagesWithNewMessage = [...messages, addedMessage];
const convo = state.conversationLookup[state.selectedConversation];
const isPublic = convo?.isPublic;
if (convo && isPublic) {
return {
...state,
messages: messagesWithNewMessage.sort(
(a: any, b: any) =>
b.attributes.serverTimestamp - a.attributes.serverTimestamp
),
};
}
if (convo) {
return {
...state,
messages: messagesWithNewMessage.sort(
(a, b) => b.attributes.timestamp - a.attributes.timestamp
),
};
}
}
return state;
return handleMessageAdded(state, action);
}
if (action.type === 'MESSAGE_EXPIRED' || action.type === 'MESSAGE_DELETED') {
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.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),
];
// 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 one one counted as an unread
return {
...state,
messages: editedMessages,
};
}
return state;
}
return state;
return handleMessageExpiredOrDeleted(state, action);
}
if (action.type === 'CONVERSATION_RESET') {
const { conversationKey } = action.payload;
if (conversationKey === state.selectedConversation) {
// just empty the list of messages
return {
...state,
messages: [],
};
}
return state;
return handleConversationReset(state, action);
}
return state;

View file

@ -9,7 +9,6 @@ const mapStateToProps = (state: StateType) => {
(conversationKey &&
state.conversations.conversationLookup[conversationKey]) ||
null;
return {
conversation,
conversationKey,