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'
|
'DataMessage.Reaction.targetAuthorUuid'
|
||||||
);
|
);
|
||||||
|
|
||||||
const { reaction } = data.message;
|
const { reaction, timestamp } = data.message;
|
||||||
|
|
||||||
if (!isValidReactionEmoji(reaction.emoji)) {
|
if (!isValidReactionEmoji(reaction.emoji)) {
|
||||||
log.warn('Received an invalid reaction emoji. Dropping it');
|
log.warn('Received an invalid reaction emoji. Dropping it');
|
||||||
|
@ -2862,7 +2862,7 @@ export async function startApp(): Promise<void> {
|
||||||
remove: reaction.remove,
|
remove: reaction.remove,
|
||||||
targetAuthorUuid,
|
targetAuthorUuid,
|
||||||
targetTimestamp: reaction.targetTimestamp,
|
targetTimestamp: reaction.targetTimestamp,
|
||||||
timestamp: Date.now(),
|
timestamp,
|
||||||
fromId: window.ConversationController.ensureContactIds({
|
fromId: window.ConversationController.ensureContactIds({
|
||||||
e164: data.source,
|
e164: data.source,
|
||||||
uuid: data.sourceUuid,
|
uuid: data.sourceUuid,
|
||||||
|
@ -3190,7 +3190,7 @@ export async function startApp(): Promise<void> {
|
||||||
'DataMessage.Reaction.targetAuthorUuid'
|
'DataMessage.Reaction.targetAuthorUuid'
|
||||||
);
|
);
|
||||||
|
|
||||||
const { reaction } = data.message;
|
const { reaction, timestamp } = data.message;
|
||||||
|
|
||||||
if (!isValidReactionEmoji(reaction.emoji)) {
|
if (!isValidReactionEmoji(reaction.emoji)) {
|
||||||
log.warn('Received an invalid reaction emoji. Dropping it');
|
log.warn('Received an invalid reaction emoji. Dropping it');
|
||||||
|
@ -3204,7 +3204,7 @@ export async function startApp(): Promise<void> {
|
||||||
remove: reaction.remove,
|
remove: reaction.remove,
|
||||||
targetAuthorUuid,
|
targetAuthorUuid,
|
||||||
targetTimestamp: reaction.targetTimestamp,
|
targetTimestamp: reaction.targetTimestamp,
|
||||||
timestamp: Date.now(),
|
timestamp,
|
||||||
fromId: window.ConversationController.getOurConversationId(),
|
fromId: window.ConversationController.getOurConversationId(),
|
||||||
fromSync: true,
|
fromSync: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -138,22 +138,29 @@ export abstract class JobQueue<T> {
|
||||||
* Add a job, which should cause it to be enqueued and run.
|
* Add a job, which should cause it to be enqueued and run.
|
||||||
*
|
*
|
||||||
* If `streamJobs` has not been called yet, this will throw an error.
|
* 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>> {
|
async add(
|
||||||
this.throwIfNotStarted();
|
data: Readonly<T>,
|
||||||
|
insert?: (job: ParsedJob<T>) => Promise<void>
|
||||||
const job = this.createJob(data);
|
): Promise<Job<T>> {
|
||||||
await this.store.insert(job);
|
|
||||||
log.info(`${this.logPrefix} added new job ${job.id}`);
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected throwIfNotStarted(): void {
|
|
||||||
if (!this.started) {
|
if (!this.started) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${this.logPrefix} has not started streaming. Make sure to call streamJobs().`
|
`${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> {
|
protected createJob(data: Readonly<T>): Job<T> {
|
||||||
|
|
|
@ -26,9 +26,7 @@ export class JobQueueDatabaseStore implements JobQueueStore {
|
||||||
|
|
||||||
async insert(
|
async insert(
|
||||||
job: Readonly<StoredJob>,
|
job: Readonly<StoredJob>,
|
||||||
{
|
{ shouldPersist = true }: Readonly<{ shouldPersist?: boolean }> = {}
|
||||||
shouldInsertIntoDatabase = true,
|
|
||||||
}: Readonly<{ shouldInsertIntoDatabase?: boolean }> = {}
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
log.info(
|
log.info(
|
||||||
`JobQueueDatabaseStore adding job ${job.id} to queue ${JSON.stringify(
|
`JobQueueDatabaseStore adding job ${job.id} to queue ${JSON.stringify(
|
||||||
|
@ -46,7 +44,7 @@ export class JobQueueDatabaseStore implements JobQueueStore {
|
||||||
}
|
}
|
||||||
await initialFetchPromise;
|
await initialFetchPromise;
|
||||||
|
|
||||||
if (shouldInsertIntoDatabase) {
|
if (shouldPersist) {
|
||||||
await this.db.insertJob(formatJobForInsert(job));
|
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 type { WebAPIType } from '../textsecure/WebAPI';
|
||||||
|
|
||||||
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
|
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
|
||||||
|
import { reactionJobQueue } from './reactionJobQueue';
|
||||||
import { readSyncJobQueue } from './readSyncJobQueue';
|
import { readSyncJobQueue } from './readSyncJobQueue';
|
||||||
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
||||||
import { reportSpamJobQueue } from './reportSpamJobQueue';
|
import { reportSpamJobQueue } from './reportSpamJobQueue';
|
||||||
|
@ -21,6 +22,7 @@ export function initializeAllJobQueues({
|
||||||
reportSpamJobQueue.initialize({ server });
|
reportSpamJobQueue.initialize({ server });
|
||||||
|
|
||||||
normalMessageSendJobQueue.streamJobs();
|
normalMessageSendJobQueue.streamJobs();
|
||||||
|
reactionJobQueue.streamJobs();
|
||||||
readSyncJobQueue.streamJobs();
|
readSyncJobQueue.streamJobs();
|
||||||
removeStorageKeyJobQueue.streamJobs();
|
removeStorageKeyJobQueue.streamJobs();
|
||||||
reportSpamJobQueue.streamJobs();
|
reportSpamJobQueue.streamJobs();
|
||||||
|
|
|
@ -3,11 +3,12 @@
|
||||||
|
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
|
|
||||||
import PQueue from 'p-queue';
|
import type PQueue from 'p-queue';
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
||||||
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
|
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
|
||||||
import { sleepFor413RetryAfterTime } from './helpers/sleepFor413RetryAfterTime';
|
import { sleepFor413RetryAfterTime } from './helpers/sleepFor413RetryAfterTime';
|
||||||
|
import { InMemoryQueues } from './helpers/InMemoryQueues';
|
||||||
import type { MessageModel } from '../models/messages';
|
import type { MessageModel } from '../models/messages';
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
import { getMessageById } from '../messages/getMessageById';
|
||||||
import type { ConversationModel } from '../models/conversations';
|
import type { ConversationModel } from '../models/conversations';
|
||||||
|
@ -23,19 +24,13 @@ import type { CallbackResultType } from '../textsecure/Types.d';
|
||||||
import { isSent } from '../messages/MessageSendState';
|
import { isSent } from '../messages/MessageSendState';
|
||||||
import { getLastChallengeError, isOutgoing } from '../state/selectors/message';
|
import { getLastChallengeError, isOutgoing } from '../state/selectors/message';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import type {
|
import type { AttachmentType } from '../textsecure/SendMessage';
|
||||||
AttachmentType,
|
|
||||||
GroupV1InfoType,
|
|
||||||
GroupV2InfoType,
|
|
||||||
} from '../textsecure/SendMessage';
|
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
import type { BodyRangesType } from '../types/Util';
|
import type { BodyRangesType } from '../types/Util';
|
||||||
import type { WhatIsThis } from '../window.d';
|
import type { WhatIsThis } from '../window.d';
|
||||||
|
|
||||||
import type { ParsedJob } from './types';
|
|
||||||
import { JobQueue } from './JobQueue';
|
import { JobQueue } from './JobQueue';
|
||||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||||
import type { Job } from './Job';
|
|
||||||
import { getHttpErrorCode } from './helpers/getHttpErrorCode';
|
import { getHttpErrorCode } from './helpers/getHttpErrorCode';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -55,31 +50,7 @@ type NormalMessageSendJobData = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData> {
|
export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData> {
|
||||||
private readonly queues = new Map<string, PQueue>();
|
private readonly inMemoryQueues = new InMemoryQueues();
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected parseData(data: unknown): NormalMessageSendJobData {
|
protected parseData(data: unknown): NormalMessageSendJobData {
|
||||||
// Because we do this so often and Zod is a bit slower, we do "manual" parsing here.
|
// 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({
|
protected getInMemoryQueue({
|
||||||
data,
|
data,
|
||||||
}: Readonly<{ data: NormalMessageSendJobData }>): PQueue {
|
}: Readonly<{ data: NormalMessageSendJobData }>): PQueue {
|
||||||
const { conversationId } = data;
|
return this.inMemoryQueues.get(data.conversationId);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async run(
|
protected async run(
|
||||||
|
@ -234,10 +192,9 @@ export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData
|
||||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||||
attachments,
|
attachments,
|
||||||
body,
|
body,
|
||||||
groupV2: updateRecipients(
|
groupV2: conversation.getGroupV2Info({
|
||||||
conversation.getGroupV2Info(),
|
members: recipientIdentifiersWithoutMe,
|
||||||
recipientIdentifiersWithoutMe
|
}),
|
||||||
),
|
|
||||||
deletedForEveryoneTimestamp,
|
deletedForEveryoneTimestamp,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
preview,
|
preview,
|
||||||
|
@ -267,14 +224,12 @@ export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData
|
||||||
attachments,
|
attachments,
|
||||||
deletedForEveryoneTimestamp,
|
deletedForEveryoneTimestamp,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
groupV1: updateRecipients(
|
groupV1: conversation.getGroupV1Info(
|
||||||
conversation.getGroupV1Info(),
|
|
||||||
recipientIdentifiersWithoutMe
|
|
||||||
),
|
|
||||||
groupV2: updateRecipients(
|
|
||||||
conversation.getGroupV2Info(),
|
|
||||||
recipientIdentifiersWithoutMe
|
recipientIdentifiersWithoutMe
|
||||||
),
|
),
|
||||||
|
groupV2: conversation.getGroupV2Info({
|
||||||
|
members: recipientIdentifiersWithoutMe,
|
||||||
|
}),
|
||||||
messageText: body,
|
messageText: body,
|
||||||
preview,
|
preview,
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -544,23 +499,3 @@ function didSendToEveryone(message: Readonly<MessageModel>): boolean {
|
||||||
isSent(sendState.status)
|
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.
|
* 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
|
* Remove a job. This should be called when a job finishes successfully or
|
||||||
|
|
|
@ -54,9 +54,7 @@ export class Reactions extends Collection<ReactionModel> {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async onReaction(
|
async onReaction(reaction: ReactionModel): Promise<void> {
|
||||||
reaction: ReactionModel
|
|
||||||
): Promise<ReactionAttributesType | undefined> {
|
|
||||||
try {
|
try {
|
||||||
// The conversation the target message was in; we have to find it in the database
|
// The conversation the target message was in; we have to find it in the database
|
||||||
// to to figure that out.
|
// to to figure that out.
|
||||||
|
@ -85,73 +83,67 @@ export class Reactions extends Collection<ReactionModel> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// awaiting is safe since `onReaction` is never called from inside the queue
|
// awaiting is safe since `onReaction` is never called from inside the queue
|
||||||
return await targetConversation.queueJob(
|
await targetConversation.queueJob('Reactions.onReaction', async () => {
|
||||||
'Reactions.onReaction',
|
log.info('Handling reaction for', reaction.get('targetTimestamp'));
|
||||||
async () => {
|
|
||||||
log.info('Handling reaction for', reaction.get('targetTimestamp'));
|
|
||||||
|
|
||||||
const messages = await window.Signal.Data.getMessagesBySentAt(
|
const messages = await window.Signal.Data.getMessagesBySentAt(
|
||||||
reaction.get('targetTimestamp'),
|
reaction.get('targetTimestamp'),
|
||||||
{
|
{
|
||||||
MessageCollection: window.Whisper.MessageCollection,
|
MessageCollection: window.Whisper.MessageCollection,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// Message is fetched inside the conversation queue so we have the
|
// Message is fetched inside the conversation queue so we have the
|
||||||
// most recent data
|
// most recent data
|
||||||
const targetMessage = messages.find(m => {
|
const targetMessage = messages.find(m => {
|
||||||
const contact = m.getContact();
|
const contact = m.getContact();
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return false;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = window.MessageController.register(
|
const mcid = contact.get('id');
|
||||||
targetMessage.id,
|
const recid = window.ConversationController.ensureContactIds({
|
||||||
targetMessage
|
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;
|
||||||
|
|
||||||
return oldReaction;
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
const message = window.MessageController.register(
|
||||||
|
targetMessage.id,
|
||||||
|
targetMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
await message.handleReaction(reaction);
|
||||||
|
|
||||||
|
this.remove(reaction);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
'Reactions.onReaction error:',
|
'Reactions.onReaction error:',
|
||||||
error && error.stack ? error.stack : 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 { SignalService as Proto } from './protobuf';
|
||||||
import { AvatarDataType } from './types/Avatar';
|
import { AvatarDataType } from './types/Avatar';
|
||||||
import { UUIDStringType } from './types/UUID';
|
import { UUIDStringType } from './types/UUID';
|
||||||
|
import { ReactionSource } from './reactions/ReactionSource';
|
||||||
|
|
||||||
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||||
import MemberRoleEnum = Proto.Member.Role;
|
import MemberRoleEnum = Proto.Member.Role;
|
||||||
|
@ -86,6 +87,15 @@ export type GroupV1Update = {
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MessageReactionType = {
|
||||||
|
emoji: undefined | string;
|
||||||
|
fromId: string;
|
||||||
|
targetAuthorUuid: string;
|
||||||
|
targetTimestamp: number;
|
||||||
|
timestamp: number;
|
||||||
|
isSentByConversationId?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
export type MessageAttributesType = {
|
export type MessageAttributesType = {
|
||||||
bodyPending?: boolean;
|
bodyPending?: boolean;
|
||||||
bodyRanges?: BodyRangesType;
|
bodyRanges?: BodyRangesType;
|
||||||
|
@ -113,13 +123,7 @@ export type MessageAttributesType = {
|
||||||
messageTimer?: unknown;
|
messageTimer?: unknown;
|
||||||
profileChange?: ProfileNameChangeType;
|
profileChange?: ProfileNameChangeType;
|
||||||
quote?: QuotedMessageType;
|
quote?: QuotedMessageType;
|
||||||
reactions?: Array<{
|
reactions?: Array<MessageReactionType>;
|
||||||
emoji: string;
|
|
||||||
fromId: string;
|
|
||||||
targetAuthorUuid: string;
|
|
||||||
targetTimestamp: number;
|
|
||||||
timestamp: number;
|
|
||||||
}>;
|
|
||||||
requiredProtocolVersion?: number;
|
requiredProtocolVersion?: number;
|
||||||
retryOptions?: RetryOptions;
|
retryOptions?: RetryOptions;
|
||||||
sourceDevice?: number;
|
sourceDevice?: number;
|
||||||
|
@ -376,5 +380,5 @@ export type ReactionAttributesType = {
|
||||||
targetTimestamp: number;
|
targetTimestamp: number;
|
||||||
fromId: string;
|
fromId: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
fromSync?: boolean;
|
source: ReactionSource;
|
||||||
};
|
};
|
||||||
|
|
|
@ -94,7 +94,6 @@ import {
|
||||||
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
||||||
import { Deletes } from '../messageModifiers/Deletes';
|
import { Deletes } from '../messageModifiers/Deletes';
|
||||||
import type { ReactionModel } from '../messageModifiers/Reactions';
|
import type { ReactionModel } from '../messageModifiers/Reactions';
|
||||||
import { Reactions } from '../messageModifiers/Reactions';
|
|
||||||
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
||||||
import { getProfile } from '../util/getProfile';
|
import { getProfile } from '../util/getProfile';
|
||||||
import { SEALED_SENDER } from '../types/SealedSender';
|
import { SEALED_SENDER } from '../types/SealedSender';
|
||||||
|
@ -1123,15 +1122,17 @@ export class ConversationModel extends window.Backbone
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
getGroupV2Info({
|
getGroupV2Info(
|
||||||
groupChange,
|
options: Readonly<
|
||||||
includePendingMembers,
|
{ groupChange?: Uint8Array } & (
|
||||||
extraConversationsForSend,
|
| {
|
||||||
}: {
|
includePendingMembers?: boolean;
|
||||||
groupChange?: Uint8Array;
|
extraConversationsForSend?: Array<string>;
|
||||||
includePendingMembers?: boolean;
|
}
|
||||||
extraConversationsForSend?: Array<string>;
|
| { members: Array<string> }
|
||||||
} = {}): GroupV2InfoType | undefined {
|
)
|
||||||
|
> = {}
|
||||||
|
): GroupV2InfoType | undefined {
|
||||||
if (isDirectConversation(this.attributes) || !isGroupV2(this.attributes)) {
|
if (isDirectConversation(this.attributes) || !isGroupV2(this.attributes)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -1142,15 +1143,13 @@ export class ConversationModel extends window.Backbone
|
||||||
),
|
),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
revision: this.get('revision')!,
|
revision: this.get('revision')!,
|
||||||
members: this.getRecipients({
|
members:
|
||||||
includePendingMembers,
|
'members' in options ? options.members : this.getRecipients(options),
|
||||||
extraConversationsForSend,
|
groupChange: options.groupChange,
|
||||||
}),
|
|
||||||
groupChange,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getGroupV1Info(): GroupV1InfoType | undefined {
|
getGroupV1Info(members?: Array<string>): GroupV1InfoType | undefined {
|
||||||
const groupId = this.get('groupId');
|
const groupId = this.get('groupId');
|
||||||
const groupVersion = this.get('groupVersion');
|
const groupVersion = this.get('groupVersion');
|
||||||
|
|
||||||
|
@ -1164,7 +1163,7 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: groupId,
|
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> {
|
async sendProfileKeyUpdate(): Promise<void> {
|
||||||
const id = this.get('id');
|
const id = this.get('id');
|
||||||
const recipients = this.getRecipients();
|
const recipients = this.getRecipients();
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 {
|
import type {
|
||||||
CustomError,
|
CustomError,
|
||||||
GroupV1Update,
|
GroupV1Update,
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
ReactionAttributesType,
|
MessageReactionType,
|
||||||
ShallowChallengeError,
|
ShallowChallengeError,
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
WhatIsThis,
|
WhatIsThis,
|
||||||
} from '../model-types.d';
|
} 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 { isNotNil } from '../util/isNotNil';
|
||||||
import { isNormalNumber } from '../util/isNormalNumber';
|
import { isNormalNumber } from '../util/isNormalNumber';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
|
@ -35,6 +42,7 @@ import * as expirationTimer from '../util/expirationTimer';
|
||||||
import type { ReactionType } from '../types/Reactions';
|
import type { ReactionType } from '../types/Reactions';
|
||||||
import { UUID } from '../types/UUID';
|
import { UUID } from '../types/UUID';
|
||||||
import type { UUIDStringType } from '../types/UUID';
|
import type { UUIDStringType } from '../types/UUID';
|
||||||
|
import * as reactionUtil from '../reactions/util';
|
||||||
import {
|
import {
|
||||||
copyStickerToAttachments,
|
copyStickerToAttachments,
|
||||||
deletePackReference,
|
deletePackReference,
|
||||||
|
@ -112,6 +120,7 @@ import {
|
||||||
import { Deletes } from '../messageModifiers/Deletes';
|
import { Deletes } from '../messageModifiers/Deletes';
|
||||||
import type { ReactionModel } from '../messageModifiers/Reactions';
|
import type { ReactionModel } from '../messageModifiers/Reactions';
|
||||||
import { Reactions } from '../messageModifiers/Reactions';
|
import { Reactions } from '../messageModifiers/Reactions';
|
||||||
|
import { ReactionSource } from '../reactions/ReactionSource';
|
||||||
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
||||||
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
|
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
|
||||||
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
||||||
|
@ -119,6 +128,7 @@ import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
||||||
import * as LinkPreview from '../types/LinkPreview';
|
import * as LinkPreview from '../types/LinkPreview';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
||||||
|
import { reactionJobQueue } from '../jobs/reactionJobQueue';
|
||||||
import { notificationService } from '../services/notifications';
|
import { notificationService } from '../services/notifications';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
@ -3121,11 +3131,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
async handleReaction(
|
async handleReaction(
|
||||||
reaction: ReactionModel,
|
reaction: ReactionModel,
|
||||||
shouldPersist = true
|
shouldPersist = true
|
||||||
): Promise<ReactionAttributesType | undefined> {
|
): Promise<void> {
|
||||||
const { attributes } = this;
|
const { attributes } = this;
|
||||||
|
|
||||||
if (this.get('deletedForEveryone')) {
|
if (this.get('deletedForEveryone')) {
|
||||||
return undefined;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We allow you to react to messages with outgoing errors only if it has sent
|
// 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()
|
window.ConversationController.getOurConversationIdOrThrow()
|
||||||
) !== 'partial-sent')
|
) !== 'partial-sent')
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reactions = this.get('reactions') || [];
|
const conversation = this.getConversation();
|
||||||
const messageId = this.idForLogging();
|
if (!conversation) {
|
||||||
const count = reactions.length;
|
return;
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(
|
|
||||||
this.get('conversationId')
|
|
||||||
);
|
|
||||||
|
|
||||||
const oldReaction = reactions.find(
|
|
||||||
re => re.fromId === reaction.get('fromId')
|
|
||||||
);
|
|
||||||
if (oldReaction) {
|
|
||||||
this.clearNotifications(oldReaction);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reaction.get('remove')) {
|
const oldReactions = this.get('reactions') || [];
|
||||||
log.info('Removing reaction for message', messageId);
|
|
||||||
const newReactions = reactions.filter(
|
let newReactions: typeof oldReactions;
|
||||||
re => re.fromId !== reaction.get('fromId')
|
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({
|
const newReaction = {
|
||||||
emoji: reaction.get('emoji'),
|
emoji: reaction.get('remove') ? undefined : reaction.get('emoji'),
|
||||||
fromId: reaction.get('fromId'),
|
fromId: reaction.get('fromId'),
|
||||||
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
||||||
targetTimestamp: reaction.get('targetTimestamp'),
|
targetTimestamp: reaction.get('targetTimestamp'),
|
||||||
});
|
timestamp: reaction.get('timestamp'),
|
||||||
|
isSentByConversationId: zipObject(
|
||||||
|
conversation.getRecipientConversationIds(),
|
||||||
|
repeat(false)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
newReactions = reactionUtil.addOutgoingReaction(
|
||||||
|
oldReactions,
|
||||||
|
newReaction
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
log.info('Adding reaction for message', messageId);
|
const oldReaction = oldReactions.find(
|
||||||
const newReactions = reactions.filter(
|
re => re.fromId === reaction.get('fromId')
|
||||||
re => re.fromId !== reaction.get('fromId')
|
|
||||||
);
|
);
|
||||||
newReactions.push(reaction.toJSON());
|
if (oldReaction) {
|
||||||
this.set({ reactions: newReactions });
|
this.clearNotifications(oldReaction);
|
||||||
|
}
|
||||||
|
|
||||||
await window.Signal.Data.addReaction({
|
if (reaction.get('remove')) {
|
||||||
conversationId: this.get('conversationId'),
|
log.info(
|
||||||
emoji: reaction.get('emoji'),
|
'handleReaction: removing reaction for message',
|
||||||
fromId: reaction.get('fromId'),
|
this.idForLogging()
|
||||||
messageId: this.id,
|
);
|
||||||
messageReceivedAt: this.get('received_at'),
|
|
||||||
targetAuthorUuid: reaction.get('targetAuthorUuid'),
|
|
||||||
targetTimestamp: reaction.get('targetTimestamp'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only notify for reactions to our own messages
|
if (reaction.get('source') === ReactionSource.FromSync) {
|
||||||
if (
|
newReactions = oldReactions.filter(
|
||||||
conversation &&
|
re =>
|
||||||
isOutgoing(this.attributes) &&
|
re.fromId !== reaction.get('fromId') ||
|
||||||
!reaction.get('fromSync')
|
re.timestamp > reaction.get('timestamp')
|
||||||
) {
|
);
|
||||||
conversation.notify(this, reaction);
|
} 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(
|
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);
|
await window.Signal.Data.saveMessage(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return oldReaction;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDeleteForEveryone(
|
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 {
|
import type {
|
||||||
LastMessageStatus,
|
LastMessageStatus,
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
|
MessageReactionType,
|
||||||
ShallowChallengeError,
|
ShallowChallengeError,
|
||||||
} from '../../model-types.d';
|
} from '../../model-types.d';
|
||||||
|
|
||||||
|
@ -54,6 +55,8 @@ import { memoizeByRoot } from '../../util/memoizeByRoot';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { isNotNil } from '../../util/isNotNil';
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
import { isMoreRecentThan } from '../../util/timestamp';
|
import { isMoreRecentThan } from '../../util/timestamp';
|
||||||
|
import * as iterables from '../../util/iterables';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
|
||||||
import type { ConversationType } from '../ducks/conversations';
|
import type { ConversationType } from '../ducks/conversations';
|
||||||
|
|
||||||
|
@ -379,7 +382,23 @@ export const getReactionsForMessage = createSelectorCreator(
|
||||||
{ reactions = [] }: MessageAttributesType,
|
{ reactions = [] }: MessageAttributesType,
|
||||||
{ conversationSelector }: { conversationSelector: GetConversationByIdType }
|
{ 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);
|
const c = conversationSelector(re.fromId);
|
||||||
|
|
||||||
type From = NonNullable<PropsData['reactions']>[0]['from'];
|
type From = NonNullable<PropsData['reactions']>[0]['from'];
|
||||||
|
@ -399,12 +418,16 @@ export const getReactionsForMessage = createSelectorCreator(
|
||||||
|
|
||||||
const from: AssertProps<From, typeof unsafe> = unsafe;
|
const from: AssertProps<From, typeof unsafe> = unsafe;
|
||||||
|
|
||||||
|
strictAssert(re.emoji, 'Expected all reactions to have an emoji');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emoji: re.emoji,
|
emoji: re.emoji,
|
||||||
timestamp: re.timestamp,
|
timestamp: re.timestamp,
|
||||||
from,
|
from,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return [...formattedReactions];
|
||||||
},
|
},
|
||||||
|
|
||||||
(_: MessageAttributesType, reactions: PropsData['reactions']) => reactions
|
(_: 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(
|
export function canReply(
|
||||||
message: Pick<
|
message: Pick<
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
|
@ -1385,53 +1453,28 @@ export function canReply(
|
||||||
conversationSelector: GetConversationByIdType
|
conversationSelector: GetConversationByIdType
|
||||||
): boolean {
|
): boolean {
|
||||||
const conversation = getConversation(message, conversationSelector);
|
const conversation = getConversation(message, conversationSelector);
|
||||||
const { deletedForEveryone, sendStateByConversationId } = message;
|
if (
|
||||||
|
!conversation ||
|
||||||
if (!conversation) {
|
(conversation.announcementsOnly && !conversation.areWeAdmin)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return canReplyOrReact(message, ourConversationId, conversation);
|
||||||
|
}
|
||||||
|
|
||||||
// If GroupV1 groups have been disabled, we can't reply.
|
export function canReact(
|
||||||
if (conversation.isGroupV1AndDisabled) {
|
message: Pick<
|
||||||
return false;
|
MessageAttributesType,
|
||||||
}
|
| 'conversationId'
|
||||||
|
| 'deletedForEveryone'
|
||||||
// If mandatory profile sharing is enabled, and we haven't shared yet, then
|
| 'sendStateByConversationId'
|
||||||
// we can't reply.
|
| 'type'
|
||||||
if (isMissingRequiredProfileSharing(conversation)) {
|
>,
|
||||||
return false;
|
ourConversationId: string,
|
||||||
}
|
conversationSelector: GetConversationByIdType
|
||||||
|
): boolean {
|
||||||
// We cannot reply if we haven't accepted the message request
|
const conversation = getConversation(message, conversationSelector);
|
||||||
if (!conversation.acceptedMessageRequest) {
|
return canReplyOrReact(message, ourConversationId, conversation);
|
||||||
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 canDeleteForEveryone(
|
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 {
|
import {
|
||||||
canDeleteForEveryone,
|
canDeleteForEveryone,
|
||||||
|
canReact,
|
||||||
canReply,
|
canReply,
|
||||||
getMessagePropStatus,
|
getMessagePropStatus,
|
||||||
isEndSession,
|
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', () => {
|
describe('canReply', () => {
|
||||||
const defaultConversation: ConversationType = {
|
const defaultConversation: ConversationType = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
|
|
|
@ -110,7 +110,7 @@ describe('JobQueueDatabaseStore', () => {
|
||||||
queueType: 'test queue',
|
queueType: 'test queue',
|
||||||
data: { hi: 5 },
|
data: { hi: 5 },
|
||||||
},
|
},
|
||||||
{ shouldInsertIntoDatabase: false }
|
{ shouldPersist: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
await streamPromise;
|
await streamPromise;
|
||||||
|
|
|
@ -228,6 +228,45 @@ describe('JobQueue', () => {
|
||||||
assert.lengthOf(queueTypes['test 2'], 2);
|
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 () => {
|
it('retries jobs, running them up to maxAttempts times', async () => {
|
||||||
type TestJobData = 'foo' | 'bar';
|
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();
|
await fakeDelay();
|
||||||
|
|
||||||
this.storedJobs.forEach(storedJob => {
|
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);
|
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 type { MessageModel } from '../models/messages';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { maybeParseUrl } from '../util/url';
|
import { maybeParseUrl } from '../util/url';
|
||||||
|
import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend';
|
||||||
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
|
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
|
||||||
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
|
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
|
||||||
import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
|
import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
|
||||||
|
@ -845,11 +846,21 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
}
|
}
|
||||||
|
|
||||||
getMessageActions(): MessageActionsType {
|
getMessageActions(): MessageActionsType {
|
||||||
const reactToMessage = (
|
const reactToMessage = async (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
reaction: { emoji: string; remove: boolean }
|
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) => {
|
const replyToMessage = (messageId: string) => {
|
||||||
this.setQuoteMessage(messageId);
|
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: {
|
async sendStickerMessage(options: {
|
||||||
packId: string;
|
packId: string;
|
||||||
stickerId: number;
|
stickerId: number;
|
||||||
|
|
Loading…
Reference in a new issue