You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/ts/receiver/queuedJob.ts

458 lines
15 KiB
TypeScript

import { queueAttachmentDownloads } from './attachments';
import { Quote } from './types';
import _ from 'lodash';
import { getConversationController } from '../session/conversations';
import { ConversationModel } from '../models/conversation';
import { MessageModel, sliceQuoteText } from '../models/message';
import { Data } from '../../ts/data/data';
import { SignalService } from '../protobuf';
import { UserUtils } from '../session/utils';
import { showMessageRequestBanner } from '../state/ducks/userConfig';
import { MessageDirection } from '../models/messageType';
import { LinkPreviews } from '../util/linkPreviews';
import { GoogleChrome } from '../util';
import { appendFetchAvatarAndProfileJob } from './userProfileImageUpdates';
import { ConversationTypeEnum } from '../models/conversationAttributes';
import { getUsBlindedInThatServer } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys';
import { handleMessageReaction } from '../util/reactions';
import { Action, Reaction } from '../types/Reaction';
function contentTypeSupported(type: string): boolean {
const Chrome = GoogleChrome;
return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type);
}
/**
* Note: this function does not trigger a write to the db nor trigger redux update.
* You have to call msg.commit() once you are done with the handling of this message
*/
async function copyFromQuotedMessage(
// tslint:disable-next-line: cyclomatic-complexity
msg: MessageModel,
quote?: SignalService.DataMessage.IQuote | null
): Promise<void> {
if (!quote) {
return;
}
const { attachments, id: quoteId, author } = quote;
const quoteLocal: Quote = {
attachments: attachments || null,
author: author,
id: _.toNumber(quoteId),
text: null,
referencedMessageNotFound: false,
};
const firstAttachment = attachments?.[0] || undefined;
const id = _.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 Data.getMessagesBySentAt(id);
// we now must make sure this is the sender we expect
const found = collection.find(message => {
return Boolean(author === message.get('source'));
});
if (!found) {
window?.log?.warn(`We did not found quoted message ${id} with author ${author}.`);
quoteLocal.referencedMessageNotFound = true;
msg.set({ quote: quoteLocal });
return;
}
window?.log?.info(`Found quoted message id: ${id}`);
quoteLocal.referencedMessageNotFound = false;
quoteLocal.text = sliceQuoteText(found.get('body') || '');
// no attachments, just save the quote with the body
if (
!firstAttachment ||
!firstAttachment.contentType ||
!contentTypeSupported(firstAttachment.contentType)
) {
msg.set({ quote: quoteLocal });
return;
}
firstAttachment.thumbnail = null;
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,
};
}
}
quoteLocal.attachments = [firstAttachment];
msg.set({ quote: quoteLocal });
}
/**
* Note: This does not trigger a redux update, nor write to the DB
*/
function handleLinkPreviews(messageBody: string, messagePreview: any, message: MessageModel) {
const urls = 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 processProfileKeyNoCommit(
conversation: ConversationModel,
sendingDeviceConversation: ConversationModel,
profileKeyBuffer?: Uint8Array
) {
if (conversation.isPrivate()) {
await conversation.setProfileKey(profileKeyBuffer, false);
} else {
await sendingDeviceConversation.setProfileKey(profileKeyBuffer, false);
}
}
/**
* Mark the conversation as mentionedUs, if the content of the message matches our id in this conversation
* @param ourIdInThisConversation can be a blinded or our naked id, depending on the case
*/
function handleMentions(
message: MessageModel,
conversation: ConversationModel,
ourIdInThisConversation: string
) {
const body = message.get('body');
if (body && body.indexOf(`@${ourIdInThisConversation}`) !== -1) {
conversation.set({ mentionedUs: true });
}
}
function updateReadStatus(message: MessageModel) {
if (message.isExpirationTimerUpdate()) {
message.set({ unread: 0 });
}
}
function handleSyncedReceiptsNoCommit(message: MessageModel, conversation: ConversationModel) {
// 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) {
conversation.markRead(sentTimestamp);
}
}
export type RegularMessageType = Pick<
SignalService.DataMessage,
| 'attachments'
| 'body'
| 'flags'
| 'openGroupInvitation'
| 'quote'
| 'preview'
| 'reaction'
| 'profile'
| 'profileKey'
| 'expireTimer'
> & { isRegularMessage: true };
/**
* This function is just used to make sure we do not forward things we shouldn't in the incoming message pipeline
*/
export function toRegularMessage(rawDataMessage: SignalService.DataMessage): RegularMessageType {
return {
..._.pick(rawDataMessage, [
'attachments',
'preview',
'reaction',
'body',
'flags',
'profileKey',
'openGroupInvitation',
'quote',
'profile',
'expireTimer',
]),
isRegularMessage: true,
};
}
async function handleRegularMessage(
conversation: ConversationModel,
sendingDeviceConversation: ConversationModel,
message: MessageModel,
rawDataMessage: RegularMessageType,
source: string,
messageHash: string
): Promise<void> {
const type = message.get('type');
// this does not trigger a UI update nor write to the db
await copyFromQuotedMessage(message, rawDataMessage.quote);
if (rawDataMessage.openGroupInvitation) {
message.set({ groupInvitation: rawDataMessage.openGroupInvitation });
}
handleLinkPreviews(rawDataMessage.body, rawDataMessage.preview, message);
const existingExpireTimer = conversation.get('expireTimer');
message.set({
flags: rawDataMessage.flags,
// quote: rawDataMessage.quote, // do not do this copy here, it must be done only in copyFromQuotedMessage()
attachments: rawDataMessage.attachments,
body: rawDataMessage.body,
conversationId: conversation.id,
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 ourIdInThisConversation =
getUsBlindedInThatServer(conversation.id) || UserUtils.getOurPubKeyStrFromCache();
handleMentions(message, conversation, ourIdInThisConversation);
if (type === 'incoming') {
if (conversation.isPrivate()) {
updateReadStatus(message);
const incomingMessageCount = await Data.getMessageCountByType(
conversation.id,
MessageDirection.incoming
);
const isFirstRequestMessage = incomingMessageCount < 2;
if (
conversation.isIncomingRequest() &&
isFirstRequestMessage &&
window.inboxStore?.getState().userConfig.hideMessageRequests
) {
window.inboxStore?.dispatch(showMessageRequestBanner());
}
// For edge case when messaging a client that's unable to explicitly send request approvals
if (conversation.isOutgoingRequest()) {
// Conversation was not approved before so a sync is needed
await conversation.addIncomingApprovalMessage(
_.toNumber(message.get('sent_at')) - 1,
source
);
}
// should only occur after isOutgoing request as it relies on didApproveMe being false.
await conversation.setDidApproveMe(true);
}
} else if (type === 'outgoing') {
// we want to do this for all types of conversations, not just private chats
handleSyncedReceiptsNoCommit(message, conversation);
if (conversation.isPrivate()) {
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(),
});
}
if (rawDataMessage.profileKey) {
await processProfileKeyNoCommit(
conversation,
sendingDeviceConversation,
rawDataMessage.profileKey
);
}
// we just received a message from that user so we reset the typing indicator for this convo
await conversation.notifyTypingNoCommit({
isTyping: false,
sender: source,
});
}
async function handleExpirationTimerUpdateNoCommit(
conversation: ConversationModel,
message: MessageModel,
source: string,
expireTimer: number
) {
message.set({
expirationTimerUpdate: {
source,
expireTimer,
},
unread: 0, // mark the message as read.
});
conversation.set({ expireTimer });
await conversation.updateExpireTimer(expireTimer, source, message.get('received_at'), {}, false);
}
export async function handleMessageJob(
messageModel: MessageModel,
conversation: ConversationModel,
regularDataMessage: RegularMessageType,
confirm: () => void,
source: string,
messageHash: string
) {
window?.log?.info(
`Starting handleMessageJob for message ${messageModel.idForLogging()}, ${messageModel.get(
'serverTimestamp'
) || messageModel.get('timestamp')} in conversation ${conversation.idForLogging()}`
);
if (!messageModel.get('isPublic') && regularDataMessage.reaction) {
await handleMessageReaction(regularDataMessage.reaction, source, false, messageHash);
if (
regularDataMessage.reaction.action === Action.REACT &&
conversation.isPrivate() &&
messageModel.get('unread')
) {
messageModel.set('reaction', regularDataMessage.reaction as Reaction);
conversation.throttledNotify(messageModel);
}
confirm?.();
} else {
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
source,
ConversationTypeEnum.PRIVATE
);
try {
messageModel.set({ flags: regularDataMessage.flags });
if (messageModel.isExpirationTimerUpdate()) {
const { expireTimer } = regularDataMessage;
const oldValue = conversation.get('expireTimer');
if (expireTimer === oldValue) {
confirm?.();
window?.log?.info(
'Dropping ExpireTimerUpdate message as we already have the same one set.'
);
return;
}
await handleExpirationTimerUpdateNoCommit(conversation, messageModel, source, expireTimer);
} else {
// this does not commit to db nor UI unless we need to approve a convo
await handleRegularMessage(
conversation,
sendingDeviceConversation,
messageModel,
regularDataMessage,
source,
messageHash
);
}
// save the message model to the db and it save the messageId generated to our in-memory copy
const id = await messageModel.commit();
messageModel.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.
const unreadCount = await conversation.getUnreadCount();
conversation.set({ unreadCount });
conversation.set({
active_at: Math.max(conversation.attributes.active_at, messageModel.get('sent_at') || 0),
});
// this is a throttled call and will only run once every 1 sec at most
conversation.updateLastMessage();
await conversation.commit();
if (conversation.id !== sendingDeviceConversation.id) {
await sendingDeviceConversation.commit();
}
void queueAttachmentDownloads(messageModel, conversation);
// 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 (messageModel.isIncoming() && regularDataMessage.profile) {
void appendFetchAvatarAndProfileJob(
sendingDeviceConversation,
regularDataMessage.profile,
regularDataMessage.profileKey
);
}
// even with all the warnings, I am very sus about if this is usefull or not
// 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(messageModel.get('id'));
// const previousUnread = messageModel.get('unread');
// // Important to update message with latest read state from database
// messageModel.merge(fetched);
// if (previousUnread !== messageModel.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 messageModel.markRead(Date.now());
// }
// } catch (error) {
// window?.log?.warn(
// 'handleMessageJob: Message',
// messageModel.idForLogging(),
// 'was deleted'
// );
// }
if (messageModel.get('unread')) {
conversation.throttledNotify(messageModel);
}
confirm?.();
} catch (error) {
const errorForLog = error && error.stack ? error.stack : error;
window?.log?.error('handleMessageJob', messageModel.idForLogging(), 'error:', errorForLog);
}
}
}