Stories: Proper notifications and handling of out-of-order messages

This commit is contained in:
Scott Nonnenberg 2023-01-11 14:54:06 -08:00 committed by GitHub
parent 81fc9ff94d
commit 50a0110192
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 425 additions and 274 deletions

View file

@ -3011,17 +3011,18 @@ export async function startApp(): Promise<void> {
log.info('Queuing incoming reaction for', reaction.targetTimestamp); log.info('Queuing incoming reaction for', reaction.targetTimestamp);
const attributes: ReactionAttributesType = { const attributes: ReactionAttributesType = {
emoji: reaction.emoji, emoji: reaction.emoji,
fromId: fromConversation.id,
remove: reaction.remove, remove: reaction.remove,
source: ReactionSource.FromSomeoneElse,
storyReactionMessage: message,
targetAuthorUuid, targetAuthorUuid,
targetTimestamp: reaction.targetTimestamp, targetTimestamp: reaction.targetTimestamp,
timestamp, timestamp,
fromId: fromConversation.id,
source: ReactionSource.FromSomeoneElse,
}; };
const reactionModel = Reactions.getSingleton().add(attributes); const reactionModel = Reactions.getSingleton().add(attributes);
// Note: We do not wait for completion here drop(Reactions.getSingleton().onReaction(reactionModel));
void Reactions.getSingleton().onReaction(reactionModel, message);
confirm(); confirm();
return; return;
} }
@ -3383,16 +3384,17 @@ export async function startApp(): Promise<void> {
log.info('Queuing sent reaction for', reaction.targetTimestamp); log.info('Queuing sent reaction for', reaction.targetTimestamp);
const attributes: ReactionAttributesType = { const attributes: ReactionAttributesType = {
emoji: reaction.emoji, emoji: reaction.emoji,
fromId: window.ConversationController.getOurConversationIdOrThrow(),
remove: reaction.remove, remove: reaction.remove,
source: ReactionSource.FromSync,
storyReactionMessage: message,
targetAuthorUuid, targetAuthorUuid,
targetTimestamp: reaction.targetTimestamp, targetTimestamp: reaction.targetTimestamp,
timestamp, timestamp,
fromId: window.ConversationController.getOurConversationIdOrThrow(),
source: ReactionSource.FromSync,
}; };
const reactionModel = Reactions.getSingleton().add(attributes); const reactionModel = Reactions.getSingleton().add(attributes);
// Note: We do not wait for completion here
void Reactions.getSingleton().onReaction(reactionModel, message); drop(Reactions.getSingleton().onReaction(reactionModel));
event.confirm(); event.confirm();
return; return;
@ -3767,7 +3769,7 @@ export async function startApp(): Promise<void> {
const attributes: MessageReceiptAttributesType = { const attributes: MessageReceiptAttributesType = {
messageSentAt: timestamp, messageSentAt: timestamp,
receiptTimestamp: envelopeTimestamp, receiptTimestamp: envelopeTimestamp,
sourceConversationId: sourceConversation?.id, sourceConversationId: sourceConversation.id,
sourceUuid, sourceUuid,
sourceDevice, sourceDevice,
type, type,

View file

@ -326,6 +326,7 @@ export function StoryViewsNRepliesModal({
return ( return (
<ReplyOrReactionMessage <ReplyOrReactionMessage
key={reply.id} key={reply.id}
id={reply.id}
i18n={i18n} i18n={i18n}
isInternalUser={isInternalUser} isInternalUser={isInternalUser}
reply={reply} reply={reply}
@ -504,6 +505,7 @@ export function StoryViewsNRepliesModal({
type ReplyOrReactionMessageProps = { type ReplyOrReactionMessageProps = {
i18n: LocalizerType; i18n: LocalizerType;
id: string;
isInternalUser?: boolean; isInternalUser?: boolean;
reply: ReplyType; reply: ReplyType;
deleteGroupStoryReply: (replyId: string) => void; deleteGroupStoryReply: (replyId: string) => void;
@ -517,6 +519,7 @@ type ReplyOrReactionMessageProps = {
function ReplyOrReactionMessage({ function ReplyOrReactionMessage({
i18n, i18n,
id,
isInternalUser, isInternalUser,
reply, reply,
deleteGroupStoryReply, deleteGroupStoryReply,
@ -532,6 +535,7 @@ function ReplyOrReactionMessage({
<div <div
className="StoryViewsNRepliesModal__reaction" className="StoryViewsNRepliesModal__reaction"
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
data-id={id}
> >
<div className="StoryViewsNRepliesModal__reaction--container"> <div className="StoryViewsNRepliesModal__reaction--container">
<Avatar <Avatar
@ -570,6 +574,7 @@ function ReplyOrReactionMessage({
} }
return ( return (
<div className="StoryViewsNRepliesModal__reply" data-id={id}>
<Message <Message
{...MESSAGE_DEFAULT_PROPS} {...MESSAGE_DEFAULT_PROPS}
author={reply.author} author={reply.author}
@ -597,6 +602,7 @@ function ReplyOrReactionMessage({
textDirection={TextDirection.Default} textDirection={TextDirection.Default}
timestamp={reply.timestamp} timestamp={reply.timestamp}
/> />
</div>
); );
}; };

View file

@ -319,7 +319,7 @@ export async function sendReaction(
ourUuid, ourUuid,
forceSave: true, forceSave: true,
}), }),
reactionMessage.hydrateStoryContext(message), reactionMessage.hydrateStoryContext(message.attributes),
]); ]);
void conversation.addSingleMessage( void conversation.addSingleMessage(

View file

@ -6,11 +6,9 @@
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { Collection, Model } from 'backbone'; import { Collection, Model } from 'backbone';
import type { ConversationModel } from '../models/conversations';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
import type { MessageAttributesType } from '../model-types.d'; import type { MessageAttributesType } from '../model-types.d';
import { isOutgoing, isStory } from '../state/selectors/message'; import { isOutgoing, isStory } from '../state/selectors/message';
import { isDirectConversation } from '../util/whatTypeOfConversation';
import { getOwn } from '../util/getOwn'; import { getOwn } from '../util/getOwn';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { createWaitBatcher } from '../util/waitBatcher'; import { createWaitBatcher } from '../util/waitBatcher';
@ -24,6 +22,7 @@ import {
import type { DeleteSentProtoRecipientOptionsType } from '../sql/Interface'; import type { DeleteSentProtoRecipientOptionsType } from '../sql/Interface';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { getSourceUuid } from '../messages/helpers';
const { deleteSentProtoRecipient } = dataInterface; const { deleteSentProtoRecipient } = dataInterface;
@ -148,24 +147,20 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
return singleton; return singleton;
} }
forMessage( forMessage(message: MessageModel): Array<MessageReceiptModel> {
conversation: ConversationModel, if (!isOutgoing(message.attributes) && !isStory(message.attributes)) {
message: MessageModel
): Array<MessageReceiptModel> {
if (!isOutgoing(message.attributes)) {
return []; return [];
} }
let ids: Array<string>;
if (isDirectConversation(conversation.attributes)) { const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
ids = [conversation.id]; const sourceUuid = getSourceUuid(message.attributes);
} else { if (ourUuid !== sourceUuid) {
ids = conversation.getMemberIds(); return [];
} }
const sentAt = message.get('sent_at'); const sentAt = message.get('sent_at');
const receipts = this.filter( const receipts = this.filter(
receipt => receipt => receipt.get('messageSentAt') === sentAt
receipt.get('messageSentAt') === sentAt &&
ids.includes(receipt.get('sourceConversationId'))
); );
if (receipts.length) { if (receipts.length) {
log.info(`MessageReceipts: found early receipts for message ${sentAt}`); log.info(`MessageReceipts: found early receipts for message ${sentAt}`);

View file

@ -15,7 +15,7 @@ import * as log from '../logging/log';
import { getContactId, getContact } from '../messages/helpers'; import { getContactId, getContact } from '../messages/helpers';
import { isDirectConversation, isMe } from '../util/whatTypeOfConversation'; import { isDirectConversation, isMe } from '../util/whatTypeOfConversation';
import { isOutgoing, isStory } from '../state/selectors/message'; import { isOutgoing, isStory } from '../state/selectors/message';
import { getMessageIdForLogging } from '../util/idForLogging'; import { strictAssert } from '../util/assert';
export class ReactionModel extends Model<ReactionAttributesType> {} export class ReactionModel extends Model<ReactionAttributesType> {}
@ -83,10 +83,7 @@ export class Reactions extends Collection<ReactionModel> {
}); });
} }
async onReaction( async onReaction(reaction: ReactionModel): Promise<void> {
reaction: ReactionModel,
generatedMessage: MessageModel
): Promise<void> {
try { try {
// The conversation the target message was in; we have to find it in the database // The conversation the target message was in; we have to find it in the database
// to to figure that out. // to to figure that out.
@ -102,6 +99,11 @@ export class Reactions extends Collection<ReactionModel> {
); );
} }
const generatedMessage = reaction.get('storyReactionMessage');
strictAssert(
generatedMessage,
'Story reactions must provide storyReactionMessage'
);
const fromConversation = window.ConversationController.get( const fromConversation = window.ConversationController.get(
generatedMessage.get('conversationId') generatedMessage.get('conversationId')
); );
@ -115,6 +117,8 @@ export class Reactions extends Collection<ReactionModel> {
if (!targetMessageCheck) { if (!targetMessageCheck) {
log.info( log.info(
'No message for reaction', 'No message for reaction',
reaction.get('timestamp'),
'targeting',
reaction.get('targetAuthorUuid'), reaction.get('targetAuthorUuid'),
reaction.get('targetTimestamp') reaction.get('targetTimestamp')
); );
@ -173,45 +177,13 @@ export class Reactions extends Collection<ReactionModel> {
// Use the generated message in ts/background.ts to create a message // Use the generated message in ts/background.ts to create a message
// if the reaction is targeted at a story. // if the reaction is targeted at a story.
if (isStory(targetMessage)) { if (!isStory(targetMessage)) {
generatedMessage.set({
expireTimer: targetConversation.get('expireTimer'),
storyId: targetMessage.id,
storyReaction: {
emoji: reaction.get('emoji'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
},
});
// Note: generatedMessage comes with an id, so we have to force this save
await Promise.all([
window.Signal.Data.saveMessage(generatedMessage.attributes, {
ourUuid: window.textsecure.storage.user
.getCheckedUuid()
.toString(),
forceSave: true,
}),
generatedMessage.hydrateStoryContext(message),
]);
log.info('Reactions.onReaction adding reaction to story', {
reactionMessageId: getMessageIdForLogging(
generatedMessage.attributes
),
storyId: getMessageIdForLogging(targetMessage),
targetTimestamp: reaction.get('targetTimestamp'),
timestamp: reaction.get('timestamp'),
});
const messageToAdd = window.MessageController.register(
generatedMessage.id,
generatedMessage
);
void targetConversation.addSingleMessage(messageToAdd);
}
await message.handleReaction(reaction); await message.handleReaction(reaction);
} else {
await generatedMessage.handleReaction(reaction, {
storyMessage: targetMessage,
});
}
this.remove(reaction); this.remove(reaction);
}); });

7
ts/model-types.d.ts vendored
View file

@ -470,10 +470,13 @@ export declare class MessageModelCollectionType extends Backbone.Collection<Mess
export type ReactionAttributesType = { export type ReactionAttributesType = {
emoji: string; emoji: string;
fromId: string;
remove?: boolean; remove?: boolean;
source: ReactionSource;
// Necessary to put 1:1 story replies into the right conversation - not the same
// conversation as the target message!
storyReactionMessage?: MessageModel;
targetAuthorUuid: string; targetAuthorUuid: string;
targetTimestamp: number; targetTimestamp: number;
fromId: string;
timestamp: number; timestamp: number;
source: ReactionSource;
}; };

View file

@ -5357,6 +5357,7 @@ export class ConversationModel extends window.Backbone
} }
const conversationId = this.id; const conversationId = this.id;
const isMessageInDirectConversation = isDirectConversation(this.attributes);
const sender = reaction const sender = reaction
? window.ConversationController.get(reaction.get('fromId')) ? window.ConversationController.get(reaction.get('fromId'))
@ -5364,7 +5365,7 @@ export class ConversationModel extends window.Backbone
const senderName = sender const senderName = sender
? sender.getTitle() ? sender.getTitle()
: window.i18n('unknownContact'); : window.i18n('unknownContact');
const senderTitle = isDirectConversation(this.attributes) const senderTitle = isMessageInDirectConversation
? senderName ? senderName
: window.i18n('notificationSenderInGroup', { : window.i18n('notificationSenderInGroup', {
sender: senderName, sender: senderName,
@ -5375,7 +5376,7 @@ export class ConversationModel extends window.Backbone
const avatar = this.get('avatar') || this.get('profileAvatar'); const avatar = this.get('avatar') || this.get('profileAvatar');
if (avatar && avatar.path) { if (avatar && avatar.path) {
notificationIconUrl = getAbsoluteAttachmentPath(avatar.path); notificationIconUrl = getAbsoluteAttachmentPath(avatar.path);
} else if (isDirectConversation(this.attributes)) { } else if (isMessageInDirectConversation) {
notificationIconUrl = await this.getIdenticon(); notificationIconUrl = await this.getIdenticon();
} else { } else {
// Not technically needed, but helps us be explicit: we don't show an icon for a // Not technically needed, but helps us be explicit: we don't show an icon for a
@ -5390,7 +5391,9 @@ export class ConversationModel extends window.Backbone
notificationService.add({ notificationService.add({
senderTitle, senderTitle,
conversationId, conversationId,
storyId: message.get('storyId'), storyId: isMessageInDirectConversation
? undefined
: message.get('storyId'),
notificationIconUrl, notificationIconUrl,
isExpiringMessage, isExpiringMessage,
message: message.getNotificationText(), message: message.getNotificationText(),

View file

@ -39,7 +39,6 @@ import { softAssert, strictAssert } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import type { ConversationModel } from './conversations'; import type { ConversationModel } from './conversations';
import { getCallingNotificationText } from '../util/callingNotification'; import { getCallingNotificationText } from '../util/callingNotification';
import type { import type {
@ -415,20 +414,22 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
async hydrateStoryContext( async hydrateStoryContext(
inMemoryMessage?: MessageModel | null inMemoryMessage?: MessageAttributesType
): Promise<void> { ): Promise<void> {
const storyId = this.get('storyId'); const storyId = this.get('storyId');
if (!storyId) { if (!storyId) {
return; return;
} }
if (this.get('storyReplyContext')) { const context = this.get('storyReplyContext');
// We'll continue trying to get the attachment as long as the message still exists
if (context && (context.attachment?.url || !context.messageId)) {
return; return;
} }
const message = const message =
inMemoryMessage === undefined inMemoryMessage === undefined
? await getMessageById(storyId) ? (await getMessageById(storyId))?.attributes
: inMemoryMessage; : inMemoryMessage;
if (!message) { if (!message) {
@ -450,13 +451,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return; return;
} }
const attachments = getAttachmentsForMessage({ ...message.attributes }); const attachments = getAttachmentsForMessage({ ...message });
let attachment: AttachmentType | undefined = attachments?.[0];
if (attachment && !attachment.url) {
attachment = undefined;
}
this.set({ this.set({
storyReplyContext: { storyReplyContext: {
attachment: attachments ? attachments[0] : undefined, attachment,
authorUuid: message.get('sourceUuid'), authorUuid: message.sourceUuid,
messageId: message.get('id'), messageId: message.id,
}, },
}); });
} }
@ -1078,7 +1083,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (this.get('storyReplyContext')) { if (this.get('storyReplyContext')) {
this.unset('storyReplyContext'); this.unset('storyReplyContext');
} }
await this.hydrateStoryContext(message); await this.hydrateStoryContext(message.attributes);
return; return;
} }
@ -2478,11 +2483,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
findStoryMessage(conversation.id, initialMessage.storyContext), findStoryMessage(conversation.id, initialMessage.storyContext),
]); ]);
if ( if (initialMessage.storyContext && !storyQuote) {
initialMessage.storyContext && if (!isDirectConversation(conversation.attributes)) {
!storyQuote &&
!isDirectConversation(conversation.attributes)
) {
log.warn( log.warn(
`${idLog}: Received storyContext message in group but no matching story. Dropping.` `${idLog}: Received storyContext message in group but no matching story. Dropping.`
); );
@ -2490,6 +2492,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
confirm(); confirm();
return; return;
} }
log.warn(
`${idLog}: Received 1:1 storyContext message but no matching story. We'll try processing this message again later.`
);
return;
}
if (storyQuote) { if (storyQuote) {
const sendStateByConversationId = const sendStateByConversationId =
@ -2511,10 +2519,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if ( if (
storyQuoteIsFromSelf && storyQuoteIsFromSelf &&
sendState.isAllowedToReplyToStory === false sendState.isAllowedToReplyToStory === false &&
isDirectConversation(conversation.attributes)
) { ) {
log.warn( log.warn(
`${idLog}: Received storyContext message but sender is not allowed to reply. Dropping.` `${idLog}: Received 1:1 storyContext message but sender is not allowed to reply. Dropping.`
); );
confirm(); confirm();
@ -2619,7 +2628,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}); });
if (storyQuote) { if (storyQuote) {
await this.hydrateStoryContext(storyQuote); await this.hydrateStoryContext(storyQuote.attributes);
} }
const isSupported = !isUnsupportedMessage(message.attributes); const isSupported = !isUnsupportedMessage(message.attributes);
@ -3063,10 +3072,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const message = this; const message = this;
const type = message.get('type'); const type = message.get('type');
let changed = false; let changed = false;
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const sourceUuid = getSourceUuid(message.attributes);
if (type === 'outgoing') { if (type === 'outgoing' || (type === 'story' && ourUuid === sourceUuid)) {
const sendActions = MessageReceipts.getSingleton() const sendActions = MessageReceipts.getSingleton()
.forMessage(conversation, message) .forMessage(message)
.map(receipt => { .map(receipt => {
let sendActionType: SendActionType; let sendActionType: SendActionType;
const receiptType = receipt.get('type'); const receiptType = receipt.get('type');
@ -3252,8 +3263,20 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const reactions = Reactions.getSingleton().forMessage(message); const reactions = Reactions.getSingleton().forMessage(message);
await Promise.all( await Promise.all(
reactions.map(async reaction => { reactions.map(async reaction => {
await message.handleReaction(reaction, false); if (isStory(this.attributes)) {
// We don't set changed = true here, because we don't modify the original story
const generatedMessage = reaction.get('storyReactionMessage');
strictAssert(
generatedMessage,
'Story reactions must provide storyReactionMessage'
);
await generatedMessage.handleReaction(reaction, {
storyMessage: this.attributes,
});
} else {
changed = true; changed = true;
await message.handleReaction(reaction, { shouldPersist: false });
}
}) })
); );
@ -3279,7 +3302,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async handleReaction( async handleReaction(
reaction: ReactionModel, reaction: ReactionModel,
shouldPersist = true {
storyMessage,
shouldPersist = true,
}: {
storyMessage?: MessageAttributesType;
shouldPersist?: boolean;
} = {}
): Promise<void> { ): Promise<void> {
const { attributes } = this; const { attributes } = this;
@ -3305,7 +3334,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return; return;
} }
const previousLength = (this.get('reactions') || []).length;
const newReaction: MessageReactionType = { const newReaction: MessageReactionType = {
emoji: reaction.get('remove') ? undefined : reaction.get('emoji'), emoji: reaction.get('remove') ? undefined : reaction.get('emoji'),
fromId: reaction.get('fromId'), fromId: reaction.get('fromId'),
@ -3328,25 +3356,95 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
'Reaction can only be from this device, from sync, or from someone else' 'Reaction can only be from this device, from sync, or from someone else'
); );
if (isStory(this.attributes)) { // Reactions to stories are saved as separate messages, and so require a totally
// different codepath.
if (storyMessage) {
if (isFromThisDevice) { if (isFromThisDevice) {
log.info( log.info(
'handleReaction: sending story reaction to ' + 'handleReaction: sending story reaction to ' +
`${this.idForLogging()} from this device` `${getMessageIdForLogging(storyMessage)} from this device`
); );
} else if (isFromSync) { } else {
const generatedMessage = reaction.get('storyReactionMessage');
strictAssert(
generatedMessage,
'Story reactions must provide storyReactionMessage'
);
const targetConversation = window.ConversationController.get(
generatedMessage.get('conversationId')
);
strictAssert(
targetConversation,
'handleReaction: targetConversation not found'
);
generatedMessage.set({
expireTimer: isDirectConversation(targetConversation.attributes)
? targetConversation.get('expireTimer')
: undefined,
storyId: storyMessage.id,
storyReaction: {
emoji: reaction.get('emoji'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
},
});
// Note: generatedMessage comes with an id, so we have to force this save
await Promise.all([
window.Signal.Data.saveMessage(generatedMessage.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
forceSave: true,
}),
generatedMessage.hydrateStoryContext(storyMessage),
]);
log.info('Reactions.onReaction adding reaction to story', {
reactionMessageId: getMessageIdForLogging(
generatedMessage.attributes
),
storyId: getMessageIdForLogging(storyMessage),
targetTimestamp: reaction.get('targetTimestamp'),
timestamp: reaction.get('timestamp'),
});
const messageToAdd = window.MessageController.register(
generatedMessage.id,
generatedMessage
);
if (isDirectConversation(targetConversation.attributes)) {
await targetConversation.addSingleMessage(messageToAdd);
if (!targetConversation.get('active_at')) {
targetConversation.set({
active_at: messageToAdd.get('timestamp'),
});
window.Signal.Data.updateConversation(
targetConversation.attributes
);
}
}
if (isFromSomeoneElse) {
drop(targetConversation.notify(messageToAdd));
}
if (isFromSync) {
log.info( log.info(
'handleReaction: receiving story reaction to ' + 'handleReaction: receiving story reaction to ' +
`${this.idForLogging()} from another device` `${getMessageIdForLogging(storyMessage)} from another device`
); );
} else { } else {
log.info( log.info(
'handleReaction: receiving story reaction to ' + 'handleReaction: receiving story reaction to ' +
`${this.idForLogging()} from someone else` `${getMessageIdForLogging(storyMessage)} from someone else`
); );
void conversation.notify(this, reaction); void conversation.notify(this, reaction);
} }
} else if (isFromThisDevice) { }
} else {
// Reactions to all messages other than stories will update the target message
const previousLength = (this.get('reactions') || []).length;
if (isFromThisDevice) {
log.info( log.info(
`handleReaction: sending reaction to ${this.idForLogging()} ` + `handleReaction: sending reaction to ${this.idForLogging()} ` +
'from this device' 'from this device'
@ -3361,7 +3459,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const oldReactions = this.get('reactions') || []; const oldReactions = this.get('reactions') || [];
let reactions: Array<MessageReactionType>; let reactions: Array<MessageReactionType>;
const oldReaction = oldReactions.find(re => const oldReaction = oldReactions.find(re =>
isNewReactionReplacingPrevious(re, reaction.attributes) isNewReactionReplacingPrevious(re, newReaction)
); );
if (oldReaction) { if (oldReaction) {
this.clearNotifications(oldReaction); this.clearNotifications(oldReaction);
@ -3376,12 +3474,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (isFromSync) { if (isFromSync) {
reactions = oldReactions.filter( reactions = oldReactions.filter(
re => re =>
!isNewReactionReplacingPrevious(re, reaction.attributes) || !isNewReactionReplacingPrevious(re, newReaction) ||
re.timestamp > reaction.get('timestamp') re.timestamp > reaction.get('timestamp')
); );
} else { } else {
reactions = oldReactions.filter( reactions = oldReactions.filter(
re => !isNewReactionReplacingPrevious(re, reaction.attributes) re => !isNewReactionReplacingPrevious(re, newReaction)
); );
} }
this.set({ reactions }); this.set({ reactions });
@ -3401,12 +3499,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
let reactionToAdd: MessageReactionType; let reactionToAdd: MessageReactionType;
if (isFromSync) { if (isFromSync) {
const ourReactions = [ const ourReactions = [
reaction.toJSON(), newReaction,
...oldReactions.filter(re => re.fromId === reaction.get('fromId')), ...oldReactions.filter(
re => re.fromId === reaction.get('fromId')
),
]; ];
reactionToAdd = maxBy(ourReactions, 'timestamp'); reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction;
} else { } else {
reactionToAdd = reaction.toJSON(); reactionToAdd = newReaction;
} }
reactions = oldReactions.filter( reactions = oldReactions.filter(
@ -3437,54 +3537,40 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
`Done processing reaction for message ${this.idForLogging()}.`, `Done processing reaction for message ${this.idForLogging()}.`,
`Went from ${previousLength} to ${currentLength} reactions.` `Went from ${previousLength} to ${currentLength} reactions.`
); );
}
if (isFromThisDevice) { if (isFromThisDevice) {
let jobData: ConversationQueueJobData; let jobData: ConversationQueueJobData;
if (isStory(this.attributes)) { if (storyMessage) {
strictAssert( strictAssert(
newReaction.emoji !== undefined, newReaction.emoji !== undefined,
'New story reaction must have an emoji' 'New story reaction must have an emoji'
); );
const reactionMessage = new window.Whisper.Message({
id: UUID.generate().toString(),
type: 'outgoing',
conversationId: conversation.id,
sent_at: newReaction.timestamp,
received_at: incrementMessageCounter(),
received_at_ms: newReaction.timestamp,
timestamp: newReaction.timestamp,
expireTimer: conversation.get('expireTimer'),
sendStateByConversationId: zipObject(
Object.keys(newReaction.isSentByConversationId || {}),
repeat({
status: SendStatus.Pending,
updatedAt: Date.now(),
})
),
storyId: this.id,
storyReaction: {
emoji: newReaction.emoji,
targetAuthorUuid: newReaction.targetAuthorUuid,
targetTimestamp: newReaction.targetTimestamp,
},
});
const generatedMessage = reaction.get('storyReactionMessage');
strictAssert(
generatedMessage,
'Story reactions must provide storyReactionmessage'
);
await Promise.all([ await Promise.all([
await window.Signal.Data.saveMessage(reactionMessage.attributes, { await window.Signal.Data.saveMessage(generatedMessage.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
forceSave: true, forceSave: true,
}), }),
reactionMessage.hydrateStoryContext(this), generatedMessage.hydrateStoryContext(this.attributes),
]); ]);
void conversation.addSingleMessage( void conversation.addSingleMessage(
window.MessageController.register(reactionMessage.id, reactionMessage) window.MessageController.register(
generatedMessage.id,
generatedMessage
)
); );
jobData = { jobData = {
type: conversationQueueJobEnum.enum.NormalMessage, type: conversationQueueJobEnum.enum.NormalMessage,
conversationId: conversation.id, conversationId: conversation.id,
messageId: reactionMessage.id, messageId: generatedMessage.id,
revision: conversation.get('revision'), revision: conversation.get('revision'),
}; };
} else { } else {

View file

@ -4,8 +4,13 @@
import { ReactionModel } from '../messageModifiers/Reactions'; import { ReactionModel } from '../messageModifiers/Reactions';
import { ReactionSource } from './ReactionSource'; import { ReactionSource } from './ReactionSource';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
import { getSourceUuid } from '../messages/helpers'; import { getSourceUuid, isStory } from '../messages/helpers';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { isDirectConversation } from '../util/whatTypeOfConversation';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { repeat, zipObject } from '../util/iterables';
import { SendStatus } from '../messages/MessageSendState';
import { UUID } from '../types/UUID';
export async function enqueueReactionForSend({ export async function enqueueReactionForSend({
emoji, emoji,
@ -31,15 +36,64 @@ export async function enqueueReactionForSend({
`enqueueReactionForSend: message ${message.idForLogging()} had no timestamp` `enqueueReactionForSend: message ${message.idForLogging()} had no timestamp`
); );
const reaction = new ReactionModel({ const timestamp = Date.now();
const messageConversation = message.getConversation();
strictAssert(
messageConversation,
'enqueueReactionForSend: No conversation extracted from target message'
);
const targetConversation =
isStory(message.attributes) &&
isDirectConversation(messageConversation.attributes)
? window.ConversationController.get(targetAuthorUuid)
: messageConversation;
strictAssert(
targetConversation,
'enqueueReactionForSend: Did not find a targetConversation'
);
const storyMessage = isStory(message.attributes)
? message.attributes
: undefined;
// Only used in story scenarios, where we use a whole message to represent the reaction
const storyReactionMessage = storyMessage
? new window.Whisper.Message({
id: UUID.generate().toString(),
type: 'outgoing',
conversationId: targetConversation.id,
sent_at: timestamp,
received_at: incrementMessageCounter(),
received_at_ms: timestamp,
timestamp,
expireTimer: targetConversation.get('expireTimer'),
sendStateByConversationId: zipObject(
targetConversation.getMemberConversationIds(),
repeat({
status: SendStatus.Pending,
updatedAt: Date.now(),
})
),
storyId: message.id,
storyReaction: {
emoji, emoji,
remove,
targetAuthorUuid, targetAuthorUuid,
targetTimestamp, targetTimestamp,
},
})
: undefined;
const reaction = new ReactionModel({
emoji,
fromId: window.ConversationController.getOurConversationIdOrThrow(), fromId: window.ConversationController.getOurConversationIdOrThrow(),
timestamp: Date.now(), remove,
source: ReactionSource.FromThisDevice, source: ReactionSource.FromThisDevice,
storyReactionMessage,
targetAuthorUuid,
targetTimestamp,
timestamp,
}); });
await message.handleReaction(reaction); await message.handleReaction(reaction, { storyMessage });
} }

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isPlainObject } from 'lodash'; import { isPlainObject } from 'lodash';
import * as log from '../logging/log';
import { isIterable } from '../util/iterables'; import { isIterable } from '../util/iterables';
@ -22,7 +23,7 @@ export function cleanDataForIpc(data: unknown): {
pathsChanged: Array<string>; pathsChanged: Array<string>;
} { } {
const pathsChanged: Array<string> = []; const pathsChanged: Array<string> = [];
const cleaned = cleanDataInner(data, 'root', pathsChanged); const cleaned = cleanDataInner(data, 'root', pathsChanged, 0);
return { cleaned, pathsChanged }; return { cleaned, pathsChanged };
} }
@ -49,8 +50,17 @@ interface CleanedArray extends Array<CleanedDataValue> {}
function cleanDataInner( function cleanDataInner(
data: unknown, data: unknown,
path: string, path: string,
pathsChanged: Array<string> pathsChanged: Array<string>,
depth: number
): CleanedDataValue { ): CleanedDataValue {
if (depth > 10) {
log.error(
`cleanDataInner: Reached maximum depth ${depth}; path is ${path}`
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { cleaned: data as any, pathsChanged };
}
switch (typeof data) { switch (typeof data) {
case 'undefined': case 'undefined':
case 'boolean': case 'boolean':
@ -77,7 +87,9 @@ function cleanDataInner(
if (item == null) { if (item == null) {
pathsChanged.push(indexPath); pathsChanged.push(indexPath);
} else { } else {
result.push(cleanDataInner(item, indexPath, pathsChanged)); result.push(
cleanDataInner(item, indexPath, pathsChanged, depth + 1)
);
} }
}); });
return result; return result;
@ -91,7 +103,8 @@ function cleanDataInner(
result[key] = cleanDataInner( result[key] = cleanDataInner(
value, value,
`${path}.<map value at ${key}>`, `${path}.<map value at ${key}>`,
pathsChanged pathsChanged,
depth + 1
); );
} else { } else {
pathsChanged.push(`${path}.<map key ${String(key)}>`); pathsChanged.push(`${path}.<map key ${String(key)}>`);
@ -121,7 +134,12 @@ function cleanDataInner(
typeof dataAsRecord.toNumber === 'function' typeof dataAsRecord.toNumber === 'function'
) { ) {
// We clean this just in case `toNumber` returns something bogus. // We clean this just in case `toNumber` returns something bogus.
return cleanDataInner(dataAsRecord.toNumber(), path, pathsChanged); return cleanDataInner(
dataAsRecord.toNumber(),
path,
pathsChanged,
depth + 1
);
} }
if (isIterable(dataAsRecord)) { if (isIterable(dataAsRecord)) {
@ -133,7 +151,8 @@ function cleanDataInner(
cleanDataInner( cleanDataInner(
value, value,
`${path}.<iterator index ${index}>`, `${path}.<iterator index ${index}>`,
pathsChanged pathsChanged,
depth + 1
) )
); );
index += 1; index += 1;
@ -151,7 +170,12 @@ function cleanDataInner(
// Conveniently, `Object.entries` removes symbol keys. // Conveniently, `Object.entries` removes symbol keys.
Object.entries(dataAsRecord).forEach(([key, value]) => { Object.entries(dataAsRecord).forEach(([key, value]) => {
result[key] = cleanDataInner(value, `${path}.${key}`, pathsChanged); result[key] = cleanDataInner(
value,
`${path}.${key}`,
pathsChanged,
depth + 1
);
}); });
return result; return result;

View file

@ -5,6 +5,7 @@ import type { MessageAttributesType } from '../model-types.d';
import { deletePackReference } from '../types/Stickers'; import { deletePackReference } from '../types/Stickers';
import { isStory } from '../messages/helpers'; import { isStory } from '../messages/helpers';
import { isDirectConversation } from './whatTypeOfConversation'; import { isDirectConversation } from './whatTypeOfConversation';
import { drop } from './drop';
export async function cleanupMessage( export async function cleanupMessage(
message: MessageAttributesType message: MessageAttributesType
@ -78,7 +79,7 @@ async function cleanupStoryReplies(
replies.forEach(reply => { replies.forEach(reply => {
const model = window.MessageController.register(reply.id, reply); const model = window.MessageController.register(reply.id, reply);
model.unset('storyReplyContext'); model.unset('storyReplyContext');
void model.hydrateStoryContext(null); drop(model.hydrateStoryContext());
}); });
} }

View file

@ -3,7 +3,10 @@
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import type { MessageAttributesType } from '../model-types.d'; import type { MessageAttributesType } from '../model-types.d';
import type { SendStateByConversationId } from '../messages/MessageSendState'; import type {
SendState,
SendStateByConversationId,
} from '../messages/MessageSendState';
import type { UUIDStringType } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID';
import * as log from '../logging/log'; import * as log from '../logging/log';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
@ -261,12 +264,14 @@ export async function sendStoryMessage(
const groupTimestamp = timestamp + index + 1; const groupTimestamp = timestamp + index + 1;
const myId = window.ConversationController.getOurConversationIdOrThrow(); const myId = window.ConversationController.getOurConversationIdOrThrow();
const sendState = { const sendState: SendState = {
status: SendStatus.Pending, status: SendStatus.Pending,
updatedAt: groupTimestamp, updatedAt: groupTimestamp,
isAllowedToReplyToStory: true,
}; };
const sendStateByConversationId = getRecipients(group.attributes).reduce( const sendStateByConversationId: SendStateByConversationId =
getRecipients(group.attributes).reduce(
(acc, id) => { (acc, id) => {
const conversation = window.ConversationController.get(id); const conversation = window.ConversationController.get(id);
if (!conversation) { if (!conversation) {