Import/export chat styles
This commit is contained in:
parent
6fb76e00c4
commit
8f2061e11d
15 changed files with 663 additions and 47 deletions
|
@ -78,6 +78,7 @@ message AccountData {
|
||||||
bool hasCompletedUsernameOnboarding = 16;
|
bool hasCompletedUsernameOnboarding = 16;
|
||||||
PhoneNumberSharingMode phoneNumberSharingMode = 17;
|
PhoneNumberSharingMode phoneNumberSharingMode = 17;
|
||||||
ChatStyle defaultChatStyle = 18;
|
ChatStyle defaultChatStyle = 18;
|
||||||
|
repeated ChatStyle.CustomChatColor customChatColors = 19;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SubscriberData {
|
message SubscriberData {
|
||||||
|
@ -1055,6 +1056,15 @@ message ChatStyle {
|
||||||
repeated float positions = 3; // percent from 0 to 1
|
repeated float positions = 3; // percent from 0 to 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CustomChatColor {
|
||||||
|
uint32 id = 1;
|
||||||
|
|
||||||
|
oneof color {
|
||||||
|
uint32 solid = 2;
|
||||||
|
Gradient gradient = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message AutomaticBubbleColor {
|
message AutomaticBubbleColor {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1115,10 +1125,12 @@ message ChatStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
oneof bubbleColor {
|
oneof bubbleColor {
|
||||||
BubbleColorPreset bubbleColorPreset = 3;
|
// Bubble setting is automatically determined based on the wallpaper setting,
|
||||||
Gradient bubbleGradient = 4;
|
// or `SOLID_ULTRAMARINE` for `noWallpaper`
|
||||||
uint32 bubbleSolidColor = 5;
|
AutomaticBubbleColor autoBubbleColor = 3;
|
||||||
// Bubble setting is automatically determined based on the wallpaper setting.
|
BubbleColorPreset bubbleColorPreset = 4;
|
||||||
AutomaticBubbleColor autoBubbleColor = 6;
|
|
||||||
|
// See AccountSettings.customChatColors
|
||||||
|
uint32 customColorId = 5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { hslToRGB } from '../../util/hslToRGB';
|
||||||
|
|
||||||
function getRatio(min: number, max: number, value: number) {
|
function getRatio(min: number, max: number, value: number) {
|
||||||
return (value - min) / (max - min);
|
return (value - min) / (max - min);
|
||||||
}
|
}
|
||||||
|
@ -22,30 +24,6 @@ function getHSLValues(percentage: number): [number, number, number] {
|
||||||
return [338 * ratio, 1, 0.5];
|
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 {
|
export function getHSL(percentage: number): string {
|
||||||
const [h, s, l] = getHSLValues(percentage);
|
const [h, s, l] = getHSLValues(percentage);
|
||||||
return `hsl(${h}, ${s * 100}%, ${l * 100}%)`;
|
return `hsl(${h}, ${s * 100}%, ${l * 100}%)`;
|
||||||
|
|
5
ts/model-types.d.ts
vendored
5
ts/model-types.d.ts
vendored
|
@ -329,6 +329,11 @@ export type ConversationAttributesType = {
|
||||||
conversationColor?: ConversationColorType;
|
conversationColor?: ConversationColorType;
|
||||||
customColor?: CustomColorType;
|
customColor?: CustomColorType;
|
||||||
customColorId?: string;
|
customColorId?: string;
|
||||||
|
|
||||||
|
// Set at backup import time, exported as is.
|
||||||
|
wallpaperPhotoPointerBase64?: string;
|
||||||
|
wallpaperPreset?: number;
|
||||||
|
|
||||||
discoveredUnregisteredAt?: number;
|
discoveredUnregisteredAt?: number;
|
||||||
firstUnregisteredAt?: number;
|
firstUnregisteredAt?: number;
|
||||||
draftChanged?: boolean;
|
draftChanged?: boolean;
|
||||||
|
|
|
@ -1,4 +1,37 @@
|
||||||
// Copyright 2023 Signal Messenger, LLC
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { Backups } from '../../protobuf';
|
||||||
|
import type { ConversationColorType } from '../../types/Colors';
|
||||||
|
|
||||||
export const BACKUP_VERSION = 1;
|
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'],
|
||||||
|
]);
|
||||||
|
|
|
@ -84,7 +84,8 @@ import { getCallsHistoryForRedux } from '../callHistoryLoader';
|
||||||
import { makeLookup } from '../../util/makeLookup';
|
import { makeLookup } from '../../util/makeLookup';
|
||||||
import type { CallHistoryDetails } from '../../types/CallDisposition';
|
import type { CallHistoryDetails } from '../../types/CallDisposition';
|
||||||
import { isAciString } from '../../util/isAciString';
|
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 { messageHasPaymentEvent } from '../../messages/helpers';
|
||||||
import {
|
import {
|
||||||
numberToAddressType,
|
numberToAddressType,
|
||||||
|
@ -173,6 +174,10 @@ export class BackupExportStream extends Readable {
|
||||||
private nextRecipientId = 0;
|
private nextRecipientId = 0;
|
||||||
private flushResolve: (() => void) | undefined;
|
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 {
|
public run(backupLevel: BackupLevel): void {
|
||||||
drop(
|
drop(
|
||||||
(async () => {
|
(async () => {
|
||||||
|
@ -338,6 +343,15 @@ export class BackupExportStream extends Readable {
|
||||||
markedUnread: attributes.markedUnread === true,
|
markedUnread: attributes.markedUnread === true,
|
||||||
dontNotifyForMentionsIfMuted:
|
dontNotifyForMentionsIfMuted:
|
||||||
attributes.dontNotifyForMentionsIfMuted === true,
|
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'
|
'hasSeenGroupStoryEducationSheet'
|
||||||
),
|
),
|
||||||
phoneNumberSharingMode,
|
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()
|
.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(
|
function checkServiceIdEquivalence(
|
||||||
|
@ -2248,3 +2420,9 @@ function checkServiceIdEquivalence(
|
||||||
|
|
||||||
return leftConvo && rightConvo && leftConvo === rightConvo;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -32,6 +32,12 @@ import {
|
||||||
STICKERPACK_KEY_BYTE_LEN,
|
STICKERPACK_KEY_BYTE_LEN,
|
||||||
downloadStickerPack,
|
downloadStickerPack,
|
||||||
} from '../../types/Stickers';
|
} from '../../types/Stickers';
|
||||||
|
import type {
|
||||||
|
ConversationColorType,
|
||||||
|
CustomColorsItemType,
|
||||||
|
CustomColorType,
|
||||||
|
CustomColorDataType,
|
||||||
|
} from '../../types/Colors';
|
||||||
import type {
|
import type {
|
||||||
ConversationAttributesType,
|
ConversationAttributesType,
|
||||||
CustomError,
|
CustomError,
|
||||||
|
@ -61,13 +67,14 @@ import { SendStatus } from '../../messages/MessageSendState';
|
||||||
import type { SendStateByConversationId } from '../../messages/MessageSendState';
|
import type { SendStateByConversationId } from '../../messages/MessageSendState';
|
||||||
import { SeenStatus } from '../../MessageSeenStatus';
|
import { SeenStatus } from '../../MessageSeenStatus';
|
||||||
import * as Bytes from '../../Bytes';
|
import * as Bytes from '../../Bytes';
|
||||||
import { BACKUP_VERSION } from './constants';
|
import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants';
|
||||||
import type { AboutMe } from './types';
|
import type { AboutMe, LocalChatStyle } from './types';
|
||||||
import type { GroupV2ChangeDetailType } from '../../groups';
|
import type { GroupV2ChangeDetailType } from '../../groups';
|
||||||
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
||||||
import { drop } from '../../util/drop';
|
import { drop } from '../../util/drop';
|
||||||
import { isNotNil } from '../../util/isNotNil';
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
import { isGroup, isGroupV2 } from '../../util/whatTypeOfConversation';
|
import { isGroup, isGroupV2 } from '../../util/whatTypeOfConversation';
|
||||||
|
import { rgbToHSL } from '../../util/rgbToHSL';
|
||||||
import {
|
import {
|
||||||
convertBackupMessageAttachmentToAttachment,
|
convertBackupMessageAttachmentToAttachment,
|
||||||
convertFilePointerToAttachment,
|
convertFilePointerToAttachment,
|
||||||
|
@ -249,6 +256,7 @@ export class BackupImportStream extends Writable {
|
||||||
});
|
});
|
||||||
private ourConversation?: ConversationAttributesType;
|
private ourConversation?: ConversationAttributesType;
|
||||||
private pinnedConversations = new Array<[number, string]>();
|
private pinnedConversations = new Array<[number, string]>();
|
||||||
|
private customColorById = new Map<number, CustomColorDataType>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ objectMode: true });
|
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);
|
this.updateConversation(me);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -959,6 +995,24 @@ export class BackupImportStream extends Writable {
|
||||||
conversation.dontNotifyForMentionsIfMuted =
|
conversation.dontNotifyForMentionsIfMuted =
|
||||||
chat.dontNotifyForMentionsIfMuted === true;
|
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);
|
this.updateConversation(conversation);
|
||||||
|
|
||||||
if (chat.pinnedOrder != null) {
|
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 };
|
||||||
}
|
}
|
||||||
|
|
8
ts/services/backups/types.d.ts
vendored
8
ts/services/backups/types.d.ts
vendored
|
@ -2,8 +2,16 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { AciString, PniString } from '../../types/ServiceId';
|
import type { AciString, PniString } from '../../types/ServiceId';
|
||||||
|
import type { ConversationColorType } from '../../types/Colors';
|
||||||
|
|
||||||
export type AboutMe = {
|
export type AboutMe = {
|
||||||
aci: AciString;
|
aci: AciString;
|
||||||
pni?: PniString;
|
pni?: PniString;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LocalChatStyle = Readonly<{
|
||||||
|
wallpaperPhotoPointer: Uint8Array | undefined;
|
||||||
|
wallpaperPreset: number | undefined;
|
||||||
|
color: ConversationColorType | undefined;
|
||||||
|
customColorId: string | undefined;
|
||||||
|
}>;
|
||||||
|
|
|
@ -368,6 +368,7 @@ async function bulkAddSignedPreKeys(
|
||||||
// Items
|
// Items
|
||||||
|
|
||||||
const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
|
const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
|
||||||
|
defaultWallpaperPhotoPointer: ['value'],
|
||||||
identityKeyMap: {
|
identityKeyMap: {
|
||||||
key: 'value',
|
key: 'value',
|
||||||
valueSpec: {
|
valueSpec: {
|
||||||
|
@ -379,6 +380,7 @@ const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
|
||||||
senderCertificate: ['value.serialized'],
|
senderCertificate: ['value.serialized'],
|
||||||
senderCertificateNoE164: ['value.serialized'],
|
senderCertificateNoE164: ['value.serialized'],
|
||||||
subscriberId: ['value'],
|
subscriberId: ['value'],
|
||||||
|
backupsSubscriberId: ['value'],
|
||||||
usernameLink: ['value.entropy', 'value.serverId'],
|
usernameLink: ['value.entropy', 'value.serverId'],
|
||||||
};
|
};
|
||||||
async function createOrUpdateItem<K extends ItemKeyType>(
|
async function createOrUpdateItem<K extends ItemKeyType>(
|
||||||
|
|
|
@ -162,7 +162,7 @@ describe('backup/bubble messages', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip('roundtrips unopened gift badge', async () => {
|
it('roundtrips unopened gift badge', async () => {
|
||||||
await symmetricRoundtripHarness([
|
await symmetricRoundtripHarness([
|
||||||
{
|
{
|
||||||
conversationId: contactA.id,
|
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([
|
await symmetricRoundtripHarness([
|
||||||
{
|
{
|
||||||
conversationId: contactA.id,
|
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([
|
await symmetricRoundtripHarness([
|
||||||
{
|
{
|
||||||
conversationId: contactA.id,
|
conversationId: contactA.id,
|
||||||
|
|
|
@ -49,6 +49,7 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
state = state.addContact(pinned, {
|
state = state.addContact(pinned, {
|
||||||
identityKey: pinned.publicKey.serialize(),
|
identityKey: pinned.publicKey.serialize(),
|
||||||
profileKey: pinned.profileKey.serialize(),
|
profileKey: pinned.profileKey.serialize(),
|
||||||
|
whitelisted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
state = state.pin(pinned);
|
state = state.pin(pinned);
|
||||||
|
@ -99,19 +100,53 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
const { contacts, phone, desktop, server } = bootstrap;
|
const { contacts, phone, desktop, server } = bootstrap;
|
||||||
const [friend, pinned] = contacts;
|
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 window = await app.getWindow();
|
||||||
|
|
||||||
const leftPane = window.locator('#LeftPane');
|
const leftPane = window.locator('#LeftPane');
|
||||||
await leftPane
|
const contact = leftPane.locator(
|
||||||
.locator(
|
|
||||||
`[data-testid="${pinned.device.aci}"] >> "${pinned.profileName}"`
|
`[data-testid="${pinned.device.aci}"] >> "${pinned.profileName}"`
|
||||||
)
|
);
|
||||||
.waitFor();
|
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) {
|
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();
|
const theirTimestamp = bootstrap.getTimestamp();
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
@ -159,14 +194,21 @@ describe('backups', function (this: Mocha.Suite) {
|
||||||
app,
|
app,
|
||||||
async (window, snapshot) => {
|
async (window, snapshot) => {
|
||||||
const leftPane = window.locator('#LeftPane');
|
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(
|
const contactElem = leftPane.locator(
|
||||||
`[data-testid="${friend.toContact().aci}"] >> "respond 4"`
|
`[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 contactElem.waitFor();
|
||||||
|
|
||||||
await snapshot('main screen');
|
await snapshot('styled bubbles');
|
||||||
|
|
||||||
debug('Going into the conversation');
|
debug('Going into the conversation');
|
||||||
await contactElem.click();
|
await contactElem.click();
|
||||||
|
|
42
ts/test-node/util/rgbToHSL_test.ts
Normal file
42
ts/test-node/util/rgbToHSL_test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -170,12 +170,14 @@ export type ConversationColorType =
|
||||||
| typeof ConversationColors[number]
|
| typeof ConversationColors[number]
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
export type DefaultConversationColorType = {
|
export type CustomColorDataType = {
|
||||||
color: ConversationColorType;
|
|
||||||
customColorData?: {
|
|
||||||
id: string;
|
id: string;
|
||||||
value: CustomColorType;
|
value: CustomColorType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DefaultConversationColorType = {
|
||||||
|
color: ConversationColorType;
|
||||||
|
customColorData?: CustomColorDataType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_CONVERSATION_COLOR: DefaultConversationColorType = {
|
export const DEFAULT_CONVERSATION_COLOR: DefaultConversationColorType = {
|
||||||
|
|
5
ts/types/Storage.d.ts
vendored
5
ts/types/Storage.d.ts
vendored
|
@ -64,6 +64,11 @@ export type StorageAccessType = {
|
||||||
attachmentMigration_lastProcessedIndex: number;
|
attachmentMigration_lastProcessedIndex: number;
|
||||||
blocked: ReadonlyArray<string>;
|
blocked: ReadonlyArray<string>;
|
||||||
defaultConversationColor: DefaultConversationColorType;
|
defaultConversationColor: DefaultConversationColorType;
|
||||||
|
|
||||||
|
// Not used UI, stored as is when imported from backup.
|
||||||
|
defaultWallpaperPhotoPointer: Uint8Array;
|
||||||
|
defaultWallpaperPreset: number;
|
||||||
|
|
||||||
customColors: CustomColorsItemType;
|
customColors: CustomColorsItemType;
|
||||||
device_name: string;
|
device_name: string;
|
||||||
existingOnboardingStoryMessageIds: ReadonlyArray<string> | undefined;
|
existingOnboardingStoryMessageIds: ReadonlyArray<string> | undefined;
|
||||||
|
|
26
ts/util/hslToRGB.ts
Normal file
26
ts/util/hslToRGB.ts
Normal 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
45
ts/util/rgbToHSL.ts
Normal 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 };
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue