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);
const attributes: ReactionAttributesType = {
emoji: reaction.emoji,
fromId: fromConversation.id,
remove: reaction.remove,
source: ReactionSource.FromSomeoneElse,
storyReactionMessage: message,
targetAuthorUuid,
targetTimestamp: reaction.targetTimestamp,
timestamp,
fromId: fromConversation.id,
source: ReactionSource.FromSomeoneElse,
};
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));
confirm();
return;
}
@ -3383,16 +3384,17 @@ export async function startApp(): Promise<void> {
log.info('Queuing sent reaction for', reaction.targetTimestamp);
const attributes: ReactionAttributesType = {
emoji: reaction.emoji,
fromId: window.ConversationController.getOurConversationIdOrThrow(),
remove: reaction.remove,
source: ReactionSource.FromSync,
storyReactionMessage: message,
targetAuthorUuid,
targetTimestamp: reaction.targetTimestamp,
timestamp,
fromId: window.ConversationController.getOurConversationIdOrThrow(),
source: ReactionSource.FromSync,
};
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();
return;
@ -3767,7 +3769,7 @@ export async function startApp(): Promise<void> {
const attributes: MessageReceiptAttributesType = {
messageSentAt: timestamp,
receiptTimestamp: envelopeTimestamp,
sourceConversationId: sourceConversation?.id,
sourceConversationId: sourceConversation.id,
sourceUuid,
sourceDevice,
type,

View file

@ -326,6 +326,7 @@ export function StoryViewsNRepliesModal({
return (
<ReplyOrReactionMessage
key={reply.id}
id={reply.id}
i18n={i18n}
isInternalUser={isInternalUser}
reply={reply}
@ -504,6 +505,7 @@ export function StoryViewsNRepliesModal({
type ReplyOrReactionMessageProps = {
i18n: LocalizerType;
id: string;
isInternalUser?: boolean;
reply: ReplyType;
deleteGroupStoryReply: (replyId: string) => void;
@ -517,6 +519,7 @@ type ReplyOrReactionMessageProps = {
function ReplyOrReactionMessage({
i18n,
id,
isInternalUser,
reply,
deleteGroupStoryReply,
@ -532,6 +535,7 @@ function ReplyOrReactionMessage({
<div
className="StoryViewsNRepliesModal__reaction"
onContextMenu={onContextMenu}
data-id={id}
>
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
@ -570,33 +574,35 @@ function ReplyOrReactionMessage({
}
return (
<Message
{...MESSAGE_DEFAULT_PROPS}
author={reply.author}
bodyRanges={reply.bodyRanges}
contactNameColor={reply.contactNameColor}
containerElementRef={containerElementRef}
conversationColor="ultramarine"
conversationId={reply.conversationId}
conversationTitle={reply.author.title}
conversationType="group"
direction="incoming"
deletedForEveryone={reply.deletedForEveryone}
renderMenu={undefined}
onContextMenu={onContextMenu}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
id={reply.id}
interactionMode="mouse"
readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal"
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={false}
text={reply.body}
textDirection={TextDirection.Default}
timestamp={reply.timestamp}
/>
<div className="StoryViewsNRepliesModal__reply" data-id={id}>
<Message
{...MESSAGE_DEFAULT_PROPS}
author={reply.author}
bodyRanges={reply.bodyRanges}
contactNameColor={reply.contactNameColor}
containerElementRef={containerElementRef}
conversationColor="ultramarine"
conversationId={reply.conversationId}
conversationTitle={reply.author.title}
conversationType="group"
direction="incoming"
deletedForEveryone={reply.deletedForEveryone}
renderMenu={undefined}
onContextMenu={onContextMenu}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
id={reply.id}
interactionMode="mouse"
readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal"
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={false}
text={reply.body}
textDirection={TextDirection.Default}
timestamp={reply.timestamp}
/>
</div>
);
};

View file

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

View file

@ -6,11 +6,9 @@
import { isEqual } from 'lodash';
import { Collection, Model } from 'backbone';
import type { ConversationModel } from '../models/conversations';
import type { MessageModel } from '../models/messages';
import type { MessageAttributesType } from '../model-types.d';
import { isOutgoing, isStory } from '../state/selectors/message';
import { isDirectConversation } from '../util/whatTypeOfConversation';
import { getOwn } from '../util/getOwn';
import { missingCaseError } from '../util/missingCaseError';
import { createWaitBatcher } from '../util/waitBatcher';
@ -24,6 +22,7 @@ import {
import type { DeleteSentProtoRecipientOptionsType } from '../sql/Interface';
import dataInterface from '../sql/Client';
import * as log from '../logging/log';
import { getSourceUuid } from '../messages/helpers';
const { deleteSentProtoRecipient } = dataInterface;
@ -148,24 +147,20 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
return singleton;
}
forMessage(
conversation: ConversationModel,
message: MessageModel
): Array<MessageReceiptModel> {
if (!isOutgoing(message.attributes)) {
forMessage(message: MessageModel): Array<MessageReceiptModel> {
if (!isOutgoing(message.attributes) && !isStory(message.attributes)) {
return [];
}
let ids: Array<string>;
if (isDirectConversation(conversation.attributes)) {
ids = [conversation.id];
} else {
ids = conversation.getMemberIds();
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const sourceUuid = getSourceUuid(message.attributes);
if (ourUuid !== sourceUuid) {
return [];
}
const sentAt = message.get('sent_at');
const receipts = this.filter(
receipt =>
receipt.get('messageSentAt') === sentAt &&
ids.includes(receipt.get('sourceConversationId'))
receipt => receipt.get('messageSentAt') === sentAt
);
if (receipts.length) {
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 { isDirectConversation, isMe } from '../util/whatTypeOfConversation';
import { isOutgoing, isStory } from '../state/selectors/message';
import { getMessageIdForLogging } from '../util/idForLogging';
import { strictAssert } from '../util/assert';
export class ReactionModel extends Model<ReactionAttributesType> {}
@ -83,10 +83,7 @@ export class Reactions extends Collection<ReactionModel> {
});
}
async onReaction(
reaction: ReactionModel,
generatedMessage: MessageModel
): Promise<void> {
async onReaction(reaction: ReactionModel): Promise<void> {
try {
// The conversation the target message was in; we have to find it in the database
// 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(
generatedMessage.get('conversationId')
);
@ -115,6 +117,8 @@ export class Reactions extends Collection<ReactionModel> {
if (!targetMessageCheck) {
log.info(
'No message for reaction',
reaction.get('timestamp'),
'targeting',
reaction.get('targetAuthorUuid'),
reaction.get('targetTimestamp')
);
@ -173,46 +177,14 @@ export class Reactions extends Collection<ReactionModel> {
// Use the generated message in ts/background.ts to create a message
// if the reaction is targeted at a story.
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'),
},
if (!isStory(targetMessage)) {
await message.handleReaction(reaction);
} else {
await generatedMessage.handleReaction(reaction, {
storyMessage: targetMessage,
});
// 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);
this.remove(reaction);
});
} catch (error) {

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

@ -470,10 +470,13 @@ export declare class MessageModelCollectionType extends Backbone.Collection<Mess
export type ReactionAttributesType = {
emoji: string;
fromId: string;
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;
targetTimestamp: number;
fromId: string;
timestamp: number;
source: ReactionSource;
};

View file

@ -5357,6 +5357,7 @@ export class ConversationModel extends window.Backbone
}
const conversationId = this.id;
const isMessageInDirectConversation = isDirectConversation(this.attributes);
const sender = reaction
? window.ConversationController.get(reaction.get('fromId'))
@ -5364,7 +5365,7 @@ export class ConversationModel extends window.Backbone
const senderName = sender
? sender.getTitle()
: window.i18n('unknownContact');
const senderTitle = isDirectConversation(this.attributes)
const senderTitle = isMessageInDirectConversation
? senderName
: window.i18n('notificationSenderInGroup', {
sender: senderName,
@ -5375,7 +5376,7 @@ export class ConversationModel extends window.Backbone
const avatar = this.get('avatar') || this.get('profileAvatar');
if (avatar && avatar.path) {
notificationIconUrl = getAbsoluteAttachmentPath(avatar.path);
} else if (isDirectConversation(this.attributes)) {
} else if (isMessageInDirectConversation) {
notificationIconUrl = await this.getIdenticon();
} else {
// 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({
senderTitle,
conversationId,
storyId: message.get('storyId'),
storyId: isMessageInDirectConversation
? undefined
: message.get('storyId'),
notificationIconUrl,
isExpiringMessage,
message: message.getNotificationText(),

View file

@ -39,7 +39,6 @@ import { softAssert, strictAssert } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError';
import { drop } from '../util/drop';
import { dropNull } from '../util/dropNull';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import type { ConversationModel } from './conversations';
import { getCallingNotificationText } from '../util/callingNotification';
import type {
@ -415,20 +414,22 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
async hydrateStoryContext(
inMemoryMessage?: MessageModel | null
inMemoryMessage?: MessageAttributesType
): Promise<void> {
const storyId = this.get('storyId');
if (!storyId) {
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;
}
const message =
inMemoryMessage === undefined
? await getMessageById(storyId)
? (await getMessageById(storyId))?.attributes
: inMemoryMessage;
if (!message) {
@ -450,13 +451,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return;
}
const attachments = getAttachmentsForMessage({ ...message.attributes });
const attachments = getAttachmentsForMessage({ ...message });
let attachment: AttachmentType | undefined = attachments?.[0];
if (attachment && !attachment.url) {
attachment = undefined;
}
this.set({
storyReplyContext: {
attachment: attachments ? attachments[0] : undefined,
authorUuid: message.get('sourceUuid'),
messageId: message.get('id'),
attachment,
authorUuid: message.sourceUuid,
messageId: message.id,
},
});
}
@ -1078,7 +1083,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (this.get('storyReplyContext')) {
this.unset('storyReplyContext');
}
await this.hydrateStoryContext(message);
await this.hydrateStoryContext(message.attributes);
return;
}
@ -2478,16 +2483,19 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
findStoryMessage(conversation.id, initialMessage.storyContext),
]);
if (
initialMessage.storyContext &&
!storyQuote &&
!isDirectConversation(conversation.attributes)
) {
if (initialMessage.storyContext && !storyQuote) {
if (!isDirectConversation(conversation.attributes)) {
log.warn(
`${idLog}: Received storyContext message in group but no matching story. Dropping.`
);
confirm();
return;
}
log.warn(
`${idLog}: Received storyContext message in group but no matching story. Dropping.`
`${idLog}: Received 1:1 storyContext message but no matching story. We'll try processing this message again later.`
);
confirm();
return;
}
@ -2511,10 +2519,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (
storyQuoteIsFromSelf &&
sendState.isAllowedToReplyToStory === false
sendState.isAllowedToReplyToStory === false &&
isDirectConversation(conversation.attributes)
) {
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();
@ -2619,7 +2628,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
});
if (storyQuote) {
await this.hydrateStoryContext(storyQuote);
await this.hydrateStoryContext(storyQuote.attributes);
}
const isSupported = !isUnsupportedMessage(message.attributes);
@ -3063,10 +3072,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const message = this;
const type = message.get('type');
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()
.forMessage(conversation, message)
.forMessage(message)
.map(receipt => {
let sendActionType: SendActionType;
const receiptType = receipt.get('type');
@ -3252,8 +3263,20 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const reactions = Reactions.getSingleton().forMessage(message);
await Promise.all(
reactions.map(async reaction => {
await message.handleReaction(reaction, false);
changed = true;
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;
await message.handleReaction(reaction, { shouldPersist: false });
}
})
);
@ -3279,7 +3302,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async handleReaction(
reaction: ReactionModel,
shouldPersist = true
{
storyMessage,
shouldPersist = true,
}: {
storyMessage?: MessageAttributesType;
shouldPersist?: boolean;
} = {}
): Promise<void> {
const { attributes } = this;
@ -3305,7 +3334,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return;
}
const previousLength = (this.get('reactions') || []).length;
const newReaction: MessageReactionType = {
emoji: reaction.get('remove') ? undefined : reaction.get('emoji'),
fromId: reaction.get('fromId'),
@ -3328,163 +3356,221 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
'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) {
log.info(
'handleReaction: sending story reaction to ' +
`${this.idForLogging()} from this device`
);
} else if (isFromSync) {
log.info(
'handleReaction: receiving story reaction to ' +
`${this.idForLogging()} from another device`
`${getMessageIdForLogging(storyMessage)} from this device`
);
} else {
log.info(
'handleReaction: receiving story reaction to ' +
`${this.idForLogging()} from someone else`
const generatedMessage = reaction.get('storyReactionMessage');
strictAssert(
generatedMessage,
'Story reactions must provide storyReactionMessage'
);
void conversation.notify(this, reaction);
}
} else if (isFromThisDevice) {
log.info(
`handleReaction: sending reaction to ${this.idForLogging()} ` +
'from this device'
);
const reactions = reactionUtil.addOutgoingReaction(
this.get('reactions') || [],
newReaction
);
this.set({ reactions });
} else {
const oldReactions = this.get('reactions') || [];
let reactions: Array<MessageReactionType>;
const oldReaction = oldReactions.find(re =>
isNewReactionReplacingPrevious(re, reaction.attributes)
);
if (oldReaction) {
this.clearNotifications(oldReaction);
}
if (reaction.get('remove')) {
log.info(
'handleReaction: removing reaction for message',
this.idForLogging()
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) {
reactions = oldReactions.filter(
re =>
!isNewReactionReplacingPrevious(re, reaction.attributes) ||
re.timestamp > reaction.get('timestamp')
log.info(
'handleReaction: receiving story reaction to ' +
`${getMessageIdForLogging(storyMessage)} from another device`
);
} else {
log.info(
'handleReaction: receiving story reaction to ' +
`${getMessageIdForLogging(storyMessage)} from someone else`
);
void conversation.notify(this, reaction);
}
}
} else {
// Reactions to all messages other than stories will update the target message
const previousLength = (this.get('reactions') || []).length;
if (isFromThisDevice) {
log.info(
`handleReaction: sending reaction to ${this.idForLogging()} ` +
'from this device'
);
const reactions = reactionUtil.addOutgoingReaction(
this.get('reactions') || [],
newReaction
);
this.set({ reactions });
} else {
const oldReactions = this.get('reactions') || [];
let reactions: Array<MessageReactionType>;
const oldReaction = oldReactions.find(re =>
isNewReactionReplacingPrevious(re, newReaction)
);
if (oldReaction) {
this.clearNotifications(oldReaction);
}
if (reaction.get('remove')) {
log.info(
'handleReaction: removing reaction for message',
this.idForLogging()
);
if (isFromSync) {
reactions = oldReactions.filter(
re =>
!isNewReactionReplacingPrevious(re, newReaction) ||
re.timestamp > reaction.get('timestamp')
);
} else {
reactions = oldReactions.filter(
re => !isNewReactionReplacingPrevious(re, newReaction)
);
}
this.set({ reactions });
await window.Signal.Data.removeReactionFromConversation({
emoji: reaction.get('emoji'),
fromId: reaction.get('fromId'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
});
} else {
log.info(
'handleReaction: adding reaction for message',
this.idForLogging()
);
let reactionToAdd: MessageReactionType;
if (isFromSync) {
const ourReactions = [
newReaction,
...oldReactions.filter(
re => re.fromId === reaction.get('fromId')
),
];
reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction;
} else {
reactionToAdd = newReaction;
}
reactions = oldReactions.filter(
re => !isNewReactionReplacingPrevious(re, reaction.attributes)
);
reactions.push(reactionToAdd);
this.set({ reactions });
if (isOutgoing(this.attributes) && isFromSomeoneElse) {
void conversation.notify(this, reaction);
}
await window.Signal.Data.addReaction({
conversationId: this.get('conversationId'),
emoji: reaction.get('emoji'),
fromId: reaction.get('fromId'),
messageId: this.id,
messageReceivedAt: this.get('received_at'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
});
}
this.set({ reactions });
await window.Signal.Data.removeReactionFromConversation({
emoji: reaction.get('emoji'),
fromId: reaction.get('fromId'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
});
} else {
log.info(
'handleReaction: adding reaction for message',
this.idForLogging()
);
let reactionToAdd: MessageReactionType;
if (isFromSync) {
const ourReactions = [
reaction.toJSON(),
...oldReactions.filter(re => re.fromId === reaction.get('fromId')),
];
reactionToAdd = maxBy(ourReactions, 'timestamp');
} else {
reactionToAdd = reaction.toJSON();
}
reactions = oldReactions.filter(
re => !isNewReactionReplacingPrevious(re, reaction.attributes)
);
reactions.push(reactionToAdd);
this.set({ reactions });
if (isOutgoing(this.attributes) && isFromSomeoneElse) {
void conversation.notify(this, reaction);
}
await window.Signal.Data.addReaction({
conversationId: this.get('conversationId'),
emoji: reaction.get('emoji'),
fromId: reaction.get('fromId'),
messageId: this.id,
messageReceivedAt: this.get('received_at'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
});
}
}
const currentLength = (this.get('reactions') || []).length;
log.info(
'handleReaction:',
`Done processing reaction for message ${this.idForLogging()}.`,
`Went from ${previousLength} to ${currentLength} reactions.`
);
const currentLength = (this.get('reactions') || []).length;
log.info(
'handleReaction:',
`Done processing reaction for message ${this.idForLogging()}.`,
`Went from ${previousLength} to ${currentLength} reactions.`
);
}
if (isFromThisDevice) {
let jobData: ConversationQueueJobData;
if (isStory(this.attributes)) {
if (storyMessage) {
strictAssert(
newReaction.emoji !== undefined,
'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 window.Signal.Data.saveMessage(reactionMessage.attributes, {
await window.Signal.Data.saveMessage(generatedMessage.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
forceSave: true,
}),
reactionMessage.hydrateStoryContext(this),
generatedMessage.hydrateStoryContext(this.attributes),
]);
void conversation.addSingleMessage(
window.MessageController.register(reactionMessage.id, reactionMessage)
window.MessageController.register(
generatedMessage.id,
generatedMessage
)
);
jobData = {
type: conversationQueueJobEnum.enum.NormalMessage,
conversationId: conversation.id,
messageId: reactionMessage.id,
messageId: generatedMessage.id,
revision: conversation.get('revision'),
};
} else {

View file

@ -4,8 +4,13 @@
import { ReactionModel } from '../messageModifiers/Reactions';
import { ReactionSource } from './ReactionSource';
import { getMessageById } from '../messages/getMessageById';
import { getSourceUuid } from '../messages/helpers';
import { getSourceUuid, isStory } from '../messages/helpers';
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({
emoji,
@ -31,15 +36,64 @@ export async function enqueueReactionForSend({
`enqueueReactionForSend: message ${message.idForLogging()} had no timestamp`
);
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,
targetAuthorUuid,
targetTimestamp,
},
})
: undefined;
const reaction = new ReactionModel({
emoji,
fromId: window.ConversationController.getOurConversationIdOrThrow(),
remove,
source: ReactionSource.FromThisDevice,
storyReactionMessage,
targetAuthorUuid,
targetTimestamp,
fromId: window.ConversationController.getOurConversationIdOrThrow(),
timestamp: Date.now(),
source: ReactionSource.FromThisDevice,
timestamp,
});
await message.handleReaction(reaction);
await message.handleReaction(reaction, { storyMessage });
}

View file

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

View file

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

View file

@ -3,7 +3,10 @@
import type { AttachmentType } from '../types/Attachment';
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 * as log from '../logging/log';
import dataInterface from '../sql/Client';
@ -261,27 +264,29 @@ export async function sendStoryMessage(
const groupTimestamp = timestamp + index + 1;
const myId = window.ConversationController.getOurConversationIdOrThrow();
const sendState = {
const sendState: SendState = {
status: SendStatus.Pending,
updatedAt: groupTimestamp,
isAllowedToReplyToStory: true,
};
const sendStateByConversationId = getRecipients(group.attributes).reduce(
(acc, id) => {
const conversation = window.ConversationController.get(id);
if (!conversation) {
return acc;
}
const sendStateByConversationId: SendStateByConversationId =
getRecipients(group.attributes).reduce(
(acc, id) => {
const conversation = window.ConversationController.get(id);
if (!conversation) {
return acc;
}
return {
...acc,
[conversation.id]: sendState,
};
},
{
[myId]: sendState,
}
);
return {
...acc,
[conversation.id]: sendState,
};
},
{
[myId]: sendState,
}
);
const messageAttributes =
await window.Signal.Migrations.upgradeMessageSchema({