// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { z } from 'zod'; import { groupBy } from 'lodash'; import * as log from '../logging/log'; import { aciSchema } from '../types/ServiceId'; import { safeParseStrict } from './schemas'; const retryItemSchema = z .object({ conversationId: z.string(), sentAt: z.number(), receivedAt: z.number(), receivedAtCounter: z.number(), senderAci: aciSchema, wasOpened: z.boolean().optional(), }) .passthrough(); export type RetryItemType = z.infer; const retryItemListSchema = z.array(retryItemSchema); export type RetryItemListType = z.infer; export type ByConversationLookupType = { [key: string]: Array; }; export type ByMessageLookupType = Map; export function getItemId(conversationId: string, sentAt: number): string { return `${conversationId}--${sentAt}`; } const HOUR = 60 * 60 * 1000; export const STORAGE_KEY = 'retryPlaceholders'; export function getDeltaIntoPast(delta?: number): number { return Date.now() - (delta || HOUR); } export class RetryPlaceholders { #items: Array; #byConversation: ByConversationLookupType; #byMessage: ByMessageLookupType; #retryReceiptLifespan: number; constructor(options: { retryReceiptLifespan?: number } = {}) { if (!window.storage) { throw new Error( 'RetryPlaceholders.constructor: window.storage not available!' ); } const parsed = safeParseStrict( retryItemListSchema, window.storage.get(STORAGE_KEY, new Array()) ); if (!parsed.success) { log.warn( `RetryPlaceholders.constructor: Data fetched from storage did not match schema: ${JSON.stringify( parsed.error.flatten() )}` ); } this.#items = parsed.success ? parsed.data : []; this.sortByExpiresAtAsc(); this.#byConversation = this.makeByConversationLookup(); this.#byMessage = this.makeByMessageLookup(); this.#retryReceiptLifespan = options.retryReceiptLifespan || HOUR; log.info( `RetryPlaceholders.constructor: Started with ${this.#items.length} items, lifespan of ${this.#retryReceiptLifespan}` ); } // Arranging local data for efficiency sortByExpiresAtAsc(): void { this.#items.sort( (left: RetryItemType, right: RetryItemType) => left.receivedAt - right.receivedAt ); } makeByConversationLookup(): ByConversationLookupType { return groupBy(this.#items, item => item.conversationId); } makeByMessageLookup(): ByMessageLookupType { const lookup = new Map(); this.#items.forEach(item => { lookup.set(getItemId(item.conversationId, item.sentAt), item); }); return lookup; } makeLookups(): void { this.#byConversation = this.makeByConversationLookup(); this.#byMessage = this.makeByMessageLookup(); } // Basic data management async add(item: RetryItemType): Promise { const parsed = safeParseStrict(retryItemSchema, item); if (!parsed.success) { throw new Error( `RetryPlaceholders.add: Item did not match schema ${JSON.stringify( parsed.error.flatten() )}` ); } this.#items.push(item); this.sortByExpiresAtAsc(); this.makeLookups(); await this.save(); } async save(): Promise { await window.storage.put(STORAGE_KEY, this.#items); } // Finding items in different ways getCount(): number { return this.#items.length; } getNextToExpire(): RetryItemType | undefined { return this.#items[0]; } async getExpiredAndRemove(): Promise> { const expiration = getDeltaIntoPast(this.#retryReceiptLifespan); const max = this.#items.length; const result: Array = []; for (let i = 0; i < max; i += 1) { const item = this.#items[i]; if (item.receivedAt <= expiration) { result.push(item); } else { break; } } log.info( `RetryPlaceholders.getExpiredAndRemove: Found ${result.length} expired items` ); this.#items.splice(0, result.length); this.makeLookups(); await this.save(); return result; } async findByConversationAndMarkOpened(conversationId: string): Promise { let changed = 0; const items = this.#byConversation[conversationId]; (items || []).forEach(item => { if (!item.wasOpened) { changed += 1; // eslint-disable-next-line no-param-reassign item.wasOpened = true; } }); if (changed > 0) { log.info( `RetryPlaceholders.findByConversationAndMarkOpened: Updated ${changed} items for conversation ${conversationId}` ); await this.save(); } } async findByMessageAndRemove( conversationId: string, sentAt: number ): Promise { const result = this.#byMessage.get(getItemId(conversationId, sentAt)); if (!result) { return undefined; } const index = this.#items.findIndex(item => item === result); this.#items.splice(index, 1); this.makeLookups(); log.info( `RetryPlaceholders.findByMessageAndRemove: Removing ${sentAt} from conversation ${conversationId}` ); await this.save(); return result; } }