Import/export chat styles

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-07-15 16:28:14 -05:00 committed by GitHub
parent 97c52662e0
commit 6e9b690068
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 663 additions and 47 deletions

View file

@ -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;
}
}

View file

@ -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}%)`;

5
ts/model-types.d.ts vendored
View file

@ -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;

View file

@ -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'],
]);

View file

@ -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<string, number>();
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<Backups.ChatStyle.ICustomChatColor> {
const customColors = window.storage.get('customColors');
if (!customColors) {
return [];
}
const result = new Array<Backups.ChatStyle.ICustomChatColor>();
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;
}

View file

@ -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<number, CustomColorDataType>();
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<Backups.ChatStyle.ICustomChatColor>
| undefined
| null
): Promise<void> {
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 };
}

View file

@ -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;
}>;

View file

@ -368,6 +368,7 @@ async function bulkAddSignedPreKeys(
// Items
const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
defaultWallpaperPhotoPointer: ['value'],
identityKeyMap: {
key: 'value',
valueSpec: {
@ -379,6 +380,7 @@ const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
senderCertificate: ['value.serialized'],
senderCertificateNoE164: ['value.serialized'],
subscriberId: ['value'],
backupsSubscriberId: ['value'],
usernameLink: ['value.entropy', 'value.serverId'],
};
async function createOrUpdateItem<K extends ItemKeyType>(

View file

@ -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,

View file

@ -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();

View file

@ -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,
});
});
});

View file

@ -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 = {

View file

@ -64,6 +64,11 @@ export type StorageAccessType = {
attachmentMigration_lastProcessedIndex: number;
blocked: ReadonlyArray<string>;
defaultConversationColor: DefaultConversationColorType;
// Not used UI, stored as is when imported from backup.
defaultWallpaperPhotoPointer: Uint8Array;
defaultWallpaperPreset: number;
customColors: CustomColorsItemType;
device_name: string;
existingOnboardingStoryMessageIds: ReadonlyArray<string> | undefined;

26
ts/util/hslToRGB.ts Normal file
View file

@ -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)),
};
}

45
ts/util/rgbToHSL.ts Normal file
View file

@ -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 };
}