diff --git a/package-lock.json b/package-lock.json index 81c3fb3c14..df2254cb60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,7 +123,7 @@ "@indutny/parallel-prettier": "3.0.0", "@indutny/rezip-electron": "2.0.1", "@napi-rs/canvas": "0.1.61", - "@signalapp/mock-server": "10.4.1", + "@signalapp/mock-server": "10.5.0", "@storybook/addon-a11y": "8.4.4", "@storybook/addon-actions": "8.4.4", "@storybook/addon-controls": "8.4.4", @@ -6491,9 +6491,9 @@ } }, "node_modules/@signalapp/mock-server": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-10.4.1.tgz", - "integrity": "sha512-9aBmFbOx3KfbN4Ptcx5PBRnVnROe6A58rRoErB/w1x+SSW9TjRHZxviJfgNpt9nGUOreryzOBsONSzWBIE12nQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-10.5.0.tgz", + "integrity": "sha512-w7KXcWYRPXhAxYWzeBNesu+A9DO6FYJUhg52md3M9yQkPR2Yr/YBvc9zBhFo5fafZmkaRoxDoz9y29Ivd5ZykQ==", "dev": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 32c6e47b7d..ed6a3083a6 100644 --- a/package.json +++ b/package.json @@ -214,7 +214,7 @@ "@indutny/parallel-prettier": "3.0.0", "@indutny/rezip-electron": "2.0.1", "@napi-rs/canvas": "0.1.61", - "@signalapp/mock-server": "10.4.1", + "@signalapp/mock-server": "10.5.0", "@storybook/addon-a11y": "8.4.4", "@storybook/addon-actions": "8.4.4", "@storybook/addon-controls": "8.4.4", diff --git a/ts/services/releaseNotesFetcher.ts b/ts/services/releaseNotesFetcher.ts index 2e947b5428..cdff63af5a 100644 --- a/ts/services/releaseNotesFetcher.ts +++ b/ts/services/releaseNotesFetcher.ts @@ -27,6 +27,8 @@ import type { } from '../textsecure/WebAPI'; import type { WithRequiredProperties } from '../types/Util'; import { MessageModel } from '../models/messages'; +import { stringToMIMEType } from '../types/MIME'; +import { isNotNil } from '../util/isNotNil'; const FETCH_INTERVAL = 3 * durations.DAY; const ERROR_RETRY_DELAY = 3 * durations.HOUR; @@ -103,7 +105,8 @@ export class ReleaseNotesFetcher { note: ManifestReleaseNoteType ): Promise { if (!window.textsecure.server) { - return undefined; + log.info('ReleaseNotesFetcher: WebAPI unavailable'); + throw new Error('WebAPI unavailable'); } const { uuid, ctaId, link } = note; @@ -157,15 +160,75 @@ export class ReleaseNotesFetcher { async #processReleaseNotes( notes: ReadonlyArray ): Promise { + if (!window.textsecure.server) { + log.info('ReleaseNotesFetcher: WebAPI unavailable'); + throw new Error('WebAPI unavailable'); + } const sortedNotes = [...notes].sort( (a: ManifestReleaseNoteType, b: ManifestReleaseNoteType) => semver.compare(a.desktopMinVersion, b.desktopMinVersion) ); - const hydratedNotes = []; - for (const note of sortedNotes) { - // eslint-disable-next-line no-await-in-loop - hydratedNotes.push(await this.#getReleaseNote(note)); - } + + const hydratedNotesWithRawAttachments = ( + await Promise.all( + sortedNotes.map(async note => { + if (!window.textsecure.server) { + log.info('ReleaseNotesFetcher: WebAPI unavailable'); + throw new Error('WebAPI unavailable'); + } + if (!note) { + return null; + } + + const hydratedNote = await this.#getReleaseNote(note); + if (!hydratedNote) { + return null; + } + if (hydratedNote.media) { + const { imageData: rawAttachmentData, contentType } = + await window.textsecure.server.getReleaseNoteImageAttachment( + hydratedNote.media + ); + + return { + hydratedNote, + rawAttachmentData, + contentType: hydratedNote.mediaContentType ?? contentType, + }; + } + + return { hydratedNote, rawAttachmentData: null, contentType: null }; + }) + ) + ).filter(isNotNil); + + const hydratedNotes = await Promise.all( + hydratedNotesWithRawAttachments.map( + async ({ hydratedNote, rawAttachmentData, contentType }) => { + if (rawAttachmentData && !contentType) { + throw new Error('Content type is missing from attachment'); + } + + if (!rawAttachmentData || !contentType) { + return { hydratedNote, processedAttachment: null }; + } + + const localAttachment = + await window.Signal.Migrations.writeNewAttachmentData( + rawAttachmentData + ); + + const processedAttachment = + await window.Signal.Migrations.processNewAttachment({ + ...localAttachment, + contentType: stringToMIMEType(contentType), + }); + + return { hydratedNote, processedAttachment }; + } + ) + ); + if (!hydratedNotes.length) { log.warn('ReleaseNotesFetcher: No hydrated notes available, stopping'); return; @@ -176,39 +239,44 @@ export class ReleaseNotesFetcher { await window.ConversationController.getOrCreateSignalConversation(); const messages: Array = []; - hydratedNotes.forEach(async (note, index) => { - if (!note) { - return; + hydratedNotes.forEach( + ({ hydratedNote: note, processedAttachment }, index) => { + if (!note) { + return; + } + + const { title, body } = note; + const messageBody = `${title}\n\n${body}`; + const bodyRanges = [ + { start: 0, length: title.length, style: BodyRange.Style.BOLD }, + ]; + const timestamp = Date.now() + index; + + const message = new MessageModel({ + ...generateMessageId(incrementMessageCounter()), + ...(processedAttachment + ? { attachments: [processedAttachment] } + : {}), + body: messageBody, + bodyRanges, + conversationId: signalConversation.id, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + received_at_ms: timestamp, + sent_at: timestamp, + serverTimestamp: timestamp, + sourceDevice: 1, + sourceServiceId: signalConversation.getServiceId(), + timestamp, + type: 'incoming', + }); + + window.MessageCache.register(message); + drop(signalConversation.onNewMessage(message)); + + messages.push(message.attributes); } - - const { title, body } = note; - const messageBody = `${title}\n\n${body}`; - const bodyRanges = [ - { start: 0, length: title.length, style: BodyRange.Style.BOLD }, - ]; - const timestamp = Date.now() + index; - - const message = new MessageModel({ - ...generateMessageId(incrementMessageCounter()), - body: messageBody, - bodyRanges, - conversationId: signalConversation.id, - readStatus: ReadStatus.Unread, - seenStatus: SeenStatus.Unseen, - received_at_ms: timestamp, - sent_at: timestamp, - serverTimestamp: timestamp, - sourceDevice: 1, - sourceServiceId: signalConversation.getServiceId(), - timestamp, - type: 'incoming', - }); - - window.MessageCache.register(message); - drop(signalConversation.onNewMessage(message)); - - messages.push(message.attributes); - }); + ); await Promise.all( messages.map(message => saveNewMessageBatcher.add(message)) @@ -276,7 +344,7 @@ export class ReleaseNotesFetcher { log.info( `ReleaseNotesFetcher: Processing ${validNotes.length} new release notes` ); - drop(this.#processReleaseNotes(validNotes)); + await this.#processReleaseNotes(validNotes); } else { log.info('ReleaseNotesFetcher: No new release notes'); } diff --git a/ts/test-mock/bootstrap.ts b/ts/test-mock/bootstrap.ts index 365403e78b..b018036a23 100644 --- a/ts/test-mock/bootstrap.ts +++ b/ts/test-mock/bootstrap.ts @@ -191,6 +191,7 @@ export class Bootstrap { // Limit number of storage read keys for easier testing maxStorageReadKeys: MAX_STORAGE_READ_KEYS, cdn3Path: this.cdn3Path, + updates2Path: path.join(__dirname, 'updates-data'), }); this.#options = { diff --git a/ts/test-mock/helpers.ts b/ts/test-mock/helpers.ts index 10483f9af1..4bcf8c7738 100644 --- a/ts/test-mock/helpers.ts +++ b/ts/test-mock/helpers.ts @@ -253,13 +253,21 @@ export async function createGroup( return group; } +export async function clickOnConversationWithAci( + page: Page, + aci: string +): Promise { + const leftPane = page.locator('#LeftPane'); + await leftPane.getByTestId(aci).click(); +} + export async function clickOnConversation( page: Page, contact: PrimaryDevice ): Promise { - const leftPane = page.locator('#LeftPane'); - await leftPane.getByTestId(contact.device.aci).click(); + await clickOnConversationWithAci(page, contact.device.aci); } + export async function pinContact( phone: PrimaryDevice, contact: PrimaryDevice diff --git a/ts/test-mock/release-notes/release_notes_test.ts b/ts/test-mock/release-notes/release_notes_test.ts index 41a6bd5363..7b0250b341 100644 --- a/ts/test-mock/release-notes/release_notes_test.ts +++ b/ts/test-mock/release-notes/release_notes_test.ts @@ -2,13 +2,17 @@ // SPDX-License-Identifier: AGPL-3.0-only import createDebug from 'debug'; -import { assert } from 'chai'; +import { expect } from 'playwright/test'; import type { App } from '../playwright'; import { Bootstrap } from '../bootstrap'; import { MINUTE } from '../../util/durations'; import { SIGNAL_ACI } from '../../types/SignalConversation'; +import { + clickOnConversationWithAci, + getTimelineMessageWithText, +} from '../helpers'; export const debug = createDebug('mock:test:releaseNotes'); @@ -37,7 +41,7 @@ describe('release notes', function (this: Mocha.Suite) { await bootstrap.teardown(); }); - it('shows release notes', async () => { + it('shows release notes with an image', async () => { const firstWindow = await app.getWindow(); await firstWindow.evaluate('window.SignalCI.resetReleaseNotesFetcher()'); @@ -52,6 +56,17 @@ describe('release notes', function (this: Mocha.Suite) { const releaseNoteConversation = leftPane.getByTestId(SIGNAL_ACI); await releaseNoteConversation.waitFor(); - assert.isTrue(await releaseNoteConversation.isVisible()); + await expect(releaseNoteConversation).toBeVisible(); + + await clickOnConversationWithAci(secondWindow, SIGNAL_ACI); + + const timelineMessage = await getTimelineMessageWithText( + secondWindow, + 'Call links' + ); + + await expect( + timelineMessage.locator('img.module-image__image') + ).toBeVisible(); }); }); diff --git a/ts/test-mock/updates-data/dynamic/release-notes/release-notes-v2.json b/ts/test-mock/updates-data/dynamic/release-notes/release-notes-v2.json new file mode 100644 index 0000000000..ea79791d37 --- /dev/null +++ b/ts/test-mock/updates-data/dynamic/release-notes/release-notes-v2.json @@ -0,0 +1,69 @@ +{ + "announcements": [ + { + "ctaId": "chat_folder", + "androidMinVersion": "9999", + "uuid": "b1b8cafb-11e8-490b-858d-dd26fc47c58a" + }, + { + "ctaId": "calls_tab", + "desktopMinVersion": "7.30.0", + "androidMinVersion": "1482", + "uuid": "1813845b-cc98-4210-a07c-164f9b328ca8" + } + ], + "megaphones": [ + { + "primaryCtaId": "donate", + "secondaryCtaId": "snooze", + "countries": "27:1000000,298:1000000,299:1000000,30:1000000,31:1000000,32:1000000,33:1000000,34:1000000,351:1000000,352:1000000,353:1000000,354:1000000,356:1000000,357:1000000,358:1000000,359:1000000,36:1000000,370:1000000,371:1000000,372:1000000,373:1000000,374:1000000,375:1000000,377:1000000,378:1000000,379:1000000,380:1000000,381:1000000,382:1000000,385:1000000,386:1000000,39:1000000,40:1000000,41:1000000,420:1000000,421:1000000,423:1000000,43:1000000,44:1000000,45:1000000,46:1000000,47:1000000,48:1000000,49:1000000,506:1000000,51:1000000,52:1000000,54:1000000,55:1000000,57:1000000,60:1000000,61:1000000,64:1000000,65:1000000,7:1000000,81:1000000,852:1000000,853:1000000,86:1000000,886:1000000,966:1000000,970:1000000,971:1000000,972:1000000,973:1000000,974:1000000,994:1000000,995:1000000", + "iosMinVersion": "6.1.0.17", + "priority": 100, + "dontShowBeforeEpochSeconds": 1732024800, + "uuid": "2f4f0c57-8e3b-437d-9d4b-6b84139cf5d7", + "showForNumberOfDays": 30, + "conditionalId": "standard_donate", + "dontShowAfterEpochSeconds": 1734616800, + "secondaryCtaData": { "snoozeDurationDays": [5, 7, 9, 100] } + }, + { + "primaryCtaId": "donate", + "secondaryCtaId": "snooze", + "androidMinVersion": "1164", + "countries": "20:1000000,212:1000000,213:1000000,216:1000000,218:1000000,221:1000000,223:1000000,225:1000000,226:1000000,229:1000000,230:1000000,233:1000000,234:1000000,237:1000000,243:1000000,244:1000000,254:1000000,255:1000000,256:1000000,27:1000000,30:1000000,31:1000000,32:1000000,33:1000000,34:1000000,351:1000000,352:1000000,353:1000000,354:1000000,355:1000000,356:1000000,357:1000000,358:1000000,359:1000000,36:1000000,370:1000000,371:1000000,372:1000000,373:1000000,374:1000000,380:1000000,381:1000000,382:1000000,383:1000000,385:1000000,386:1000000,387:1000000,389:1000000,39:1000000,40:1000000,41:1000000,420:1000000,421:1000000,43:1000000,44:1000000,45:1000000,46:1000000,47:1000000,48:1000000,49:1000000,502:1000000,503:1000000,505:1000000,506:1000000,507:1000000,51:1000000,52:1000000,54:1000000,55:1000000,56:1000000,57:1000000,58:1000000,591:1000000,593:1000000,595:1000000,596:1000000,598:1000000,60:1000000,61:1000000,62:1000000,63:1000000,64:1000000,65:1000000,66:1000000,7:1000000,81:1000000,82:1000000,84:1000000,852:1000000,853:1000000,855:1000000,880:1000000,886:1000000,90:1000000,91:1000000,92:1000000,93:1000000,94:1000000,95:1000000,961:1000000,962:1000000,964:1000000,965:1000000,966:1000000,967:1000000,968:1000000,971:1000000,972:1000000,973:1000000,974:1000000,977:1000000,994:1000000,995:1000000,996:1000000", + "priority": 100, + "dontShowBeforeEpochSeconds": 1732024800, + "uuid": "0ef1b765-1887-4620-b6a7-e4284720acde", + "showForNumberOfDays": 30, + "conditionalId": "standard_donate", + "dontShowAfterEpochSeconds": 1734616800, + "secondaryCtaData": { "snoozeDurationDays": [5, 7, 9, 100] } + }, + { + "primaryCtaId": "donate", + "secondaryCtaId": "snooze", + "countries": "1:1000000", + "iosMinVersion": "6.1.0.17", + "priority": 100, + "dontShowBeforeEpochSeconds": 1732024800, + "uuid": "999f2573-7d83-4d60-b045-259de448da8d", + "showForNumberOfDays": 30, + "conditionalId": "standard_donate", + "dontShowAfterEpochSeconds": 1734616800, + "secondaryCtaData": { "snoozeDurationDays": [5, 7, 9, 100] } + }, + { + "primaryCtaId": "donate", + "secondaryCtaId": "snooze", + "androidMinVersion": "1164", + "countries": "1:1000000", + "priority": 100, + "dontShowBeforeEpochSeconds": 1732024800, + "uuid": "9c3f0ba5-cce1-400b-b3b1-af34d5f073a5", + "showForNumberOfDays": 30, + "conditionalId": "standard_donate", + "dontShowAfterEpochSeconds": 1734616800, + "secondaryCtaData": { "snoozeDurationDays": [5, 7, 9, 100] } + } + ] +} diff --git a/ts/test-mock/updates-data/static/release-notes/1813845b-cc98-4210-a07c-164f9b328ca8/en.json b/ts/test-mock/updates-data/static/release-notes/1813845b-cc98-4210-a07c-164f9b328ca8/en.json new file mode 100644 index 0000000000..4e05b0b187 --- /dev/null +++ b/ts/test-mock/updates-data/static/release-notes/1813845b-cc98-4210-a07c-164f9b328ca8/en.json @@ -0,0 +1,9 @@ +{ + "mediaHeight": "864", + "mediaWidth": "1536", + "media": "/static/release-notes/call_links.png", + "uuid": "1813845b-cc98-4210-a07c-164f9b328ca8", + "title": "Introducing Call Links", + "body": "Call links are the missing link for calendar invites and impromptu gatherings. Now you can quickly create an easy link that anyone on Signal can use to join a group call without having to join a Signal group chat first.\n\nCall links are reusable and ideal for recurring phone dates with your best friends or weekly check-ins with your coworkers. You can manage your call links, control approval settings, and copy links from the calls tab for quick sharing. ", + "callToActionText": "Create Call Link" +} diff --git a/ts/test-mock/updates-data/static/release-notes/call_links.png b/ts/test-mock/updates-data/static/release-notes/call_links.png new file mode 100644 index 0000000000..c9f9c98a95 Binary files /dev/null and b/ts/test-mock/updates-data/static/release-notes/call_links.png differ diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index e0fda86318..411d0a4b05 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1267,6 +1267,11 @@ export type ReleaseNotesManifestResponseType = z.infer< typeof releaseNotesManifestSchema >; +export type GetReleaseNoteImageAttachmentResultType = Readonly<{ + imageData: Uint8Array; + contentType: string | null; +}>; + export type CallLinkCreateAuthResponseType = Readonly<{ credential: string; }>; @@ -1417,6 +1422,9 @@ export type WebAPIType = { ) => Promise; getReleaseNotesManifest: () => Promise; getReleaseNotesManifestHash: () => Promise; + getReleaseNoteImageAttachment: ( + path: string + ) => Promise; getSticker: (packId: string, stickerId: number) => Promise; getStickerPackManifest: (packId: string) => Promise; getStorageCredentials: MessageSender['getStorageCredentials']; @@ -1890,6 +1898,7 @@ export function initialize({ getReleaseNoteHash, getReleaseNotesManifest, getReleaseNotesManifestHash, + getReleaseNoteImageAttachment, getTransferArchive, getSenderCertificate, getSocketStatus, @@ -2257,6 +2266,29 @@ export function initialize({ return etag; } + async function getReleaseNoteImageAttachment( + path: string + ): Promise { + const { origin: expectedOrigin } = new URL(resourcesUrl); + const url = `${resourcesUrl}${path}`; + const { origin } = new URL(url); + strictAssert(origin === expectedOrigin, `Unexpected origin: ${origin}`); + + const { data: imageData, contentType } = await _outerAjax(url, { + certificateAuthority, + proxyUrl, + responseType: 'byteswithdetails', + timeout: 0, + type: 'GET', + version, + }); + + return { + imageData, + contentType, + }; + } + async function getStorageManifest( options: StorageServiceCallOptionsType = {} ): Promise { @@ -3917,7 +3949,12 @@ export function initialize({ if (options?.downloadOffset) { targetHeaders.range = `bytes=${options.downloadOffset}-`; } - streamWithDetails = await _outerAjax(`${cdnUrl}${cdnPath}`, { + const { origin: expectedOrigin } = new URL(cdnUrl); + const fullCdnUrl = `${cdnUrl}${cdnPath}`; + const { origin } = new URL(fullCdnUrl); + strictAssert(origin === expectedOrigin, `Unexpected origin: ${origin}`); + + streamWithDetails = await _outerAjax(fullCdnUrl, { headers: targetHeaders, certificateAuthority, disableRetries: options?.disableRetries,