diff --git a/protos/Backups.proto b/protos/Backups.proto index a1988c59b622..5fdbb0510559 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -78,6 +78,7 @@ message AccountData { bool hasCompletedUsernameOnboarding = 16; PhoneNumberSharingMode phoneNumberSharingMode = 17; ChatStyle defaultChatStyle = 18; + repeated ChatStyle.CustomChatColor customChatColors = 19; } message SubscriberData { @@ -1055,6 +1056,15 @@ message ChatStyle { repeated float positions = 3; // percent from 0 to 1 } + message CustomChatColor { + uint32 id = 1; + + oneof color { + uint32 solid = 2; + Gradient gradient = 3; + } + } + message AutomaticBubbleColor { } @@ -1115,10 +1125,12 @@ message ChatStyle { } oneof bubbleColor { - BubbleColorPreset bubbleColorPreset = 3; - Gradient bubbleGradient = 4; - uint32 bubbleSolidColor = 5; - // Bubble setting is automatically determined based on the wallpaper setting. - AutomaticBubbleColor autoBubbleColor = 6; + // Bubble setting is automatically determined based on the wallpaper setting, + // or `SOLID_ULTRAMARINE` for `noWallpaper` + AutomaticBubbleColor autoBubbleColor = 3; + BubbleColorPreset bubbleColorPreset = 4; + + // See AccountSettings.customChatColors + uint32 customColorId = 5; } } diff --git a/ts/mediaEditor/util/color.ts b/ts/mediaEditor/util/color.ts index 08ca1c0c186c..f0264914b247 100644 --- a/ts/mediaEditor/util/color.ts +++ b/ts/mediaEditor/util/color.ts @@ -1,6 +1,8 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { hslToRGB } from '../../util/hslToRGB'; + function getRatio(min: number, max: number, value: number) { return (value - min) / (max - min); } @@ -22,30 +24,6 @@ function getHSLValues(percentage: number): [number, number, number] { return [338 * ratio, 1, 0.5]; } -// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative -function hslToRGB( - h: number, - s: number, - l: number -): { - r: number; - g: number; - b: number; -} { - const a = s * Math.min(l, 1 - l); - - function f(n: number): number { - const k = (n + h / 30) % 12; - return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); - } - - return { - r: Math.round(255 * f(0)), - g: Math.round(255 * f(8)), - b: Math.round(255 * f(4)), - }; -} - export function getHSL(percentage: number): string { const [h, s, l] = getHSLValues(percentage); return `hsl(${h}, ${s * 100}%, ${l * 100}%)`; diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 81917ecb78b9..bb727ad3dc8e 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -329,6 +329,11 @@ export type ConversationAttributesType = { conversationColor?: ConversationColorType; customColor?: CustomColorType; customColorId?: string; + + // Set at backup import time, exported as is. + wallpaperPhotoPointerBase64?: string; + wallpaperPreset?: number; + discoveredUnregisteredAt?: number; firstUnregisteredAt?: number; draftChanged?: boolean; diff --git a/ts/services/backups/constants.ts b/ts/services/backups/constants.ts index 198fe893652d..ea64bf885447 100644 --- a/ts/services/backups/constants.ts +++ b/ts/services/backups/constants.ts @@ -1,4 +1,37 @@ // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { Backups } from '../../protobuf'; +import type { ConversationColorType } from '../../types/Colors'; + export const BACKUP_VERSION = 1; + +const { WallpaperPreset } = Backups.ChatStyle; + +// See https://github.com/signalapp/Signal-Android-Private/blob/4a41e9f9a1ed0aba7cae0e0dc4dbcac50fddc469/app/src/main/java/org/thoughtcrime/securesms/conversation/colors/ChatColorsMapper.kt#L32 +export const WALLPAPER_TO_BUBBLE_COLOR = new Map< + Backups.ChatStyle.WallpaperPreset, + ConversationColorType +>([ + [WallpaperPreset.SOLID_BLUSH, 'crimson'], + [WallpaperPreset.SOLID_COPPER, 'vermilion'], + [WallpaperPreset.SOLID_DUST, 'burlap'], + [WallpaperPreset.SOLID_CELADON, 'forest'], + [WallpaperPreset.SOLID_RAINFOREST, 'wintergreen'], + [WallpaperPreset.SOLID_PACIFIC, 'teal'], + [WallpaperPreset.SOLID_FROST, 'blue'], + [WallpaperPreset.SOLID_NAVY, 'indigo'], + [WallpaperPreset.SOLID_LILAC, 'violet'], + [WallpaperPreset.SOLID_PINK, 'plum'], + [WallpaperPreset.SOLID_EGGPLANT, 'taupe'], + [WallpaperPreset.SOLID_SILVER, 'steel'], + [WallpaperPreset.GRADIENT_SUNSET, 'ember'], + [WallpaperPreset.GRADIENT_NOIR, 'midnight'], + [WallpaperPreset.GRADIENT_HEATMAP, 'infrared'], + [WallpaperPreset.GRADIENT_AQUA, 'lagoon'], + [WallpaperPreset.GRADIENT_IRIDESCENT, 'fluorescent'], + [WallpaperPreset.GRADIENT_MONSTERA, 'basil'], + [WallpaperPreset.GRADIENT_BLISS, 'sublime'], + [WallpaperPreset.GRADIENT_SKY, 'sea'], + [WallpaperPreset.GRADIENT_PEACH, 'tangerine'], +]); diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 4bdba46f2c12..2ab44b9258ab 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -84,7 +84,8 @@ import { getCallsHistoryForRedux } from '../callHistoryLoader'; import { makeLookup } from '../../util/makeLookup'; import type { CallHistoryDetails } from '../../types/CallDisposition'; import { isAciString } from '../../util/isAciString'; -import type { AboutMe } from './types'; +import { hslToRGB } from '../../util/hslToRGB'; +import type { AboutMe, LocalChatStyle } from './types'; import { messageHasPaymentEvent } from '../../messages/helpers'; import { numberToAddressType, @@ -173,6 +174,10 @@ export class BackupExportStream extends Readable { private nextRecipientId = 0; private flushResolve: (() => void) | undefined; + // Map from custom color uuid to an index in accountSettings.customColors + // array. + private customColorIdByUuid = new Map(); + public run(backupLevel: BackupLevel): void { drop( (async () => { @@ -338,6 +343,15 @@ export class BackupExportStream extends Readable { markedUnread: attributes.markedUnread === true, dontNotifyForMentionsIfMuted: attributes.dontNotifyForMentionsIfMuted === true, + + style: this.toChatStyle({ + wallpaperPhotoPointer: attributes.wallpaperPhotoPointerBase64 + ? Bytes.fromBase64(attributes.wallpaperPhotoPointerBase64) + : undefined, + wallpaperPreset: attributes.wallpaperPreset, + color: attributes.conversationColor, + customColorId: attributes.customColorId, + }), }, }); @@ -545,6 +559,10 @@ export class BackupExportStream extends Readable { 'hasSeenGroupStoryEducationSheet' ), phoneNumberSharingMode, + // Note that this should be called before `toDefaultChatStyle` because + // it builds `customColorIdByUuid` + customChatColors: this.toCustomChatColors(), + defaultChatStyle: this.toDefaultChatStyle(), }, }; } @@ -2237,6 +2255,160 @@ export class BackupExportStream extends Readable { .reverse() ); } + + private toCustomChatColors(): Array { + const customColors = window.storage.get('customColors'); + if (!customColors) { + return []; + } + + const result = new Array(); + for (const [uuid, color] of Object.entries(customColors.colors)) { + const id = result.length; + this.customColorIdByUuid.set(uuid, id); + + const start = hslToRGBInt(color.start.hue, color.start.saturation); + + if (color.end == null) { + result.push({ + id, + solid: start, + }); + } else { + const end = hslToRGBInt(color.end.hue, color.end.saturation); + + result.push({ + id, + gradient: { + colors: [start, end], + positions: [0, 1], + angle: color.deg, + }, + }); + } + } + + return result; + } + + private toDefaultChatStyle(): Backups.IChatStyle { + const defaultColor = window.storage.get('defaultConversationColor'); + + return this.toChatStyle({ + wallpaperPhotoPointer: window.storage.get('defaultWallpaperPhotoPointer'), + wallpaperPreset: window.storage.get('defaultWallpaperPreset'), + color: defaultColor?.color, + customColorId: defaultColor?.customColorData?.id, + }); + } + + private toChatStyle({ + wallpaperPhotoPointer, + wallpaperPreset, + color, + customColorId, + }: LocalChatStyle): Backups.IChatStyle { + const result: Backups.IChatStyle = {}; + + if (Bytes.isNotEmpty(wallpaperPhotoPointer)) { + result.wallpaperPhoto = Backups.FilePointer.decode(wallpaperPhotoPointer); + } else if (wallpaperPreset) { + result.wallpaperPreset = wallpaperPreset; + } + + if (color == null) { + result.autoBubbleColor = {}; + return result; + } + + if (color === 'custom') { + strictAssert( + customColorId != null, + 'No custom color id for custom color' + ); + + const index = this.customColorIdByUuid.get(customColorId); + strictAssert(index != null, 'Missing custom color'); + + result.customColorId = index; + return result; + } + + const { BubbleColorPreset } = Backups.ChatStyle; + + switch (color) { + case 'ultramarine': + result.bubbleColorPreset = BubbleColorPreset.SOLID_ULTRAMARINE; + break; + case 'crimson': + result.bubbleColorPreset = BubbleColorPreset.SOLID_CRIMSON; + break; + case 'vermilion': + result.bubbleColorPreset = BubbleColorPreset.SOLID_VERMILION; + break; + case 'burlap': + result.bubbleColorPreset = BubbleColorPreset.SOLID_BURLAP; + break; + case 'forest': + result.bubbleColorPreset = BubbleColorPreset.SOLID_FOREST; + break; + case 'wintergreen': + result.bubbleColorPreset = BubbleColorPreset.SOLID_WINTERGREEN; + break; + case 'teal': + result.bubbleColorPreset = BubbleColorPreset.SOLID_TEAL; + break; + case 'blue': + result.bubbleColorPreset = BubbleColorPreset.SOLID_BLUE; + break; + case 'indigo': + result.bubbleColorPreset = BubbleColorPreset.SOLID_INDIGO; + break; + case 'violet': + result.bubbleColorPreset = BubbleColorPreset.SOLID_VIOLET; + break; + case 'plum': + result.bubbleColorPreset = BubbleColorPreset.SOLID_PLUM; + break; + case 'taupe': + result.bubbleColorPreset = BubbleColorPreset.SOLID_TAUPE; + break; + case 'steel': + result.bubbleColorPreset = BubbleColorPreset.SOLID_STEEL; + break; + case 'ember': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_EMBER; + break; + case 'midnight': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_MIDNIGHT; + break; + case 'infrared': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_INFRARED; + break; + case 'lagoon': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_LAGOON; + break; + case 'fluorescent': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_FLUORESCENT; + break; + case 'basil': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_BASIL; + break; + case 'sublime': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_SUBLIME; + break; + case 'sea': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_SEA; + break; + case 'tangerine': + result.bubbleColorPreset = BubbleColorPreset.GRADIENT_TANGERINE; + break; + default: + throw missingCaseError(color); + } + + return result; + } } function checkServiceIdEquivalence( @@ -2248,3 +2420,9 @@ function checkServiceIdEquivalence( return leftConvo && rightConvo && leftConvo === rightConvo; } + +function hslToRGBInt(hue: number, saturation: number): number { + const { r, g, b } = hslToRGB(hue, saturation, 1); + // eslint-disable-next-line no-bitwise + return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0; +} diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 9e456a183926..ef617032fed1 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -32,6 +32,12 @@ import { STICKERPACK_KEY_BYTE_LEN, downloadStickerPack, } from '../../types/Stickers'; +import type { + ConversationColorType, + CustomColorsItemType, + CustomColorType, + CustomColorDataType, +} from '../../types/Colors'; import type { ConversationAttributesType, CustomError, @@ -61,13 +67,14 @@ import { SendStatus } from '../../messages/MessageSendState'; import type { SendStateByConversationId } from '../../messages/MessageSendState'; import { SeenStatus } from '../../MessageSeenStatus'; import * as Bytes from '../../Bytes'; -import { BACKUP_VERSION } from './constants'; -import type { AboutMe } from './types'; +import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants'; +import type { AboutMe, LocalChatStyle } from './types'; import type { GroupV2ChangeDetailType } from '../../groups'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { drop } from '../../util/drop'; import { isNotNil } from '../../util/isNotNil'; import { isGroup, isGroupV2 } from '../../util/whatTypeOfConversation'; +import { rgbToHSL } from '../../util/rgbToHSL'; import { convertBackupMessageAttachmentToAttachment, convertFilePointerToAttachment, @@ -249,6 +256,7 @@ export class BackupImportStream extends Writable { }); private ourConversation?: ConversationAttributesType; private pinnedConversations = new Array<[number, string]>(); + private customColorById = new Map(); constructor() { super({ objectMode: true }); @@ -613,6 +621,34 @@ export class BackupImportStream extends Writable { ); } + // It is important to import custom chat colors before default styles + // because we build the uuid => integer id map for the colors. + await this.fromCustomChatColors(accountSettings?.customChatColors); + + const defaultChatStyle = this.fromChatStyle( + accountSettings?.defaultChatStyle + ); + + if (defaultChatStyle.color != null) { + await window.storage.put('defaultConversationColor', { + color: defaultChatStyle.color, + customColorData: defaultChatStyle.customColorData, + }); + } + + if (defaultChatStyle.wallpaperPhotoPointer != null) { + await window.storage.put( + 'defaultWallpaperPhotoPointer', + defaultChatStyle.wallpaperPhotoPointer + ); + } + if (defaultChatStyle.wallpaperPreset != null) { + await window.storage.put( + 'defaultWallpaperPreset', + defaultChatStyle.wallpaperPreset + ); + } + this.updateConversation(me); } @@ -959,6 +995,24 @@ export class BackupImportStream extends Writable { conversation.dontNotifyForMentionsIfMuted = chat.dontNotifyForMentionsIfMuted === true; + const chatStyle = this.fromChatStyle(chat.style); + + if (chatStyle.wallpaperPhotoPointer != null) { + conversation.wallpaperPhotoPointerBase64 = Bytes.toBase64( + chatStyle.wallpaperPhotoPointer + ); + } + if (chatStyle.wallpaperPreset != null) { + conversation.wallpaperPreset = chatStyle.wallpaperPreset; + } + if (chatStyle.color != null) { + conversation.conversationColor = chatStyle.color; + } + if (chatStyle.customColorData != null) { + conversation.customColor = chatStyle.customColorData.value; + conversation.customColorId = chatStyle.customColorData.id; + } + this.updateConversation(conversation); if (chat.pinnedOrder != null) { @@ -2477,4 +2531,188 @@ export class BackupImportStream extends Writable { }) ); } + + private async fromCustomChatColors( + customChatColors: + | ReadonlyArray + | undefined + | null + ): Promise { + if (!customChatColors?.length) { + return; + } + + const customColors: CustomColorsItemType = { + version: 1, + colors: {}, + }; + + for (const color of customChatColors) { + const uuid = generateUuid(); + let value: CustomColorType; + + if (color.solid) { + value = { + start: rgbIntToHSL(color.solid), + }; + } else { + strictAssert(color.gradient != null, 'Either solid or gradient'); + strictAssert(color.gradient.colors != null, 'Missing gradient colors'); + + const start = color.gradient.colors.at(0); + const end = color.gradient.colors.at(-1); + const deg = color.gradient.angle; + + strictAssert(start != null, 'Missing start color'); + strictAssert(end != null, 'Missing end color'); + strictAssert(deg != null, 'Missing angle'); + + value = { + start: rgbIntToHSL(start), + end: rgbIntToHSL(end), + deg, + }; + } + + customColors.colors[uuid] = value; + this.customColorById.set(color.id || 0, { + id: uuid, + value, + }); + } + + await window.storage.put('customColors', customColors); + } + + private fromChatStyle(chatStyle: Backups.IChatStyle | null | undefined): Omit< + LocalChatStyle, + 'customColorId' + > & { + customColorData: CustomColorDataType | undefined; + } { + if (!chatStyle) { + return { + wallpaperPhotoPointer: undefined, + wallpaperPreset: undefined, + color: 'ultramarine', + customColorData: undefined, + }; + } + + let wallpaperPhotoPointer: Uint8Array | undefined; + let wallpaperPreset: number | undefined; + + if (chatStyle.wallpaperPhoto) { + wallpaperPhotoPointer = Backups.FilePointer.encode( + chatStyle.wallpaperPhoto + ).finish(); + } else if (chatStyle.wallpaperPreset != null) { + wallpaperPreset = chatStyle.wallpaperPreset; + } + + let color: ConversationColorType | undefined; + let customColorData: CustomColorDataType | undefined; + if (chatStyle.autoBubbleColor) { + if (wallpaperPreset != null) { + color = WALLPAPER_TO_BUBBLE_COLOR.get(wallpaperPreset) || 'ultramarine'; + } else { + color = 'ultramarine'; + } + } else if (chatStyle.bubbleColorPreset != null) { + const { BubbleColorPreset } = Backups.ChatStyle; + + switch (chatStyle.bubbleColorPreset) { + case BubbleColorPreset.SOLID_CRIMSON: + color = 'crimson'; + break; + case BubbleColorPreset.SOLID_VERMILION: + color = 'vermilion'; + break; + case BubbleColorPreset.SOLID_BURLAP: + color = 'burlap'; + break; + case BubbleColorPreset.SOLID_FOREST: + color = 'forest'; + break; + case BubbleColorPreset.SOLID_WINTERGREEN: + color = 'wintergreen'; + break; + case BubbleColorPreset.SOLID_TEAL: + color = 'teal'; + break; + case BubbleColorPreset.SOLID_BLUE: + color = 'blue'; + break; + case BubbleColorPreset.SOLID_INDIGO: + color = 'indigo'; + break; + case BubbleColorPreset.SOLID_VIOLET: + color = 'violet'; + break; + case BubbleColorPreset.SOLID_PLUM: + color = 'plum'; + break; + case BubbleColorPreset.SOLID_TAUPE: + color = 'taupe'; + break; + case BubbleColorPreset.SOLID_STEEL: + color = 'steel'; + break; + case BubbleColorPreset.GRADIENT_EMBER: + color = 'ember'; + break; + case BubbleColorPreset.GRADIENT_MIDNIGHT: + color = 'midnight'; + break; + case BubbleColorPreset.GRADIENT_INFRARED: + color = 'infrared'; + break; + case BubbleColorPreset.GRADIENT_LAGOON: + color = 'lagoon'; + break; + case BubbleColorPreset.GRADIENT_FLUORESCENT: + color = 'fluorescent'; + break; + case BubbleColorPreset.GRADIENT_BASIL: + color = 'basil'; + break; + case BubbleColorPreset.GRADIENT_SUBLIME: + color = 'sublime'; + break; + case BubbleColorPreset.GRADIENT_SEA: + color = 'sea'; + break; + case BubbleColorPreset.GRADIENT_TANGERINE: + color = 'tangerine'; + break; + case BubbleColorPreset.SOLID_ULTRAMARINE: + default: + color = 'ultramarine'; + break; + } + } else { + strictAssert(chatStyle.customColorId != null, 'Missing custom color id'); + + const entry = this.customColorById.get(chatStyle.customColorId); + strictAssert(entry != null, 'Missing custom color'); + + color = 'custom'; + customColorData = entry; + } + + return { wallpaperPhotoPointer, wallpaperPreset, color, customColorData }; + } +} + +function rgbIntToHSL(intValue: number): { hue: number; saturation: number } { + const { h: hue, s: saturation } = rgbToHSL( + // eslint-disable-next-line no-bitwise + (intValue >>> 16) & 0xff, + // eslint-disable-next-line no-bitwise + (intValue >>> 8) & 0xff, + // eslint-disable-next-line no-bitwise + intValue & 0xff + ); + + return { hue, saturation }; } diff --git a/ts/services/backups/types.d.ts b/ts/services/backups/types.d.ts index 31db9f647d57..afa9b5309ac7 100644 --- a/ts/services/backups/types.d.ts +++ b/ts/services/backups/types.d.ts @@ -2,8 +2,16 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { AciString, PniString } from '../../types/ServiceId'; +import type { ConversationColorType } from '../../types/Colors'; export type AboutMe = { aci: AciString; pni?: PniString; }; + +export type LocalChatStyle = Readonly<{ + wallpaperPhotoPointer: Uint8Array | undefined; + wallpaperPreset: number | undefined; + color: ConversationColorType | undefined; + customColorId: string | undefined; +}>; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index d37be3e9e80b..86fdba87fa32 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -368,6 +368,7 @@ async function bulkAddSignedPreKeys( // Items const ITEM_SPECS: Partial> = { + defaultWallpaperPhotoPointer: ['value'], identityKeyMap: { key: 'value', valueSpec: { @@ -379,6 +380,7 @@ const ITEM_SPECS: Partial> = { senderCertificate: ['value.serialized'], senderCertificateNoE164: ['value.serialized'], subscriberId: ['value'], + backupsSubscriberId: ['value'], usernameLink: ['value.entropy', 'value.serverId'], }; async function createOrUpdateItem( diff --git a/ts/test-electron/backup/bubble_test.ts b/ts/test-electron/backup/bubble_test.ts index 946d80a4fe5f..9a53bcbc39bd 100644 --- a/ts/test-electron/backup/bubble_test.ts +++ b/ts/test-electron/backup/bubble_test.ts @@ -162,7 +162,7 @@ describe('backup/bubble messages', () => { ]); }); - it.skip('roundtrips unopened gift badge', async () => { + it('roundtrips unopened gift badge', async () => { await symmetricRoundtripHarness([ { conversationId: contactA.id, @@ -187,7 +187,7 @@ describe('backup/bubble messages', () => { ]); }); - it.skip('roundtrips opened gift badge', async () => { + it('roundtrips opened gift badge', async () => { await symmetricRoundtripHarness([ { conversationId: contactA.id, @@ -212,7 +212,7 @@ describe('backup/bubble messages', () => { ]); }); - it.skip('roundtrips gift badge quote', async () => { + it('roundtrips gift badge quote', async () => { await symmetricRoundtripHarness([ { conversationId: contactA.id, diff --git a/ts/test-mock/backups/backups_test.ts b/ts/test-mock/backups/backups_test.ts index f04b0770a5e2..02922fe3a015 100644 --- a/ts/test-mock/backups/backups_test.ts +++ b/ts/test-mock/backups/backups_test.ts @@ -49,6 +49,7 @@ describe('backups', function (this: Mocha.Suite) { state = state.addContact(pinned, { identityKey: pinned.publicKey.serialize(), profileKey: pinned.profileKey.serialize(), + whitelisted: true, }); state = state.pin(pinned); @@ -99,19 +100,53 @@ describe('backups', function (this: Mocha.Suite) { const { contacts, phone, desktop, server } = bootstrap; const [friend, pinned] = contacts; - debug('wait for storage service sync to finish'); { + debug('wait for storage service sync to finish'); const window = await app.getWindow(); const leftPane = window.locator('#LeftPane'); - await leftPane - .locator( - `[data-testid="${pinned.device.aci}"] >> "${pinned.profileName}"` - ) - .waitFor(); + const contact = leftPane.locator( + `[data-testid="${pinned.device.aci}"] >> "${pinned.profileName}"` + ); + await contact.click(); + + debug('setting bubble color'); + const conversationStack = window.locator('.Inbox__conversation-stack'); + await conversationStack + .locator('button.module-ConversationHeader__button--more') + .click(); + + await window + .locator('.react-contextmenu-item >> "Chat settings"') + .click(); + + await conversationStack + .locator('.ConversationDetails__chat-color') + .click(); + await conversationStack + .locator('.ChatColorPicker__bubble--infrared') + .click(); + + const backButton = conversationStack.locator( + '.ConversationPanel__header__back-button' + ); + // Go back from colors + await backButton.first().click(); + // Go back from settings + await backButton.last().click(); } for (let i = 0; i < 5; i += 1) { + // eslint-disable-next-line no-await-in-loop + await server.send( + desktop, + // eslint-disable-next-line no-await-in-loop + await phone.encryptSyncSent(desktop, `to pinned ${i}`, { + timestamp: bootstrap.getTimestamp(), + destinationServiceId: pinned.device.aci, + }) + ); + const theirTimestamp = bootstrap.getTimestamp(); // eslint-disable-next-line no-await-in-loop @@ -159,14 +194,21 @@ describe('backups', function (this: Mocha.Suite) { app, async (window, snapshot) => { const leftPane = window.locator('#LeftPane'); + const pinnedElem = leftPane.locator( + `[data-testid="${pinned.toContact().aci}"] >> "to pinned 4"` + ); + + debug('Waiting for messages to pinned contact to come through'); + await pinnedElem.click(); + const contactElem = leftPane.locator( `[data-testid="${friend.toContact().aci}"] >> "respond 4"` ); - debug('Waiting for messages to come through'); + debug('Waiting for messages to regular contact to come through'); await contactElem.waitFor(); - await snapshot('main screen'); + await snapshot('styled bubbles'); debug('Going into the conversation'); await contactElem.click(); diff --git a/ts/test-node/util/rgbToHSL_test.ts b/ts/test-node/util/rgbToHSL_test.ts new file mode 100644 index 000000000000..b22391759ec3 --- /dev/null +++ b/ts/test-node/util/rgbToHSL_test.ts @@ -0,0 +1,42 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { rgbToHSL } from '../../util/rgbToHSL'; + +describe('rgbToHSL', () => { + it('converts pure rgb colors', () => { + assert.deepStrictEqual(rgbToHSL(255, 0, 0), { + h: 0, + s: 1, + l: 0.5, + }); + + assert.deepStrictEqual(rgbToHSL(0, 255, 0), { + h: 120, + s: 1, + l: 0.5, + }); + + assert.deepStrictEqual(rgbToHSL(0, 0, 255), { + h: 240, + s: 1, + l: 0.5, + }); + }); + + it('converts random sampled rgb colors', () => { + assert.deepStrictEqual(rgbToHSL(27, 132, 116), { + h: 170.85714285714283, + s: 0.6603773584905662, + l: 0.31176470588235294, + }); + + assert.deepStrictEqual(rgbToHSL(27, 175, 82), { + h: 142.2972972972973, + s: 0.7326732673267328, + l: 0.396078431372549, + }); + }); +}); diff --git a/ts/types/Colors.ts b/ts/types/Colors.ts index 936d0ffc2355..9017596159b3 100644 --- a/ts/types/Colors.ts +++ b/ts/types/Colors.ts @@ -170,12 +170,14 @@ export type ConversationColorType = | typeof ConversationColors[number] | 'custom'; +export type CustomColorDataType = { + id: string; + value: CustomColorType; +}; + export type DefaultConversationColorType = { color: ConversationColorType; - customColorData?: { - id: string; - value: CustomColorType; - }; + customColorData?: CustomColorDataType; }; export const DEFAULT_CONVERSATION_COLOR: DefaultConversationColorType = { diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 5553d9f749c8..08516631f6f6 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -64,6 +64,11 @@ export type StorageAccessType = { attachmentMigration_lastProcessedIndex: number; blocked: ReadonlyArray; defaultConversationColor: DefaultConversationColorType; + + // Not used UI, stored as is when imported from backup. + defaultWallpaperPhotoPointer: Uint8Array; + defaultWallpaperPreset: number; + customColors: CustomColorsItemType; device_name: string; existingOnboardingStoryMessageIds: ReadonlyArray | undefined; diff --git a/ts/util/hslToRGB.ts b/ts/util/hslToRGB.ts new file mode 100644 index 000000000000..a02362e0d6c7 --- /dev/null +++ b/ts/util/hslToRGB.ts @@ -0,0 +1,26 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative +export function hslToRGB( + h: number, + s: number, + l: number +): { + r: number; + g: number; + b: number; +} { + const a = s * Math.min(l, 1 - l); + + function f(n: number): number { + const k = (n + h / 30) % 12; + return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + } + + return { + r: Math.round(255 * f(0)), + g: Math.round(255 * f(8)), + b: Math.round(255 * f(4)), + }; +} diff --git a/ts/util/rgbToHSL.ts b/ts/util/rgbToHSL.ts new file mode 100644 index 000000000000..964d193a337f --- /dev/null +++ b/ts/util/rgbToHSL.ts @@ -0,0 +1,45 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB +export function rgbToHSL( + r: number, + g: number, + b: number +): { + h: number; + s: number; + l: number; +} { + // Normalize to [0, 1] + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + + const xMax = Math.max(rn, gn, bn); + const xMin = Math.min(rn, gn, bn); + const v = xMax; + const c = xMax - xMin; + const l = v - c / 2; + let h: number; + + if (c === 0) { + h = 0; + } else if (v === rn) { + h = 60 * (((gn - bn) / c) % 6); + } else if (v === gn) { + h = 60 * ((bn - rn) / c + 2); + } else { + // v === b + h = 60 * ((rn - gn) / c + 4); + } + + let s: number; + if (l === 0 || l === 1) { + s = 0; + } else { + s = (v - l) / Math.min(l, 1 - l); + } + + return { h, s, l }; +}