Retry outbound reactions for up to a day
This commit is contained in:
parent
4a6b7968c1
commit
8670a4d864
25 changed files with 1444 additions and 473 deletions
|
@ -2848,7 +2848,7 @@ export async function startApp(): Promise<void> {
|
|||
'DataMessage.Reaction.targetAuthorUuid'
|
||||
);
|
||||
|
||||
const { reaction } = data.message;
|
||||
const { reaction, timestamp } = data.message;
|
||||
|
||||
if (!isValidReactionEmoji(reaction.emoji)) {
|
||||
log.warn('Received an invalid reaction emoji. Dropping it');
|
||||
|
@ -2862,7 +2862,7 @@ export async function startApp(): Promise<void> {
|
|||
remove: reaction.remove,
|
||||
targetAuthorUuid,
|
||||
targetTimestamp: reaction.targetTimestamp,
|
||||
timestamp: Date.now(),
|
||||
timestamp,
|
||||
fromId: window.ConversationController.ensureContactIds({
|
||||
e164: data.source,
|
||||
uuid: data.sourceUuid,
|
||||
|
@ -3190,7 +3190,7 @@ export async function startApp(): Promise<void> {
|
|||
'DataMessage.Reaction.targetAuthorUuid'
|
||||
);
|
||||
|
||||
const { reaction } = data.message;
|
||||
const { reaction, timestamp } = data.message;
|
||||
|
||||
if (!isValidReactionEmoji(reaction.emoji)) {
|
||||
log.warn('Received an invalid reaction emoji. Dropping it');
|
||||
|
@ -3204,7 +3204,7 @@ export async function startApp(): Promise<void> {
|
|||
remove: reaction.remove,
|
||||
targetAuthorUuid,
|
||||
targetTimestamp: reaction.targetTimestamp,
|
||||
timestamp: Date.now(),
|
||||
timestamp,
|
||||
fromId: window.ConversationController.getOurConversationId(),
|
||||
fromSync: true,
|
||||
});
|
||||
|
|
|
@ -138,22 +138,29 @@ export abstract class JobQueue<T> {
|
|||
* Add a job, which should cause it to be enqueued and run.
|
||||
*
|
||||
* If `streamJobs` has not been called yet, this will throw an error.
|
||||
*
|
||||
* You can override `insert` to change the way the job is added to the database. This is
|
||||
* useful if you're trying to save a message and a job in the same database transaction.
|
||||
*/
|
||||
async add(data: Readonly<T>): Promise<Job<T>> {
|
||||
this.throwIfNotStarted();
|
||||
|
||||
const job = this.createJob(data);
|
||||
await this.store.insert(job);
|
||||
log.info(`${this.logPrefix} added new job ${job.id}`);
|
||||
return job;
|
||||
}
|
||||
|
||||
protected throwIfNotStarted(): void {
|
||||
async add(
|
||||
data: Readonly<T>,
|
||||
insert?: (job: ParsedJob<T>) => Promise<void>
|
||||
): Promise<Job<T>> {
|
||||
if (!this.started) {
|
||||
throw new Error(
|
||||
`${this.logPrefix} has not started streaming. Make sure to call streamJobs().`
|
||||
);
|
||||
}
|
||||
|
||||
const job = this.createJob(data);
|
||||
|
||||
if (insert) {
|
||||
await insert(job);
|
||||
}
|
||||
await this.store.insert(job, { shouldPersist: !insert });
|
||||
|
||||
log.info(`${this.logPrefix} added new job ${job.id}`);
|
||||
return job;
|
||||
}
|
||||
|
||||
protected createJob(data: Readonly<T>): Job<T> {
|
||||
|
|
|
@ -26,9 +26,7 @@ export class JobQueueDatabaseStore implements JobQueueStore {
|
|||
|
||||
async insert(
|
||||
job: Readonly<StoredJob>,
|
||||
{
|
||||
shouldInsertIntoDatabase = true,
|
||||
}: Readonly<{ shouldInsertIntoDatabase?: boolean }> = {}
|
||||
{ shouldPersist = true }: Readonly<{ shouldPersist?: boolean }> = {}
|
||||
): Promise<void> {
|
||||
log.info(
|
||||
`JobQueueDatabaseStore adding job ${job.id} to queue ${JSON.stringify(
|
||||
|
@ -46,7 +44,7 @@ export class JobQueueDatabaseStore implements JobQueueStore {
|
|||
}
|
||||
await initialFetchPromise;
|
||||
|
||||
if (shouldInsertIntoDatabase) {
|
||||
if (shouldPersist) {
|
||||
await this.db.insertJob(formatJobForInsert(job));
|
||||
}
|
||||
|
||||
|
|
23
ts/jobs/helpers/InMemoryQueues.ts
Normal file
23
ts/jobs/helpers/InMemoryQueues.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import PQueue from 'p-queue';
|
||||
|
||||
export class InMemoryQueues {
|
||||
private readonly queues = new Map<string, PQueue>();
|
||||
|
||||
get(key: string): PQueue {
|
||||
const existingQueue = this.queues.get(key);
|
||||
if (existingQueue) {
|
||||
return existingQueue;
|
||||
}
|
||||
|
||||
const newQueue = new PQueue({ concurrency: 1 });
|
||||
newQueue.once('idle', () => {
|
||||
this.queues.delete(key);
|
||||
});
|
||||
|
||||
this.queues.set(key, newQueue);
|
||||
return newQueue;
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
import type { WebAPIType } from '../textsecure/WebAPI';
|
||||
|
||||
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
|
||||
import { reactionJobQueue } from './reactionJobQueue';
|
||||
import { readSyncJobQueue } from './readSyncJobQueue';
|
||||
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
||||
import { reportSpamJobQueue } from './reportSpamJobQueue';
|
||||
|
@ -21,6 +22,7 @@ export function initializeAllJobQueues({
|
|||
reportSpamJobQueue.initialize({ server });
|
||||
|
||||
normalMessageSendJobQueue.streamJobs();
|
||||
reactionJobQueue.streamJobs();
|
||||
readSyncJobQueue.streamJobs();
|
||||
removeStorageKeyJobQueue.streamJobs();
|
||||
reportSpamJobQueue.streamJobs();
|
||||
|
|
|
@ -3,11 +3,12 @@
|
|||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
import PQueue from 'p-queue';
|
||||
import type PQueue from 'p-queue';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
||||
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
|
||||
import { sleepFor413RetryAfterTime } from './helpers/sleepFor413RetryAfterTime';
|
||||
import { InMemoryQueues } from './helpers/InMemoryQueues';
|
||||
import type { MessageModel } from '../models/messages';
|
||||
import { getMessageById } from '../messages/getMessageById';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
|
@ -23,19 +24,13 @@ import type { CallbackResultType } from '../textsecure/Types.d';
|
|||
import { isSent } from '../messages/MessageSendState';
|
||||
import { getLastChallengeError, isOutgoing } from '../state/selectors/message';
|
||||
import * as Errors from '../types/errors';
|
||||
import type {
|
||||
AttachmentType,
|
||||
GroupV1InfoType,
|
||||
GroupV2InfoType,
|
||||
} from '../textsecure/SendMessage';
|
||||
import type { AttachmentType } from '../textsecure/SendMessage';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import type { BodyRangesType } from '../types/Util';
|
||||
import type { WhatIsThis } from '../window.d';
|
||||
|
||||
import type { ParsedJob } from './types';
|
||||
import { JobQueue } from './JobQueue';
|
||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||
import type { Job } from './Job';
|
||||
import { getHttpErrorCode } from './helpers/getHttpErrorCode';
|
||||
|
||||
const {
|
||||
|
@ -55,31 +50,7 @@ type NormalMessageSendJobData = {
|
|||
};
|
||||
|
||||
export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData> {
|
||||
private readonly queues = new Map<string, PQueue>();
|
||||
|
||||
/**
|
||||
* Add a job (see `JobQueue.prototype.add`).
|
||||
*
|
||||
* You can override `insert` to change the way the job is added to the database. This is
|
||||
* useful if you're trying to save a message and a job in the same database transaction.
|
||||
*/
|
||||
async add(
|
||||
data: Readonly<NormalMessageSendJobData>,
|
||||
insert?: (job: ParsedJob<NormalMessageSendJobData>) => Promise<void>
|
||||
): Promise<Job<NormalMessageSendJobData>> {
|
||||
if (!insert) {
|
||||
return super.add(data);
|
||||
}
|
||||
|
||||
this.throwIfNotStarted();
|
||||
|
||||
const job = this.createJob(data);
|
||||
await insert(job);
|
||||
await jobQueueDatabaseStore.insert(job, {
|
||||
shouldInsertIntoDatabase: false,
|
||||
});
|
||||
return job;
|
||||
}
|
||||
private readonly inMemoryQueues = new InMemoryQueues();
|
||||
|
||||
protected parseData(data: unknown): NormalMessageSendJobData {
|
||||
// Because we do this so often and Zod is a bit slower, we do "manual" parsing here.
|
||||
|
@ -99,20 +70,7 @@ export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData
|
|||
protected getInMemoryQueue({
|
||||
data,
|
||||
}: Readonly<{ data: NormalMessageSendJobData }>): PQueue {
|
||||
const { conversationId } = data;
|
||||
|
||||
const existingQueue = this.queues.get(conversationId);
|
||||
if (existingQueue) {
|
||||
return existingQueue;
|
||||
}
|
||||
|
||||
const newQueue = new PQueue({ concurrency: 1 });
|
||||
newQueue.once('idle', () => {
|
||||
this.queues.delete(conversationId);
|
||||
});
|
||||
|
||||
this.queues.set(conversationId, newQueue);
|
||||
return newQueue;
|
||||
return this.inMemoryQueues.get(data.conversationId);
|
||||
}
|
||||
|
||||
protected async run(
|
||||
|
@ -234,10 +192,9 @@ export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData
|
|||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||
attachments,
|
||||
body,
|
||||
groupV2: updateRecipients(
|
||||
conversation.getGroupV2Info(),
|
||||
recipientIdentifiersWithoutMe
|
||||
),
|
||||
groupV2: conversation.getGroupV2Info({
|
||||
members: recipientIdentifiersWithoutMe,
|
||||
}),
|
||||
deletedForEveryoneTimestamp,
|
||||
expireTimer,
|
||||
preview,
|
||||
|
@ -267,14 +224,12 @@ export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData
|
|||
attachments,
|
||||
deletedForEveryoneTimestamp,
|
||||
expireTimer,
|
||||
groupV1: updateRecipients(
|
||||
conversation.getGroupV1Info(),
|
||||
recipientIdentifiersWithoutMe
|
||||
),
|
||||
groupV2: updateRecipients(
|
||||
conversation.getGroupV2Info(),
|
||||
groupV1: conversation.getGroupV1Info(
|
||||
recipientIdentifiersWithoutMe
|
||||
),
|
||||
groupV2: conversation.getGroupV2Info({
|
||||
members: recipientIdentifiersWithoutMe,
|
||||
}),
|
||||
messageText: body,
|
||||
preview,
|
||||
profileKey,
|
||||
|
@ -544,23 +499,3 @@ function didSendToEveryone(message: Readonly<MessageModel>): boolean {
|
|||
isSent(sendState.status)
|
||||
);
|
||||
}
|
||||
|
||||
function updateRecipients(
|
||||
groupInfo: undefined | GroupV1InfoType,
|
||||
recipients: Array<string>
|
||||
): undefined | GroupV1InfoType;
|
||||
function updateRecipients(
|
||||
groupInfo: undefined | GroupV2InfoType,
|
||||
recipients: Array<string>
|
||||
): undefined | GroupV2InfoType;
|
||||
function updateRecipients(
|
||||
groupInfo: undefined | GroupV1InfoType | GroupV2InfoType,
|
||||
recipients: Array<string>
|
||||
): undefined | GroupV1InfoType | GroupV2InfoType {
|
||||
return (
|
||||
groupInfo && {
|
||||
...groupInfo,
|
||||
members: recipients,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
337
ts/jobs/reactionJobQueue.ts
Normal file
337
ts/jobs/reactionJobQueue.ts
Normal file
|
@ -0,0 +1,337 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as z from 'zod';
|
||||
import type PQueue from 'p-queue';
|
||||
import { repeat, zipObject } from '../util/iterables';
|
||||
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
||||
import * as durations from '../util/durations';
|
||||
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import type { CallbackResultType } from '../textsecure/Types.d';
|
||||
import type { MessageModel } from '../models/messages';
|
||||
import type { MessageReactionType } from '../model-types.d';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
|
||||
import * as reactionUtil from '../reactions/util';
|
||||
import { isSent, SendStatus } from '../messages/MessageSendState';
|
||||
import { getMessageById } from '../messages/getMessageById';
|
||||
import { isMe, isDirectConversation } from '../util/whatTypeOfConversation';
|
||||
import { getSendOptions } from '../util/getSendOptions';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { handleMessageSend } from '../util/handleMessageSend';
|
||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
||||
import { canReact } from '../state/selectors/message';
|
||||
import { findAndFormatContact } from '../util/findAndFormatContact';
|
||||
import { UUID } from '../types/UUID';
|
||||
|
||||
import { JobQueue } from './JobQueue';
|
||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
|
||||
import { handleCommonJobRequestError } from './helpers/handleCommonJobRequestError';
|
||||
import { InMemoryQueues } from './helpers/InMemoryQueues';
|
||||
|
||||
const MAX_RETRY_TIME = durations.DAY;
|
||||
const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME);
|
||||
|
||||
const reactionJobData = z.object({
|
||||
messageId: z.string(),
|
||||
});
|
||||
|
||||
export type ReactionJobData = z.infer<typeof reactionJobData>;
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
export class ReactionJobQueue extends JobQueue<ReactionJobData> {
|
||||
private readonly inMemoryQueues = new InMemoryQueues();
|
||||
|
||||
protected parseData(data: unknown): ReactionJobData {
|
||||
return reactionJobData.parse(data);
|
||||
}
|
||||
|
||||
protected getInMemoryQueue({
|
||||
data,
|
||||
}: Readonly<{ data: Pick<ReactionJobData, 'messageId'> }>): PQueue {
|
||||
return this.inMemoryQueues.get(data.messageId);
|
||||
}
|
||||
|
||||
protected async run(
|
||||
{ data, timestamp }: Readonly<{ data: ReactionJobData; timestamp: number }>,
|
||||
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
|
||||
): Promise<void> {
|
||||
const { messageId } = data;
|
||||
|
||||
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
|
||||
const isFinalAttempt = attempt >= MAX_ATTEMPTS;
|
||||
|
||||
// We don't immediately use this value because we may want to mark the reaction
|
||||
// failed before doing so.
|
||||
const shouldContinue = await commonShouldJobContinue({
|
||||
attempt,
|
||||
log,
|
||||
timeRemaining,
|
||||
});
|
||||
|
||||
await window.ConversationController.loadPromise();
|
||||
|
||||
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||
|
||||
const message = await getMessageById(messageId);
|
||||
if (!message) {
|
||||
log.info(
|
||||
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
pendingReaction,
|
||||
emojiToRemove,
|
||||
} = reactionUtil.getNewestPendingOutgoingReaction(
|
||||
getReactions(message),
|
||||
ourConversationId
|
||||
);
|
||||
if (!pendingReaction) {
|
||||
log.info(`no pending reaction for ${messageId}. Doing nothing`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!canReact(message.attributes, ourConversationId, findAndFormatContact)
|
||||
) {
|
||||
log.info(
|
||||
`could not react to ${messageId}. Removing this pending reaction`
|
||||
);
|
||||
markReactionFailed(message, pendingReaction);
|
||||
await window.Signal.Data.saveMessage(message.attributes);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldContinue) {
|
||||
log.info(
|
||||
`reacting to message ${messageId} ran out of time. Giving up on sending it`
|
||||
);
|
||||
markReactionFailed(message, pendingReaction);
|
||||
await window.Signal.Data.saveMessage(message.attributes);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const conversation = message.getConversation();
|
||||
if (!conversation) {
|
||||
throw new Error(
|
||||
`could not find conversation for message with ID ${messageId}`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
allRecipientIdentifiers,
|
||||
recipientIdentifiersWithoutMe,
|
||||
} = getRecipients(pendingReaction, conversation);
|
||||
|
||||
const expireTimer = message.get('expireTimer');
|
||||
const profileKey = conversation.get('profileSharing')
|
||||
? await ourProfileKeyService.get()
|
||||
: undefined;
|
||||
|
||||
const reactionForSend = pendingReaction.emoji
|
||||
? pendingReaction
|
||||
: {
|
||||
...pendingReaction,
|
||||
emoji: emojiToRemove,
|
||||
remove: true,
|
||||
};
|
||||
|
||||
const ephemeralMessageForReactionSend = new window.Whisper.Message({
|
||||
id: UUID.generate.toString(),
|
||||
type: 'outgoing',
|
||||
conversationId: conversation.get('id'),
|
||||
sent_at: pendingReaction.timestamp,
|
||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
received_at_ms: pendingReaction.timestamp,
|
||||
reaction: reactionForSend,
|
||||
timestamp: pendingReaction.timestamp,
|
||||
sendStateByConversationId: zipObject(
|
||||
Object.keys(pendingReaction.isSentByConversationId || {}),
|
||||
repeat({
|
||||
status: SendStatus.Pending,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
),
|
||||
});
|
||||
ephemeralMessageForReactionSend.doNotSave = true;
|
||||
|
||||
const successfulConversationIds = new Set<string>();
|
||||
|
||||
if (recipientIdentifiersWithoutMe.length === 0) {
|
||||
log.info('sending sync reaction message only');
|
||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||
attachments: [],
|
||||
expireTimer,
|
||||
groupV2: conversation.getGroupV2Info({
|
||||
members: recipientIdentifiersWithoutMe,
|
||||
}),
|
||||
preview: [],
|
||||
profileKey,
|
||||
reaction: reactionForSend,
|
||||
recipients: allRecipientIdentifiers,
|
||||
timestamp: pendingReaction.timestamp,
|
||||
});
|
||||
await ephemeralMessageForReactionSend.sendSyncMessageOnly(dataMessage);
|
||||
|
||||
successfulConversationIds.add(ourConversationId);
|
||||
} else {
|
||||
const sendOptions = await getSendOptions(conversation.attributes);
|
||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||
|
||||
let promise: Promise<CallbackResultType>;
|
||||
if (isDirectConversation(conversation.attributes)) {
|
||||
log.info('sending direct reaction message');
|
||||
promise = window.textsecure.messaging.sendMessageToIdentifier({
|
||||
identifier: recipientIdentifiersWithoutMe[0],
|
||||
messageText: undefined,
|
||||
attachments: [],
|
||||
quote: undefined,
|
||||
preview: [],
|
||||
sticker: undefined,
|
||||
reaction: reactionForSend,
|
||||
deletedForEveryoneTimestamp: undefined,
|
||||
timestamp: pendingReaction.timestamp,
|
||||
expireTimer,
|
||||
contentHint: ContentHint.RESENDABLE,
|
||||
groupId: undefined,
|
||||
profileKey,
|
||||
options: sendOptions,
|
||||
});
|
||||
} else {
|
||||
log.info('sending group reaction message');
|
||||
promise = window.Signal.Util.sendToGroup({
|
||||
groupSendOptions: {
|
||||
groupV1: conversation.getGroupV1Info(
|
||||
recipientIdentifiersWithoutMe
|
||||
),
|
||||
groupV2: conversation.getGroupV2Info({
|
||||
members: recipientIdentifiersWithoutMe,
|
||||
}),
|
||||
reaction: reactionForSend,
|
||||
timestamp: pendingReaction.timestamp,
|
||||
expireTimer,
|
||||
profileKey,
|
||||
},
|
||||
conversation,
|
||||
contentHint: ContentHint.RESENDABLE,
|
||||
messageId,
|
||||
sendOptions,
|
||||
sendType: 'reaction',
|
||||
});
|
||||
}
|
||||
|
||||
await ephemeralMessageForReactionSend.send(
|
||||
handleMessageSend(promise, {
|
||||
messageIds: [messageId],
|
||||
sendType: 'reaction',
|
||||
})
|
||||
);
|
||||
|
||||
const reactionSendStateByConversationId =
|
||||
ephemeralMessageForReactionSend.get('sendStateByConversationId') ||
|
||||
{};
|
||||
for (const [conversationId, sendState] of Object.entries(
|
||||
reactionSendStateByConversationId
|
||||
)) {
|
||||
if (isSent(sendState.status)) {
|
||||
successfulConversationIds.add(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newReactions = reactionUtil.markOutgoingReactionSent(
|
||||
getReactions(message),
|
||||
pendingReaction,
|
||||
successfulConversationIds
|
||||
);
|
||||
setReactions(message, newReactions);
|
||||
|
||||
const didFullySend = true;
|
||||
if (!didFullySend) {
|
||||
throw new Error('reaction did not fully send');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isFinalAttempt) {
|
||||
markReactionFailed(message, pendingReaction);
|
||||
}
|
||||
await handleCommonJobRequestError({ err, log, timeRemaining });
|
||||
} finally {
|
||||
await window.Signal.Data.saveMessage(message.attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const reactionJobQueue = new ReactionJobQueue({
|
||||
store: jobQueueDatabaseStore,
|
||||
queueType: 'reactions',
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
});
|
||||
|
||||
const getReactions = (message: MessageModel): Array<MessageReactionType> =>
|
||||
message.get('reactions') || [];
|
||||
|
||||
const setReactions = (
|
||||
message: MessageModel,
|
||||
reactions: Array<MessageReactionType>
|
||||
): void => {
|
||||
if (reactions.length) {
|
||||
message.set('reactions', reactions);
|
||||
} else {
|
||||
message.unset('reactions');
|
||||
}
|
||||
};
|
||||
|
||||
function getRecipients(
|
||||
reaction: Readonly<MessageReactionType>,
|
||||
conversation: ConversationModel
|
||||
): {
|
||||
allRecipientIdentifiers: Array<string>;
|
||||
recipientIdentifiersWithoutMe: Array<string>;
|
||||
} {
|
||||
const allRecipientIdentifiers: Array<string> = [];
|
||||
const recipientIdentifiersWithoutMe: Array<string> = [];
|
||||
|
||||
const currentConversationRecipients = conversation.getRecipientConversationIds();
|
||||
|
||||
for (const id of reactionUtil.getUnsentConversationIds(reaction)) {
|
||||
const recipient = window.ConversationController.get(id);
|
||||
if (!recipient) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recipientIdentifier = recipient.getSendTarget();
|
||||
const isRecipientMe = isMe(recipient.attributes);
|
||||
|
||||
if (
|
||||
!recipientIdentifier ||
|
||||
recipient.isUntrusted() ||
|
||||
(!currentConversationRecipients.has(id) && !isRecipientMe)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allRecipientIdentifiers.push(recipientIdentifier);
|
||||
if (!isRecipientMe) {
|
||||
recipientIdentifiersWithoutMe.push(recipientIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return { allRecipientIdentifiers, recipientIdentifiersWithoutMe };
|
||||
}
|
||||
|
||||
function markReactionFailed(
|
||||
message: MessageModel,
|
||||
pendingReaction: MessageReactionType
|
||||
): void {
|
||||
const newReactions = reactionUtil.markOutgoingReactionFailed(
|
||||
getReactions(message),
|
||||
pendingReaction
|
||||
);
|
||||
setReactions(message, newReactions);
|
||||
}
|
|
@ -5,7 +5,10 @@ export type JobQueueStore = {
|
|||
/**
|
||||
* Add a job to the database. Doing this should enqueue it in the stream.
|
||||
*/
|
||||
insert(job: Readonly<StoredJob>): Promise<void>;
|
||||
insert(
|
||||
job: Readonly<StoredJob>,
|
||||
options?: Readonly<{ shouldPersist?: boolean }>
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove a job. This should be called when a job finishes successfully or
|
||||
|
|
|
@ -54,9 +54,7 @@ export class Reactions extends Collection<ReactionModel> {
|
|||
return [];
|
||||
}
|
||||
|
||||
async onReaction(
|
||||
reaction: ReactionModel
|
||||
): Promise<ReactionAttributesType | undefined> {
|
||||
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.
|
||||
|
@ -85,73 +83,67 @@ export class Reactions extends Collection<ReactionModel> {
|
|||
}
|
||||
|
||||
// awaiting is safe since `onReaction` is never called from inside the queue
|
||||
return await targetConversation.queueJob(
|
||||
'Reactions.onReaction',
|
||||
async () => {
|
||||
log.info('Handling reaction for', reaction.get('targetTimestamp'));
|
||||
await targetConversation.queueJob('Reactions.onReaction', async () => {
|
||||
log.info('Handling reaction for', reaction.get('targetTimestamp'));
|
||||
|
||||
const messages = await window.Signal.Data.getMessagesBySentAt(
|
||||
reaction.get('targetTimestamp'),
|
||||
{
|
||||
MessageCollection: window.Whisper.MessageCollection,
|
||||
}
|
||||
);
|
||||
// Message is fetched inside the conversation queue so we have the
|
||||
// most recent data
|
||||
const targetMessage = messages.find(m => {
|
||||
const contact = m.getContact();
|
||||
const messages = await window.Signal.Data.getMessagesBySentAt(
|
||||
reaction.get('targetTimestamp'),
|
||||
{
|
||||
MessageCollection: window.Whisper.MessageCollection,
|
||||
}
|
||||
);
|
||||
// Message is fetched inside the conversation queue so we have the
|
||||
// most recent data
|
||||
const targetMessage = messages.find(m => {
|
||||
const contact = m.getContact();
|
||||
|
||||
if (!contact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mcid = contact.get('id');
|
||||
const recid = window.ConversationController.ensureContactIds({
|
||||
uuid: reaction.get('targetAuthorUuid'),
|
||||
});
|
||||
return mcid === recid;
|
||||
});
|
||||
|
||||
if (!targetMessage) {
|
||||
log.info(
|
||||
'No message for reaction',
|
||||
reaction.get('targetAuthorUuid'),
|
||||
reaction.get('targetTimestamp')
|
||||
);
|
||||
|
||||
// Since we haven't received the message for which we are removing a
|
||||
// reaction, we can just remove those pending reactions
|
||||
if (reaction.get('remove')) {
|
||||
this.remove(reaction);
|
||||
const oldReaction = this.where({
|
||||
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
||||
targetTimestamp: reaction.get('targetTimestamp'),
|
||||
emoji: reaction.get('emoji'),
|
||||
});
|
||||
oldReaction.forEach(r => this.remove(r));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
if (!contact) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = window.MessageController.register(
|
||||
targetMessage.id,
|
||||
targetMessage
|
||||
const mcid = contact.get('id');
|
||||
const recid = window.ConversationController.ensureContactIds({
|
||||
uuid: reaction.get('targetAuthorUuid'),
|
||||
});
|
||||
return mcid === recid;
|
||||
});
|
||||
|
||||
if (!targetMessage) {
|
||||
log.info(
|
||||
'No message for reaction',
|
||||
reaction.get('targetAuthorUuid'),
|
||||
reaction.get('targetTimestamp')
|
||||
);
|
||||
|
||||
const oldReaction = await message.handleReaction(reaction);
|
||||
// Since we haven't received the message for which we are removing a
|
||||
// reaction, we can just remove those pending reactions
|
||||
if (reaction.get('remove')) {
|
||||
this.remove(reaction);
|
||||
const oldReaction = this.where({
|
||||
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
||||
targetTimestamp: reaction.get('targetTimestamp'),
|
||||
emoji: reaction.get('emoji'),
|
||||
});
|
||||
oldReaction.forEach(r => this.remove(r));
|
||||
}
|
||||
|
||||
this.remove(reaction);
|
||||
|
||||
return oldReaction;
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
const message = window.MessageController.register(
|
||||
targetMessage.id,
|
||||
targetMessage
|
||||
);
|
||||
|
||||
await message.handleReaction(reaction);
|
||||
|
||||
this.remove(reaction);
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Reactions.onReaction error:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
20
ts/model-types.d.ts
vendored
20
ts/model-types.d.ts
vendored
|
@ -27,6 +27,7 @@ import { EmbeddedContactType } from './types/EmbeddedContact';
|
|||
import { SignalService as Proto } from './protobuf';
|
||||
import { AvatarDataType } from './types/Avatar';
|
||||
import { UUIDStringType } from './types/UUID';
|
||||
import { ReactionSource } from './reactions/ReactionSource';
|
||||
|
||||
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||
import MemberRoleEnum = Proto.Member.Role;
|
||||
|
@ -86,6 +87,15 @@ export type GroupV1Update = {
|
|||
name?: string;
|
||||
};
|
||||
|
||||
export type MessageReactionType = {
|
||||
emoji: undefined | string;
|
||||
fromId: string;
|
||||
targetAuthorUuid: string;
|
||||
targetTimestamp: number;
|
||||
timestamp: number;
|
||||
isSentByConversationId?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type MessageAttributesType = {
|
||||
bodyPending?: boolean;
|
||||
bodyRanges?: BodyRangesType;
|
||||
|
@ -113,13 +123,7 @@ export type MessageAttributesType = {
|
|||
messageTimer?: unknown;
|
||||
profileChange?: ProfileNameChangeType;
|
||||
quote?: QuotedMessageType;
|
||||
reactions?: Array<{
|
||||
emoji: string;
|
||||
fromId: string;
|
||||
targetAuthorUuid: string;
|
||||
targetTimestamp: number;
|
||||
timestamp: number;
|
||||
}>;
|
||||
reactions?: Array<MessageReactionType>;
|
||||
requiredProtocolVersion?: number;
|
||||
retryOptions?: RetryOptions;
|
||||
sourceDevice?: number;
|
||||
|
@ -376,5 +380,5 @@ export type ReactionAttributesType = {
|
|||
targetTimestamp: number;
|
||||
fromId: string;
|
||||
timestamp: number;
|
||||
fromSync?: boolean;
|
||||
source: ReactionSource;
|
||||
};
|
||||
|
|
|
@ -94,7 +94,6 @@ import {
|
|||
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
||||
import { Deletes } from '../messageModifiers/Deletes';
|
||||
import type { ReactionModel } from '../messageModifiers/Reactions';
|
||||
import { Reactions } from '../messageModifiers/Reactions';
|
||||
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
||||
import { getProfile } from '../util/getProfile';
|
||||
import { SEALED_SENDER } from '../types/SealedSender';
|
||||
|
@ -1123,15 +1122,17 @@ export class ConversationModel extends window.Backbone
|
|||
window.Signal.Data.updateConversation(this.attributes);
|
||||
}
|
||||
|
||||
getGroupV2Info({
|
||||
groupChange,
|
||||
includePendingMembers,
|
||||
extraConversationsForSend,
|
||||
}: {
|
||||
groupChange?: Uint8Array;
|
||||
includePendingMembers?: boolean;
|
||||
extraConversationsForSend?: Array<string>;
|
||||
} = {}): GroupV2InfoType | undefined {
|
||||
getGroupV2Info(
|
||||
options: Readonly<
|
||||
{ groupChange?: Uint8Array } & (
|
||||
| {
|
||||
includePendingMembers?: boolean;
|
||||
extraConversationsForSend?: Array<string>;
|
||||
}
|
||||
| { members: Array<string> }
|
||||
)
|
||||
> = {}
|
||||
): GroupV2InfoType | undefined {
|
||||
if (isDirectConversation(this.attributes) || !isGroupV2(this.attributes)) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -1142,15 +1143,13 @@ export class ConversationModel extends window.Backbone
|
|||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
revision: this.get('revision')!,
|
||||
members: this.getRecipients({
|
||||
includePendingMembers,
|
||||
extraConversationsForSend,
|
||||
}),
|
||||
groupChange,
|
||||
members:
|
||||
'members' in options ? options.members : this.getRecipients(options),
|
||||
groupChange: options.groupChange,
|
||||
};
|
||||
}
|
||||
|
||||
getGroupV1Info(): GroupV1InfoType | undefined {
|
||||
getGroupV1Info(members?: Array<string>): GroupV1InfoType | undefined {
|
||||
const groupId = this.get('groupId');
|
||||
const groupVersion = this.get('groupVersion');
|
||||
|
||||
|
@ -1164,7 +1163,7 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
return {
|
||||
id: groupId,
|
||||
members: this.getRecipients(),
|
||||
members: members || this.getRecipients(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -3478,166 +3477,6 @@ export class ConversationModel extends window.Backbone
|
|||
});
|
||||
}
|
||||
|
||||
async sendReactionMessage(
|
||||
reaction: { emoji: string; remove: boolean },
|
||||
target: {
|
||||
messageId: string;
|
||||
targetAuthorUuid: string;
|
||||
targetTimestamp: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { messageId } = target;
|
||||
const timestamp = Date.now();
|
||||
const outgoingReaction = { ...reaction, ...target };
|
||||
|
||||
const reactionModel = Reactions.getSingleton().add({
|
||||
...outgoingReaction,
|
||||
fromId: window.ConversationController.getOurConversationId(),
|
||||
timestamp,
|
||||
fromSync: true,
|
||||
});
|
||||
|
||||
// Apply reaction optimistically
|
||||
const oldReaction = await Reactions.getSingleton().onReaction(
|
||||
reactionModel
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const destination = this.getSendTarget()!;
|
||||
|
||||
return this.queueJob('sendReactionMessage', async () => {
|
||||
log.info(
|
||||
'Sending reaction to conversation',
|
||||
this.idForLogging(),
|
||||
'with timestamp',
|
||||
timestamp
|
||||
);
|
||||
|
||||
await this.maybeApplyUniversalTimer(false);
|
||||
|
||||
const expireTimer = this.get('expireTimer');
|
||||
|
||||
// We are only creating this model so we can use its sync message
|
||||
// sending functionality. It will not be saved to the database.
|
||||
const message = new window.Whisper.Message({
|
||||
id: UUID.generate.toString(),
|
||||
type: 'outgoing',
|
||||
conversationId: this.get('id'),
|
||||
sent_at: timestamp,
|
||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
received_at_ms: timestamp,
|
||||
reaction: outgoingReaction,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// This is to ensure that the functions in send() and sendSyncMessage() don't save
|
||||
// anything to the database.
|
||||
message.doNotSave = true;
|
||||
|
||||
// We're offline!
|
||||
if (!window.textsecure.messaging) {
|
||||
throw new Error('Cannot send reaction while offline!');
|
||||
}
|
||||
|
||||
let profileKey: Uint8Array | undefined;
|
||||
if (this.get('profileSharing')) {
|
||||
profileKey = await ourProfileKeyService.get();
|
||||
}
|
||||
// Special-case the self-send case - we send only a sync message
|
||||
if (isMe(this.attributes)) {
|
||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||
attachments: [],
|
||||
// body
|
||||
// deletedForEveryoneTimestamp
|
||||
expireTimer,
|
||||
preview: [],
|
||||
profileKey,
|
||||
// quote
|
||||
reaction: outgoingReaction,
|
||||
recipients: [destination],
|
||||
// sticker
|
||||
timestamp,
|
||||
});
|
||||
const result = await message.sendSyncMessageOnly(dataMessage);
|
||||
Reactions.getSingleton().onReaction(reactionModel);
|
||||
return result;
|
||||
}
|
||||
|
||||
const options = await getSendOptions(this.attributes);
|
||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||
|
||||
const promise = (() => {
|
||||
if (isDirectConversation(this.attributes)) {
|
||||
return window.textsecure.messaging.sendMessageToIdentifier({
|
||||
identifier: destination,
|
||||
messageText: undefined,
|
||||
attachments: [],
|
||||
quote: undefined,
|
||||
preview: [],
|
||||
sticker: undefined,
|
||||
reaction: outgoingReaction,
|
||||
deletedForEveryoneTimestamp: undefined,
|
||||
timestamp,
|
||||
expireTimer,
|
||||
contentHint: ContentHint.RESENDABLE,
|
||||
groupId: undefined,
|
||||
profileKey,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
return window.Signal.Util.sendToGroup({
|
||||
groupSendOptions: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
groupV1: this.getGroupV1Info()!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
groupV2: this.getGroupV2Info()!,
|
||||
reaction: outgoingReaction,
|
||||
timestamp,
|
||||
expireTimer,
|
||||
profileKey,
|
||||
},
|
||||
conversation: this,
|
||||
contentHint: ContentHint.RESENDABLE,
|
||||
messageId,
|
||||
sendOptions: options,
|
||||
sendType: 'reaction',
|
||||
});
|
||||
})();
|
||||
|
||||
const result = await message.send(
|
||||
handleMessageSend(promise, {
|
||||
messageIds: [messageId],
|
||||
sendType: 'reaction',
|
||||
})
|
||||
);
|
||||
|
||||
if (!message.hasSuccessfulDelivery()) {
|
||||
// This is handled by `conversation_view` which displays a toast on
|
||||
// send error.
|
||||
throw new Error('No successful delivery for reaction');
|
||||
}
|
||||
|
||||
return result;
|
||||
}).catch(() => {
|
||||
let reverseReaction: ReactionModel;
|
||||
if (oldReaction) {
|
||||
// Either restore old reaction
|
||||
reverseReaction = Reactions.getSingleton().add({
|
||||
...oldReaction,
|
||||
fromId: window.ConversationController.getOurConversationId(),
|
||||
timestamp,
|
||||
});
|
||||
} else {
|
||||
// Or remove a new one on failure
|
||||
reverseReaction = reactionModel.clone();
|
||||
reverseReaction.set('remove', !reverseReaction.get('remove'));
|
||||
}
|
||||
|
||||
Reactions.getSingleton().onReaction(reverseReaction);
|
||||
});
|
||||
}
|
||||
|
||||
async sendProfileKeyUpdate(): Promise<void> {
|
||||
const id = this.get('id');
|
||||
const recipients = this.getRecipients();
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isEmpty, isEqual, mapValues, noop, omit, union } from 'lodash';
|
||||
import { isEmpty, isEqual, mapValues, maxBy, noop, omit, union } from 'lodash';
|
||||
import type {
|
||||
CustomError,
|
||||
GroupV1Update,
|
||||
MessageAttributesType,
|
||||
ReactionAttributesType,
|
||||
MessageReactionType,
|
||||
ShallowChallengeError,
|
||||
QuotedMessageType,
|
||||
WhatIsThis,
|
||||
} from '../model-types.d';
|
||||
import { filter, find, map, reduce } from '../util/iterables';
|
||||
import {
|
||||
filter,
|
||||
find,
|
||||
map,
|
||||
reduce,
|
||||
repeat,
|
||||
zipObject,
|
||||
} from '../util/iterables';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
import { isNormalNumber } from '../util/isNormalNumber';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
@ -35,6 +42,7 @@ import * as expirationTimer from '../util/expirationTimer';
|
|||
import type { ReactionType } from '../types/Reactions';
|
||||
import { UUID } from '../types/UUID';
|
||||
import type { UUIDStringType } from '../types/UUID';
|
||||
import * as reactionUtil from '../reactions/util';
|
||||
import {
|
||||
copyStickerToAttachments,
|
||||
deletePackReference,
|
||||
|
@ -112,6 +120,7 @@ import {
|
|||
import { Deletes } from '../messageModifiers/Deletes';
|
||||
import type { ReactionModel } from '../messageModifiers/Reactions';
|
||||
import { Reactions } from '../messageModifiers/Reactions';
|
||||
import { ReactionSource } from '../reactions/ReactionSource';
|
||||
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
||||
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
|
||||
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
||||
|
@ -119,6 +128,7 @@ import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
|||
import * as LinkPreview from '../types/LinkPreview';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
||||
import { reactionJobQueue } from '../jobs/reactionJobQueue';
|
||||
import { notificationService } from '../services/notifications';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import * as log from '../logging/log';
|
||||
|
@ -3121,11 +3131,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
async handleReaction(
|
||||
reaction: ReactionModel,
|
||||
shouldPersist = true
|
||||
): Promise<ReactionAttributesType | undefined> {
|
||||
): Promise<void> {
|
||||
const { attributes } = this;
|
||||
|
||||
if (this.get('deletedForEveryone')) {
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// We allow you to react to messages with outgoing errors only if it has sent
|
||||
|
@ -3138,75 +3148,138 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
window.ConversationController.getOurConversationIdOrThrow()
|
||||
) !== 'partial-sent')
|
||||
) {
|
||||
return undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = this.get('reactions') || [];
|
||||
const messageId = this.idForLogging();
|
||||
const count = reactions.length;
|
||||
|
||||
const conversation = window.ConversationController.get(
|
||||
this.get('conversationId')
|
||||
);
|
||||
|
||||
const oldReaction = reactions.find(
|
||||
re => re.fromId === reaction.get('fromId')
|
||||
);
|
||||
if (oldReaction) {
|
||||
this.clearNotifications(oldReaction);
|
||||
const conversation = this.getConversation();
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reaction.get('remove')) {
|
||||
log.info('Removing reaction for message', messageId);
|
||||
const newReactions = reactions.filter(
|
||||
re => re.fromId !== reaction.get('fromId')
|
||||
const oldReactions = this.get('reactions') || [];
|
||||
|
||||
let newReactions: typeof oldReactions;
|
||||
if (reaction.get('source') === ReactionSource.FromThisDevice) {
|
||||
log.info(
|
||||
`handleReaction: sending reaction to ${this.idForLogging()} from this device`
|
||||
);
|
||||
this.set({ reactions: newReactions });
|
||||
|
||||
await window.Signal.Data.removeReactionFromConversation({
|
||||
emoji: reaction.get('emoji'),
|
||||
const newReaction = {
|
||||
emoji: reaction.get('remove') ? undefined : reaction.get('emoji'),
|
||||
fromId: reaction.get('fromId'),
|
||||
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
||||
targetTimestamp: reaction.get('targetTimestamp'),
|
||||
});
|
||||
timestamp: reaction.get('timestamp'),
|
||||
isSentByConversationId: zipObject(
|
||||
conversation.getRecipientConversationIds(),
|
||||
repeat(false)
|
||||
),
|
||||
};
|
||||
|
||||
newReactions = reactionUtil.addOutgoingReaction(
|
||||
oldReactions,
|
||||
newReaction
|
||||
);
|
||||
} else {
|
||||
log.info('Adding reaction for message', messageId);
|
||||
const newReactions = reactions.filter(
|
||||
re => re.fromId !== reaction.get('fromId')
|
||||
const oldReaction = oldReactions.find(
|
||||
re => re.fromId === reaction.get('fromId')
|
||||
);
|
||||
newReactions.push(reaction.toJSON());
|
||||
this.set({ reactions: newReactions });
|
||||
if (oldReaction) {
|
||||
this.clearNotifications(oldReaction);
|
||||
}
|
||||
|
||||
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'),
|
||||
});
|
||||
if (reaction.get('remove')) {
|
||||
log.info(
|
||||
'handleReaction: removing reaction for message',
|
||||
this.idForLogging()
|
||||
);
|
||||
|
||||
// Only notify for reactions to our own messages
|
||||
if (
|
||||
conversation &&
|
||||
isOutgoing(this.attributes) &&
|
||||
!reaction.get('fromSync')
|
||||
) {
|
||||
conversation.notify(this, reaction);
|
||||
if (reaction.get('source') === ReactionSource.FromSync) {
|
||||
newReactions = oldReactions.filter(
|
||||
re =>
|
||||
re.fromId !== reaction.get('fromId') ||
|
||||
re.timestamp > reaction.get('timestamp')
|
||||
);
|
||||
} else {
|
||||
newReactions = oldReactions.filter(
|
||||
re => re.fromId !== reaction.get('fromId')
|
||||
);
|
||||
}
|
||||
|
||||
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 (reaction.get('source') === ReactionSource.FromSync) {
|
||||
const ourReactions = [
|
||||
reaction.toJSON(),
|
||||
...oldReactions.filter(re => re.fromId === reaction.get('fromId')),
|
||||
];
|
||||
reactionToAdd = maxBy(ourReactions, 'timestamp');
|
||||
} else {
|
||||
reactionToAdd = reaction.toJSON();
|
||||
}
|
||||
|
||||
newReactions = oldReactions.filter(
|
||||
re => re.fromId !== reaction.get('fromId')
|
||||
);
|
||||
newReactions.push(reactionToAdd);
|
||||
|
||||
if (
|
||||
isOutgoing(this.attributes) &&
|
||||
reaction.get('source') === ReactionSource.FromSomeoneElse
|
||||
) {
|
||||
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 newCount = (this.get('reactions') || []).length;
|
||||
this.set({ reactions: newReactions });
|
||||
|
||||
log.info(
|
||||
`Done processing reaction for message ${messageId}. Went from ${count} to ${newCount} reactions.`
|
||||
'handleReaction:',
|
||||
`Done processing reaction for message ${this.idForLogging()}.`,
|
||||
`Went from ${oldReactions.length} to ${newReactions.length} reactions.`
|
||||
);
|
||||
|
||||
if (shouldPersist) {
|
||||
if (reaction.get('source') === ReactionSource.FromThisDevice) {
|
||||
const jobData = { messageId: this.id };
|
||||
if (shouldPersist) {
|
||||
await reactionJobQueue.add(jobData, async jobToInsert => {
|
||||
log.info(
|
||||
`enqueueReactionForSend: saving message ${this.idForLogging()} and job ${
|
||||
jobToInsert.id
|
||||
}`
|
||||
);
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
jobToInsert,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await reactionJobQueue.add(jobData);
|
||||
}
|
||||
} else if (shouldPersist) {
|
||||
await window.Signal.Data.saveMessage(this.attributes);
|
||||
}
|
||||
|
||||
return oldReaction;
|
||||
}
|
||||
|
||||
async handleDeleteForEveryone(
|
||||
|
|
8
ts/reactions/ReactionSource.ts
Normal file
8
ts/reactions/ReactionSource.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export enum ReactionSource {
|
||||
FromSomeoneElse,
|
||||
FromSync,
|
||||
FromThisDevice,
|
||||
}
|
46
ts/reactions/enqueueReactionForSend.ts
Normal file
46
ts/reactions/enqueueReactionForSend.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { ReactionModel } from '../messageModifiers/Reactions';
|
||||
import { ReactionSource } from './ReactionSource';
|
||||
import { getMessageById } from '../messages/getMessageById';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
||||
export async function enqueueReactionForSend({
|
||||
emoji,
|
||||
messageId,
|
||||
remove,
|
||||
}: Readonly<{
|
||||
emoji: string;
|
||||
messageId: string;
|
||||
remove: boolean;
|
||||
}>): Promise<void> {
|
||||
const message = await getMessageById(messageId);
|
||||
strictAssert(message, 'enqueueReactionForSend: no message found');
|
||||
|
||||
const targetAuthorUuid = message.getSourceUuid();
|
||||
strictAssert(
|
||||
targetAuthorUuid,
|
||||
`enqueueReactionForSend: message ${message.idForLogging()} had no source UUID`
|
||||
);
|
||||
|
||||
const targetTimestamp = message.get('sent_at') || message.get('timestamp');
|
||||
strictAssert(
|
||||
targetTimestamp,
|
||||
`enqueueReactionForSend: message ${message.idForLogging()} had no timestamp`
|
||||
);
|
||||
|
||||
const reaction = new ReactionModel({
|
||||
emoji,
|
||||
remove,
|
||||
targetAuthorUuid,
|
||||
targetTimestamp,
|
||||
fromId: window.ConversationController.getOurConversationIdOrThrow(),
|
||||
timestamp: Date.now(),
|
||||
source: ReactionSource.FromThisDevice,
|
||||
});
|
||||
|
||||
await message.getConversation()?.maybeApplyUniversalTimer(false);
|
||||
|
||||
await message.handleReaction(reaction);
|
||||
}
|
158
ts/reactions/util.ts
Normal file
158
ts/reactions/util.ts
Normal file
|
@ -0,0 +1,158 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { findLastIndex, has, identity, omit, negate } from 'lodash';
|
||||
import type { MessageReactionType } from '../model-types.d';
|
||||
import { areObjectEntriesEqual } from '../util/areObjectEntriesEqual';
|
||||
|
||||
const isReactionEqual = (
|
||||
a: undefined | Readonly<MessageReactionType>,
|
||||
b: undefined | Readonly<MessageReactionType>
|
||||
): boolean =>
|
||||
a === b ||
|
||||
Boolean(
|
||||
a && b && areObjectEntriesEqual(a, b, ['emoji', 'fromId', 'timestamp'])
|
||||
);
|
||||
|
||||
const isOutgoingReactionFullySent = ({
|
||||
isSentByConversationId = {},
|
||||
}: Readonly<Pick<MessageReactionType, 'isSentByConversationId'>>): boolean =>
|
||||
!isSentByConversationId ||
|
||||
Object.values(isSentByConversationId).every(identity);
|
||||
|
||||
const isOutgoingReactionPending = negate(isOutgoingReactionFullySent);
|
||||
|
||||
const isOutgoingReactionCompletelyUnsent = ({
|
||||
isSentByConversationId = {},
|
||||
}: Readonly<Pick<MessageReactionType, 'isSentByConversationId'>>): boolean => {
|
||||
const sendStates = Object.values(isSentByConversationId);
|
||||
return sendStates.length > 0 && sendStates.every(state => state === false);
|
||||
};
|
||||
|
||||
export function addOutgoingReaction(
|
||||
oldReactions: ReadonlyArray<MessageReactionType>,
|
||||
newReaction: Readonly<MessageReactionType>
|
||||
): Array<MessageReactionType> {
|
||||
const pendingOutgoingReactions = new Set(
|
||||
oldReactions.filter(isOutgoingReactionPending)
|
||||
);
|
||||
return [
|
||||
...oldReactions.filter(re => !pendingOutgoingReactions.has(re)),
|
||||
newReaction,
|
||||
];
|
||||
}
|
||||
|
||||
export function getNewestPendingOutgoingReaction(
|
||||
reactions: ReadonlyArray<MessageReactionType>,
|
||||
ourConversationId: string
|
||||
):
|
||||
| { pendingReaction?: undefined; emojiToRemove?: undefined }
|
||||
| {
|
||||
pendingReaction: MessageReactionType;
|
||||
emojiToRemove?: string;
|
||||
} {
|
||||
const ourReactions = reactions
|
||||
.filter(({ fromId }) => fromId === ourConversationId)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const newestFinishedReactionIndex = findLastIndex(
|
||||
ourReactions,
|
||||
re => re.emoji && isOutgoingReactionFullySent(re)
|
||||
);
|
||||
const newestFinishedReaction = ourReactions[newestFinishedReactionIndex];
|
||||
|
||||
const newestPendingReactionIndex = findLastIndex(
|
||||
ourReactions,
|
||||
isOutgoingReactionPending
|
||||
);
|
||||
const pendingReaction: undefined | MessageReactionType =
|
||||
newestPendingReactionIndex > newestFinishedReactionIndex
|
||||
? ourReactions[newestPendingReactionIndex]
|
||||
: undefined;
|
||||
|
||||
return pendingReaction
|
||||
? {
|
||||
pendingReaction,
|
||||
// This might not be right in some cases. For example, imagine the following
|
||||
// sequence:
|
||||
//
|
||||
// 1. I send reaction A to Alice and Bob, but it was only delivered to Alice.
|
||||
// 2. I send reaction B to Alice and Bob, but it was only delivered to Bob.
|
||||
// 3. I remove the reaction.
|
||||
//
|
||||
// Android and iOS don't care what your previous reaction is. Old Desktop versions
|
||||
// *do* care, so we make our best guess. We should be able to remove this after
|
||||
// Desktop has ignored this field for awhile. See commit
|
||||
// `1dc353f08910389ad8cc5487949e6998e90038e2`.
|
||||
emojiToRemove: newestFinishedReaction?.emoji,
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
export function* getUnsentConversationIds({
|
||||
isSentByConversationId = {},
|
||||
}: Readonly<
|
||||
Pick<MessageReactionType, 'isSentByConversationId'>
|
||||
>): Iterable<string> {
|
||||
for (const [id, isSent] of Object.entries(isSentByConversationId)) {
|
||||
if (!isSent) {
|
||||
yield id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const markOutgoingReactionFailed = (
|
||||
reactions: Array<MessageReactionType>,
|
||||
reaction: Readonly<MessageReactionType>
|
||||
): Array<MessageReactionType> =>
|
||||
isOutgoingReactionCompletelyUnsent(reaction) || !reaction.emoji
|
||||
? reactions.filter(re => !isReactionEqual(re, reaction))
|
||||
: reactions.map(re =>
|
||||
isReactionEqual(re, reaction)
|
||||
? omit(re, ['isSentByConversationId'])
|
||||
: re
|
||||
);
|
||||
|
||||
export const markOutgoingReactionSent = (
|
||||
reactions: ReadonlyArray<MessageReactionType>,
|
||||
reaction: Readonly<MessageReactionType>,
|
||||
conversationIdsSentTo: Iterable<string>
|
||||
): Array<MessageReactionType> => {
|
||||
const result: Array<MessageReactionType> = [];
|
||||
|
||||
const newIsSentByConversationId = {
|
||||
...(reaction.isSentByConversationId || {}),
|
||||
};
|
||||
for (const id of conversationIdsSentTo) {
|
||||
if (has(newIsSentByConversationId, id)) {
|
||||
newIsSentByConversationId[id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
const isFullySent = Object.values(newIsSentByConversationId).every(identity);
|
||||
|
||||
for (const re of reactions) {
|
||||
if (!isReactionEqual(re, reaction)) {
|
||||
const shouldKeep = !isFullySent
|
||||
? true
|
||||
: re.fromId !== reaction.fromId || re.timestamp > reaction.timestamp;
|
||||
if (shouldKeep) {
|
||||
result.push(re);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFullySent) {
|
||||
if (re.emoji) {
|
||||
result.push(omit(re, ['isSentByConversationId']));
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
...re,
|
||||
isSentByConversationId: newIsSentByConversationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
|
@ -17,6 +17,7 @@ import filesize from 'filesize';
|
|||
import type {
|
||||
LastMessageStatus,
|
||||
MessageAttributesType,
|
||||
MessageReactionType,
|
||||
ShallowChallengeError,
|
||||
} from '../../model-types.d';
|
||||
|
||||
|
@ -54,6 +55,8 @@ import { memoizeByRoot } from '../../util/memoizeByRoot';
|
|||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { isNotNil } from '../../util/isNotNil';
|
||||
import { isMoreRecentThan } from '../../util/timestamp';
|
||||
import * as iterables from '../../util/iterables';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
|
||||
import type { ConversationType } from '../ducks/conversations';
|
||||
|
||||
|
@ -379,7 +382,23 @@ export const getReactionsForMessage = createSelectorCreator(
|
|||
{ reactions = [] }: MessageAttributesType,
|
||||
{ conversationSelector }: { conversationSelector: GetConversationByIdType }
|
||||
) => {
|
||||
return reactions.map(re => {
|
||||
const reactionBySender = new Map<string, MessageReactionType>();
|
||||
for (const reaction of reactions) {
|
||||
const existingReaction = reactionBySender.get(reaction.fromId);
|
||||
if (
|
||||
!existingReaction ||
|
||||
reaction.timestamp > existingReaction.timestamp
|
||||
) {
|
||||
reactionBySender.set(reaction.fromId, reaction);
|
||||
}
|
||||
}
|
||||
|
||||
const reactionsWithEmpties = reactionBySender.values();
|
||||
const reactionsWithEmoji = iterables.filter(
|
||||
reactionsWithEmpties,
|
||||
re => re.emoji
|
||||
);
|
||||
const formattedReactions = iterables.map(reactionsWithEmoji, re => {
|
||||
const c = conversationSelector(re.fromId);
|
||||
|
||||
type From = NonNullable<PropsData['reactions']>[0]['from'];
|
||||
|
@ -399,12 +418,16 @@ export const getReactionsForMessage = createSelectorCreator(
|
|||
|
||||
const from: AssertProps<From, typeof unsafe> = unsafe;
|
||||
|
||||
strictAssert(re.emoji, 'Expected all reactions to have an emoji');
|
||||
|
||||
return {
|
||||
emoji: re.emoji,
|
||||
timestamp: re.timestamp,
|
||||
from,
|
||||
};
|
||||
});
|
||||
|
||||
return [...formattedReactions];
|
||||
},
|
||||
|
||||
(_: MessageAttributesType, reactions: PropsData['reactions']) => reactions
|
||||
|
@ -1373,6 +1396,51 @@ function processQuoteAttachment(
|
|||
};
|
||||
}
|
||||
|
||||
function canReplyOrReact(
|
||||
message: Pick<
|
||||
MessageAttributesType,
|
||||
'deletedForEveryone' | 'sendStateByConversationId' | 'type'
|
||||
>,
|
||||
ourConversationId: string,
|
||||
conversation: undefined | Readonly<ConversationType>
|
||||
): boolean {
|
||||
const { deletedForEveryone, sendStateByConversationId } = message;
|
||||
|
||||
if (!conversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conversation.isGroupV1AndDisabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isMissingRequiredProfileSharing(conversation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!conversation.acceptedMessageRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deletedForEveryone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isOutgoing(message)) {
|
||||
return (
|
||||
isMessageJustForMe(sendStateByConversationId, ourConversationId) ||
|
||||
someSendStatus(omit(sendStateByConversationId, ourConversationId), isSent)
|
||||
);
|
||||
}
|
||||
|
||||
if (isIncoming(message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fail safe.
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canReply(
|
||||
message: Pick<
|
||||
MessageAttributesType,
|
||||
|
@ -1385,53 +1453,28 @@ export function canReply(
|
|||
conversationSelector: GetConversationByIdType
|
||||
): boolean {
|
||||
const conversation = getConversation(message, conversationSelector);
|
||||
const { deletedForEveryone, sendStateByConversationId } = message;
|
||||
|
||||
if (!conversation) {
|
||||
if (
|
||||
!conversation ||
|
||||
(conversation.announcementsOnly && !conversation.areWeAdmin)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return canReplyOrReact(message, ourConversationId, conversation);
|
||||
}
|
||||
|
||||
// If GroupV1 groups have been disabled, we can't reply.
|
||||
if (conversation.isGroupV1AndDisabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If mandatory profile sharing is enabled, and we haven't shared yet, then
|
||||
// we can't reply.
|
||||
if (isMissingRequiredProfileSharing(conversation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We cannot reply if we haven't accepted the message request
|
||||
if (!conversation.acceptedMessageRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We cannot reply if this message is deleted for everyone
|
||||
if (deletedForEveryone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Groups where only admins can send messages
|
||||
if (conversation.announcementsOnly && !conversation.areWeAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We can reply if this is outgoing and sent to at least one recipient
|
||||
if (isOutgoing(message)) {
|
||||
return (
|
||||
isMessageJustForMe(sendStateByConversationId, ourConversationId) ||
|
||||
someSendStatus(omit(sendStateByConversationId, ourConversationId), isSent)
|
||||
);
|
||||
}
|
||||
|
||||
// We can reply to incoming messages
|
||||
if (isIncoming(message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fail safe.
|
||||
return false;
|
||||
export function canReact(
|
||||
message: Pick<
|
||||
MessageAttributesType,
|
||||
| 'conversationId'
|
||||
| 'deletedForEveryone'
|
||||
| 'sendStateByConversationId'
|
||||
| 'type'
|
||||
>,
|
||||
ourConversationId: string,
|
||||
conversationSelector: GetConversationByIdType
|
||||
): boolean {
|
||||
const conversation = getConversation(message, conversationSelector);
|
||||
return canReplyOrReact(message, ourConversationId, conversation);
|
||||
}
|
||||
|
||||
export function canDeleteForEveryone(
|
||||
|
|
270
ts/test-both/reactions/util_test.ts
Normal file
270
ts/test-both/reactions/util_test.ts
Normal file
|
@ -0,0 +1,270 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { omit } from 'lodash';
|
||||
import type { MessageReactionType } from '../../model-types.d';
|
||||
import { isEmpty } from '../../util/iterables';
|
||||
|
||||
import {
|
||||
addOutgoingReaction,
|
||||
getNewestPendingOutgoingReaction,
|
||||
getUnsentConversationIds,
|
||||
markOutgoingReactionFailed,
|
||||
markOutgoingReactionSent,
|
||||
} from '../../reactions/util';
|
||||
|
||||
describe('reaction utilities', () => {
|
||||
const OUR_CONVO_ID = uuid();
|
||||
|
||||
const rxn = (
|
||||
emoji: undefined | string,
|
||||
{ isPending = false }: Readonly<{ isPending?: boolean }> = {}
|
||||
): MessageReactionType => ({
|
||||
emoji,
|
||||
fromId: OUR_CONVO_ID,
|
||||
targetAuthorUuid: uuid(),
|
||||
targetTimestamp: Date.now(),
|
||||
timestamp: Date.now(),
|
||||
...(isPending ? { isSentByConversationId: { [uuid()]: false } } : {}),
|
||||
});
|
||||
|
||||
describe('addOutgoingReaction', () => {
|
||||
it('adds the reaction to the end of an empty list', () => {
|
||||
const reaction = rxn('💅');
|
||||
const result = addOutgoingReaction([], reaction);
|
||||
assert.deepStrictEqual(result, [reaction]);
|
||||
});
|
||||
|
||||
it('removes all pending reactions', () => {
|
||||
const oldReactions = [
|
||||
{ ...rxn('😭', { isPending: true }), timestamp: 3 },
|
||||
{ ...rxn('💬'), fromId: uuid() },
|
||||
{ ...rxn('🥀', { isPending: true }), timestamp: 1 },
|
||||
{ ...rxn('🌹', { isPending: true }), timestamp: 2 },
|
||||
];
|
||||
const reaction = rxn('😀');
|
||||
const newReactions = addOutgoingReaction(oldReactions, reaction);
|
||||
assert.deepStrictEqual(newReactions, [oldReactions[1], reaction]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewestPendingOutgoingReaction', () => {
|
||||
it('returns undefined if there are no pending outgoing reactions', () => {
|
||||
[[], [rxn('🔔')], [rxn('😭'), { ...rxn('💬'), fromId: uuid() }]].forEach(
|
||||
oldReactions => {
|
||||
assert.deepStrictEqual(
|
||||
getNewestPendingOutgoingReaction(oldReactions, OUR_CONVO_ID),
|
||||
{}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined if there's a pending reaction before a fully sent one", () => {
|
||||
const oldReactions = [
|
||||
{ ...rxn('⭐️'), timestamp: 2 },
|
||||
{ ...rxn('🔥', { isPending: true }), timestamp: 1 },
|
||||
];
|
||||
const {
|
||||
pendingReaction,
|
||||
emojiToRemove,
|
||||
} = getNewestPendingOutgoingReaction(oldReactions, OUR_CONVO_ID);
|
||||
|
||||
assert.isUndefined(pendingReaction);
|
||||
assert.isUndefined(emojiToRemove);
|
||||
});
|
||||
|
||||
it('returns the newest pending reaction', () => {
|
||||
[
|
||||
[rxn('⭐️', { isPending: true })],
|
||||
[
|
||||
{ ...rxn('🥀', { isPending: true }), timestamp: 1 },
|
||||
{ ...rxn('⭐️', { isPending: true }), timestamp: 2 },
|
||||
],
|
||||
].forEach(oldReactions => {
|
||||
const {
|
||||
pendingReaction,
|
||||
emojiToRemove,
|
||||
} = getNewestPendingOutgoingReaction(oldReactions, OUR_CONVO_ID);
|
||||
|
||||
assert.strictEqual(pendingReaction?.emoji, '⭐️');
|
||||
assert.isUndefined(emojiToRemove);
|
||||
});
|
||||
});
|
||||
|
||||
it('makes its best guess of an emoji to remove, if applicable', () => {
|
||||
const oldReactions = [
|
||||
{ ...rxn('⭐️'), timestamp: 1 },
|
||||
{ ...rxn(undefined, { isPending: true }), timestamp: 3 },
|
||||
{ ...rxn('🔥', { isPending: true }), timestamp: 2 },
|
||||
];
|
||||
const {
|
||||
pendingReaction,
|
||||
emojiToRemove,
|
||||
} = getNewestPendingOutgoingReaction(oldReactions, OUR_CONVO_ID);
|
||||
|
||||
assert.isDefined(pendingReaction);
|
||||
assert.isUndefined(pendingReaction?.emoji);
|
||||
assert.strictEqual(emojiToRemove, '⭐️');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnsentConversationIds', () => {
|
||||
it("returns an empty iterable if there's nothing to send", () => {
|
||||
assert(isEmpty(getUnsentConversationIds({})));
|
||||
assert(
|
||||
isEmpty(
|
||||
getUnsentConversationIds({
|
||||
isSentByConversationId: { [uuid()]: true },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an iterable of all unsent conversation IDs', () => {
|
||||
const unsent1 = uuid();
|
||||
const unsent2 = uuid();
|
||||
const fakeReaction = {
|
||||
isSentByConversationId: {
|
||||
[unsent1]: false,
|
||||
[unsent2]: false,
|
||||
[uuid()]: true,
|
||||
[uuid()]: true,
|
||||
},
|
||||
};
|
||||
|
||||
assert.sameMembers(
|
||||
[...getUnsentConversationIds(fakeReaction)],
|
||||
[unsent1, unsent2]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markReactionFailed', () => {
|
||||
const fullySent = rxn('⭐️');
|
||||
const partiallySent = {
|
||||
...rxn('🔥'),
|
||||
isSentByConversationId: { [uuid()]: true, [uuid()]: false },
|
||||
};
|
||||
const unsent = rxn('🤫', { isPending: true });
|
||||
|
||||
const reactions = [fullySent, partiallySent, unsent];
|
||||
|
||||
it('removes the pending state if the reaction, with emoji, was partially sent', () => {
|
||||
assert.deepStrictEqual(
|
||||
markOutgoingReactionFailed(reactions, partiallySent),
|
||||
[fullySent, omit(partiallySent, 'isSentByConversationId'), unsent]
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the removal reaction', () => {
|
||||
const none = rxn(undefined, { isPending: true });
|
||||
assert.isEmpty(markOutgoingReactionFailed([none], none));
|
||||
});
|
||||
|
||||
it('does nothing if the reaction is not in the list', () => {
|
||||
assert.deepStrictEqual(
|
||||
markOutgoingReactionFailed(reactions, rxn('🥀', { isPending: true })),
|
||||
reactions
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the completely-unsent emoji reaction', () => {
|
||||
assert.deepStrictEqual(markOutgoingReactionFailed(reactions, unsent), [
|
||||
fullySent,
|
||||
partiallySent,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markOutgoingReactionSent', () => {
|
||||
const uuid1 = uuid();
|
||||
const uuid2 = uuid();
|
||||
const uuid3 = uuid();
|
||||
|
||||
const star = {
|
||||
...rxn('⭐️'),
|
||||
timestamp: 2,
|
||||
isSentByConversationId: {
|
||||
[uuid1]: false,
|
||||
[uuid2]: false,
|
||||
[uuid3]: false,
|
||||
},
|
||||
};
|
||||
const none = {
|
||||
...rxn(undefined),
|
||||
timestamp: 3,
|
||||
isSentByConversationId: {
|
||||
[uuid1]: false,
|
||||
[uuid2]: false,
|
||||
[uuid3]: false,
|
||||
},
|
||||
};
|
||||
|
||||
const reactions = [star, none, { ...rxn('🔕'), timestamp: 1 }];
|
||||
|
||||
it("does nothing if the reaction isn't in the list", () => {
|
||||
const result = markOutgoingReactionSent(
|
||||
reactions,
|
||||
rxn('🥀', { isPending: true }),
|
||||
[uuid()]
|
||||
);
|
||||
assert.deepStrictEqual(result, reactions);
|
||||
});
|
||||
|
||||
it('updates reactions to be partially sent', () => {
|
||||
[star, none].forEach(reaction => {
|
||||
const result = markOutgoingReactionSent(reactions, reaction, [
|
||||
uuid1,
|
||||
uuid2,
|
||||
]);
|
||||
assert.deepStrictEqual(
|
||||
result.find(re => re.emoji === reaction.emoji)
|
||||
?.isSentByConversationId,
|
||||
{
|
||||
[uuid1]: true,
|
||||
[uuid2]: true,
|
||||
[uuid3]: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes sent state if a reaction with emoji is fully sent', () => {
|
||||
const result = markOutgoingReactionSent(reactions, star, [
|
||||
uuid1,
|
||||
uuid2,
|
||||
uuid3,
|
||||
]);
|
||||
|
||||
const newReaction = result.find(re => re.emoji === '⭐️');
|
||||
assert.isDefined(newReaction);
|
||||
assert.isUndefined(newReaction?.isSentByConversationId);
|
||||
});
|
||||
|
||||
it('removes a fully-sent reaction removal', () => {
|
||||
const result = markOutgoingReactionSent(reactions, none, [
|
||||
uuid1,
|
||||
uuid2,
|
||||
uuid3,
|
||||
]);
|
||||
|
||||
assert(
|
||||
result.every(({ emoji }) => typeof emoji === 'string'),
|
||||
'Expected the emoji removal to be gone'
|
||||
);
|
||||
});
|
||||
|
||||
it('removes older reactions of mine', () => {
|
||||
const result = markOutgoingReactionSent(reactions, star, [
|
||||
uuid1,
|
||||
uuid2,
|
||||
uuid3,
|
||||
]);
|
||||
|
||||
assert.isUndefined(result.find(re => re.emoji === '🔕'));
|
||||
});
|
||||
});
|
||||
});
|
46
ts/test-both/util/areObjectEntriesEqual_test.ts
Normal file
46
ts/test-both/util/areObjectEntriesEqual_test.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { areObjectEntriesEqual } from '../../util/areObjectEntriesEqual';
|
||||
|
||||
describe('areObjectEntriesEqual', () => {
|
||||
type TestObject = { foo?: number; bar?: number };
|
||||
|
||||
const empty: TestObject = {};
|
||||
const foo: TestObject = { foo: 1 };
|
||||
const bar: TestObject = { bar: 2 };
|
||||
const undefinedEntries: TestObject = { foo: undefined, bar: undefined };
|
||||
|
||||
it('returns true for an empty list of keys', () => {
|
||||
assert.isTrue(areObjectEntriesEqual({}, {}, []));
|
||||
assert.isTrue(areObjectEntriesEqual(foo, foo, []));
|
||||
assert.isTrue(areObjectEntriesEqual(foo, bar, []));
|
||||
});
|
||||
|
||||
it('returns true for empty objects', () => {
|
||||
assert.isTrue(areObjectEntriesEqual(empty, empty, ['foo']));
|
||||
});
|
||||
|
||||
it('considers missing keys equal to undefined keys', () => {
|
||||
assert.isTrue(
|
||||
areObjectEntriesEqual(empty, undefinedEntries, ['foo', 'bar'])
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores unspecified properties', () => {
|
||||
assert.isTrue(areObjectEntriesEqual({ x: 1, y: 2 }, { x: 1, y: 3 }, ['x']));
|
||||
});
|
||||
|
||||
it('returns false for different objects', () => {
|
||||
assert.isFalse(areObjectEntriesEqual({ x: 1 }, { x: 2 }, ['x']));
|
||||
assert.isFalse(
|
||||
areObjectEntriesEqual({ x: 1, y: 2 }, { x: 1, y: 3 }, ['x', 'y'])
|
||||
);
|
||||
});
|
||||
|
||||
it('only performs a shallow check', () => {
|
||||
assert.isFalse(areObjectEntriesEqual({ x: [1, 2] }, { x: [1, 2] }, ['x']));
|
||||
});
|
||||
});
|
|
@ -13,6 +13,7 @@ import type { ConversationType } from '../../../state/ducks/conversations';
|
|||
|
||||
import {
|
||||
canDeleteForEveryone,
|
||||
canReact,
|
||||
canReply,
|
||||
getMessagePropStatus,
|
||||
isEndSession,
|
||||
|
@ -120,6 +121,117 @@ describe('state/selectors/messages', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('canReact', () => {
|
||||
const defaultConversation: ConversationType = {
|
||||
id: uuid(),
|
||||
type: 'direct',
|
||||
title: 'Test conversation',
|
||||
isMe: false,
|
||||
sharedGroupNames: [],
|
||||
acceptedMessageRequest: true,
|
||||
};
|
||||
|
||||
it('returns false for disabled v1 groups', () => {
|
||||
const message = {
|
||||
conversationId: 'fake-conversation-id',
|
||||
type: 'incoming' as const,
|
||||
};
|
||||
const getConversationById = () => ({
|
||||
...defaultConversation,
|
||||
type: 'group' as const,
|
||||
isGroupV1AndDisabled: true,
|
||||
});
|
||||
|
||||
assert.isFalse(canReact(message, ourConversationId, getConversationById));
|
||||
});
|
||||
|
||||
// NOTE: This is missing a test for mandatory profile sharing.
|
||||
|
||||
it('returns false if the message was deleted for everyone', () => {
|
||||
const message = {
|
||||
conversationId: 'fake-conversation-id',
|
||||
type: 'incoming' as const,
|
||||
deletedForEveryone: true,
|
||||
};
|
||||
const getConversationById = () => defaultConversation;
|
||||
|
||||
assert.isFalse(canReact(message, ourConversationId, getConversationById));
|
||||
});
|
||||
|
||||
it('returns false for outgoing messages that have not been sent', () => {
|
||||
const message = {
|
||||
conversationId: 'fake-conversation-id',
|
||||
type: 'outgoing' as const,
|
||||
sendStateByConversationId: {
|
||||
[ourConversationId]: {
|
||||
status: SendStatus.Sent,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
[uuid()]: {
|
||||
status: SendStatus.Pending,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
const getConversationById = () => defaultConversation;
|
||||
|
||||
assert.isFalse(canReact(message, ourConversationId, getConversationById));
|
||||
});
|
||||
|
||||
it('returns true for outgoing messages that are only sent to yourself', () => {
|
||||
const message = {
|
||||
conversationId: 'fake-conversation-id',
|
||||
type: 'outgoing' as const,
|
||||
sendStateByConversationId: {
|
||||
[ourConversationId]: {
|
||||
status: SendStatus.Pending,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
const getConversationById = () => defaultConversation;
|
||||
|
||||
assert.isTrue(canReact(message, ourConversationId, getConversationById));
|
||||
});
|
||||
|
||||
it('returns true for outgoing messages that have been sent to at least one person', () => {
|
||||
const message = {
|
||||
conversationId: 'fake-conversation-id',
|
||||
type: 'outgoing' as const,
|
||||
sendStateByConversationId: {
|
||||
[ourConversationId]: {
|
||||
status: SendStatus.Sent,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
[uuid()]: {
|
||||
status: SendStatus.Pending,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
[uuid()]: {
|
||||
status: SendStatus.Sent,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
const getConversationById = () => ({
|
||||
...defaultConversation,
|
||||
type: 'group' as const,
|
||||
});
|
||||
|
||||
assert.isTrue(canReact(message, ourConversationId, getConversationById));
|
||||
});
|
||||
|
||||
it('returns true for incoming messages', () => {
|
||||
const message = {
|
||||
conversationId: 'fake-conversation-id',
|
||||
type: 'incoming' as const,
|
||||
};
|
||||
const getConversationById = () => defaultConversation;
|
||||
|
||||
assert.isTrue(canReact(message, ourConversationId, getConversationById));
|
||||
});
|
||||
});
|
||||
|
||||
describe('canReply', () => {
|
||||
const defaultConversation: ConversationType = {
|
||||
id: uuid(),
|
||||
|
|
|
@ -110,7 +110,7 @@ describe('JobQueueDatabaseStore', () => {
|
|||
queueType: 'test queue',
|
||||
data: { hi: 5 },
|
||||
},
|
||||
{ shouldInsertIntoDatabase: false }
|
||||
{ shouldPersist: false }
|
||||
);
|
||||
|
||||
await streamPromise;
|
||||
|
|
|
@ -228,6 +228,45 @@ describe('JobQueue', () => {
|
|||
assert.lengthOf(queueTypes['test 2'], 2);
|
||||
});
|
||||
|
||||
it('can override the insertion logic, skipping storage persistence', async () => {
|
||||
const store = new TestJobQueueStore();
|
||||
|
||||
class TestQueue extends JobQueue<string> {
|
||||
parseData(data: unknown): string {
|
||||
return z.string().parse(data);
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const queue = new TestQueue({
|
||||
store,
|
||||
queueType: 'test queue',
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
queue.streamJobs();
|
||||
|
||||
const insert = sinon.stub().resolves();
|
||||
|
||||
await queue.add('foo bar', insert);
|
||||
|
||||
assert.lengthOf(store.storedJobs, 0);
|
||||
|
||||
sinon.assert.calledOnce(insert);
|
||||
sinon.assert.calledWith(
|
||||
insert,
|
||||
sinon.match({
|
||||
id: sinon.match.string,
|
||||
timestamp: sinon.match.number,
|
||||
queueType: 'test queue',
|
||||
data: 'foo bar',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('retries jobs, running them up to maxAttempts times', async () => {
|
||||
type TestJobData = 'foo' | 'bar';
|
||||
|
||||
|
|
|
@ -24,7 +24,10 @@ export class TestJobQueueStore implements JobQueueStore {
|
|||
});
|
||||
}
|
||||
|
||||
async insert(job: Readonly<StoredJob>): Promise<void> {
|
||||
async insert(
|
||||
job: Readonly<StoredJob>,
|
||||
{ shouldPersist = true }: Readonly<{ shouldPersist?: boolean }> = {}
|
||||
): Promise<void> {
|
||||
await fakeDelay();
|
||||
|
||||
this.storedJobs.forEach(storedJob => {
|
||||
|
@ -33,7 +36,9 @@ export class TestJobQueueStore implements JobQueueStore {
|
|||
}
|
||||
});
|
||||
|
||||
this.storedJobs.push(job);
|
||||
if (shouldPersist) {
|
||||
this.storedJobs.push(job);
|
||||
}
|
||||
|
||||
this.getPipe(job.queueType).add(job);
|
||||
|
||||
|
|
44
ts/test-node/jobs/helpers/InMemoryQueues_test.ts
Normal file
44
ts/test-node/jobs/helpers/InMemoryQueues_test.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { InMemoryQueues } from '../../../jobs/helpers/InMemoryQueues';
|
||||
|
||||
describe('InMemoryQueues', () => {
|
||||
describe('get', () => {
|
||||
it('returns a new PQueue for each key', () => {
|
||||
const queues = new InMemoryQueues();
|
||||
|
||||
assert.strictEqual(queues.get('a'), queues.get('a'));
|
||||
assert.notStrictEqual(queues.get('a'), queues.get('b'));
|
||||
assert.notStrictEqual(queues.get('b'), queues.get('c'));
|
||||
});
|
||||
|
||||
it('returns a queue that only executes one thing at a time', () => {
|
||||
const queue = new InMemoryQueues().get('foo');
|
||||
|
||||
assert.strictEqual(queue.concurrency, 1);
|
||||
});
|
||||
|
||||
it('cleans up the queues when all tasks have run', async () => {
|
||||
const queues = new InMemoryQueues();
|
||||
|
||||
const originalQueue = queues.get('foo');
|
||||
|
||||
originalQueue.pause();
|
||||
const tasksPromise = originalQueue.addAll([
|
||||
async () => {
|
||||
assert.strictEqual(queues.get('foo'), originalQueue);
|
||||
},
|
||||
async () => {
|
||||
assert.strictEqual(queues.get('foo'), originalQueue);
|
||||
},
|
||||
]);
|
||||
originalQueue.start();
|
||||
await tasksPromise;
|
||||
|
||||
assert.notStrictEqual(queues.get('foo'), originalQueue);
|
||||
});
|
||||
});
|
||||
});
|
8
ts/util/areObjectEntriesEqual.ts
Normal file
8
ts/util/areObjectEntriesEqual.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export const areObjectEntriesEqual = <T>(
|
||||
a: Readonly<T>,
|
||||
b: Readonly<T>,
|
||||
keys: ReadonlyArray<keyof T>
|
||||
): boolean => a === b || keys.every(key => a[key] === b[key]);
|
|
@ -32,6 +32,7 @@ import type {
|
|||
import type { MessageModel } from '../models/messages';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { maybeParseUrl } from '../util/url';
|
||||
import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend';
|
||||
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
|
||||
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
|
||||
import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
|
||||
|
@ -845,11 +846,21 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
|||
}
|
||||
|
||||
getMessageActions(): MessageActionsType {
|
||||
const reactToMessage = (
|
||||
const reactToMessage = async (
|
||||
messageId: string,
|
||||
reaction: { emoji: string; remove: boolean }
|
||||
) => {
|
||||
this.sendReactionMessage(messageId, reaction);
|
||||
const { emoji, remove } = reaction;
|
||||
try {
|
||||
await enqueueReactionForSend({
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Error sending reaction', error, messageId, reaction);
|
||||
showToast(ToastReactionFailed);
|
||||
}
|
||||
};
|
||||
const replyToMessage = (messageId: string) => {
|
||||
this.setQuoteMessage(messageId);
|
||||
|
@ -2997,38 +3008,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
|||
});
|
||||
}
|
||||
|
||||
async sendReactionMessage(
|
||||
messageId: string,
|
||||
reaction: { emoji: string; remove: boolean }
|
||||
): Promise<void> {
|
||||
const messageModel = messageId
|
||||
? await getMessageById(messageId, {
|
||||
Message: Whisper.Message,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (!messageModel) {
|
||||
throw new Error('sendReactionMessage: Message not found');
|
||||
}
|
||||
const targetAuthorUuid = messageModel.getSourceUuid();
|
||||
if (!targetAuthorUuid) {
|
||||
throw new Error(
|
||||
`sendReactionMessage: Message ${messageModel.idForLogging()} had no source uuid! Cannot send reaction.`
|
||||
);
|
||||
}
|
||||
|
||||
await this.model.sendReactionMessage(reaction, {
|
||||
messageId,
|
||||
targetAuthorUuid,
|
||||
targetTimestamp: messageModel.get('sent_at'),
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Error sending reaction', error, messageId, reaction);
|
||||
showToast(ToastReactionFailed);
|
||||
}
|
||||
}
|
||||
|
||||
async sendStickerMessage(options: {
|
||||
packId: string;
|
||||
stickerId: number;
|
||||
|
|
Loading…
Reference in a new issue