session-desktop/ts/receiver/queuedJob.ts

505 lines
15 KiB
TypeScript

import { queueAttachmentDownloads } from './attachments';
import { Quote } from './types';
import { PubKey } from '../session/types';
import _ from 'lodash';
import { SignalService } from '../protobuf';
import { UserUtils } from '../session/utils';
import { getConversationController } from '../session/conversations';
import { ConversationModel, ConversationTypeEnum } from '../models/conversation';
import { MessageModel } from '../models/message';
import { getMessageById, getMessagesBySentAt } from '../../ts/data/data';
import { MessageModelPropsWithoutConvoProps, messagesAdded } from '../state/ducks/conversations';
import { updateProfileOneAtATime } from './dataMessage';
import Long from 'long';
async function handleGroups(
conversation: ConversationModel,
group: any,
source: any
): Promise<any> {
const GROUP_TYPES = SignalService.GroupContext.Type;
let groupUpdate = null;
// conversation attributes
const attributes: any = {
type: 'group',
groupId: group.id,
...conversation.attributes,
};
const oldMembers = conversation.get('members');
if (group.type === GROUP_TYPES.UPDATE) {
attributes.name = group.name;
attributes.members = group.members;
groupUpdate = conversation.changedAttributes(_.pick(group, 'name', 'avatar')) || {};
const addedMembers = _.difference(attributes.members, oldMembers);
if (addedMembers.length > 0) {
groupUpdate.joined = addedMembers;
}
if (conversation.get('left')) {
// TODO: Maybe we shouldn't assume this message adds us:
// we could maybe still get this message by mistake
window?.log?.warn('re-added to a left group');
attributes.left = false;
}
if (attributes.isKickedFromGroup) {
// Assume somebody re-invited us since we received this update
attributes.isKickedFromGroup = false;
}
// Check if anyone got kicked:
const removedMembers = _.difference(oldMembers, attributes.members);
const ourDeviceWasRemoved = removedMembers.some(member => UserUtils.isUsFromCache(member));
if (ourDeviceWasRemoved) {
groupUpdate.kicked = 'You';
attributes.isKickedFromGroup = true;
} else if (removedMembers.length) {
groupUpdate.kicked = removedMembers;
}
} else if (group.type === GROUP_TYPES.QUIT) {
if (UserUtils.isUsFromCache(source)) {
attributes.left = true;
groupUpdate = { left: 'You' };
} else {
groupUpdate = { left: source };
}
attributes.members = _.without(oldMembers, source);
}
conversation.set(attributes);
return groupUpdate;
}
function contentTypeSupported(type: string): boolean {
const Chrome = window.Signal.Util.GoogleChrome;
return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type);
}
// tslint:disable-next-line: cyclomatic-complexity
async function copyFromQuotedMessage(msg: MessageModel, quote?: Quote): Promise<void> {
const { upgradeMessageSchema } = window.Signal.Migrations;
const { Message: TypedMessage, Errors } = window.Signal.Types;
if (!quote) {
return;
}
const { attachments, id: quoteId, author } = quote;
const firstAttachment = attachments[0];
const id: number = Long.isLong(quoteId) ? quoteId.toNumber() : quoteId;
// We always look for the quote by sentAt timestamp, for opengroups, closed groups and session chats
// this will return an array of sent message by id we have locally.
const collection = await getMessagesBySentAt(id);
// we now must make sure this is the sender we expect
const found = collection.find(message => {
return Boolean(author === message.getSource());
});
if (!found) {
window?.log?.warn(`We did not found quoted message ${id}.`);
quote.referencedMessageNotFound = true;
msg.set({ quote });
await msg.commit();
return;
}
window?.log?.info(`Found quoted message id: ${id}`);
quote.referencedMessageNotFound = false;
quote.text = found.get('body') || '';
if (!firstAttachment || !contentTypeSupported(firstAttachment.contentType)) {
return;
}
firstAttachment.thumbnail = null;
try {
if ((found.get('schemaVersion') || 0) < TypedMessage.VERSION_NEEDED_FOR_DISPLAY) {
const upgradedMessage = await upgradeMessageSchema(found.attributes);
found.set(upgradedMessage);
await upgradedMessage.commit();
}
} catch (error) {
window?.log?.error(
'Problem upgrading message quoted message from database',
Errors.toLogFormat(error)
);
return;
}
const queryAttachments = found.get('attachments') || [];
if (queryAttachments.length > 0) {
const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
if (thumbnail && thumbnail.path) {
firstAttachment.thumbnail = {
...thumbnail,
copied: true,
};
}
}
const queryPreview = found.get('preview') || [];
if (queryPreview.length > 0) {
const queryFirst = queryPreview[0];
const { image } = queryFirst;
if (image && image.path) {
firstAttachment.thumbnail = {
...image,
copied: true,
};
}
}
}
function handleLinkPreviews(messageBody: string, messagePreview: any, message: MessageModel) {
const urls = window.Signal.LinkPreviews.findLinks(messageBody);
const incomingPreview = messagePreview || [];
const preview = incomingPreview.filter(
(item: any) => (item.image || item.title) && urls.includes(item.url)
);
if (preview.length < incomingPreview.length) {
window?.log?.info(
`${message.idForLogging()}: Eliminated ${preview.length -
incomingPreview.length} previews with invalid urls'`
);
}
message.set({ preview });
}
async function processProfileKey(
conversation: ConversationModel,
sendingDeviceConversation: ConversationModel,
profileKeyBuffer?: Uint8Array
) {
if (conversation.isPrivate()) {
await conversation.setProfileKey(profileKeyBuffer);
} else {
await sendingDeviceConversation.setProfileKey(profileKeyBuffer);
}
}
function handleMentions(
message: MessageModel,
conversation: ConversationModel,
ourPrimaryNumber: PubKey
) {
const body = message.get('body');
if (body && body.indexOf(`@${ourPrimaryNumber.key}`) !== -1) {
conversation.set({ mentionedUs: true });
}
}
function updateReadStatus(message: MessageModel, conversation: ConversationModel) {
const readSync = window.Whisper.ReadSyncs.forMessage(message);
if (readSync) {
const shouldExpire = message.get('expireTimer');
const alreadyStarted = message.get('expirationStartTimestamp');
if (shouldExpire && !alreadyStarted) {
// Start message expiration timer
const start = Math.min(readSync.get('read_at'), Date.now());
message.set('expirationStartTimestamp', start);
}
}
if (readSync || message.isExpirationTimerUpdate()) {
message.set({ unread: 0 });
// This is primarily to allow the conversation to mark all older
// messages as read, as is done when we receive a read sync for
// a message we already know about.
void conversation.onReadMessage(message, Date.now());
}
}
async function handleSyncedReceipts(message: MessageModel, conversation: ConversationModel) {
const readReceipts = window.Whisper.ReadReceipts.forMessage(conversation, message);
if (readReceipts.length) {
const readBy = readReceipts.map((receipt: any) => receipt.get('reader'));
message.set({
read_by: _.union(message.get('read_by'), readBy),
});
}
// A sync'd message to ourself is automatically considered read
const recipients = conversation.getRecipients();
if (conversation.isMe()) {
message.set({
read_by: recipients,
});
}
message.set({ recipients });
// If the newly received message is from us, we assume that we've seen the messages up until that point
const sentTimestamp = message.get('sent_at');
if (sentTimestamp) {
await conversation.markRead(sentTimestamp);
}
}
async function handleRegularMessage(
conversation: ConversationModel,
message: MessageModel,
initialMessage: any,
source: string,
ourNumber: string,
messageHash: string
) {
const { upgradeMessageSchema } = window.Signal.Migrations;
const type = message.get('type');
await copyFromQuotedMessage(message, initialMessage.quote);
// `upgradeMessageSchema` only seems to add `schemaVersion: 10` to the message
const dataMessage = await upgradeMessageSchema(initialMessage);
const now = Date.now();
// Medium groups might have `group` set even if with group chat messages...
if (dataMessage.group && !conversation.isMediumGroup()) {
// This is not necessarily a group update message, it could also be a regular group message
const groupUpdate = await handleGroups(conversation, dataMessage.group, source);
if (groupUpdate !== null) {
message.set({ group_update: groupUpdate });
}
}
if (dataMessage.openGroupInvitation) {
message.set({ groupInvitation: dataMessage.openGroupInvitation });
}
handleLinkPreviews(dataMessage.body, dataMessage.preview, message);
const existingExpireTimer = conversation.get('expireTimer');
message.set({
flags: dataMessage.flags,
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
quote: dataMessage.quote,
schemaVersion: dataMessage.schemaVersion,
attachments: dataMessage.attachments,
body: dataMessage.body,
conversationId: conversation.id,
decrypted_at: now,
messageHash,
errors: [],
});
if (existingExpireTimer) {
message.set({ expireTimer: existingExpireTimer });
}
// Expire timer updates are now explicit.
// We don't handle an expire timer from a incoming message except if it is an ExpireTimerUpdate message.
const ourPubKey = PubKey.cast(ourNumber);
handleMentions(message, conversation, ourPubKey);
if (type === 'incoming') {
updateReadStatus(message, conversation);
}
if (type === 'outgoing') {
await handleSyncedReceipts(message, conversation);
if (window.lokiFeatureFlags.useMessageRequests) {
// assumes sync receipts are always from linked device outgoings
await conversation.setIsApproved(true);
}
}
const conversationActiveAt = conversation.get('active_at');
if (!conversationActiveAt || (message.get('sent_at') || 0) > conversationActiveAt) {
conversation.set({
active_at: message.get('sent_at'),
lastMessage: message.getNotificationText(),
});
}
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
source,
ConversationTypeEnum.PRIVATE
);
// Check if we need to update any profile names
// the only profile we don't update with what is coming here is ours,
// as our profile is shared accross our devices with a ConfigurationMessage
if (type === 'incoming' && dataMessage.profile) {
void updateProfileOneAtATime(
sendingDeviceConversation,
dataMessage.profile,
dataMessage.profileKey
);
}
if (dataMessage.profileKey) {
await processProfileKey(conversation, sendingDeviceConversation, dataMessage.profileKey);
}
// we just received a message from that user so we reset the typing indicator for this convo
await conversation.notifyTyping({
isTyping: false,
sender: source,
});
}
async function handleExpirationTimerUpdate(
conversation: ConversationModel,
message: MessageModel,
source: string,
expireTimer: number
) {
message.set({
expirationTimerUpdate: {
source,
expireTimer,
},
unread: 0, // mark the message as read.
});
conversation.set({ expireTimer });
window?.log?.info("Update conversation 'expireTimer'", {
id: conversation.idForLogging(),
expireTimer,
source: 'handleDataMessage',
});
await conversation.updateExpireTimer(expireTimer, source, message.get('received_at'));
}
export async function handleMessageJob(
message: MessageModel,
conversation: ConversationModel,
initialMessage: any,
ourNumber: string,
confirm: () => void,
source: string,
messageHash: string
) {
window?.log?.info(
`Starting handleDataMessage for message ${message.idForLogging()}, ${message.get(
'serverTimestamp'
) || message.get('timestamp')} in conversation ${conversation.idForLogging()}`
);
try {
message.set({ flags: initialMessage.flags });
if (message.isExpirationTimerUpdate()) {
const { expireTimer } = initialMessage;
const oldValue = conversation.get('expireTimer');
if (expireTimer === oldValue) {
if (confirm) {
confirm();
}
window?.log?.info(
'Dropping ExpireTimerUpdate message as we already have the same one set.'
);
return;
}
await handleExpirationTimerUpdate(conversation, message, source, expireTimer);
} else {
await handleRegularMessage(
conversation,
message,
initialMessage,
source,
ourNumber,
messageHash
);
}
const id = await message.commit();
message.set({ id });
// Note that this can save the message again, if jobs were queued. We need to
// call it after we have an id for this message, because the jobs refer back
// to their source message.
void queueAttachmentDownloads(message, conversation);
const unreadCount = await conversation.getUnreadCount();
conversation.set({ unreadCount });
// this is a throttled call and will only run once every 1 sec
conversation.updateLastMessage();
await conversation.commit();
try {
// We go to the database here because, between the message save above and
// the previous line's trigger() call, we might have marked all messages
// unread in the database. This message might already be read!
const fetched = await getMessageById(message.get('id'));
const previousUnread = message.get('unread');
// Important to update message with latest read state from database
message.merge(fetched);
if (previousUnread !== message.get('unread')) {
window?.log?.warn(
'Caught race condition on new message read state! ' + 'Manually starting timers.'
);
// We call markRead() even though the message is already
// marked read because we need to start expiration
// timers, etc.
await message.markRead(Date.now());
}
} catch (error) {
window?.log?.warn('handleDataMessage: Message', message.idForLogging(), 'was deleted');
}
// this updates the redux store.
// if the convo on which this message should become visible,
// it will be shown to the user, and might as well be read right away
updatesToDispatch.set(message.id, {
conversationKey: conversation.id,
messageModelProps: message.getMessageModelProps(),
});
throttledAllMessagesAddedDispatch();
if (message.get('unread')) {
conversation.throttledNotify(message);
}
if (confirm) {
confirm();
}
} catch (error) {
const errorForLog = error && error.stack ? error.stack : error;
window?.log?.error('handleDataMessage', message.idForLogging(), 'error:', errorForLog);
throw error;
}
}
const throttledAllMessagesAddedDispatch = _.throttle(() => {
if (updatesToDispatch.size === 0) {
return;
}
window.inboxStore?.dispatch(messagesAdded([...updatesToDispatch.values()]));
updatesToDispatch.clear();
}, 1000);
const updatesToDispatch: Map<
string,
{ conversationKey: string; messageModelProps: MessageModelPropsWithoutConvoProps }
> = new Map();