// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { z } from 'zod'; import type { ReadonlyMessageAttributesType } from '../model-types.d'; import * as Errors from '../types/errors'; import * as log from '../logging/log'; import { GiftBadgeStates } from '../components/conversation/Message'; import { ReadStatus } from '../messages/MessageReadStatus'; import { getMessageIdForLogging } from '../util/idForLogging'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import { isDownloaded } from '../types/Attachment'; import { isIncoming } from '../state/selectors/message'; import { markViewed } from '../services/MessageUpdater'; import { notificationService } from '../services/notifications'; import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; import { queueUpdateMessage } from '../util/messageBatcher'; import { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager'; import { isAciString } from '../util/isAciString'; import { DataReader, DataWriter } from '../sql/Client'; export const viewSyncTaskSchema = z.object({ type: z.literal('ViewSync').readonly(), senderAci: z.string().refine(isAciString), senderE164: z.string().optional(), senderId: z.string(), timestamp: z.number(), viewedAt: z.number(), }); export type ViewSyncTaskType = z.infer; export type ViewSyncAttributesType = { envelopeId: string; syncTaskId: string; viewSync: ViewSyncTaskType; }; const viewSyncs = new Map(); async function remove(sync: ViewSyncAttributesType): Promise { const { syncTaskId } = sync; viewSyncs.delete(syncTaskId); await DataWriter.removeSyncTaskById(syncTaskId); } export async function forMessage( message: ReadonlyMessageAttributesType ): Promise> { const logId = `ViewSyncs.forMessage(${getMessageIdForLogging(message)})`; const sender = window.ConversationController.lookupOrCreate({ e164: message.source, serviceId: message.sourceServiceId, reason: logId, }); const messageTimestamp = getMessageSentTimestamp(message, { log, }); const viewSyncValues = Array.from(viewSyncs.values()); const matchingSyncs = viewSyncValues.filter(item => { const { viewSync } = item; return ( viewSync.senderId === sender?.id && viewSync.timestamp === messageTimestamp ); }); if (matchingSyncs.length > 0) { log.info( `${logId}: Found ${matchingSyncs.length} early view sync(s) for message ${messageTimestamp}` ); } await Promise.all( matchingSyncs.map(async sync => { await remove(sync); }) ); return matchingSyncs; } export async function onSync(sync: ViewSyncAttributesType): Promise { viewSyncs.set(sync.syncTaskId, sync); const { viewSync } = sync; const logId = `ViewSyncs.onSync(timestamp=${viewSync.timestamp})`; try { const messages = await DataReader.getMessagesBySentAt(viewSync.timestamp); const found = messages.find(item => { const sender = window.ConversationController.lookupOrCreate({ e164: item.source, serviceId: item.sourceServiceId, reason: logId, }); return sender?.id === viewSync.senderId; }); if (!found) { log.info( `${logId}: nothing found`, viewSync.senderId, viewSync.senderE164, viewSync.senderAci ); return; } notificationService.removeBy({ messageId: found.id }); const message = window.MessageCache.__DEPRECATED$register( found.id, found, 'ViewSyncs.onSync' ); let didChangeMessage = false; if (message.get('readStatus') !== ReadStatus.Viewed) { didChangeMessage = true; message.set(markViewed(message.attributes, viewSync.viewedAt)); const attachments = message.get('attachments'); if (!attachments?.every(isDownloaded)) { const updatedFields = await queueAttachmentDownloads( message.attributes, { urgency: AttachmentDownloadUrgency.STANDARD } ); if (updatedFields) { message.set(updatedFields); } } } const giftBadge = message.get('giftBadge'); if (giftBadge && giftBadge.state !== GiftBadgeStates.Failed) { didChangeMessage = true; message.set({ giftBadge: { ...giftBadge, state: isIncoming(message.attributes) ? GiftBadgeStates.Redeemed : GiftBadgeStates.Opened, }, }); } if (didChangeMessage) { queueUpdateMessage(message.attributes); } await remove(sync); } catch (error) { log.error(`${logId} error:`, Errors.toLogFormat(error)); await remove(sync); } }