diff --git a/ts/services/releaseNotesFetcher.ts b/ts/services/releaseNotesFetcher.ts index cdff63af5a..c233ef87cb 100644 --- a/ts/services/releaseNotesFetcher.ts +++ b/ts/services/releaseNotesFetcher.ts @@ -18,6 +18,7 @@ import { incrementMessageCounter } from '../util/incrementMessageCounter'; import { SeenStatus } from '../MessageSeenStatus'; import { saveNewMessageBatcher } from '../util/messageBatcher'; import { generateMessageId } from '../util/generateMessageId'; +import type { RawBodyRange } from '../types/BodyRange'; import { BodyRange } from '../types/BodyRange'; import * as RemoteConfig from '../RemoteConfig'; import { isBeta, isProduction } from '../util/version'; @@ -50,6 +51,13 @@ export type ReleaseNoteType = ReleaseNoteResponseType & let initComplete = false; +const STYLE_MAPPING: Record = { + bold: BodyRange.Style.BOLD, + italic: BodyRange.Style.ITALIC, + strikethrough: BodyRange.Style.STRIKETHROUGH, + spoiler: BodyRange.Style.SPOILER, + mono: BodyRange.Style.MONOSPACE, +}; export class ReleaseNotesFetcher { #timeout: NodeJS.Timeout | undefined; #isRunning = false; @@ -245,10 +253,37 @@ export class ReleaseNotesFetcher { return; } - const { title, body } = note; - const messageBody = `${title}\n\n${body}`; - const bodyRanges = [ + const { title, body, bodyRanges: noteBodyRanges } = note; + const titleBodySeparator = '\n\n'; + const filteredNoteBodyRanges: Array = ( + noteBodyRanges ?? [] + ) + .map(range => { + if ( + range.length == null || + range.start == null || + range.style == null || + !STYLE_MAPPING[range.style] || + range.start + range.length - 1 >= body.length + ) { + return null; + } + + const relativeStart = + range.start + title.length + titleBodySeparator.length; + + return { + start: relativeStart, + length: range.length, + style: STYLE_MAPPING[range.style], + }; + }) + .filter(isNotNil); + + const messageBody = `${title}${titleBodySeparator}${body}`; + const bodyRanges: Array = [ { start: 0, length: title.length, style: BodyRange.Style.BOLD }, + ...filteredNoteBodyRanges, ]; const timestamp = Date.now() + index; diff --git a/ts/test-mock/release-notes/release_notes_test.ts b/ts/test-mock/release-notes/release_notes_test.ts index 7b0250b341..eca28aaaf0 100644 --- a/ts/test-mock/release-notes/release_notes_test.ts +++ b/ts/test-mock/release-notes/release_notes_test.ts @@ -4,6 +4,7 @@ import createDebug from 'debug'; import { expect } from 'playwright/test'; +import { assert } from 'chai'; import type { App } from '../playwright'; import { Bootstrap } from '../bootstrap'; import { MINUTE } from '../../util/durations'; @@ -41,7 +42,7 @@ describe('release notes', function (this: Mocha.Suite) { await bootstrap.teardown(); }); - it('shows release notes with an image', async () => { + it('shows release notes with an image and body ranges', async () => { const firstWindow = await app.getWindow(); await firstWindow.evaluate('window.SignalCI.resetReleaseNotesFetcher()'); @@ -68,5 +69,68 @@ describe('release notes', function (this: Mocha.Suite) { await expect( timelineMessage.locator('img.module-image__image') ).toBeVisible(); + const boldCallBodyRange = timelineMessage + .locator('span > strong') + .getByText('Call', { exact: true }); + + assert.isTrue( + await boldCallBodyRange.isVisible(), + 'expected message to have bold text' + ); + + const italicBodyRange = timelineMessage + .locator('span > em') + .getByText('links', { exact: true }); + + assert.isTrue( + await italicBodyRange.isVisible(), + 'expected message to have italicized text' + ); + + const strikethroughBodyRange = timelineMessage + .locator('span > s') + .getByText('are', { exact: true }); + + assert.isTrue( + await strikethroughBodyRange.isVisible(), + 'expected message to have strikethrough text' + ); + + const spoilerBodyRange = timelineMessage + .locator('.MessageTextRenderer__formatting--spoiler') + .getByText('the', { exact: true }); + + assert.isTrue( + (await spoilerBodyRange.count()) > 0, + 'expected message to have spoiler text' + ); + + const monospaceBodyRange = timelineMessage + .locator('span.MessageTextRenderer__formatting--monospace') + .getByText('missing', { exact: true }); + + assert.isTrue( + await monospaceBodyRange.isVisible(), + 'expected message to have monospace text' + ); + + const secondTimelineMessage = await getTimelineMessageWithText( + secondWindow, + 'Bold text has invalid ranges, italic has valid' + ); + + await expect(secondTimelineMessage).toBeVisible(); + + const boldCallBodyRanges = secondTimelineMessage.locator('span > strong'); + + // 1 for the title + assert.isTrue((await boldCallBodyRanges.count()) === 1); + + const italicBodyRanges = secondTimelineMessage.locator('span > em'); + + assert.isTrue( + (await italicBodyRanges.count()) === 1, + 'expected message to have italic text' + ); }); }); 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 index ea79791d37..2f3d5d96fc 100644 --- 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 @@ -10,6 +10,12 @@ "desktopMinVersion": "7.30.0", "androidMinVersion": "1482", "uuid": "1813845b-cc98-4210-a07c-164f9b328ca8" + }, + { + "ctaId": "sample_note", + "desktopMinVersion": "7.30.0", + "androidMinVersion": "1482", + "uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } ], "megaphones": [ 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 index 4e05b0b187..443b796098 100644 --- 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 @@ -1,4 +1,11 @@ { + "bodyRanges": [ + { "style": "bold", "start": 0, "length": 4 }, + { "style": "italic", "start": 5, "length": 5 }, + { "style": "strikethrough", "start": 11, "length": 3 }, + { "style": "spoiler", "start": 15, "length": 3 }, + { "style": "mono", "start": 19, "length": 7 } + ], "mediaHeight": "864", "mediaWidth": "1536", "media": "/static/release-notes/call_links.png", diff --git a/ts/test-mock/updates-data/static/release-notes/f47ac10b-58cc-4372-a567-0e02b2c3d479/en.json b/ts/test-mock/updates-data/static/release-notes/f47ac10b-58cc-4372-a567-0e02b2c3d479/en.json new file mode 100644 index 0000000000..69552cb891 --- /dev/null +++ b/ts/test-mock/updates-data/static/release-notes/f47ac10b-58cc-4372-a567-0e02b2c3d479/en.json @@ -0,0 +1,13 @@ +{ + "bodyRanges": [ + { "style": "bold", "start": 109, "length": 1 }, + { "style": "bold", "start": 0, "length": 110 }, + { "style": "bold", "start": 10, "length": 0 }, + { "style": "bold", "start": 10, "length": 100 }, + { "style": "italic", "start": 100, "length": 9 } + ], + "uuid": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + "title": "Formatting Test", + "body": "Sample body text that tests formatting boundaries. Bold text has invalid ranges, italic has valid. length 109", + "callToActionText": "Create Call Link" +} diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 411d0a4b05..e94e8fbd88 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1231,9 +1231,9 @@ export const releaseNoteSchema = z.object({ bodyRanges: z .array( z.object({ - style: z.string(), - start: z.number(), - length: z.number(), + style: z.string().optional(), + start: z.number().optional(), + length: z.number().optional(), }) ) .optional(),