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.
509 lines
16 KiB
TypeScript
509 lines
16 KiB
TypeScript
import _, { isEmpty, isNumber } from 'lodash';
|
|
import { queueAttachmentDownloads } from './attachments';
|
|
|
|
import { Data } from '../data/data';
|
|
import { ConversationModel } from '../models/conversation';
|
|
import { MessageModel } from '../models/message';
|
|
import { getConversationController } from '../session/conversations';
|
|
import { Quote } from './types';
|
|
|
|
import { MessageDirection } from '../models/messageType';
|
|
import { SignalService } from '../protobuf';
|
|
import { DisappearingMessages } from '../session/disappearing_messages';
|
|
import { ProfileManager } from '../session/profile_manager/ProfileManager';
|
|
import { PubKey } from '../session/types';
|
|
import { UserUtils } from '../session/utils';
|
|
import {
|
|
MessageModelPropsWithoutConvoProps,
|
|
lookupQuote,
|
|
pushQuotedMessageDetails,
|
|
} from '../state/ducks/conversations';
|
|
import { showMessageRequestBannerOutsideRedux } from '../state/ducks/userConfig';
|
|
import { getHideMessageRequestBannerOutsideRedux } from '../state/selectors/userConfig';
|
|
import { GoogleChrome } from '../util';
|
|
import { LinkPreviews } from '../util/linkPreviews';
|
|
import { ConversationTypeEnum } from '../models/types';
|
|
|
|
function contentTypeSupported(type: string): boolean {
|
|
const Chrome = GoogleChrome;
|
|
return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type);
|
|
}
|
|
|
|
function isMessageModel(
|
|
msg: MessageModel | MessageModelPropsWithoutConvoProps
|
|
): msg is MessageModel {
|
|
return (msg as MessageModel).get !== undefined;
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
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,
|
|
id: _.toNumber(quoteId),
|
|
text: null,
|
|
referencedMessageNotFound: false,
|
|
};
|
|
|
|
const firstAttachment = attachments?.[0] || undefined;
|
|
|
|
const id = _.toNumber(quoteId);
|
|
|
|
// First we try to look for the quote in memory
|
|
const stateConversations = window.inboxStore?.getState().conversations;
|
|
const { messages, quotes } = stateConversations;
|
|
let quotedMessage: MessageModelPropsWithoutConvoProps | MessageModel | undefined = lookupQuote(
|
|
quotes,
|
|
messages,
|
|
id,
|
|
quote.author
|
|
);
|
|
|
|
// If the quote is not found in memory, we try to find it in the DB
|
|
if (!quotedMessage) {
|
|
// We always look for the quote by sentAt timestamp, for opengroups, closed groups and session chats
|
|
// this will return an array of sent messages by id that we have locally.
|
|
const quotedMessagesCollection = await Data.getMessagesBySenderAndSentAt([
|
|
{
|
|
timestamp: id,
|
|
source: quote.author,
|
|
},
|
|
]);
|
|
|
|
if (quotedMessagesCollection?.length) {
|
|
quotedMessage = quotedMessagesCollection.at(0);
|
|
}
|
|
}
|
|
|
|
if (!quotedMessage) {
|
|
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;
|
|
// NOTE we send the entire body to be consistent with the other platforms
|
|
quoteLocal.text =
|
|
(isMessageModel(quotedMessage)
|
|
? quotedMessage.get('body')
|
|
: quotedMessage.propsForMessage.text) || '';
|
|
|
|
if (isMessageModel(quotedMessage)) {
|
|
window.inboxStore?.dispatch(pushQuotedMessageDetails(quotedMessage.getMessageModelProps()));
|
|
} else {
|
|
window.inboxStore?.dispatch(pushQuotedMessageDetails(quotedMessage));
|
|
}
|
|
|
|
// 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 =
|
|
(isMessageModel(quotedMessage)
|
|
? quotedMessage.get('attachments')
|
|
: quotedMessage.propsForMessage.attachments) || [];
|
|
|
|
if (queryAttachments.length > 0) {
|
|
const queryFirst = queryAttachments[0];
|
|
const { thumbnail } = queryFirst;
|
|
|
|
if (thumbnail && thumbnail.path) {
|
|
firstAttachment.thumbnail = {
|
|
...thumbnail,
|
|
copied: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
const queryPreview =
|
|
(isMessageModel(quotedMessage)
|
|
? quotedMessage.get('preview')
|
|
: quotedMessage.propsForMessage.previews) || [];
|
|
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);
|
|
}
|
|
}
|
|
|
|
export type RegularMessageType = Pick<
|
|
SignalService.DataMessage,
|
|
| 'attachments'
|
|
| 'body'
|
|
| 'flags'
|
|
| 'openGroupInvitation'
|
|
| 'quote'
|
|
| 'preview'
|
|
| 'reaction'
|
|
| 'profile'
|
|
| 'profileKey'
|
|
// TODO legacy messages support will be removed in a future release
|
|
| 'expireTimer'
|
|
| 'blocksCommunityMessageRequests'
|
|
> & { 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',
|
|
'blocksCommunityMessageRequests',
|
|
]),
|
|
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);
|
|
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: [],
|
|
});
|
|
|
|
const serverTimestamp = message.get('serverTimestamp');
|
|
if (
|
|
conversation.isPublic() &&
|
|
PubKey.isBlinded(sendingDeviceConversation.id) &&
|
|
isNumber(serverTimestamp)
|
|
) {
|
|
const updateBlockTimestamp = !rawDataMessage.blocksCommunityMessageRequests
|
|
? 0
|
|
: serverTimestamp;
|
|
await sendingDeviceConversation.updateBlocksSogsMsgReqsTimestamp(updateBlockTimestamp, false);
|
|
}
|
|
|
|
if (type === 'incoming') {
|
|
if (conversation.isPrivate()) {
|
|
const incomingMessageCount = await Data.getMessageCountByType(
|
|
conversation.id,
|
|
MessageDirection.incoming
|
|
);
|
|
const isFirstRequestMessage = incomingMessageCount < 2;
|
|
if (
|
|
conversation.isIncomingRequest() &&
|
|
isFirstRequestMessage &&
|
|
getHideMessageRequestBannerOutsideRedux()
|
|
) {
|
|
showMessageRequestBannerOutsideRedux();
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
const conversationActiveAt = conversation.get('active_at');
|
|
if (
|
|
!conversationActiveAt ||
|
|
conversation.isHidden() ||
|
|
(message.get('sent_at') || 0) > conversationActiveAt
|
|
) {
|
|
const interactionNotification = message.getInteractionNotification();
|
|
conversation.set({
|
|
active_at: message.get('sent_at'),
|
|
lastMessage: message.getNotificationText(),
|
|
lastMessageInteractionType: interactionNotification?.interactionType,
|
|
lastMessageInteractionStatus: interactionNotification?.interactionStatus,
|
|
});
|
|
// a new message was received for that conversation. If it was not it should not be hidden anymore
|
|
await conversation.unhideIfNeeded(false);
|
|
}
|
|
|
|
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 markConvoAsReadIfOutgoingMessage(
|
|
conversation: ConversationModel,
|
|
message: MessageModel
|
|
) {
|
|
const isOutgoingMessage =
|
|
message.get('type') === 'outgoing' || message.get('direction') === 'outgoing';
|
|
if (isOutgoingMessage) {
|
|
const sentAt = message.get('sent_at') || message.get('serverTimestamp');
|
|
if (sentAt) {
|
|
const expirationType = message.getExpirationType();
|
|
const expireTimer = message.getExpireTimerSeconds();
|
|
// NOTE starting disappearing messages timer for all outbound messages
|
|
if (
|
|
expirationType &&
|
|
expireTimer > 0 &&
|
|
Boolean(message.getExpirationStartTimestamp()) === false
|
|
) {
|
|
const expirationMode = DisappearingMessages.changeToDisappearingConversationMode(
|
|
conversation,
|
|
expirationType,
|
|
expireTimer
|
|
);
|
|
|
|
if (expirationMode !== 'off') {
|
|
message.set({
|
|
expirationStartTimestamp: DisappearingMessages.setExpirationStartTimestamp(
|
|
expirationMode,
|
|
message.get('sent_at'),
|
|
'markConvoAsReadIfOutgoingMessage',
|
|
message.get('id')
|
|
),
|
|
});
|
|
await message.commit();
|
|
}
|
|
}
|
|
conversation.markConversationRead({
|
|
newestUnreadDate: sentAt,
|
|
fromConfigMessage: 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()}, messageHash:${messageHash}`
|
|
);
|
|
|
|
const sendingDeviceConversation = await getConversationController().getOrCreateAndWait(
|
|
source,
|
|
ConversationTypeEnum.PRIVATE
|
|
);
|
|
try {
|
|
messageModel.set({ flags: regularDataMessage.flags });
|
|
|
|
// NOTE we handle incoming disappear after send messages and sync messages here
|
|
if (
|
|
conversation &&
|
|
messageModel.getExpireTimerSeconds() > 0 &&
|
|
!messageModel.getExpirationStartTimestamp()
|
|
) {
|
|
const expirationMode = DisappearingMessages.changeToDisappearingConversationMode(
|
|
conversation,
|
|
messageModel.getExpirationType(),
|
|
messageModel.getExpireTimerSeconds()
|
|
);
|
|
|
|
// TODO legacy messages support will be removed in a future release
|
|
const canBeDeleteAfterSend = conversation && (conversation.isMe() || conversation.isGroup());
|
|
if (
|
|
(canBeDeleteAfterSend && expirationMode === 'legacy') ||
|
|
expirationMode === 'deleteAfterSend'
|
|
) {
|
|
messageModel.set({
|
|
expirationStartTimestamp: DisappearingMessages.setExpirationStartTimestamp(
|
|
expirationMode,
|
|
messageModel.get('sent_at'),
|
|
'handleMessageJob',
|
|
messageModel.get('id')
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (messageModel.isExpirationTimerUpdate()) {
|
|
// NOTE if we turn off disappearing messages from a legacy client expirationTimerUpdate can be undefined but the flags value is correctly set
|
|
const expirationTimerUpdate = messageModel.getExpirationTimerUpdate();
|
|
if (
|
|
messageModel.get('flags') !== SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE &&
|
|
(!expirationTimerUpdate || isEmpty(expirationTimerUpdate))
|
|
) {
|
|
window.log.debug(
|
|
`[handleMessageJob] The ExpirationTimerUpdate is not defined correctly message: ${messageModel.get(
|
|
'id'
|
|
)}\nexpirationTimerUpdate: ${JSON.stringify(expirationTimerUpdate)}`
|
|
);
|
|
confirm?.();
|
|
return;
|
|
}
|
|
|
|
const expireTimerUpdate = expirationTimerUpdate?.expireTimer || 0;
|
|
const expirationModeUpdate = DisappearingMessages.changeToDisappearingConversationMode(
|
|
conversation,
|
|
expirationTimerUpdate?.expirationType,
|
|
expireTimerUpdate
|
|
);
|
|
|
|
await conversation.updateExpireTimer({
|
|
providedDisappearingMode: expirationModeUpdate,
|
|
providedExpireTimer: expireTimerUpdate,
|
|
providedSource: source,
|
|
fromSync: source === UserUtils.getOurPubKeyStrFromCache(),
|
|
receivedAt: messageModel.get('received_at'),
|
|
existingMessage: messageModel,
|
|
shouldCommitConvo: false,
|
|
fromCurrentDevice: false,
|
|
fromConfigMessage: false,
|
|
// NOTE we don't commit yet because we want to get the message id, see below
|
|
});
|
|
} 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 then 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.
|
|
|
|
conversation.set({
|
|
active_at: Math.max(conversation.get('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 across our devices with a ConfigurationMessage
|
|
if (messageModel.isIncoming() && regularDataMessage.profile) {
|
|
await ProfileManager.updateProfileOfContact(
|
|
sendingDeviceConversation.id,
|
|
regularDataMessage.profile.displayName,
|
|
regularDataMessage.profile.profilePicture,
|
|
regularDataMessage.profileKey
|
|
);
|
|
}
|
|
|
|
await markConvoAsReadIfOutgoingMessage(conversation, messageModel);
|
|
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);
|
|
}
|
|
}
|