signal-desktop/ts/services/MessageCache.ts
2024-04-17 09:00:29 +02:00

504 lines
15 KiB
TypeScript

// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import cloneDeep from 'lodash/cloneDeep';
import { throttle } from 'lodash';
import LRU from 'lru-cache';
import type { MessageAttributesType } from '../model-types.d';
import type { MessageModel } from '../models/messages';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { getEnvironment, Environment } from '../environment';
import { getMessageConversation } from '../util/getMessageConversation';
import { getMessageModelLogger } from '../util/MessageModelLogger';
import { getSenderIdentifier } from '../util/getSenderIdentifier';
import { isNotNil } from '../util/isNotNil';
import { map } from '../util/iterables';
import { softAssert, strictAssert } from '../util/assert';
import { isStory } from '../messages/helpers';
import type { SendStateByConversationId } from '../messages/MessageSendState';
import { getStoryDataFromMessageAttributes } from './storyLoader';
const MAX_THROTTLED_REDUX_UPDATERS = 200;
export class MessageCache {
private state = {
messages: new Map<string, MessageAttributesType>(),
messageIdsBySender: new Map<string, string>(),
messageIdsBySentAt: new Map<number, Array<string>>(),
lastAccessedAt: new Map<string, number>(),
};
// Stores the models so that __DEPRECATED$register always returns the existing
// copy instead of a new model.
private modelCache = new Map<string, MessageModel>();
// Synchronously access a message's attributes from internal cache. Will
// return undefined if the message does not exist in memory.
public accessAttributes(
messageId: string
): Readonly<MessageAttributesType> | undefined {
const messageAttributes = this.state.messages.get(messageId);
return messageAttributes
? this.freezeAttributes(messageAttributes)
: undefined;
}
// Synchronously access a message's attributes from internal cache. Throws
// if the message does not exist in memory.
public accessAttributesOrThrow(
source: string,
messageId: string
): Readonly<MessageAttributesType> {
const messageAttributes = this.accessAttributes(messageId);
strictAssert(
messageAttributes,
`MessageCache.accessAttributesOrThrow/${source}: no message for id ${messageId}`
);
return messageAttributes;
}
// Evicts messages from the message cache if they have not been accessed past
// the expiry time.
public deleteExpiredMessages(expiryTime: number): void {
const now = Date.now();
for (const [messageId, messageAttributes] of this.state.messages) {
const timeLastAccessed = this.state.lastAccessedAt.get(messageId) ?? 0;
const conversation = getMessageConversation(messageAttributes);
const state = window.reduxStore.getState();
const selectedId = state?.conversations?.selectedConversationId;
const inActiveConversation =
conversation && selectedId && conversation.id === selectedId;
if (now - timeLastAccessed > expiryTime && !inActiveConversation) {
this.__DEPRECATED$unregister(messageId);
}
}
}
// Finds a message in the cache by sender identifier
public findBySender(
senderIdentifier: string
): Readonly<MessageAttributesType> | undefined {
const id = this.state.messageIdsBySender.get(senderIdentifier);
if (!id) {
return undefined;
}
return this.accessAttributes(id);
}
public replaceAllObsoleteConversationIds({
conversationId,
obsoleteId,
}: {
conversationId: string;
obsoleteId: string;
}): void {
const updateSendState = (
sendState?: SendStateByConversationId
): SendStateByConversationId | undefined => {
if (!sendState?.[obsoleteId]) {
return sendState;
}
const { [obsoleteId]: obsoleteSendState, ...rest } = sendState;
return {
[conversationId]: obsoleteSendState,
...rest,
};
};
for (const [messageId, messageAttributes] of this.state.messages) {
if (messageAttributes.conversationId !== obsoleteId) {
continue;
}
const editHistory = messageAttributes.editHistory?.map(history => {
return {
...history,
sendStateByConversationId: updateSendState(
history.sendStateByConversationId
),
};
});
this.setAttributes({
messageId,
messageAttributes: {
conversationId,
sendStateByConversationId: updateSendState(
messageAttributes.sendStateByConversationId
),
editHistory,
},
skipSaveToDatabase: true,
});
}
}
// Find the message's attributes whether in memory or in the database.
// Refresh the attributes in the cache if they exist. Throw if we cannot find
// a matching message.
public async resolveAttributes(
source: string,
messageId: string
): Promise<Readonly<MessageAttributesType>> {
const inMemoryMessageAttributes = this.accessAttributes(messageId);
if (inMemoryMessageAttributes) {
return inMemoryMessageAttributes;
}
let messageAttributesFromDatabase: MessageAttributesType | undefined;
try {
messageAttributesFromDatabase = await window.Signal.Data.getMessageById(
messageId
);
} catch (err: unknown) {
log.error(
`MessageCache.resolveAttributes(${messageId}): db error ${Errors.toLogFormat(
err
)}`
);
}
strictAssert(
messageAttributesFromDatabase,
`MessageCache.resolveAttributes/${source}: no message for id ${messageId}`
);
return this.freezeAttributes(messageAttributesFromDatabase);
}
// Updates a message's attributes and saves the message to cache and to the
// database. Option to skip the save to the database.
// Overload #1: if skipSaveToDatabase = true, returns void
public setAttributes({
messageId,
messageAttributes,
skipSaveToDatabase,
}: {
messageId: string;
messageAttributes: Partial<MessageAttributesType>;
skipSaveToDatabase: true;
}): void;
// Overload #2: if skipSaveToDatabase = false, returns DB save promise
public setAttributes({
messageId,
messageAttributes,
skipSaveToDatabase,
}: {
messageId: string;
messageAttributes: Partial<MessageAttributesType>;
skipSaveToDatabase: false;
}): Promise<string>;
// Implementation
public setAttributes({
messageId,
messageAttributes: partialMessageAttributes,
skipSaveToDatabase,
}: {
messageId: string;
messageAttributes: Partial<MessageAttributesType>;
skipSaveToDatabase: boolean;
}): Promise<string> | undefined {
let messageAttributes = this.accessAttributes(messageId);
softAssert(messageAttributes, 'could not find message attributes');
if (!messageAttributes) {
// We expect message attributes to be defined in cache if one is trying to
// set new attributes. In the case that the attributes are missing in cache
// we'll add whatever we currently have to cache as a defensive measure so
// that the code continues to work properly downstream. The softAssert above
// that logs/debugger should be addressed upstream immediately by ensuring
// that message is in cache.
const partiallyCachedMessage = {
id: messageId,
...partialMessageAttributes,
} as MessageAttributesType;
this.addMessageToCache(partiallyCachedMessage);
messageAttributes = partiallyCachedMessage;
}
this.state.messageIdsBySender.delete(
getSenderIdentifier(messageAttributes)
);
const nextMessageAttributes = {
...messageAttributes,
...partialMessageAttributes,
};
const { id, sent_at: sentAt } = nextMessageAttributes;
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt);
let nextIdsBySentAtSet: Set<string>;
if (previousIdsBySentAt) {
nextIdsBySentAtSet = new Set(previousIdsBySentAt);
nextIdsBySentAtSet.add(id);
} else {
nextIdsBySentAtSet = new Set([id]);
}
this.state.messages.set(id, nextMessageAttributes);
this.state.lastAccessedAt.set(id, Date.now());
this.state.messageIdsBySender.set(
getSenderIdentifier(messageAttributes),
id
);
this.markModelStale(nextMessageAttributes);
this.throttledUpdateRedux(nextMessageAttributes);
if (skipSaveToDatabase) {
return;
}
return window.Signal.Data.saveMessage(nextMessageAttributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
}
private throttledReduxUpdaters = new LRU<string, typeof this.updateRedux>({
max: MAX_THROTTLED_REDUX_UPDATERS,
});
private throttledUpdateRedux(attributes: MessageAttributesType) {
let updater = this.throttledReduxUpdaters.get(attributes.id);
if (!updater) {
updater = throttle(this.updateRedux.bind(this), 200, {
leading: true,
trailing: true,
});
this.throttledReduxUpdaters.set(attributes.id, updater);
}
updater(attributes);
}
private updateRedux(attributes: MessageAttributesType) {
if (!window.reduxActions) {
return;
}
if (isStory(attributes)) {
const storyData = getStoryDataFromMessageAttributes({
...attributes,
});
if (!storyData) {
return;
}
window.reduxActions.stories.storyChanged(storyData);
// We don't want messageChanged to run
return;
}
window.reduxActions.conversations.messageChanged(
attributes.id,
attributes.conversationId,
attributes
);
}
// When you already have the message attributes from the db and want to
// ensure that they're added to the cache. The latest attributes from cache
// are returned if they exist, if not the attributes passed in are returned.
public toMessageAttributes(
messageAttributes: MessageAttributesType
): Readonly<MessageAttributesType> {
this.addMessageToCache(messageAttributes);
const nextMessageAttributes = this.state.messages.get(messageAttributes.id);
strictAssert(
nextMessageAttributes,
`MessageCache.toMessageAttributes: no message for id ${messageAttributes.id}`
);
if (getEnvironment() === Environment.Development) {
return Object.freeze(cloneDeep(nextMessageAttributes));
}
return nextMessageAttributes;
}
static install(): MessageCache {
const instance = new MessageCache();
window.MessageCache = instance;
return instance;
}
private addMessageToCache(messageAttributes: MessageAttributesType): void {
if (!messageAttributes.id) {
return;
}
if (this.state.messages.has(messageAttributes.id)) {
this.state.lastAccessedAt.set(messageAttributes.id, Date.now());
return;
}
const { id, sent_at: sentAt } = messageAttributes;
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt);
let nextIdsBySentAtSet: Set<string>;
if (previousIdsBySentAt) {
nextIdsBySentAtSet = new Set(previousIdsBySentAt);
nextIdsBySentAtSet.add(id);
} else {
nextIdsBySentAtSet = new Set([id]);
}
this.state.messages.set(messageAttributes.id, { ...messageAttributes });
this.state.lastAccessedAt.set(messageAttributes.id, Date.now());
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
this.state.messageIdsBySender.set(
getSenderIdentifier(messageAttributes),
id
);
}
private freezeAttributes(
messageAttributes: MessageAttributesType
): Readonly<MessageAttributesType> {
this.addMessageToCache(messageAttributes);
if (getEnvironment() === Environment.Development) {
return Object.freeze(cloneDeep(messageAttributes));
}
return messageAttributes;
}
private removeMessage(messageId: string): void {
const messageAttributes = this.state.messages.get(messageId);
if (!messageAttributes) {
return;
}
const { id, sent_at: sentAt } = messageAttributes;
const nextIdsBySentAtSet =
new Set(this.state.messageIdsBySentAt.get(sentAt)) || new Set();
nextIdsBySentAtSet.delete(id);
if (nextIdsBySentAtSet.size) {
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
} else {
this.state.messageIdsBySentAt.delete(sentAt);
}
this.state.messages.delete(messageId);
this.state.lastAccessedAt.delete(messageId);
this.state.messageIdsBySender.delete(
getSenderIdentifier(messageAttributes)
);
}
// Deprecated methods below
// Adds the message into the cache and eturns a Proxy that resembles
// a MessageModel
public __DEPRECATED$register(
id: string,
data: MessageModel | MessageAttributesType,
location: string
): MessageModel {
if (!id || !data) {
throw new Error(
'MessageCache.__DEPRECATED$register: Got falsey id or message'
);
}
const existing = this.__DEPRECATED$getById(id);
if (existing) {
this.addMessageToCache(existing.attributes);
return existing;
}
const modelProxy = this.toModel(data);
const messageAttributes = 'attributes' in data ? data.attributes : data;
this.addMessageToCache(messageAttributes);
modelProxy.registerLocations.add(location);
return modelProxy;
}
// Deletes the message from our cache
public __DEPRECATED$unregister(id: string): void {
const model = this.modelCache.get(id);
if (!model) {
return;
}
this.removeMessage(id);
this.modelCache.delete(id);
}
// Finds a message in the cache by Id
public __DEPRECATED$getById(id: string): MessageModel | undefined {
const data = this.state.messages.get(id);
if (!data) {
return undefined;
}
return this.toModel(data);
}
// Finds a message in the cache by sentAt/timestamp
public __DEPRECATED$filterBySentAt(sentAt: number): Iterable<MessageModel> {
const items = this.state.messageIdsBySentAt.get(sentAt) ?? [];
const attrs = items.map(id => this.accessAttributes(id)).filter(isNotNil);
return map(attrs, data => this.toModel(data));
}
// Marks cached model as "should be stale" to discourage continued use.
// The model's attributes are directly updated so that the model is in sync
// with the in-memory attributes.
private markModelStale(messageAttributes: MessageAttributesType): void {
const { id } = messageAttributes;
const model = this.modelCache.get(id);
if (!model) {
return;
}
model.attributes = { ...messageAttributes };
if (getEnvironment() === Environment.Development) {
log.warn('MessageCache: stale model', {
cid: model.cid,
locations: Array.from(model.registerLocations).join('+'),
});
}
}
// Creates a proxy object for MessageModel which logs usage in development
// so that we're able to migrate off of models
private toModel(
messageAttributes: MessageAttributesType | MessageModel
): MessageModel {
const existingModel = this.modelCache.get(messageAttributes.id);
if (existingModel) {
return existingModel;
}
const model =
'attributes' in messageAttributes
? messageAttributes
: new window.Whisper.Message(messageAttributes);
const proxy = getMessageModelLogger(model);
this.modelCache.set(messageAttributes.id, proxy);
return proxy;
}
}