Stories: Proper notifications and handling of out-of-order messages
This commit is contained in:
parent
81fc9ff94d
commit
50a0110192
12 changed files with 425 additions and 274 deletions
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -319,7 +319,7 @@ export async function sendReaction(
|
|||
ourUuid,
|
||||
forceSave: true,
|
||||
}),
|
||||
reactionMessage.hydrateStoryContext(message),
|
||||
reactionMessage.hydrateStoryContext(message.attributes),
|
||||
]);
|
||||
|
||||
void conversation.addSingleMessage(
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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
7
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Reference in a new issue