diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index fbff0be00b5a..ae47cecf8fe9 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -196,14 +196,22 @@ export enum GiftBadgeStates { Unopened = 'Unopened', Opened = 'Opened', Redeemed = 'Redeemed', + Failed = 'Failed', } -export type GiftBadgeType = { - expiration: number; - id: string | undefined; - level: number; - state: GiftBadgeStates; -}; +export type GiftBadgeType = + | { + state: + | GiftBadgeStates.Unopened + | GiftBadgeStates.Opened + | GiftBadgeStates.Redeemed; + expiration: number; + id: string | undefined; + level: number; + } + | { + state: GiftBadgeStates.Failed; + }; export type PropsData = { id: string; @@ -1385,7 +1393,10 @@ export class Message extends React.PureComponent { return null; } - if (giftBadge.state === GiftBadgeStates.Unopened) { + if ( + giftBadge.state === GiftBadgeStates.Unopened || + giftBadge.state === GiftBadgeStates.Failed + ) { const description = direction === 'incoming' ? i18n('icu:message--donation--unopened--incoming') diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index 0f12f9605134..13a941e54597 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -2009,6 +2009,13 @@ GiftBadgeUnopened.args = { }, }; +export const GiftBadgeFailed = Template.bind({}); +GiftBadgeFailed.args = { + giftBadge: { + state: GiftBadgeStates.Failed, + }, +}; + const getPreferredBadge = () => ({ category: BadgeCategory.Donor, descriptionTemplate: 'This is a description of the badge', diff --git a/ts/messageModifiers/ViewSyncs.ts b/ts/messageModifiers/ViewSyncs.ts index f302266c8547..95747f8f37e8 100644 --- a/ts/messageModifiers/ViewSyncs.ts +++ b/ts/messageModifiers/ViewSyncs.ts @@ -138,7 +138,7 @@ export async function onSync(sync: ViewSyncAttributesType): Promise { } const giftBadge = message.get('giftBadge'); - if (giftBadge) { + if (giftBadge && giftBadge.state !== GiftBadgeStates.Failed) { didChangeMessage = true; message.set({ giftBadge: { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index f5bec26ff4fe..f0d9cd02abb0 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -136,6 +136,9 @@ export type EditHistoryType = { timestamp: number; received_at: number; received_at_ms?: number; + serverTimestamp?: number; + readStatus?: ReadStatus; + unidentifiedDeliveryReceived?: boolean; }; type MessageType = @@ -225,13 +228,20 @@ export type MessageAttributesType = { targetAuthorAci: AciString; targetTimestamp: number; }; - giftBadge?: { - expiration: number; - level: number; - id: string | undefined; - receiptCredentialPresentation: string; - state: GiftBadgeStates; - }; + giftBadge?: + | { + state: + | GiftBadgeStates.Unopened + | GiftBadgeStates.Opened + | GiftBadgeStates.Redeemed; + expiration: number; + level: number; + id: string | undefined; + receiptCredentialPresentation: string; + } + | { + state: GiftBadgeStates.Failed; + }; expirationTimerUpdate?: { expireTimer?: DurationInSeconds; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 80d4cd8bff37..7dbfb4c22cc6 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -158,6 +158,7 @@ import { } from '../messages/copyQuote'; import { getRoomIdFromCallLink } from '../util/callLinksRingrtc'; import { explodePromise } from '../util/explodePromise'; +import { GiftBadgeStates } from '../components/conversation/Message'; /* eslint-disable more/no-then */ @@ -2076,7 +2077,7 @@ export class MessageModel extends window.Backbone.Model { await DataWriter.updateConversation(conversation.attributes); const giftBadge = message.get('giftBadge'); - if (giftBadge) { + if (giftBadge && giftBadge.state !== GiftBadgeStates.Failed) { const { level } = giftBadge; const { updatesUrl } = window.SignalContext.config; strictAssert( diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 04135b5c502e..5aa50142ef38 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -22,6 +22,7 @@ import * as log from '../../logging/log'; import { GiftBadgeStates } from '../../components/conversation/Message'; import { type CustomColorType } from '../../types/Colors'; import { StorySendMode, MY_STORY_ID } from '../../types/Stories'; +import { getStickerPacksForBackup } from '../../types/Stickers'; import { isPniString, type AciString, @@ -384,7 +385,7 @@ export class BackupExportStream extends Readable { stats.callLinks += 1; } - const stickerPacks = await DataReader.getInstalledStickerPacks(); + const stickerPacks = await getStickerPacksForBackup(); for (const { id, key } of stickerPacks) { this.pushFrame({ @@ -1142,27 +1143,33 @@ export class BackupExportStream extends Readable { const { giftBadge } = message; strictAssert(giftBadge != null, 'Message must have gift badge'); - let state: Backups.GiftBadge.State; - switch (giftBadge.state) { - case GiftBadgeStates.Unopened: - state = Backups.GiftBadge.State.UNOPENED; - break; - case GiftBadgeStates.Opened: - state = Backups.GiftBadge.State.OPENED; - break; - case GiftBadgeStates.Redeemed: - state = Backups.GiftBadge.State.REDEEMED; - break; - default: - throw missingCaseError(giftBadge.state); - } + if (giftBadge.state === GiftBadgeStates.Failed) { + result.giftBadge = { + state: Backups.GiftBadge.State.FAILED, + }; + } else { + let state: Backups.GiftBadge.State; + switch (giftBadge.state) { + case GiftBadgeStates.Unopened: + state = Backups.GiftBadge.State.UNOPENED; + break; + case GiftBadgeStates.Opened: + state = Backups.GiftBadge.State.OPENED; + break; + case GiftBadgeStates.Redeemed: + state = Backups.GiftBadge.State.REDEEMED; + break; + default: + throw missingCaseError(giftBadge); + } - result.giftBadge = { - receiptCredentialPresentation: Bytes.fromBase64( - giftBadge.receiptCredentialPresentation - ), - state, - }; + result.giftBadge = { + receiptCredentialPresentation: Bytes.fromBase64( + giftBadge.receiptCredentialPresentation + ), + state, + }; + } } else { result.standardMessage = await this.toStandardMessage( message, diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 0189d8cebd90..d99910d894bc 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -35,7 +35,8 @@ import { import { STICKERPACK_ID_BYTE_LEN, STICKERPACK_KEY_BYTE_LEN, - downloadStickerPack, + createPacksFromBackup, + type StickerPackPointerType, } from '../../types/Stickers'; import type { ConversationColorType, @@ -285,6 +286,7 @@ export class BackupImportStream extends Writable { return processMessagesBatch(ourAci, batch); }, }); + private readonly stickerPacks = new Array(); private ourConversation?: ConversationAttributesType; private pinnedConversations = new Array<[number, string]>(); private customColorById = new Map(); @@ -357,6 +359,9 @@ export class BackupImportStream extends Writable { await this.conversationOpBatcher.flushAndWait(); await this.saveMessageBatcher.flushAndWait(); + // Store sticker packs and schedule downloads + await createPacksFromBackup(this.stickerPacks); + // Reset and reload conversations and storage again window.ConversationController.reset(); @@ -1537,8 +1542,14 @@ export class BackupImportStream extends Writable { const timestamp = getTimestampFromLong(rev.dateSent); const { - // eslint-disable-next-line camelcase - patch: { sendStateByConversationId, received_at_ms }, + patch: { + sendStateByConversationId, + // eslint-disable-next-line camelcase + received_at_ms, + serverTimestamp, + readStatus, + unidentifiedDeliveryReceived, + }, } = this.fromDirectionDetails(rev, timestamp); return { @@ -1551,6 +1562,9 @@ export class BackupImportStream extends Writable { sendStateByConversationId, // eslint-disable-next-line camelcase received_at_ms, + serverTimestamp, + readStatus, + unidentifiedDeliveryReceived, }; }) // Fix order: from newest to oldest @@ -1572,6 +1586,9 @@ export class BackupImportStream extends Writable { timestamp: mainMessage.timestamp, received_at: mainMessage.received_at, received_at_ms: mainMessage.received_at_ms, + serverTimestamp: mainMessage.serverTimestamp, + readStatus: mainMessage.readStatus, + unidentifiedDeliveryReceived: mainMessage.unidentifiedDeliveryReceived, }); return result; @@ -1863,6 +1880,17 @@ export class BackupImportStream extends Writable { } if (chatItem.giftBadge) { const { giftBadge } = chatItem; + if (giftBadge.state === Backups.GiftBadge.State.FAILED) { + return { + message: { + giftBadge: { + state: GiftBadgeStates.Failed, + }, + }, + additionalMessages: [], + }; + } + strictAssert( Bytes.isNotEmpty(giftBadge.receiptCredentialPresentation), 'Gift badge must have a presentation' @@ -1874,15 +1902,11 @@ export class BackupImportStream extends Writable { state = GiftBadgeStates.Opened; break; - case Backups.GiftBadge.State.FAILED: case Backups.GiftBadge.State.REDEEMED: state = GiftBadgeStates.Redeemed; break; case Backups.GiftBadge.State.UNOPENED: - state = GiftBadgeStates.Unopened; - break; - default: state = GiftBadgeStates.Unopened; break; @@ -2842,25 +2866,23 @@ export class BackupImportStream extends Writable { } private async fromStickerPack({ - packId: id, - packKey: key, + packId: packIdBytes, + packKey: packKeyBytes, }: Backups.IStickerPack): Promise { strictAssert( - id?.length === STICKERPACK_ID_BYTE_LEN, + packIdBytes?.length === STICKERPACK_ID_BYTE_LEN, 'Sticker pack must have a valid pack id' ); - const logId = `fromStickerPack(${Bytes.toHex(id).slice(-2)})`; + const id = Bytes.toHex(packIdBytes); + const logId = `fromStickerPack(${id.slice(-2)})`; strictAssert( - key?.length === STICKERPACK_KEY_BYTE_LEN, + packKeyBytes?.length === STICKERPACK_KEY_BYTE_LEN, `${logId}: must have a valid pack key` ); + const key = Bytes.toBase64(packKeyBytes); - drop( - downloadStickerPack(Bytes.toHex(id), Bytes.toBase64(key), { - fromBackup: true, - }) - ); + this.stickerPacks.push({ id, key }); } private async fromAdHocCall({ diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 672c99cdcc39..02f917903541 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -875,6 +875,7 @@ type WritableInterface = { ) => void; createOrUpdateStickerPack: (pack: StickerPackType) => void; + createOrUpdateStickerPacks: (packs: ReadonlyArray) => void; updateStickerPackStatus: ( id: string, status: StickerPackStatusType, @@ -895,6 +896,9 @@ type WritableInterface = { ) => ReadonlyArray | undefined; deleteStickerPack: (packId: string) => Array; addUninstalledStickerPack: (pack: UninstalledStickerPackType) => void; + addUninstalledStickerPacks: ( + pack: ReadonlyArray + ) => void; removeUninstalledStickerPack: (packId: string) => void; installStickerPack: (packId: string, timestamp: number) => void; uninstallStickerPack: (packId: string, timestamp: number) => void; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 9be08df9b6e9..0bedf3122b9b 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -486,6 +486,7 @@ export const DataWriter: ServerWritableInterface = { saveBackupCdnObjectMetadata, createOrUpdateStickerPack, + createOrUpdateStickerPacks, updateStickerPackStatus, updateStickerPackInfo, createOrUpdateSticker, @@ -495,6 +496,7 @@ export const DataWriter: ServerWritableInterface = { deleteStickerPackReference, deleteStickerPack, addUninstalledStickerPack, + addUninstalledStickerPacks, removeUninstalledStickerPack, installStickerPack, uninstallStickerPack, @@ -5236,6 +5238,16 @@ function createOrUpdateStickerPack( ` ).run(payload); } +function createOrUpdateStickerPacks( + db: WritableDB, + packs: ReadonlyArray +): void { + db.transaction(() => { + for (const pack of packs) { + createOrUpdateStickerPack(db, pack); + } + })(); +} function updateStickerPackStatus( db: WritableDB, id: string, @@ -5630,6 +5642,16 @@ function addUninstalledStickerPack( storageNeedsSync: pack.storageNeedsSync ? 1 : 0, }); } +function addUninstalledStickerPacks( + db: WritableDB, + packs: ReadonlyArray +): void { + return db.transaction(() => { + for (const pack of packs) { + addUninstalledStickerPack(db, pack); + } + })(); +} function removeUninstalledStickerPack(db: WritableDB, packId: string): void { db.prepare( 'DELETE FROM uninstalled_sticker_packs WHERE id IS $id' diff --git a/ts/test-electron/backup/bubble_test.ts b/ts/test-electron/backup/bubble_test.ts index 85917869d035..d3447c07e785 100644 --- a/ts/test-electron/backup/bubble_test.ts +++ b/ts/test-electron/backup/bubble_test.ts @@ -94,18 +94,24 @@ describe('backup/bubble messages', () => { timestamp: 5, received_at: 5, received_at_ms: 5, + readStatus: ReadStatus.Unread, + unidentifiedDeliveryReceived: true, }, { body: 'c', timestamp: 4, received_at: 4, received_at_ms: 4, + readStatus: ReadStatus.Unread, + unidentifiedDeliveryReceived: false, }, { body: 'b', timestamp: 3, received_at: 3, received_at_ms: 3, + readStatus: ReadStatus.Read, + unidentifiedDeliveryReceived: false, }, ], }, diff --git a/ts/test-mock/backups/integration.ts b/ts/test-mock/backups/integration.ts index 2ecda0e192a6..ee9125a4281b 100644 --- a/ts/test-mock/backups/integration.ts +++ b/ts/test-mock/backups/integration.ts @@ -14,6 +14,7 @@ import { } from '@signalapp/libsignal-client/dist/MessageBackup'; import { FileStream } from '../../services/backups/util/FileStream'; +import { drop } from '../../util/drop'; import type { App } from '../playwright'; import { Bootstrap } from '../bootstrap'; @@ -96,11 +97,16 @@ async function runOne(filePath: string): Promise { await bootstrap.saveLogs(app, basename(filePath)); fail(filePath, error.stack); } finally { - try { - await bootstrap.teardown(); - } catch (error) { - console.error(`Failed to teardown ${basename(filePath)}`, error); - } + // No need to block on this + drop( + (async () => { + try { + await bootstrap.teardown(); + } catch (error) { + console.error(`Failed to teardown ${basename(filePath)}`, error); + } + })() + ); } } diff --git a/ts/types/Stickers.ts b/ts/types/Stickers.ts index 1195a1d25df9..5d059615b575 100644 --- a/ts/types/Stickers.ts +++ b/ts/types/Stickers.ts @@ -19,6 +19,7 @@ import type { StickerType as StickerFromDBType, StickerPackType, StickerPackStatusType, + UninstalledStickerPackType, } from '../sql/Interface'; import { DataReader, DataWriter } from '../sql/Client'; import { SignalService as Proto } from '../protobuf'; @@ -62,6 +63,11 @@ export type DownloadMap = Record< } >; +export type StickerPackPointerType = Readonly<{ + id: string; + key: string; +}>; + export const STICKERPACK_ID_BYTE_LEN = 16; export const STICKERPACK_KEY_BYTE_LEN = 32; @@ -135,9 +141,65 @@ export async function load(): Promise { packsToDownload = capturePacksToDownload(packs); } +export async function createPacksFromBackup( + packs: ReadonlyArray +): Promise { + const known = new Set(packs.map(({ id }) => id)); + const pairs = packs.slice(); + const uninstalled = new Array(); + + for (const [id, { key }] of Object.entries(BLESSED_PACKS)) { + if (known.has(id)) { + continue; + } + + // Blessed packs that are not in the backup were uninstalled + pairs.push({ id, key }); + uninstalled.push({ + id, + key: undefined, + uninstalledAt: Date.now(), + storageNeedsSync: false, + }); + } + + const packsToStore = pairs.map( + ({ id, key }): StickerPackType => ({ + ...STICKER_PACK_DEFAULTS, + + id, + key, + status: 'known' as const, + }) + ); + + await DataWriter.createOrUpdateStickerPacks(packsToStore); + await DataWriter.addUninstalledStickerPacks(uninstalled); + + packsToDownload = capturePacksToDownload(makeLookup(packsToStore, 'id')); +} + +export async function getStickerPacksForBackup(): Promise< + Array +> { + const result = new Array(); + const stickerPacks = await DataReader.getAllStickerPacks(); + const uninstalled = new Set( + (await DataReader.getUninstalledStickerPacks()).map(({ id }) => id) + ); + for (const { id, key } of stickerPacks) { + if (uninstalled.has(id)) { + continue; + } + + result.push({ id, key }); + } + return result; +} + export function getDataFromLink( link: string -): undefined | { id: string; key: string } { +): undefined | StickerPackPointerType { const url = maybeParseUrl(link); if (!url) { return undefined; diff --git a/ts/util/handleEditMessage.ts b/ts/util/handleEditMessage.ts index 21c7900c061b..61d036017516 100644 --- a/ts/util/handleEditMessage.ts +++ b/ts/util/handleEditMessage.ts @@ -124,6 +124,9 @@ export async function handleEditMessage( timestamp: mainMessage.timestamp, received_at: mainMessage.received_at, received_at_ms: mainMessage.received_at_ms, + serverTimestamp: mainMessage.serverTimestamp, + readStatus: mainMessage.readStatus, + unidentifiedDeliveryReceived: mainMessage.unidentifiedDeliveryReceived, }, ]; @@ -258,6 +261,10 @@ export async function handleEditMessage( timestamp: upgradedEditedMessageData.timestamp, received_at: upgradedEditedMessageData.received_at, received_at_ms: upgradedEditedMessageData.received_at_ms, + serverTimestamp: upgradedEditedMessageData.serverTimestamp, + readStatus: upgradedEditedMessageData.readStatus, + unidentifiedDeliveryReceived: + upgradedEditedMessageData.unidentifiedDeliveryReceived, quote: nextEditedMessageQuote, };