Retry delivery and read receipts for up to 24 hours
This commit is contained in:
parent
e81821f4a6
commit
f9e98836b0
15 changed files with 316 additions and 243 deletions
|
@ -23,7 +23,8 @@ import type {
|
||||||
} from './model-types.d';
|
} from './model-types.d';
|
||||||
import * as Bytes from './Bytes';
|
import * as Bytes from './Bytes';
|
||||||
import * as Timers from './Timers';
|
import * as Timers from './Timers';
|
||||||
import type { WhatIsThis, DeliveryReceiptBatcherItemType } from './window.d';
|
import type { WhatIsThis } from './window.d';
|
||||||
|
import type { Receipt } from './types/Receipt';
|
||||||
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
|
||||||
import { SocketStatus } from './types/SocketStatus';
|
import { SocketStatus } from './types/SocketStatus';
|
||||||
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
|
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
|
||||||
|
@ -131,6 +132,7 @@ import { ToastConversationArchived } from './components/ToastConversationArchive
|
||||||
import { ToastConversationUnarchived } from './components/ToastConversationUnarchived';
|
import { ToastConversationUnarchived } from './components/ToastConversationUnarchived';
|
||||||
import { showToast } from './util/showToast';
|
import { showToast } from './util/showToast';
|
||||||
import { startInteractionMode } from './windows/startInteractionMode';
|
import { startInteractionMode } from './windows/startInteractionMode';
|
||||||
|
import { deliveryReceiptsJobQueue } from './jobs/deliveryReceiptsJobQueue';
|
||||||
|
|
||||||
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
|
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
|
||||||
|
|
||||||
|
@ -378,59 +380,12 @@ export async function startApp(): Promise<void> {
|
||||||
});
|
});
|
||||||
window.Whisper.deliveryReceiptQueue.pause();
|
window.Whisper.deliveryReceiptQueue.pause();
|
||||||
window.Whisper.deliveryReceiptBatcher =
|
window.Whisper.deliveryReceiptBatcher =
|
||||||
window.Signal.Util.createBatcher<DeliveryReceiptBatcherItemType>({
|
window.Signal.Util.createBatcher<Receipt>({
|
||||||
name: 'Whisper.deliveryReceiptBatcher',
|
name: 'Whisper.deliveryReceiptBatcher',
|
||||||
wait: 500,
|
wait: 500,
|
||||||
maxSize: 100,
|
maxSize: 100,
|
||||||
processBatch: async items => {
|
processBatch: async deliveryReceipts => {
|
||||||
const byConversationId = window._.groupBy(items, item =>
|
await deliveryReceiptsJobQueue.add({ deliveryReceipts });
|
||||||
window.ConversationController.ensureContactIds({
|
|
||||||
e164: item.source,
|
|
||||||
uuid: item.sourceUuid,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const ids = Object.keys(byConversationId);
|
|
||||||
|
|
||||||
for (let i = 0, max = ids.length; i < max; i += 1) {
|
|
||||||
const conversationId = ids[i];
|
|
||||||
const ourItems = byConversationId[conversationId];
|
|
||||||
const timestamps = ourItems.map(item => item.timestamp);
|
|
||||||
const messageIds = ourItems.map(item => item.messageId);
|
|
||||||
|
|
||||||
const c = window.ConversationController.get(conversationId);
|
|
||||||
if (!c) {
|
|
||||||
log.warn(
|
|
||||||
`deliveryReceiptBatcher: Conversation ${conversationId} does not exist! ` +
|
|
||||||
`Will not send delivery receipts for timestamps ${timestamps}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const senderUuid = c.get('uuid');
|
|
||||||
const senderE164 = c.get('e164');
|
|
||||||
|
|
||||||
c.queueJob('sendDeliveryReceipt', async () => {
|
|
||||||
try {
|
|
||||||
const sendOptions = await getSendOptions(c.attributes);
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await handleMessageSend(
|
|
||||||
window.textsecure.messaging.sendDeliveryReceipt({
|
|
||||||
senderE164,
|
|
||||||
senderUuid,
|
|
||||||
timestamps,
|
|
||||||
options: sendOptions,
|
|
||||||
}),
|
|
||||||
{ messageIds, sendType: 'deliveryReceipt' }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(
|
|
||||||
`Failed to send delivery receipt to ${senderE164}/${senderUuid} for timestamps ${timestamps}:`,
|
|
||||||
error && error.stack ? error.stack : error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
45
ts/jobs/deliveryReceiptsJobQueue.ts
Normal file
45
ts/jobs/deliveryReceiptsJobQueue.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { LoggerType } from '../types/Logging';
|
||||||
|
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
||||||
|
import { receiptSchema, ReceiptType } from '../types/Receipt';
|
||||||
|
import { MAX_RETRY_TIME, runReceiptJob } from './helpers/receiptHelpers';
|
||||||
|
|
||||||
|
import { JobQueue } from './JobQueue';
|
||||||
|
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||||
|
|
||||||
|
const deliveryReceiptsJobDataSchema = z.object({
|
||||||
|
deliveryReceipts: receiptSchema.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeliveryReceiptsJobData = z.infer<typeof deliveryReceiptsJobDataSchema>;
|
||||||
|
|
||||||
|
export class DeliveryReceiptsJobQueue extends JobQueue<DeliveryReceiptsJobData> {
|
||||||
|
protected parseData(data: unknown): DeliveryReceiptsJobData {
|
||||||
|
return deliveryReceiptsJobDataSchema.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async run(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
timestamp,
|
||||||
|
}: Readonly<{ data: DeliveryReceiptsJobData; timestamp: number }>,
|
||||||
|
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
|
||||||
|
): Promise<void> {
|
||||||
|
await runReceiptJob({
|
||||||
|
attempt,
|
||||||
|
log,
|
||||||
|
timestamp,
|
||||||
|
receipts: data.deliveryReceipts,
|
||||||
|
type: ReceiptType.Delivery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deliveryReceiptsJobQueue = new DeliveryReceiptsJobQueue({
|
||||||
|
store: jobQueueDatabaseStore,
|
||||||
|
queueType: 'delivery receipts',
|
||||||
|
maxAttempts: exponentialBackoffMaxAttempts(MAX_RETRY_TIME),
|
||||||
|
});
|
42
ts/jobs/helpers/receiptHelpers.ts
Normal file
42
ts/jobs/helpers/receiptHelpers.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as durations from '../../util/durations';
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
import type { Receipt, ReceiptType } from '../../types/Receipt';
|
||||||
|
import { sendReceipts } from '../../util/sendReceipts';
|
||||||
|
import { commonShouldJobContinue } from './commonShouldJobContinue';
|
||||||
|
import { handleCommonJobRequestError } from './handleCommonJobRequestError';
|
||||||
|
|
||||||
|
export const MAX_RETRY_TIME = durations.DAY;
|
||||||
|
|
||||||
|
export async function runReceiptJob({
|
||||||
|
attempt,
|
||||||
|
log,
|
||||||
|
timestamp,
|
||||||
|
receipts,
|
||||||
|
type,
|
||||||
|
}: Readonly<{
|
||||||
|
attempt: number;
|
||||||
|
log: LoggerType;
|
||||||
|
receipts: ReadonlyArray<Receipt>;
|
||||||
|
timestamp: number;
|
||||||
|
type: ReceiptType;
|
||||||
|
}>): Promise<void> {
|
||||||
|
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
|
||||||
|
|
||||||
|
const shouldContinue = await commonShouldJobContinue({
|
||||||
|
attempt,
|
||||||
|
log,
|
||||||
|
timeRemaining,
|
||||||
|
});
|
||||||
|
if (!shouldContinue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendReceipts({ log, receipts, type });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
await handleCommonJobRequestError({ err, log, timeRemaining });
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,8 +3,10 @@
|
||||||
|
|
||||||
import type { WebAPIType } from '../textsecure/WebAPI';
|
import type { WebAPIType } from '../textsecure/WebAPI';
|
||||||
|
|
||||||
|
import { deliveryReceiptsJobQueue } from './deliveryReceiptsJobQueue';
|
||||||
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
|
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
|
||||||
import { reactionJobQueue } from './reactionJobQueue';
|
import { reactionJobQueue } from './reactionJobQueue';
|
||||||
|
import { readReceiptsJobQueue } from './readReceiptsJobQueue';
|
||||||
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,8 +23,10 @@ export function initializeAllJobQueues({
|
||||||
}): void {
|
}): void {
|
||||||
reportSpamJobQueue.initialize({ server });
|
reportSpamJobQueue.initialize({ server });
|
||||||
|
|
||||||
|
deliveryReceiptsJobQueue.streamJobs();
|
||||||
normalMessageSendJobQueue.streamJobs();
|
normalMessageSendJobQueue.streamJobs();
|
||||||
reactionJobQueue.streamJobs();
|
reactionJobQueue.streamJobs();
|
||||||
|
readReceiptsJobQueue.streamJobs();
|
||||||
readSyncJobQueue.streamJobs();
|
readSyncJobQueue.streamJobs();
|
||||||
removeStorageKeyJobQueue.streamJobs();
|
removeStorageKeyJobQueue.streamJobs();
|
||||||
reportSpamJobQueue.streamJobs();
|
reportSpamJobQueue.streamJobs();
|
||||||
|
|
56
ts/jobs/readReceiptsJobQueue.ts
Normal file
56
ts/jobs/readReceiptsJobQueue.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { LoggerType } from '../types/Logging';
|
||||||
|
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
||||||
|
import type { StorageInterface } from '../types/Storage.d';
|
||||||
|
import type { Receipt } from '../types/Receipt';
|
||||||
|
import { receiptSchema, ReceiptType } from '../types/Receipt';
|
||||||
|
import { MAX_RETRY_TIME, runReceiptJob } from './helpers/receiptHelpers';
|
||||||
|
|
||||||
|
import { JobQueue } from './JobQueue';
|
||||||
|
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||||
|
|
||||||
|
const readReceiptsJobDataSchema = z.object({
|
||||||
|
readReceipts: receiptSchema.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ReadReceiptsJobData = z.infer<typeof readReceiptsJobDataSchema>;
|
||||||
|
|
||||||
|
export class ReadReceiptsJobQueue extends JobQueue<ReadReceiptsJobData> {
|
||||||
|
public async addIfAllowedByUser(
|
||||||
|
storage: Pick<StorageInterface, 'get'>,
|
||||||
|
readReceipts: Array<Receipt>
|
||||||
|
): Promise<void> {
|
||||||
|
if (storage.get('read-receipt-setting')) {
|
||||||
|
await this.add({ readReceipts });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseData(data: unknown): ReadReceiptsJobData {
|
||||||
|
return readReceiptsJobDataSchema.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async run(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
timestamp,
|
||||||
|
}: Readonly<{ data: ReadReceiptsJobData; timestamp: number }>,
|
||||||
|
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
|
||||||
|
): Promise<void> {
|
||||||
|
await runReceiptJob({
|
||||||
|
attempt,
|
||||||
|
log,
|
||||||
|
timestamp,
|
||||||
|
receipts: data.readReceipts,
|
||||||
|
type: ReceiptType.Read,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readReceiptsJobQueue = new ReadReceiptsJobQueue({
|
||||||
|
store: jobQueueDatabaseStore,
|
||||||
|
queueType: 'read receipts',
|
||||||
|
maxAttempts: exponentialBackoffMaxAttempts(MAX_RETRY_TIME),
|
||||||
|
});
|
|
@ -2,26 +2,15 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import * as durations from '../util/durations';
|
|
||||||
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 { receiptSchema, ReceiptType } from '../types/Receipt';
|
||||||
import { sendViewedReceipt } from '../util/sendViewedReceipt';
|
import { MAX_RETRY_TIME, runReceiptJob } from './helpers/receiptHelpers';
|
||||||
|
|
||||||
import { JobQueue } from './JobQueue';
|
import { JobQueue } from './JobQueue';
|
||||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||||
import { handleCommonJobRequestError } from './helpers/handleCommonJobRequestError';
|
|
||||||
|
|
||||||
const MAX_RETRY_TIME = durations.DAY;
|
const viewedReceiptsJobDataSchema = z.object({ viewedReceipt: receiptSchema });
|
||||||
|
|
||||||
const viewedReceiptsJobDataSchema = z.object({
|
|
||||||
viewedReceipt: z.object({
|
|
||||||
messageId: z.string(),
|
|
||||||
senderE164: z.string().optional(),
|
|
||||||
senderUuid: z.string().optional(),
|
|
||||||
timestamp: z.number(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ViewedReceiptsJobData = z.infer<typeof viewedReceiptsJobDataSchema>;
|
type ViewedReceiptsJobData = z.infer<typeof viewedReceiptsJobDataSchema>;
|
||||||
|
|
||||||
|
@ -37,22 +26,13 @@ export class ViewedReceiptsJobQueue extends JobQueue<ViewedReceiptsJobData> {
|
||||||
}: Readonly<{ data: ViewedReceiptsJobData; timestamp: number }>,
|
}: Readonly<{ data: ViewedReceiptsJobData; timestamp: number }>,
|
||||||
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
|
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
|
await runReceiptJob({
|
||||||
|
|
||||||
const shouldContinue = await commonShouldJobContinue({
|
|
||||||
attempt,
|
attempt,
|
||||||
log,
|
log,
|
||||||
timeRemaining,
|
timestamp,
|
||||||
|
receipts: [data.viewedReceipt],
|
||||||
|
type: ReceiptType.Viewed,
|
||||||
});
|
});
|
||||||
if (!shouldContinue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendViewedReceipt(data.viewedReceipt, log);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
await handleCommonJobRequestError({ err, log, timeRemaining });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -62,7 +62,6 @@ import { isConversationAccepted } from '../util/isConversationAccepted';
|
||||||
import { markConversationRead } from '../util/markConversationRead';
|
import { markConversationRead } from '../util/markConversationRead';
|
||||||
import { handleMessageSend } from '../util/handleMessageSend';
|
import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
import { getConversationMembers } from '../util/getConversationMembers';
|
import { getConversationMembers } from '../util/getConversationMembers';
|
||||||
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
|
|
||||||
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
|
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
|
||||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import { SendStatus } from '../messages/MessageSendState';
|
import { SendStatus } from '../messages/MessageSendState';
|
||||||
|
@ -92,6 +91,7 @@ import {
|
||||||
getMessagePropStatus,
|
getMessagePropStatus,
|
||||||
} from '../state/selectors/message';
|
} from '../state/selectors/message';
|
||||||
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
||||||
|
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
|
||||||
import { Deletes } from '../messageModifiers/Deletes';
|
import { Deletes } from '../messageModifiers/Deletes';
|
||||||
import type { ReactionModel } from '../messageModifiers/Reactions';
|
import type { ReactionModel } from '../messageModifiers/Reactions';
|
||||||
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
||||||
|
@ -1976,21 +1976,18 @@ export class ConversationModel extends window.Backbone
|
||||||
const readMessages = messages.filter(
|
const readMessages = messages.filter(
|
||||||
m => !hasErrors(m.attributes) && isIncoming(m.attributes)
|
m => !hasErrors(m.attributes) && isIncoming(m.attributes)
|
||||||
);
|
);
|
||||||
const receiptSpecs = readMessages.map(m => ({
|
|
||||||
messageId: m.id,
|
|
||||||
senderE164: m.get('source'),
|
|
||||||
senderUuid: m.get('sourceUuid'),
|
|
||||||
senderId: window.ConversationController.ensureContactIds({
|
|
||||||
e164: m.get('source'),
|
|
||||||
uuid: m.get('sourceUuid'),
|
|
||||||
}),
|
|
||||||
timestamp: m.get('sent_at'),
|
|
||||||
hasErrors: hasErrors(m.attributes),
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (isLocalAction) {
|
if (isLocalAction) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await sendReadReceiptsFor(this.attributes, receiptSpecs);
|
await readReceiptsJobQueue.addIfAllowedByUser(
|
||||||
|
window.storage,
|
||||||
|
readMessages.map(m => ({
|
||||||
|
messageId: m.id,
|
||||||
|
senderE164: m.get('source'),
|
||||||
|
senderUuid: m.get('sourceUuid'),
|
||||||
|
timestamp: m.get('sent_at'),
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
|
|
@ -2524,8 +2524,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
window.Whisper.deliveryReceiptQueue.add(() => {
|
window.Whisper.deliveryReceiptQueue.add(() => {
|
||||||
window.Whisper.deliveryReceiptBatcher.add({
|
window.Whisper.deliveryReceiptBatcher.add({
|
||||||
messageId,
|
messageId,
|
||||||
source,
|
senderE164: source,
|
||||||
sourceUuid,
|
senderUuid: sourceUuid,
|
||||||
timestamp: this.get('sent_at'),
|
timestamp: this.get('sent_at'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1584,7 +1584,7 @@ export default class MessageSender {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendReadReceipts(
|
async sendReadReceipt(
|
||||||
options: Readonly<{
|
options: Readonly<{
|
||||||
senderE164?: string;
|
senderE164?: string;
|
||||||
senderUuid?: string;
|
senderUuid?: string;
|
||||||
|
@ -1598,7 +1598,7 @@ export default class MessageSender {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendViewedReceipts(
|
async sendViewedReceipt(
|
||||||
options: Readonly<{
|
options: Readonly<{
|
||||||
senderE164?: string;
|
senderE164?: string;
|
||||||
senderUuid?: string;
|
senderUuid?: string;
|
||||||
|
|
19
ts/types/Receipt.ts
Normal file
19
ts/types/Receipt.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const receiptSchema = z.object({
|
||||||
|
messageId: z.string(),
|
||||||
|
senderE164: z.string().optional(),
|
||||||
|
senderUuid: z.string().optional(),
|
||||||
|
timestamp: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export enum ReceiptType {
|
||||||
|
Delivery = 'deliveryReceipt',
|
||||||
|
Read = 'readReceipt',
|
||||||
|
Viewed = 'viewedReceipt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Receipt = z.infer<typeof receiptSchema>;
|
|
@ -2,8 +2,8 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ConversationAttributesType } from '../model-types.d';
|
import type { ConversationAttributesType } from '../model-types.d';
|
||||||
import { sendReadReceiptsFor } from './sendReadReceiptsFor';
|
|
||||||
import { hasErrors } from '../state/selectors/message';
|
import { hasErrors } from '../state/selectors/message';
|
||||||
|
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
|
||||||
import { readSyncJobQueue } from '../jobs/readSyncJobQueue';
|
import { readSyncJobQueue } from '../jobs/readSyncJobQueue';
|
||||||
import { notificationService } from '../services/notifications';
|
import { notificationService } from '../services/notifications';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
@ -115,7 +115,10 @@ export async function markConversationRead(
|
||||||
readSyncJobQueue.add({ readSyncs });
|
readSyncJobQueue.add({ readSyncs });
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendReadReceiptsFor(conversationAttrs, unreadMessagesSyncData);
|
await readReceiptsJobQueue.addIfAllowedByUser(
|
||||||
|
window.storage,
|
||||||
|
allReadMessagesSync
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Whisper.ExpiringMessagesListener.update();
|
window.Whisper.ExpiringMessagesListener.update();
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import { chunk, groupBy, map } from 'lodash';
|
|
||||||
import type { ConversationAttributesType } from '../model-types.d';
|
|
||||||
import { getSendOptions } from './getSendOptions';
|
|
||||||
import { handleMessageSend } from './handleMessageSend';
|
|
||||||
import { isConversationAccepted } from './isConversationAccepted';
|
|
||||||
import * as log from '../logging/log';
|
|
||||||
|
|
||||||
type ReceiptSpecType = {
|
|
||||||
messageId: string;
|
|
||||||
senderE164?: string;
|
|
||||||
senderUuid?: string;
|
|
||||||
senderId?: string;
|
|
||||||
timestamp: number;
|
|
||||||
hasErrors: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CHUNK_SIZE = 100;
|
|
||||||
|
|
||||||
export async function sendReadReceiptsFor(
|
|
||||||
conversationAttrs: ConversationAttributesType,
|
|
||||||
items: Array<ReceiptSpecType>
|
|
||||||
): Promise<void> {
|
|
||||||
// Only send read receipts for accepted conversations
|
|
||||||
if (
|
|
||||||
window.Events.getReadReceiptSetting() &&
|
|
||||||
isConversationAccepted(conversationAttrs)
|
|
||||||
) {
|
|
||||||
log.info(`Sending ${items.length} read receipts`);
|
|
||||||
const sendOptions = await getSendOptions(conversationAttrs);
|
|
||||||
const receiptsBySender = groupBy(items, 'senderId');
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
map(receiptsBySender, async (receipts, senderId) => {
|
|
||||||
const conversation = window.ConversationController.get(senderId);
|
|
||||||
|
|
||||||
if (!conversation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batches = chunk(receipts, CHUNK_SIZE);
|
|
||||||
await Promise.all(
|
|
||||||
batches.map(batch => {
|
|
||||||
const timestamps = map(batch, item => item.timestamp);
|
|
||||||
const messageIds = map(batch, item => item.messageId);
|
|
||||||
|
|
||||||
return handleMessageSend(
|
|
||||||
window.textsecure.messaging.sendReadReceipts({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
senderE164: conversation.get('e164')!,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
senderUuid: conversation.get('uuid')!,
|
|
||||||
timestamps,
|
|
||||||
options: sendOptions,
|
|
||||||
}),
|
|
||||||
{ messageIds, sendType: 'readReceipt' }
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
116
ts/util/sendReceipts.ts
Normal file
116
ts/util/sendReceipts.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { chunk } from 'lodash';
|
||||||
|
import type { LoggerType } from '../types/Logging';
|
||||||
|
import type { Receipt } from '../types/Receipt';
|
||||||
|
import { ReceiptType } from '../types/Receipt';
|
||||||
|
import { getSendOptions } from './getSendOptions';
|
||||||
|
import { handleMessageSend } from './handleMessageSend';
|
||||||
|
import { isConversationAccepted } from './isConversationAccepted';
|
||||||
|
import { map } from './iterables';
|
||||||
|
import { missingCaseError } from './missingCaseError';
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 100;
|
||||||
|
|
||||||
|
export async function sendReceipts({
|
||||||
|
log,
|
||||||
|
receipts,
|
||||||
|
type,
|
||||||
|
}: Readonly<{
|
||||||
|
log: LoggerType;
|
||||||
|
receipts: ReadonlyArray<Receipt>;
|
||||||
|
type: ReceiptType;
|
||||||
|
}>): Promise<void> {
|
||||||
|
let requiresUserSetting: boolean;
|
||||||
|
let methodName:
|
||||||
|
| 'sendDeliveryReceipt'
|
||||||
|
| 'sendReadReceipt'
|
||||||
|
| 'sendViewedReceipt';
|
||||||
|
switch (type) {
|
||||||
|
case ReceiptType.Delivery:
|
||||||
|
requiresUserSetting = false;
|
||||||
|
methodName = 'sendDeliveryReceipt';
|
||||||
|
break;
|
||||||
|
case ReceiptType.Read:
|
||||||
|
requiresUserSetting = true;
|
||||||
|
methodName = 'sendReadReceipt';
|
||||||
|
break;
|
||||||
|
case ReceiptType.Viewed:
|
||||||
|
requiresUserSetting = true;
|
||||||
|
methodName = 'sendViewedReceipt';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw missingCaseError(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresUserSetting && !window.storage.get('read-receipt-setting')) {
|
||||||
|
log.info('requires user setting. Not sending these receipts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiptsBySenderId: Map<string, Array<Receipt>> = receipts.reduce(
|
||||||
|
(result, receipt) => {
|
||||||
|
const { senderE164, senderUuid } = receipt;
|
||||||
|
if (!senderE164 && !senderUuid) {
|
||||||
|
log.error('no sender E164 or UUID. Skipping this receipt');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderId = window.ConversationController.ensureContactIds({
|
||||||
|
e164: senderE164,
|
||||||
|
uuid: senderUuid,
|
||||||
|
});
|
||||||
|
if (!senderId) {
|
||||||
|
throw new Error(
|
||||||
|
'no conversation found with that E164/UUID. Cannot send this receipt'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingGroup = result.get(senderId);
|
||||||
|
if (existingGroup) {
|
||||||
|
existingGroup.push(receipt);
|
||||||
|
} else {
|
||||||
|
result.set(senderId, [receipt]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
map(receiptsBySenderId, async ([senderId, receiptsForSender]) => {
|
||||||
|
const sender = window.ConversationController.get(senderId);
|
||||||
|
if (!sender) {
|
||||||
|
throw new Error(
|
||||||
|
'despite having a conversation ID, no conversation was found'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConversationAccepted(sender.attributes)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendOptions = await getSendOptions(sender.attributes);
|
||||||
|
|
||||||
|
const batches = chunk(receiptsForSender, CHUNK_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
map(batches, async batch => {
|
||||||
|
const timestamps = batch.map(receipt => receipt.timestamp);
|
||||||
|
const messageIds = batch.map(receipt => receipt.messageId);
|
||||||
|
|
||||||
|
await handleMessageSend(
|
||||||
|
window.textsecure.messaging[methodName]({
|
||||||
|
senderE164: sender.get('e164'),
|
||||||
|
senderUuid: sender.get('uuid'),
|
||||||
|
timestamps,
|
||||||
|
options: sendOptions,
|
||||||
|
}),
|
||||||
|
{ messageIds, sendType: type }
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,73 +0,0 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import type { ConversationAttributesType } from '../model-types.d';
|
|
||||||
import type { LoggerType } from '../types/Logging';
|
|
||||||
import { getSendOptions } from './getSendOptions';
|
|
||||||
import { handleMessageSend } from './handleMessageSend';
|
|
||||||
import { isConversationAccepted } from './isConversationAccepted';
|
|
||||||
|
|
||||||
export async function sendViewedReceipt(
|
|
||||||
{
|
|
||||||
messageId,
|
|
||||||
senderE164,
|
|
||||||
senderUuid,
|
|
||||||
timestamp,
|
|
||||||
}: Readonly<{
|
|
||||||
messageId: string;
|
|
||||||
senderE164?: string;
|
|
||||||
senderUuid?: string;
|
|
||||||
timestamp: number;
|
|
||||||
}>,
|
|
||||||
log: LoggerType
|
|
||||||
): Promise<void> {
|
|
||||||
if (!window.storage.get('read-receipt-setting')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We introduced a bug in `75f0cd50beff73885ebae92e4ac977de9f56d6c9` where we'd enqueue
|
|
||||||
// jobs that had no sender information. These jobs cannot possibly succeed. This
|
|
||||||
// removes them from the queue to avoid constantly retrying something.
|
|
||||||
//
|
|
||||||
// We should be able to safely remove this check after the fix has been present for
|
|
||||||
// awhile. Probably ~40 days from when this is first deployed (30 days to unlink + 10
|
|
||||||
// days of buffer).
|
|
||||||
if (!senderE164 && !senderUuid) {
|
|
||||||
log.error(
|
|
||||||
'sendViewedReceipt: no sender E164 or UUID. Cannot possibly complete this job. Giving up'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversationId = window.ConversationController.ensureContactIds({
|
|
||||||
e164: senderE164,
|
|
||||||
uuid: senderUuid,
|
|
||||||
});
|
|
||||||
if (!conversationId) {
|
|
||||||
throw new Error(
|
|
||||||
'sendViewedReceipt: no conversation found with that E164/UUID'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
|
||||||
if (!conversation) {
|
|
||||||
throw new Error(
|
|
||||||
'sendViewedReceipt: no conversation found with that conversation ID, even though we found the ID with E164/UUID?'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversationAttrs: ConversationAttributesType = conversation.attributes;
|
|
||||||
if (!isConversationAccepted(conversationAttrs)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleMessageSend(
|
|
||||||
window.textsecure.messaging.sendViewedReceipts({
|
|
||||||
senderE164,
|
|
||||||
senderUuid,
|
|
||||||
timestamps: [timestamp],
|
|
||||||
options: await getSendOptions(conversationAttrs),
|
|
||||||
}),
|
|
||||||
{ messageIds: [messageId], sendType: 'viewedReceipt' }
|
|
||||||
);
|
|
||||||
}
|
|
10
ts/window.d.ts
vendored
10
ts/window.d.ts
vendored
|
@ -35,6 +35,7 @@ import { getEnvironment } from './environment';
|
||||||
import * as zkgroup from './util/zkgroup';
|
import * as zkgroup from './util/zkgroup';
|
||||||
import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
|
import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
|
||||||
import * as EmbeddedContact from './types/EmbeddedContact';
|
import * as EmbeddedContact from './types/EmbeddedContact';
|
||||||
|
import type { Receipt } from './types/Receipt';
|
||||||
import * as Errors from './types/errors';
|
import * as Errors from './types/errors';
|
||||||
import { ConversationController } from './ConversationController';
|
import { ConversationController } from './ConversationController';
|
||||||
import { ReduxActions } from './state/types';
|
import { ReduxActions } from './state/types';
|
||||||
|
@ -523,13 +524,6 @@ export class CanvasVideoRenderer {
|
||||||
constructor(canvas: Ref<HTMLCanvasElement>);
|
constructor(canvas: Ref<HTMLCanvasElement>);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeliveryReceiptBatcherItemType = {
|
|
||||||
messageId: string;
|
|
||||||
source?: string;
|
|
||||||
sourceUuid?: string;
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class AnyViewClass extends window.Backbone.View<any> {
|
export class AnyViewClass extends window.Backbone.View<any> {
|
||||||
public headerTitle?: string;
|
public headerTitle?: string;
|
||||||
static show(view: typeof AnyViewClass, element: Element): void;
|
static show(view: typeof AnyViewClass, element: Element): void;
|
||||||
|
@ -551,7 +545,7 @@ export type WhisperType = {
|
||||||
WallClockListener: WhatIsThis;
|
WallClockListener: WhatIsThis;
|
||||||
|
|
||||||
deliveryReceiptQueue: PQueue;
|
deliveryReceiptQueue: PQueue;
|
||||||
deliveryReceiptBatcher: BatcherType<DeliveryReceiptBatcherItemType>;
|
deliveryReceiptBatcher: BatcherType<Receipt>;
|
||||||
events: Backbone.Events;
|
events: Backbone.Events;
|
||||||
activeConfirmationView: WhatIsThis;
|
activeConfirmationView: WhatIsThis;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue