Conversation Colors

This commit is contained in:
Josh Perez 2021-05-28 12:15:17 -04:00 committed by GitHub
parent b63d8e908c
commit 28f016ce48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
128 changed files with 3997 additions and 1207 deletions

View file

@ -2533,7 +2533,6 @@ export async function startApp(): Promise<void> {
conversation.set({
name: details.name,
color: details.color,
inbox_position: details.inboxPosition,
});
@ -2640,7 +2639,6 @@ export async function startApp(): Promise<void> {
const updates = {
name: details.name,
members,
color: details.color,
type: 'group',
inbox_position: details.inboxPosition,
} as WhatIsThis;

View file

@ -11,13 +11,13 @@ import { action } from '@storybook/addon-actions';
import { Avatar, AvatarBlur, Props } from './Avatar';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { Colors, ColorType } from '../types/Colors';
import { AvatarColors, AvatarColorType } from '../types/Colors';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Avatar', module);
const colorMap: Record<string, ColorType> = Colors.reduce(
const colorMap: Record<string, AvatarColorType> = AvatarColors.reduce(
(m, color) => ({
...m,
[color]: color,
@ -129,12 +129,14 @@ story.add('Group Icon', () => {
story.add('Colors', () => {
const props = createProps();
return Colors.map(color => <Avatar key={color} {...props} color={color} />);
return AvatarColors.map(color => (
<Avatar key={color} {...props} color={color} />
));
});
story.add('Broken Color', () => {
const props = createProps({
color: 'nope' as ColorType,
color: 'nope' as AvatarColorType,
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);

View file

@ -14,7 +14,7 @@ import { Spinner } from './Spinner';
import { getInitials } from '../util/getInitials';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
import { AvatarColorType } from '../types/Colors';
import * as log from '../logging/log';
import { assert } from '../util/assert';
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
@ -37,7 +37,7 @@ export enum AvatarSize {
export type Props = {
avatarPath?: string;
blur?: AvatarBlur;
color?: ColorType;
color?: AvatarColorType;
loading?: boolean;
acceptedMessageRequest: boolean;

View file

@ -8,13 +8,13 @@ import { action } from '@storybook/addon-actions';
import { boolean, select, text } from '@storybook/addon-knobs';
import { AvatarPopup, Props } from './AvatarPopup';
import { Colors, ColorType } from '../types/Colors';
import { AvatarColors, AvatarColorType } from '../types/Colors';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const colorMap: Record<string, ColorType> = Colors.reduce(
const colorMap: Record<string, AvatarColorType> = AvatarColors.reduce(
(m, color) => ({
...m,
[color]: color,
@ -41,6 +41,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
name: text('name', overrideProps.name || ''),
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
onClick: action('onClick'),
onSetChatColor: action('onSetChatColor'),
onViewArchive: action('onViewArchive'),
onViewPreferences: action('onViewPreferences'),
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),

View file

@ -12,6 +12,7 @@ import { LocalizerType } from '../types/Util';
export type Props = {
readonly i18n: LocalizerType;
onSetChatColor: () => unknown;
onViewPreferences: () => unknown;
onViewArchive: () => unknown;
@ -28,6 +29,7 @@ export const AvatarPopup = (props: Props): JSX.Element => {
profileName,
phoneNumber,
title,
onSetChatColor,
onViewPreferences,
onViewArchive,
style,
@ -72,6 +74,21 @@ export const AvatarPopup = (props: Props): JSX.Element => {
{i18n('mainMenuSettings')}
</div>
</button>
<button
type="button"
className="module-avatar-popup__item"
onClick={onSetChatColor}
>
<div
className={classNames(
'module-avatar-popup__item__icon',
'module-avatar-popup__item__icon-colors'
)}
/>
<div className="module-avatar-popup__item__text">
{i18n('avatarMenuChatColors')}
</div>
</button>
<button
type="button"
className="module-avatar-popup__item"

View file

@ -3,12 +3,12 @@
import React from 'react';
import classNames from 'classnames';
import { ColorType } from '../types/Colors';
import { AvatarColorType } from '../types/Colors';
export type PropsType = {
avatarPath?: string;
children: React.ReactNode;
color?: ColorType;
color?: AvatarColorType;
};
export const CallBackgroundBlur = ({

View file

@ -15,7 +15,7 @@ import {
GroupCallJoinState,
} from '../types/Calling';
import { ConversationTypeType } from '../state/ducks/conversations';
import { Colors, ColorType } from '../types/Colors';
import { AvatarColors, AvatarColorType } from '../types/Colors';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { setup as setupI18n } from '../../js/modules/i18n';
@ -28,7 +28,11 @@ const getConversation = () =>
getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
color: select('Callee color', Colors, 'ultramarine' as ColorType),
color: select(
'Callee color',
AvatarColors,
'ultramarine' as AvatarColorType
),
title: text('Callee Title', 'Rick Sanchez'),
name: text('Callee Name', 'Rick Sanchez'),
phoneNumber: '3051234567',
@ -74,7 +78,11 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
keyChangeOk: action('key-change-ok'),
me: {
...getDefaultConversation({
color: select('Caller color', Colors, 'ultramarine' as ColorType),
color: select(
'Caller color',
AvatarColors,
'ultramarine' as AvatarColorType
),
title: text('Caller Title', 'Morty Smith'),
}),
uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541',

View file

@ -16,7 +16,7 @@ import {
GroupCallRemoteParticipantType,
} from '../types/Calling';
import { ConversationType } from '../state/ducks/conversations';
import { Colors } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import { CallScreen, PropsType } from './CallScreen';
import { setup as setupI18n } from '../../js/modules/i18n';
import { missingCaseError } from '../util/missingCaseError';
@ -31,7 +31,7 @@ const i18n = setupI18n('en', enMessages);
const conversation = getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
color: Colors[0],
color: AvatarColors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '3051234567',
@ -145,7 +145,7 @@ const createProps = (
hangUp: action('hang-up'),
i18n,
me: {
color: Colors[1],
color: AvatarColors[1],
name: 'Morty Smith',
profileName: 'Morty Smith',
title: 'Morty Smith',

View file

@ -24,15 +24,15 @@ import {
PresentedSource,
VideoFrameSource,
} from '../types/Calling';
import { AvatarColorType } from '../types/Colors';
import { CallingToastManager } from './CallingToastManager';
import { ColorType } from '../types/Colors';
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
import { LocalizerType } from '../types/Util';
import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal';
import { isScreenSharingEnabled } from '../util/isScreenSharingEnabled';
import { missingCaseError } from '../util/missingCaseError';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal';
export type PropsType = {
activeCall: ActiveCallType;
@ -43,7 +43,7 @@ export type PropsType = {
joinedAt?: number;
me: {
avatarPath?: string;
color?: ColorType;
color?: AvatarColorType;
name?: string;
phoneNumber?: string;
profileName?: string;

View file

@ -7,7 +7,7 @@ import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { v4 as generateUuid } from 'uuid';
import { ColorType } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
import { CallingLobby, PropsType } from './CallingLobby';
import { setup as setupI18n } from '../../js/modules/i18n';
@ -37,7 +37,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
isGroupCall: boolean('isGroupCall', overrideProps.isGroupCall || false),
isCallFull: boolean('isCallFull', overrideProps.isCallFull || false),
me: overrideProps.me || {
color: 'ultramarine' as ColorType,
color: AvatarColors[0],
uuid: generateUuid(),
},
onCallCanceled: action('on-call-canceled'),
@ -79,7 +79,7 @@ story.add('No Camera, local avatar', () => {
availableCameras: [],
me: {
avatarPath: '/fixtures/kitten-4-112-112.jpg',
color: 'ultramarine' as ColorType,
color: AvatarColors[0],
uuid: generateUuid(),
},
});

View file

@ -14,7 +14,7 @@ import { TooltipPlacement } from './Tooltip';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { CallingHeader } from './CallingHeader';
import { Spinner } from './Spinner';
import { ColorType } from '../types/Colors';
import { AvatarColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import {
@ -39,7 +39,7 @@ export type PropsType = {
isCallFull?: boolean;
me: {
avatarPath?: string;
color?: ColorType;
color?: AvatarColorType;
uuid: string;
};
onCallCanceled: () => void;

View file

@ -2,12 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { sample } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { v4 as generateUuid } from 'uuid';
import { CallingParticipantsList, PropsType } from './CallingParticipantsList';
import { Colors } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import { GroupCallRemoteParticipantType } from '../types/Calling';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../js/modules/i18n';
@ -18,7 +19,6 @@ const i18n = setupI18n('en', enMessages);
function createParticipant(
participantProps: Partial<GroupCallRemoteParticipantType>
): GroupCallRemoteParticipantType {
const randomColor = Math.floor(Math.random() * Colors.length - 1);
return {
demuxId: 2,
hasRemoteAudio: Boolean(participantProps.hasRemoteAudio),
@ -28,7 +28,7 @@ function createParticipant(
videoAspectRatio: 1.3,
...getDefaultConversation({
avatarPath: participantProps.avatarPath,
color: Colors[randomColor],
color: sample(AvatarColors),
isBlocked: Boolean(participantProps.isBlocked),
name: participantProps.name,
profileName: participantProps.title,

View file

@ -6,7 +6,7 @@ import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { ColorType } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
import { CallingPip, PropsType } from './CallingPip';
import {
@ -26,7 +26,7 @@ const i18n = setupI18n('en', enMessages);
const conversation: ConversationType = getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
color: 'ultramarine' as ColorType,
color: AvatarColors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '3051234567',

View file

@ -0,0 +1,68 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { select } from '@storybook/addon-knobs';
import enMessages from '../../_locales/en/messages.json';
import { ChatColorPicker, PropsType } from './ChatColorPicker';
import { ConversationColors } from '../types/Colors';
import { setup as setupI18n } from '../../js/modules/i18n';
const story = storiesOf('Components/ChatColorPicker', module);
const i18n = setupI18n('en', enMessages);
const SAMPLE_CUSTOM_COLOR = {
deg: 90,
end: { hue: 197, saturation: 100 },
start: { hue: 315, saturation: 78 },
};
const createProps = (): PropsType => ({
addCustomColor: action('addCustomColor'),
editCustomColor: action('editCustomColor'),
getConversationsWithCustomColor: (_: string) => [],
i18n,
onChatColorReset: action('onChatColorReset'),
onSelectColor: action('onSelectColor'),
removeCustomColor: action('removeCustomColor'),
removeCustomColorOnConversations: action('removeCustomColorOnConversations'),
resetAllChatColors: action('resetAllChatColors'),
selectedColor: select('selectedColor', ConversationColors, 'basil' as const),
selectedCustomColor: {},
});
story.add('Default', () => <ChatColorPicker {...createProps()} />);
const CUSTOM_COLORS = {
abc: {
start: { hue: 32, saturation: 100 },
},
def: {
deg: 90,
start: { hue: 180, saturation: 100 },
end: { hue: 0, saturation: 100 },
},
ghi: SAMPLE_CUSTOM_COLOR,
jkl: {
deg: 90,
start: { hue: 161, saturation: 52 },
end: { hue: 153, saturation: 89 },
},
};
story.add('Custom Colors', () => (
<ChatColorPicker
{...createProps()}
customColors={CUSTOM_COLORS}
selectedColor="custom"
selectedCustomColor={{
id: 'ghi',
value: SAMPLE_CUSTOM_COLOR,
}}
/>
));

View file

@ -0,0 +1,386 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { KeyboardEvent, MouseEvent, useRef, useState } from 'react';
import classNames from 'classnames';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { ConfirmationDialog } from './ConfirmationDialog';
import { CustomColorEditor } from './CustomColorEditor';
import { Modal } from './Modal';
import {
ConversationColors,
ConversationColorType,
CustomColorType,
} from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
import { LocalizerType } from '../types/Util';
import { SampleMessageBubbles } from './SampleMessageBubbles';
import { PanelRow } from './conversation/conversation-details/PanelRow';
import { getCustomColorStyle } from '../util/getCustomColorStyle';
type CustomColorDataType = {
id?: string;
value?: CustomColorType;
};
export type PropsDataType = {
customColors?: Record<string, CustomColorType>;
getConversationsWithCustomColor: (colorId: string) => Array<ConversationType>;
i18n: LocalizerType;
isInModal?: boolean;
onChatColorReset?: () => unknown;
onSelectColor: (
color: ConversationColorType,
customColorData?: {
id: string;
value: CustomColorType;
}
) => unknown;
selectedColor?: ConversationColorType;
selectedCustomColor: CustomColorDataType;
};
type PropsActionType = {
addCustomColor: (color: CustomColorType) => unknown;
editCustomColor: (colorId: string, color: CustomColorType) => unknown;
removeCustomColor: (colorId: string) => unknown;
removeCustomColorOnConversations: (colorId: string) => unknown;
resetAllChatColors: () => unknown;
};
export type PropsType = PropsDataType & PropsActionType;
export const ChatColorPicker = ({
addCustomColor,
customColors = {},
editCustomColor,
getConversationsWithCustomColor,
i18n,
isInModal = false,
onChatColorReset,
onSelectColor,
removeCustomColor,
removeCustomColorOnConversations,
resetAllChatColors,
selectedColor = ConversationColors[0],
selectedCustomColor,
}: PropsType): JSX.Element => {
const [confirmResetAll, setConfirmResetAll] = useState(false);
const [customColorToEdit, setCustomColorToEdit] = useState<
CustomColorDataType | undefined
>(undefined);
const renderCustomColorEditorWrapper = () => (
<CustomColorEditorWrapper
customColorToEdit={customColorToEdit}
i18n={i18n}
isInModal={isInModal}
onClose={() => setCustomColorToEdit(undefined)}
onSave={(color: CustomColorType) => {
if (customColorToEdit?.id) {
editCustomColor(customColorToEdit.id, color);
onSelectColor('custom', {
id: customColorToEdit.id,
value: color,
});
} else {
addCustomColor(color);
}
}}
/>
);
if (isInModal && customColorToEdit) {
return renderCustomColorEditorWrapper();
}
return (
<>
{customColorToEdit ? renderCustomColorEditorWrapper() : null}
{confirmResetAll ? (
<ConfirmationDialog
actions={[
{
action: resetAllChatColors,
style: 'affirmative',
text: i18n('ChatColorPicker__confirm-reset'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmResetAll(false);
}}
title={i18n('ChatColorPicker__resetAll')}
>
{i18n('ChatColorPicker__confirm-reset-message')}
</ConfirmationDialog>
) : null}
<SampleMessageBubbles
backgroundStyle={getCustomColorStyle(selectedCustomColor.value)}
color={selectedColor}
i18n={i18n}
/>
<hr />
<div className="ChatColorPicker__bubbles">
{ConversationColors.map(color => (
<div
aria-label={color}
className={classNames(
`ChatColorPicker__bubble ChatColorPicker__bubble--${color}`,
{
'ChatColorPicker__bubble--selected': color === selectedColor,
}
)}
key={color}
onClick={() => onSelectColor(color)}
onKeyDown={(ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
onSelectColor(color);
}
}}
role="button"
tabIndex={0}
/>
))}
{Object.keys(customColors).map(colorId => {
const colorValues = customColors[colorId];
return (
<CustomColorBubble
color={colorValues}
colorId={colorId}
getConversationsWithCustomColor={getConversationsWithCustomColor}
key={colorId}
i18n={i18n}
isSelected={colorId === selectedCustomColor.id}
onChoose={() => {
onSelectColor('custom', {
id: colorId,
value: colorValues,
});
}}
onDelete={() => {
removeCustomColor(colorId);
removeCustomColorOnConversations(colorId);
}}
onDupe={() => {
addCustomColor(colorValues);
}}
onEdit={() => {
setCustomColorToEdit({ id: colorId, value: colorValues });
}}
/>
);
})}
<div
aria-label={i18n('ChatColorPicker__custom-color--label')}
className="ChatColorPicker__bubble ChatColorPicker__bubble"
onClick={() =>
setCustomColorToEdit({ id: undefined, value: undefined })
}
onKeyDown={(ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
setCustomColorToEdit({ id: undefined, value: undefined });
}
}}
role="button"
tabIndex={0}
>
<i className="ChatColorPicker__add-icon" />
</div>
</div>
<hr />
{onChatColorReset ? (
<PanelRow
label={i18n('ChatColorPicker__reset')}
onClick={onChatColorReset}
/>
) : null}
<PanelRow
label={i18n('ChatColorPicker__resetAll')}
onClick={() => {
setConfirmResetAll(true);
}}
/>
</>
);
};
type CustomColorBubblePropsType = {
color: CustomColorType;
colorId: string;
getConversationsWithCustomColor: (colorId: string) => Array<ConversationType>;
i18n: LocalizerType;
isSelected: boolean;
onDelete: () => unknown;
onDupe: () => unknown;
onEdit: () => unknown;
onChoose: () => unknown;
};
const CustomColorBubble = ({
color,
colorId,
getConversationsWithCustomColor,
i18n,
isSelected,
onDelete,
onDupe,
onEdit,
onChoose,
}: CustomColorBubblePropsType): JSX.Element => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const menuRef = useRef<any | null>(null);
const [confirmDeleteCount, setConfirmDeleteCount] = useState<
number | undefined
>(undefined);
const handleClick = (ev: KeyboardEvent | MouseEvent) => {
if (!isSelected) {
onChoose();
return;
}
if (menuRef && menuRef.current) {
menuRef.current.handleContextClick(ev);
}
};
const bubble = (
<div
aria-label={colorId}
className={classNames('ChatColorPicker__bubble', {
'ChatColorPicker__bubble--selected': isSelected,
})}
onClick={handleClick}
onKeyDown={(ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
handleClick(ev);
}
}}
role="button"
tabIndex={0}
style={{
...getCustomColorStyle(color),
}}
/>
);
return (
<>
{confirmDeleteCount ? (
<ConfirmationDialog
actions={[
{
action: onDelete,
style: 'negative',
text: i18n('ChatColorPicker__context--delete'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmDeleteCount(undefined);
}}
title={i18n('ChatColorPicker__delete--title')}
>
{i18n('ChatColorPicker__delete--message', [
String(confirmDeleteCount),
])}
</ConfirmationDialog>
) : null}
{isSelected ? (
<ContextMenuTrigger id={colorId} ref={menuRef}>
{bubble}
</ContextMenuTrigger>
) : (
bubble
)}
<ContextMenu id={colorId}>
<MenuItem
attributes={{
className: 'ChatColorPicker__context--edit',
}}
onClick={(event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onEdit();
}}
>
{i18n('ChatColorPicker__context--edit')}
</MenuItem>
<MenuItem
attributes={{
className: 'ChatColorPicker__context--duplicate',
}}
onClick={(event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onDupe();
}}
>
{i18n('ChatColorPicker__context--duplicate')}
</MenuItem>
<MenuItem
attributes={{
className: 'ChatColorPicker__context--delete',
}}
onClick={(event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
const conversations = getConversationsWithCustomColor(colorId);
if (!conversations.length) {
onDelete();
} else {
setConfirmDeleteCount(conversations.length);
}
}}
>
{i18n('ChatColorPicker__context--delete')}
</MenuItem>
</ContextMenu>
</>
);
};
type CustomColorEditorWrapperPropsType = {
customColorToEdit?: CustomColorDataType;
i18n: LocalizerType;
isInModal: boolean;
onClose: () => unknown;
onSave: (color: CustomColorType) => unknown;
};
const CustomColorEditorWrapper = ({
customColorToEdit,
i18n,
isInModal,
onClose,
onSave,
}: CustomColorEditorWrapperPropsType): JSX.Element => {
const editor = (
<CustomColorEditor
customColor={customColorToEdit?.value}
i18n={i18n}
onClose={onClose}
onSave={onSave}
/>
);
if (!isInModal) {
return (
<Modal
hasXButton
i18n={i18n}
noMouseClose
onClose={onClose}
title={i18n('CustomColorEditor__title')}
>
{editor}
</Modal>
);
}
return editor;
};

View file

@ -22,7 +22,7 @@ type ContactType = Omit<ContactPillPropsType, 'i18n' | 'onClickRemove'>;
const contacts: Array<ContactType> = times(50, index =>
getDefaultConversation({
color: 'red',
color: 'crimson',
id: `contact-${index}`,
name: `Contact ${index}`,
phoneNumber: '(202) 555-0001',
@ -37,7 +37,7 @@ const contactPillProps = (
...(overrideProps ||
getDefaultConversation({
avatarPath: gifUrl,
color: 'red',
color: 'crimson',
firstName: 'John',
id: 'abc123',
isMe: false,

View file

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import enMessages from '../../_locales/en/messages.json';
import { CustomColorEditor, PropsType } from './CustomColorEditor';
import { setup as setupI18n } from '../../js/modules/i18n';
const story = storiesOf('Components/CustomColorEditor', module);
const i18n = setupI18n('en', enMessages);
const createProps = (): PropsType => ({
i18n,
onClose: action('onClose'),
onSave: action('onSave'),
});
story.add('Default', () => <CustomColorEditor {...createProps()} />);

View file

@ -0,0 +1,182 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import { Button, ButtonVariant } from './Button';
import { GradientDial, KnobType } from './GradientDial';
import { SampleMessageBubbles } from './SampleMessageBubbles';
import { Slider } from './Slider';
import { Tabs } from './Tabs';
import { CustomColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { getHSL } from '../util/getHSL';
import { getCustomColorStyle } from '../util/getCustomColorStyle';
export type PropsType = {
customColor?: CustomColorType;
i18n: LocalizerType;
onClose: () => unknown;
onSave: (color: CustomColorType) => unknown;
};
enum TabViews {
Solid = 'Solid',
Gradient = 'Gradient',
}
function getPercentage(value: number, max: number): number {
return (100 * value) / max;
}
function getValue(percentage: number, max: number): number {
return Math.round((max / 100) * percentage);
}
const MAX_HUE = 360;
const ULTRAMARINE_ISH_VALUES = {
hue: 220,
saturation: 84,
};
const ULTRAMARINE_ISH: CustomColorType = {
start: ULTRAMARINE_ISH_VALUES,
deg: 180,
};
export const CustomColorEditor = ({
customColor = ULTRAMARINE_ISH,
i18n,
onClose,
onSave,
}: PropsType): JSX.Element => {
const [color, setColor] = useState<CustomColorType>(customColor);
const [selectedColorKnob, setSelectedColorKnob] = useState<KnobType>(
KnobType.start
);
const { hue, saturation } =
color[selectedColorKnob] || ULTRAMARINE_ISH_VALUES;
return (
<>
<Tabs
initialSelectedTab={color.end ? TabViews.Gradient : TabViews.Solid}
moduleClassName="CustomColorEditor__tabs"
onTabChange={selectedTab => {
if (selectedTab === TabViews.Gradient && !color.end) {
setColor({
...color,
end: ULTRAMARINE_ISH_VALUES,
});
}
}}
tabs={[
{
id: TabViews.Solid,
label: i18n('CustomColorEditor__solid'),
},
{
id: TabViews.Gradient,
label: i18n('CustomColorEditor__gradient'),
},
]}
>
{({ selectedTab }) => (
<>
<div className="CustomColorEditor__messages">
<SampleMessageBubbles
backgroundStyle={getCustomColorStyle(color)}
i18n={i18n}
includeAnotherBubble
/>
{selectedTab === TabViews.Gradient && (
<>
<GradientDial
deg={color.deg}
knob1Style={{ backgroundColor: getHSL(color.start) }}
knob2Style={{
backgroundColor: getHSL(
color.end || ULTRAMARINE_ISH_VALUES
),
}}
onChange={deg => {
setColor({
...color,
deg,
});
}}
onClick={knob => setSelectedColorKnob(knob)}
selectedKnob={selectedColorKnob}
/>
</>
)}
</div>
<div className="CustomColorEditor__slider-container">
{i18n('CustomColorEditor__hue')}
<Slider
handleStyle={{
backgroundColor: getHSL(
color[selectedColorKnob] || ULTRAMARINE_ISH_VALUES
),
}}
label={i18n('CustomColorEditor__hue')}
moduleClassName="CustomColorEditor__hue-slider"
onChange={(percentage: number) => {
setColor({
...color,
[selectedColorKnob]: {
...ULTRAMARINE_ISH_VALUES,
...color[selectedColorKnob],
hue: getValue(percentage, MAX_HUE),
},
});
}}
value={getPercentage(hue, MAX_HUE)}
/>
</div>
<div className="CustomColorEditor__slider-container">
{i18n('CustomColorEditor__saturation')}
<Slider
containerStyle={getCustomColorStyle({
deg: 180,
start: { hue, saturation: 0 },
end: { hue, saturation: 100 },
})}
handleStyle={{
backgroundColor: getHSL(
color[selectedColorKnob] || ULTRAMARINE_ISH_VALUES
),
}}
label={i18n('CustomColorEditor__saturation')}
moduleClassName="CustomColorEditor__saturation-slider"
onChange={(value: number) => {
setColor({
...color,
[selectedColorKnob]: {
...ULTRAMARINE_ISH_VALUES,
...color[selectedColorKnob],
saturation: value,
},
});
}}
value={saturation}
/>
</div>
<div className="CustomColorEditor__footer">
<Button variant={ButtonVariant.Secondary} onClick={onClose}>
{i18n('cancel')}
</Button>
<Button
onClick={() => {
onSave(color);
onClose();
}}
>
{i18n('save')}
</Button>
</div>
</>
)}
</Tabs>
</>
);
};

View file

@ -0,0 +1,43 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Modal } from './Modal';
import { LocalizerType } from '../types/Util';
import { ConversationColorType } from '../types/Colors';
type PropsType = {
i18n: LocalizerType;
isChatColorEditorVisible: boolean;
renderChatColorPicker: (actions: {
setAllConversationColors: (color: ConversationColorType) => unknown;
}) => JSX.Element;
setAllConversationColors: (color: ConversationColorType) => unknown;
toggleChatColorEditor: () => unknown;
};
export const GlobalModalContainer = ({
i18n,
isChatColorEditorVisible,
renderChatColorPicker,
setAllConversationColors,
toggleChatColorEditor,
}: PropsType): JSX.Element | null => {
if (isChatColorEditorVisible) {
return (
<Modal
hasXButton
i18n={i18n}
noMouseClose
onClose={toggleChatColorEditor}
title={i18n('ChatColorPicker__global-chat-color')}
>
{renderChatColorPicker({
setAllConversationColors,
})}
</Modal>
);
}
return null;
};

View file

@ -0,0 +1,309 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// eslint-disable-next-line max-len
/* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus */
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
export enum KnobType {
start = 'start',
end = 'end',
}
export type PropsType = {
deg?: number;
knob1Style: CSSProperties;
knob2Style: CSSProperties;
onChange: (deg: number) => unknown;
onClick: (knob: KnobType) => unknown;
selectedKnob: KnobType;
};
// Converts from degrees to radians.
function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
// Converts from radians to degrees.
function toDegrees(radians: number): number {
return (radians * 180) / Math.PI;
}
type CSSPosition = { left: number; top: number };
function getKnobCoordinates(
degrees: number,
rect: ClientRect
): { start: CSSPosition; end: CSSPosition } {
const center = {
x: rect.width / 2,
y: rect.height / 2,
};
const alpha = toDegrees(Math.atan(rect.height / rect.width));
const beta = (360.0 - alpha * 4) / 4;
if (degrees < alpha) {
// Right top
const a = center.x;
const b = a * Math.tan(toRadians(degrees));
return {
start: {
left: rect.width,
top: center.y - b,
},
end: {
left: 0,
top: center.y + b,
},
};
}
if (degrees < 90) {
// Top right
const phi = 90 - degrees;
const a = center.y;
const b = a * Math.tan(toRadians(phi));
return {
start: {
left: center.x + b,
top: 0,
},
end: {
left: center.x - b,
top: rect.height,
},
};
}
if (degrees < 90 + beta) {
// Top left
const phi = degrees - 90;
const a = center.y;
const b = a * Math.tan(toRadians(phi));
return {
start: {
left: center.x - b,
top: 0,
},
end: {
left: center.x + b,
top: rect.height,
},
};
}
if (degrees < 180) {
// left top
const phi = 180 - degrees;
const a = center.x;
const b = a * Math.tan(toRadians(phi));
return {
start: {
left: 0,
top: center.y - b,
},
end: {
left: rect.width,
top: center.y + b,
},
};
}
if (degrees < 180 + alpha) {
// left bottom
const phi = degrees - 180;
const a = center.x;
const b = a * Math.tan(toRadians(phi));
return {
start: {
left: 0,
top: center.y + b,
},
end: {
left: rect.width,
top: center.y - b,
},
};
}
if (degrees < 270) {
// bottom left
const phi = 270 - degrees;
const a = center.y;
const b = a * Math.tan(toRadians(phi));
return {
start: {
left: center.x - b,
top: rect.height,
},
end: {
left: center.x + b,
top: 0,
},
};
}
if (degrees < 270 + beta) {
// bottom right
const phi = degrees - 270;
const a = center.y;
const b = a * Math.tan(toRadians(phi));
return {
start: {
left: center.x + b,
top: rect.height,
},
end: {
left: center.x - b,
top: 0,
},
};
}
// right bottom
const phi = 360 - degrees;
const a = center.x;
const b = a * Math.tan(toRadians(phi));
return {
start: {
left: rect.width,
top: center.y + b,
},
end: {
left: 0,
top: center.y - b,
},
};
}
export const GradientDial = ({
deg = 180,
knob1Style,
knob2Style,
onChange,
onClick,
selectedKnob,
}: PropsType): JSX.Element => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [knobDim, setKnobDim] = useState<{
start?: CSSPosition;
end?: CSSPosition;
}>({});
const handleMouseMove = (ev: MouseEvent) => {
if (!containerRef || !containerRef.current) {
return;
}
const rect = containerRef.current.getBoundingClientRect();
const center = {
x: rect.width / 2,
y: rect.height / 2,
};
const a = { x: ev.clientX - center.x, y: ev.clientY - center.y };
const b = { x: center.x, y: 0 };
const dot = a.x * b.x + a.y * b.y;
const det = a.x * b.y - a.y * b.x;
const offset = selectedKnob === KnobType.end ? 180 : 0;
const degrees = (toDegrees(Math.atan2(det, dot)) + 360 + offset) % 360;
onChange(degrees);
ev.preventDefault();
ev.stopPropagation();
};
const handleMouseUp = () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
// We want to use React.MouseEvent here because above we
// use the regular MouseEvent
const handleMouseDown = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
useEffect(() => {
if (!containerRef || !containerRef.current) {
return;
}
const containerRect = containerRef.current.getBoundingClientRect();
setKnobDim(getKnobCoordinates(deg, containerRect));
}, [containerRef, deg]);
return (
<div className="GradientDial__container" ref={containerRef}>
{knobDim.start && (
<div
aria-label="0"
className={classNames('GradientDial__knob', {
'GradientDial__knob--selected': selectedKnob === KnobType.start,
})}
onMouseDown={ev => {
if (selectedKnob === KnobType.start) {
handleMouseDown(ev);
}
}}
onClick={() => {
onClick(KnobType.start);
}}
role="button"
style={{
...knob1Style,
...knobDim.start,
}}
/>
)}
{knobDim.end && (
<div
aria-label="1"
className={classNames('GradientDial__knob', {
'GradientDial__knob--selected': selectedKnob === KnobType.end,
})}
onMouseDown={ev => {
if (selectedKnob === KnobType.end) {
handleMouseDown(ev);
}
}}
onClick={() => {
onClick(KnobType.end);
}}
role="button"
style={{
...knob2Style,
...knobDim.end,
}}
/>
)}
{knobDim.start && knobDim.end && (
<div className="GradientDial__bar--container">
<div
className="GradientDial__bar--node"
style={{
transform: `translate(-50%, -50%) rotate(${90 - deg}deg)`,
}}
/>
</div>
)}
</div>
);
};

View file

@ -7,7 +7,7 @@ import { boolean, select, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { IncomingCallBar } from './IncomingCallBar';
import { Colors, ColorType } from '../types/Colors';
import { AvatarColors } from '../types/Colors';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
@ -25,7 +25,7 @@ const defaultProps = {
conversation: getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
color: 'ultramarine' as ColorType,
color: AvatarColors[0],
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
@ -53,7 +53,7 @@ const permutations = [
storiesOf('Components/IncomingCallBar', module)
.add('Knobs Playground', () => {
const color = select('color', Colors, 'ultramarine');
const color = select('color', AvatarColors, 'ultramarine');
const isVideoCall = boolean('isVideoCall', false);
const name = text(
'name',

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -58,6 +58,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
showArchivedConversations: action('showArchivedConversations'),
startComposing: action('startComposing'),
toggleChatColorEditor: action('toggleChatColorEditor'),
});
story.add('Basic', () => {

View file

@ -11,7 +11,7 @@ import { showSettings } from '../shims/Whisper';
import { Avatar } from './Avatar';
import { AvatarPopup } from './AvatarPopup';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
import { AvatarColorType } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
export type PropsType = {
@ -31,7 +31,7 @@ export type PropsType = {
phoneNumber?: string;
isMe?: boolean;
name?: string;
color?: ColorType;
color?: AvatarColorType;
disabled?: boolean;
isVerified?: boolean;
profileName?: string;
@ -64,6 +64,7 @@ export type PropsType = {
showArchivedConversations: () => void;
startComposing: () => void;
toggleChatColorEditor: () => void;
};
type StateType = {
@ -351,6 +352,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
searchConversationName,
searchTerm,
showArchivedConversations,
toggleChatColorEditor,
} = this.props;
const { showingAvatarPopup, popperRoot } = this.state;
@ -408,6 +410,10 @@ export class MainHeader extends React.Component<PropsType, StateType> {
size={28}
// See the comment above about `sharedGroupNames`.
sharedGroupNames={[]}
onSetChatColor={() => {
toggleChatColorEditor();
this.hideAvatarPopup();
}}
onViewPreferences={() => {
showSettings();
this.hideAvatarPopup();

View file

@ -16,6 +16,7 @@ type PropsType = {
hasXButton?: boolean;
i18n: LocalizerType;
moduleClassName?: string;
noMouseClose?: boolean;
onClose?: () => void;
title?: ReactNode;
theme?: Theme;
@ -28,6 +29,7 @@ export function Modal({
hasXButton,
i18n,
moduleClassName,
noMouseClose,
onClose = noop,
title,
theme,
@ -38,7 +40,7 @@ export function Modal({
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
return (
<ModalHost onClose={onClose} theme={theme}>
<ModalHost noMouseClose={noMouseClose} onClose={onClose} theme={theme}>
<div
className={classNames(
getClassName(''),

View file

@ -7,6 +7,7 @@ import { createPortal } from 'react-dom';
import { Theme, themeClassName } from '../util/theme';
export type PropsType = {
readonly noMouseClose?: boolean;
readonly onEscape?: () => unknown;
readonly onClose: () => unknown;
readonly children: React.ReactElement;
@ -14,7 +15,7 @@ export type PropsType = {
};
export const ModalHost = React.memo(
({ onEscape, onClose, children, theme }: PropsType) => {
({ onEscape, onClose, children, noMouseClose, theme }: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
useEffect(() => {
@ -67,7 +68,7 @@ export const ModalHost = React.memo(
'module-modal-host__overlay',
theme ? themeClassName(theme) : undefined
)}
onClick={handleCancel}
onClick={noMouseClose ? undefined : handleCancel}
>
{children}
</div>,

View file

@ -15,7 +15,7 @@ const i18n = setupI18n('en', enMessages);
const contactWithAllData = getDefaultConversation({
id: 'abc',
avatarPath: undefined,
color: 'signal-blue',
color: 'ultramarine',
profileName: '-*Smartest Dude*-',
title: 'Rick Sanchez',
name: 'Rick Sanchez',
@ -25,7 +25,7 @@ const contactWithAllData = getDefaultConversation({
const contactWithJustProfile = getDefaultConversation({
id: 'def',
avatarPath: undefined,
color: 'signal-blue',
color: 'ultramarine',
title: '-*Smartest Dude*-',
profileName: '-*Smartest Dude*-',
name: undefined,
@ -35,7 +35,7 @@ const contactWithJustProfile = getDefaultConversation({
const contactWithJustNumber = getDefaultConversation({
id: 'xyz',
avatarPath: undefined,
color: 'signal-blue',
color: 'ultramarine',
profileName: undefined,
name: undefined,
title: '(305) 123-4567',
@ -45,7 +45,7 @@ const contactWithJustNumber = getDefaultConversation({
const contactWithNothing = getDefaultConversation({
id: 'some-guid',
avatarPath: undefined,
color: 'signal-blue',
color: 'ultramarine',
profileName: undefined,
name: undefined,
phoneNumber: undefined,

View file

@ -22,7 +22,7 @@ const contactWithAllData = {
const contactWithJustProfile = {
avatarPath: undefined,
color: 'signal-blue',
color: 'ultramarine',
title: '-*Smartest Dude*-',
profileName: '-*Smartest Dude*-',
name: undefined,
@ -31,7 +31,7 @@ const contactWithJustProfile = {
const contactWithJustNumber = {
avatarPath: undefined,
color: 'signal-blue',
color: 'ultramarine',
profileName: undefined,
name: undefined,
title: '(305) 123-4567',
@ -41,7 +41,7 @@ const contactWithJustNumber = {
const contactWithNothing = {
id: 'some-guid',
avatarPath: undefined,
color: 'signal-blue',
color: 'ultramarine',
profileName: undefined,
title: 'Unknown contact',
name: undefined,

View file

@ -0,0 +1,112 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties } from 'react';
import { ConversationColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { formatRelativeTime } from '../util/formatRelativeTime';
export type PropsType = {
backgroundStyle?: CSSProperties;
color?: ConversationColorType;
i18n: LocalizerType;
includeAnotherBubble?: boolean;
};
const A_FEW_DAYS_AGO = 60 * 60 * 24 * 5 * 1000;
const SampleMessage = ({
color = 'ultramarine',
direction,
i18n,
text,
timestamp,
status,
style,
}: {
color?: ConversationColorType;
direction: 'incoming' | 'outgoing';
i18n: LocalizerType;
text: string;
timestamp: number;
status: 'delivered' | 'read' | 'sent';
style?: CSSProperties;
}): JSX.Element => (
<div className={`module-message module-message--${direction}`}>
<div className="module-message__container-outer">
<div
className={`module-message__container module-message__container--${direction} module-message__container--${direction}-${color}`}
style={style}
>
<div
dir="auto"
className={`module-message__text module-message__text--${direction}`}
>
<span>{text}</span>
</div>
<div
className={`module-message__metadata module-message__metadata--${direction}`}
>
<span
className={`module-message__metadata__date module-message__metadata__date--${direction}`}
>
{formatRelativeTime(timestamp, { extended: true, i18n })}
</span>
{direction === 'outgoing' && (
<div
className={`module-message__metadata__status-icon module-message__metadata__status-icon--${status}`}
/>
)}
</div>
</div>
</div>
</div>
);
export const SampleMessageBubbles = ({
backgroundStyle = {},
color,
i18n,
includeAnotherBubble = false,
}: PropsType): JSX.Element => {
const firstBubbleStyle = includeAnotherBubble ? backgroundStyle : undefined;
return (
<>
<SampleMessage
color={color}
direction={includeAnotherBubble ? 'outgoing' : 'incoming'}
i18n={i18n}
text={i18n('ChatColorPicker__sampleBubble1')}
timestamp={Date.now() - A_FEW_DAYS_AGO}
status="read"
style={firstBubbleStyle}
/>
<br />
{includeAnotherBubble ? (
<>
<br style={{ clear: 'both' }} />
<br />
<SampleMessage
direction="incoming"
i18n={i18n}
text={i18n('ChatColorPicker__sampleBubble2')}
timestamp={Date.now() - A_FEW_DAYS_AGO / 2}
status="read"
/>
<br />
<br />
</>
) : null}
<SampleMessage
color={color}
direction="outgoing"
i18n={i18n}
text={i18n('ChatColorPicker__sampleBubble3')}
timestamp={Date.now()}
status="delivered"
style={backgroundStyle}
/>
<br style={{ clear: 'both' }} />
</>
);
};

View file

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { Slider, PropsType } from './Slider';
const story = storiesOf('Components/Slider', module);
const createProps = (): PropsType => ({
label: 'Slider Handle',
onChange: action('onChange'),
value: 30,
});
story.add('Default', () => <Slider {...createProps()} />);
story.add('Draggable Test', () => {
function StatefulSliderController(props: PropsType): JSX.Element {
const [value, setValue] = useState(30);
return <Slider {...props} onChange={setValue} value={value} />;
}
return <StatefulSliderController {...createProps()} />;
});

126
ts/components/Slider.tsx Normal file
View file

@ -0,0 +1,126 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, KeyboardEvent, useRef } from 'react';
import { getClassNamesFor } from '../util/getClassNamesFor';
export type PropsType = {
containerStyle?: CSSProperties;
label: string;
handleStyle?: CSSProperties;
moduleClassName?: string;
onChange: (value: number) => unknown;
value: number;
};
export const Slider = ({
containerStyle = {},
label,
handleStyle = {},
moduleClassName,
onChange,
value,
}: PropsType): JSX.Element => {
const diff = useRef<number>(0);
const handleRef = useRef<HTMLDivElement | null>(null);
const sliderRef = useRef<HTMLDivElement | null>(null);
const getClassName = getClassNamesFor('Slider', moduleClassName);
const handleValueChange = (ev: MouseEvent | React.MouseEvent) => {
if (!sliderRef || !sliderRef.current) {
return;
}
let x =
ev.clientX -
diff.current -
sliderRef.current.getBoundingClientRect().left;
const max = sliderRef.current.offsetWidth;
x = Math.min(max, Math.max(0, x));
const nextValue = (100 * x) / max;
onChange(nextValue);
ev.preventDefault();
ev.stopPropagation();
};
const handleMouseUp = () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleValueChange);
};
// We want to use React.MouseEvent here because above we
// use the regular MouseEvent
const handleMouseDown = (ev: React.MouseEvent) => {
if (!handleRef || !handleRef.current) {
return;
}
diff.current = ev.clientX - handleRef.current.getBoundingClientRect().left;
document.addEventListener('mousemove', handleValueChange);
document.addEventListener('mouseup', handleMouseUp);
};
const handleKeyDown = (ev: KeyboardEvent) => {
let preventDefault = false;
if (ev.key === 'ArrowRight') {
const nextValue = value + 1;
onChange(Math.min(nextValue, 100));
preventDefault = true;
}
if (ev.key === 'ArrowLeft') {
const nextValue = value - 1;
onChange(Math.max(0, nextValue));
preventDefault = true;
}
if (ev.key === 'Home') {
onChange(0);
preventDefault = true;
}
if (ev.key === 'End') {
onChange(100);
preventDefault = true;
}
if (preventDefault) {
ev.preventDefault();
ev.stopPropagation();
}
};
return (
<div
aria-label={label}
className={getClassName('')}
onClick={handleValueChange}
onKeyDown={handleKeyDown}
ref={sliderRef}
role="button"
style={containerStyle}
tabIndex={0}
>
<div
aria-label={label}
aria-valuenow={value}
className={getClassName('__handle')}
onMouseDown={handleMouseDown}
ref={handleRef}
role="slider"
style={{ ...handleStyle, left: `${value}%` }}
tabIndex={-1}
/>
</div>
);
};

68
ts/components/Tabs.tsx Normal file
View file

@ -0,0 +1,68 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { KeyboardEvent, ReactNode, useState } from 'react';
import classNames from 'classnames';
import { assert } from '../util/assert';
import { getClassNamesFor } from '../util/getClassNamesFor';
type Tab = {
id: string;
label: string;
};
type PropsType = {
children: (renderProps: { selectedTab: string }) => ReactNode;
initialSelectedTab?: string;
moduleClassName?: string;
onTabChange?: (selectedTab: string) => unknown;
tabs: Array<Tab>;
};
export const Tabs = ({
children,
initialSelectedTab,
moduleClassName,
onTabChange,
tabs,
}: PropsType): JSX.Element => {
assert(tabs.length, 'Tabs needs more than 1 tab present');
const [selectedTab, setSelectedTab] = useState<string>(
initialSelectedTab || tabs[0].id
);
const getClassName = getClassNamesFor('Tabs', moduleClassName);
return (
<>
<div className={getClassName('')}>
{tabs.map(({ id, label }) => (
<div
className={classNames(
getClassName('__tab'),
selectedTab === id && getClassName('__tab--selected')
)}
key={id}
onClick={() => {
setSelectedTab(id);
onTabChange?.(id);
}}
onKeyUp={(e: KeyboardEvent) => {
if (e.target === e.currentTarget && e.keyCode === 13) {
setSelectedTab(id);
e.preventDefault();
e.stopPropagation();
}
}}
role="tab"
tabIndex={0}
>
{label}
</div>
))}
</div>
{children({ selectedTab })}
</>
);
};

View file

@ -8,6 +8,7 @@ import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { ContactName } from './ContactName';
import { ContactNameColors } from '../../types/Colors';
const i18n = setupI18n('en', enMessages);
@ -42,6 +43,18 @@ storiesOf('Components/Conversation/ContactName', module)
/>
);
})
.add('Colors', () => {
return ContactNameColors.map(color => (
<div key={color}>
<ContactName
title={`Hello ${color}`}
contactNameColor={color}
i18n={i18n}
phoneNumber="(202) 555-0011"
/>
</div>
));
})
.add('No data provided', () => {
return <ContactName title="unknownContact" i18n={i18n} />;
});

View file

@ -2,11 +2,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../../types/Util';
import { Emojify } from './Emojify';
import { ContactNameColorType } from '../../types/Colors';
import { LocalizerType } from '../../types/Util';
import { getClassNamesFor } from '../../util/getClassNamesFor';
export type PropsType = {
contactNameColor?: ContactNameColorType;
firstName?: string;
i18n: LocalizerType;
module?: string;
@ -18,12 +22,13 @@ export type PropsType = {
};
export const ContactName = ({
contactNameColor,
firstName,
module,
preferFirstName,
title,
}: PropsType): JSX.Element => {
const prefix = module || 'module-contact-name';
const getClassName = getClassNamesFor('module-contact-name', module);
let text: string;
if (preferFirstName) {
@ -33,7 +38,13 @@ export const ContactName = ({
}
return (
<span className={prefix} dir="auto">
<span
className={classNames(
getClassName(''),
contactNameColor ? getClassName(`--${contactNameColor}`) : null
)}
dir="auto"
>
<Emojify text={text} />
</span>
);

View file

@ -48,6 +48,7 @@ const commonProps = {
'onOutgoingVideoCallInConversation'
),
onShowChatColorEditor: action('onShowChatColorEditor'),
onShowSafetyNumber: action('onShowSafetyNumber'),
onShowAllMedia: action('onShowAllMedia'),
onShowContactModal: action('onShowContactModal'),
@ -70,7 +71,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'With name and profile, verified',
props: {
...commonProps,
color: 'red',
color: 'crimson',
isVerified: true,
avatarPath: gifUrl,
title: 'Someone 🔥 Somewhere',
@ -114,7 +115,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Profile, no name',
props: {
...commonProps,
color: 'teal',
color: 'wintergreen',
isVerified: false,
phoneNumber: '(202) 555-0003',
type: 'direct',
@ -140,7 +141,7 @@ const stories: Array<ConversationHeaderStory> = [
props: {
...commonProps,
showBackButton: true,
color: 'deep_orange',
color: 'vermilion',
phoneNumber: '(202) 555-0004',
title: '(202) 555-0004',
type: 'direct',
@ -212,7 +213,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Basic',
props: {
...commonProps,
color: 'signal-blue',
color: 'ultramarine',
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@ -227,7 +228,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'In a group you left - no disappearing messages',
props: {
...commonProps,
color: 'signal-blue',
color: 'ultramarine',
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@ -243,7 +244,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'In a group with an active group call',
props: {
...commonProps,
color: 'signal-blue',
color: 'ultramarine',
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@ -258,7 +259,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'In a forever muted group',
props: {
...commonProps,
color: 'signal-blue',
color: 'ultramarine',
title: 'Way too many messages',
name: 'Way too many messages',
phoneNumber: '',

View file

@ -72,6 +72,7 @@ export type PropsActionsType = {
onOutgoingVideoCallInConversation: () => void;
onSetPin: (value: boolean) => void;
onShowChatColorEditor: () => void;
onShowConversationDetails: () => void;
onShowSafetyNumber: () => void;
onShowAllMedia: () => void;
@ -368,6 +369,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
onSetDisappearingMessages,
onSetMuteNotifications,
onShowAllMedia,
onShowChatColorEditor,
onShowConversationDetails,
onShowGroupMembers,
onShowSafetyNumber,
@ -456,6 +458,11 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
</MenuItem>
))}
</SubMenu>
{!isGroup ? (
<MenuItem onClick={onShowChatColorEditor}>
{i18n('showChatColorEditor')}
</MenuItem>
) : null}
{hasGV2AdminEnabled ? (
<MenuItem onClick={onShowConversationDetails}>
{i18n('showConversationDetails')}

View file

@ -9,7 +9,7 @@ import { boolean, number, select, text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { SignalService } from '../../protobuf';
import { Colors } from '../../types/Colors';
import { ConversationColors } from '../../types/Colors';
import { EmojiPicker } from '../emoji/EmojiPicker';
import { Message, Props, AudioAttachmentProps } from './Message';
import {
@ -70,10 +70,7 @@ const renderAudioAttachment: Props['renderAudioAttachment'] = props => (
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
attachments: overrideProps.attachments,
author: overrideProps.author || {
...getDefaultConversation(),
color: select('authorColor', Colors, 'red'),
},
author: overrideProps.author || getDefaultConversation(),
reducedMotion: boolean('reducedMotion', false),
bodyRanges: overrideProps.bodyRanges,
canReply: true,
@ -81,6 +78,9 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
canDeleteForEveryone: overrideProps.canDeleteForEveryone || false,
clearSelectedMessage: action('clearSelectedMessage'),
collapseMetadata: overrideProps.collapseMetadata,
conversationColor:
overrideProps.conversationColor ||
select('conversationColor', ConversationColors, ConversationColors[0]),
conversationId: text('conversationId', overrideProps.conversationId || ''),
conversationType: overrideProps.conversationType || 'direct',
deletedForEveryone: overrideProps.deletedForEveryone,
@ -137,7 +137,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showMessageDetail: action('showMessageDetail'),
showVisualAttachment: action('showVisualAttachment'),
status: overrideProps.status || 'sent',
text: text('text', overrideProps.text || ''),
text: overrideProps.text || text('text', ''),
textPending: boolean('textPending', overrideProps.textPending || false),
timestamp: number('timestamp', overrideProps.timestamp || Date.now()),
});
@ -1007,14 +1007,15 @@ story.add('Dangerous File Type', () => {
story.add('Colors', () => {
return (
<>
{Colors.map(color => (
<Message
{...createProps({
author: getDefaultConversation({ color }),
text:
'Hello there from a pal! I am sending a long message so that it will wrap a bit, since I like that look.',
})}
/>
{ConversationColors.map(color => (
<div key={color}>
{renderBothDirections(
createProps({
conversationColor: color,
text: `Here is a preview of the chat color: ${color}. The color is visible to only you.`,
})
)}
</div>
))}
</>
);
@ -1081,3 +1082,25 @@ story.add('Not approved, with link preview', () => {
return renderBothDirections(props);
});
story.add('Custom Color', () => (
<>
<Message
{...createProps({ text: 'Solid.' })}
direction="outgoing"
customColor={{
start: { hue: 82, saturation: 35 },
}}
/>
<br style={{ clear: 'both' }} />
<Message
{...createProps({ text: 'Gradient.' })}
direction="outgoing"
customColor={{
deg: 192,
start: { hue: 304, saturation: 85 },
end: { hue: 231, saturation: 76 },
}}
/>
</>
));

View file

@ -50,10 +50,15 @@ import { ContactType } from '../../types/Contact';
import { getIncrement } from '../../util/timer';
import { isFileDangerous } from '../../util/isFileDangerous';
import { BodyRangesType, LocalizerType, ThemeType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import {
ContactNameColorType,
ConversationColorType,
CustomColorType,
} from '../../types/Colors';
import { createRefMerger } from '../_util';
import { emojiToData } from '../emoji/lib';
import { SmartReactionPicker } from '../../state/smart/ReactionPicker';
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
@ -100,6 +105,9 @@ export type AudioAttachmentProps = {
export type PropsData = {
id: string;
contactNameColor?: ContactNameColorType;
conversationColor: ConversationColorType;
customColor?: CustomColorType;
conversationId: string;
text?: string;
textPending?: boolean;
@ -128,6 +136,8 @@ export type PropsData = {
conversationType: ConversationTypesType;
attachments?: Array<AttachmentType>;
quote?: {
conversationColor: ConversationColorType;
customColor?: CustomColorType;
text: string;
rawAttachment?: QuotedAttachmentType;
isFromMe: boolean;
@ -137,7 +147,6 @@ export type PropsData = {
authorProfileName?: string;
authorTitle: string;
authorName?: string;
authorColor?: ColorType;
bodyRanges?: BodyRangesType;
referencedMessageNotFound: boolean;
};
@ -656,6 +665,7 @@ export class Message extends React.Component<Props, State> {
const {
author,
collapseMetadata,
contactNameColor,
conversationType,
direction,
i18n,
@ -687,6 +697,7 @@ export class Message extends React.Component<Props, State> {
return (
<div className={moduleName}>
<ContactName
contactNameColor={contactNameColor}
title={author.title}
phoneNumber={author.phoneNumber}
name={author.name}
@ -1035,8 +1046,9 @@ export class Message extends React.Component<Props, State> {
public renderQuote(): JSX.Element | null {
const {
author,
conversationColor,
conversationType,
customColor,
direction,
disableScroll,
i18n,
@ -1050,8 +1062,6 @@ export class Message extends React.Component<Props, State> {
const withContentAbove =
conversationType === 'group' && direction === 'incoming';
const quoteColor =
direction === 'incoming' ? author.color : quote.authorColor;
const { referencedMessageNotFound } = quote;
const clickHandler = disableScroll
@ -1073,9 +1083,10 @@ export class Message extends React.Component<Props, State> {
authorPhoneNumber={quote.authorPhoneNumber}
authorProfileName={quote.authorProfileName}
authorName={quote.authorName}
authorColor={quoteColor}
authorTitle={quote.authorTitle}
bodyRanges={quote.bodyRanges}
conversationColor={conversationColor}
customColor={customColor}
referencedMessageNotFound={referencedMessageNotFound}
isFromMe={quote.isFromMe}
withContentAbove={withContentAbove}
@ -2250,7 +2261,8 @@ export class Message extends React.Component<Props, State> {
public renderContainer(): JSX.Element {
const {
attachments,
author,
conversationColor,
customColor,
deletedForEveryone,
direction,
isSticker,
@ -2275,14 +2287,14 @@ export class Message extends React.Component<Props, State> {
isTapToView && isTapToViewExpired
? 'module-message__container--with-tap-to-view-expired'
: null,
!isSticker && direction === 'incoming'
? `module-message__container--incoming-${author.color}`
!isSticker && direction === 'outgoing'
? `module-message__container--outgoing-${conversationColor}`
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? 'module-message__container--with-tap-to-view-pending'
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? `module-message__container--${direction}-${author.color}-tap-to-view-pending`
? `module-message__container--${direction}-${conversationColor}-tap-to-view-pending`
: null,
isTapToViewError
? 'module-message__container--with-tap-to-view-error'
@ -2295,6 +2307,9 @@ export class Message extends React.Component<Props, State> {
const containerStyles = {
width: isShowingImage ? width : undefined,
};
if (!isSticker && direction === 'outgoing') {
Object.assign(containerStyles, getCustomColorStyle(customColor));
}
return (
<div className="module-message__container-outer">

View file

@ -25,6 +25,7 @@ const defaultMessage: MessageDataPropsType = {
canReply: true,
canDeleteForEveryone: true,
canDownload: true,
conversationColor: 'crimson',
conversationId: 'my-convo',
conversationType: 'direct',
direction: 'incoming',
@ -41,7 +42,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
contacts: overrideProps.contacts || [
{
...getDefaultConversation({
color: 'green',
color: 'indigo',
title: 'Just Max',
}),
isOutgoingKeyError: false,
@ -102,7 +103,7 @@ story.add('Message Statuses', () => {
contacts: [
{
...getDefaultConversation({
color: 'green',
color: 'forest',
title: 'Max',
}),
isOutgoingKeyError: false,
@ -124,7 +125,7 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
color: 'brown',
color: 'burlap',
title: 'Terry',
}),
isOutgoingKeyError: false,
@ -135,7 +136,7 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
color: 'light_green',
color: 'wintergreen',
title: 'Theo',
}),
isOutgoingKeyError: false,
@ -146,7 +147,7 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
color: 'blue_grey',
color: 'steel',
title: 'Nikki',
}),
isOutgoingKeyError: false,
@ -205,7 +206,7 @@ story.add('All Errors', () => {
contacts: [
{
...getDefaultConversation({
color: 'green',
color: 'forest',
title: 'Max',
}),
isOutgoingKeyError: true,
@ -233,7 +234,7 @@ story.add('All Errors', () => {
},
{
...getDefaultConversation({
color: 'brown',
color: 'taupe',
title: 'Terry',
}),
isOutgoingKeyError: true,

View file

@ -8,7 +8,7 @@ import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { Colors } from '../../types/Colors';
import { ConversationColors } from '../../types/Colors';
import { pngUrl } from '../../storybook/Fixtures';
import { Message, Props as MessagesProps } from './Message';
import {
@ -36,6 +36,7 @@ const defaultMessageProps: MessagesProps = {
canDeleteForEveryone: true,
canDownload: true,
clearSelectedMessage: () => null,
conversationColor: 'crimson',
conversationId: 'conversationId',
conversationType: 'direct', // override
deleteMessage: () => null,
@ -73,11 +74,11 @@ const defaultMessageProps: MessagesProps = {
};
const renderInMessage = ({
authorColor,
authorName,
authorPhoneNumber,
authorProfileName,
authorTitle,
conversationColor,
isFromMe,
rawAttachment,
referencedMessageNotFound,
@ -85,14 +86,14 @@ const renderInMessage = ({
}: Props) => {
const messageProps = {
...defaultMessageProps,
authorColor,
conversationColor,
quote: {
authorId: 'an-author',
authorColor,
authorName,
authorPhoneNumber,
authorProfileName,
authorTitle,
conversationColor,
isFromMe,
rawAttachment,
referencedMessageNotFound,
@ -111,7 +112,6 @@ const renderInMessage = ({
};
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
authorColor: overrideProps.authorColor || 'green',
authorName: text('authorName', overrideProps.authorName || ''),
authorPhoneNumber: text(
'authorPhoneNumber',
@ -122,6 +122,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
overrideProps.authorProfileName || ''
),
authorTitle: text('authorTitle', overrideProps.authorTitle || ''),
conversationColor: overrideProps.conversationColor || 'forest',
i18n,
isFromMe: boolean('isFromMe', overrideProps.isFromMe || false),
isIncoming: boolean('isIncoming', overrideProps.isIncoming || false),
@ -182,7 +183,9 @@ story.add('Incoming/Outgoing Colors', () => {
const props = createProps({});
return (
<>
{Colors.map(color => renderInMessage({ ...props, authorColor: color }))}
{ConversationColors.map(color =>
renderInMessage({ ...props, conversationColor: color })
)}
</>
);
});
@ -440,3 +443,22 @@ story.add('@mention + incoming + me', () => {
return <Quote {...props} />;
});
story.add('Custom Color', () => (
<>
<Quote
{...createProps({ isIncoming: true, text: 'Solid + Gradient' })}
customColor={{
start: { hue: 82, saturation: 35 },
}}
/>
<Quote
{...createProps()}
customColor={{
deg: 192,
start: { hue: 304, saturation: 85 },
end: { hue: 231, saturation: 76 },
}}
/>
</>
));

View file

@ -10,16 +10,18 @@ import * as GoogleChrome from '../../util/GoogleChrome';
import { MessageBody } from './MessageBody';
import { BodyRangesType, LocalizerType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import { ConversationColorType, CustomColorType } from '../../types/Colors';
import { ContactName } from './ContactName';
import { getTextWithMentions } from '../../util/getTextWithMentions';
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
export type Props = {
authorTitle: string;
authorPhoneNumber?: string;
authorProfileName?: string;
authorName?: string;
authorColor?: ColorType;
conversationColor: ConversationColorType;
customColor?: CustomColorType;
bodyRanges?: BodyRangesType;
i18n: LocalizerType;
isFromMe: boolean;
@ -361,7 +363,13 @@ export class Quote extends React.Component<Props, State> {
}
public renderReferenceWarning(): JSX.Element | null {
const { i18n, isIncoming, referencedMessageNotFound } = this.props;
const {
conversationColor,
customColor,
i18n,
isIncoming,
referencedMessageNotFound,
} = this.props;
if (!referencedMessageNotFound) {
return null;
@ -371,8 +379,11 @@ export class Quote extends React.Component<Props, State> {
<div
className={classNames(
'module-quote__reference-warning',
isIncoming ? 'module-quote__reference-warning--incoming' : null
isIncoming
? `module-quote--incoming-${conversationColor}`
: `module-quote--outgoing-${conversationColor}`
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
<div
className={classNames(
@ -398,7 +409,8 @@ export class Quote extends React.Component<Props, State> {
public render(): JSX.Element | null {
const {
authorColor,
conversationColor,
customColor,
isIncoming,
onClick,
referencedMessageNotFound,
@ -424,14 +436,15 @@ export class Quote extends React.Component<Props, State> {
'module-quote',
isIncoming ? 'module-quote--incoming' : 'module-quote--outgoing',
isIncoming
? `module-quote--incoming-${authorColor}`
: `module-quote--outgoing-${authorColor}`,
? `module-quote--incoming-${conversationColor}`
: `module-quote--outgoing-${conversationColor}`,
!onClick ? 'module-quote--no-click' : null,
withContentAbove ? 'module-quote--with-content-above' : null,
referencedMessageNotFound
? 'module-quote--with-reference-warning'
: null
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
<div className="module-quote__primary">
{this.renderAuthor()}

View file

@ -37,7 +37,7 @@ const items: Record<string, TimelineItemType> = {
timestamp: Date.now(),
author: {
phoneNumber: '(202) 555-2001',
color: 'green',
color: 'forest',
},
text: '🔥',
},
@ -50,7 +50,7 @@ const items: Record<string, TimelineItemType> = {
direction: 'incoming',
timestamp: Date.now(),
author: {
color: 'green',
color: 'forest',
},
text: 'Hello there from the new world! http://somewhere.com',
},
@ -75,7 +75,7 @@ const items: Record<string, TimelineItemType> = {
direction: 'incoming',
timestamp: Date.now(),
author: {
color: 'red',
color: 'crimson',
},
text: 'Hello there from the new world!',
},
@ -161,7 +161,7 @@ const items: Record<string, TimelineItemType> = {
timestamp: Date.now(),
status: 'sent',
author: {
color: 'pink',
color: 'plum',
},
text: '🔥',
},
@ -174,7 +174,7 @@ const items: Record<string, TimelineItemType> = {
timestamp: Date.now(),
status: 'read',
author: {
color: 'pink',
color: 'plum',
},
text: 'Hello there from the new world! http://somewhere.com',
},
@ -336,7 +336,7 @@ const renderLoadingRow = () => <TimelineLoadingRow state="loading" />;
const renderTypingBubble = () => (
<TypingBubble
acceptedMessageRequest
color="red"
color="crimson"
conversationType="direct"
phoneNumber="+18005552222"
i18n={i18n}

View file

@ -86,7 +86,7 @@ storiesOf('Components/Conversation/TimelineItem', module)
timestamp: Date.now(),
author: {
phoneNumber: '(202) 555-2001',
color: 'green',
color: 'forest',
},
text: '🔥',
},

View file

@ -8,7 +8,7 @@ import { select, text } from '@storybook/addon-knobs';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { Props, TypingBubble } from './TypingBubble';
import { Colors } from '../../types/Colors';
import { AvatarColors } from '../../types/Colors';
const i18n = setupI18n('en', enMessages);
@ -20,8 +20,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
color: select(
'color',
Colors.reduce((m, c) => ({ ...m, [c]: c }), {}),
overrideProps.color || 'red'
AvatarColors.reduce((m, c) => ({ ...m, [c]: c }), {}),
overrideProps.color || AvatarColors[0]
),
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
title: '',

View file

@ -69,7 +69,7 @@ export class TypingBubble extends React.PureComponent<Props> {
}
public render(): JSX.Element {
const { i18n, color, conversationType } = this.props;
const { i18n, conversationType } = this.props;
const isGroup = conversationType === 'group';
return (
@ -85,8 +85,7 @@ export class TypingBubble extends React.PureComponent<Props> {
<div
className={classNames(
'module-message__container',
'module-message__container--incoming',
`module-message__container--incoming-${color}`
'module-message__container--incoming'
)}
>
<div className="module-message__typing-container">

View file

@ -48,7 +48,7 @@ export function renderAvatar({
acceptedMessageRequest={false}
avatarPath={avatarPath}
blur={AvatarBlur.NoBlur}
color="grey"
color="steel"
conversationType="direct"
i18n={i18n}
isMe

View file

@ -26,6 +26,7 @@ const conversation: ConversationType = getDefaultConversation({
title: 'Some Conversation',
type: 'group',
sharedGroupNames: [],
conversationColor: 'ultramarine' as const,
});
const createProps = (hasGroupLink = false): Props => ({
@ -55,6 +56,7 @@ const createProps = (hasGroupLink = false): Props => ({
setDisappearingMessages: action('setDisappearingMessages'),
showAllMedia: action('showAllMedia'),
showContactModal: action('showContactModal'),
showGroupChatColorEditor: action('showGroupChatColorEditor'),
showGroupLinkManagement: action('showGroupLinkManagement'),
showGroupV2Permissions: action('showGroupV2Permissions'),
showPendingInvites: action('showPendingInvites'),

View file

@ -28,6 +28,7 @@ import {
} from './PendingInvites';
import { EditConversationAttributesModal } from './EditConversationAttributesModal';
import { RequestState } from './util';
import { getCustomColorStyle } from '../../../util/getCustomColorStyle';
enum ModalState {
NothingOpen,
@ -50,6 +51,7 @@ export type StateProps = {
setDisappearingMessages: (seconds: number) => void;
showAllMedia: () => void;
showContactModal: (conversationId: string) => void;
showGroupChatColorEditor: () => void;
showGroupLinkManagement: () => void;
showGroupV2Permissions: () => void;
showPendingInvites: () => void;
@ -88,6 +90,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
setDisappearingMessages,
showAllMedia,
showContactModal,
showGroupChatColorEditor,
showGroupLinkManagement,
showGroupV2Permissions,
showPendingInvites,
@ -224,8 +227,8 @@ export const ConversationDetails: React.ComponentType<Props> = ({
}}
/>
{canEditGroupInfo ? (
<PanelSection>
<PanelSection>
{canEditGroupInfo ? (
<PanelRow
icon={
<ConversationDetailsIcon
@ -252,8 +255,26 @@ export const ConversationDetails: React.ComponentType<Props> = ({
</div>
}
/>
</PanelSection>
) : null}
) : null}
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('showChatColorEditor')}
icon="color"
/>
}
label={i18n('showChatColorEditor')}
onClick={showGroupChatColorEditor}
right={
<div
className={`module-conversation-details__chat-color module-conversation-details__chat-color--${conversation.conversationColor}`}
style={{
...getCustomColorStyle(conversation.customColor),
}}
/>
}
/>
</PanelSection>
<ConversationDetailsMembershipList
canAddNewMembers={canEditGroupInfo}

View file

@ -19,7 +19,7 @@ export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={false}
color="grey"
color="steel"
conversationType="group"
headerName={title}
i18n={i18n}

View file

@ -33,7 +33,7 @@ export const StartNewConversation: FunctionComponent<Props> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={false}
color="grey"
color="steel"
conversationType="direct"
headerName={phoneNumber}
i18n={i18n}

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

@ -6,7 +6,7 @@ import * as Backbone from 'backbone';
import { GroupV2ChangeType } from './groups';
import { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util';
import { CallHistoryDetailsFromDiskType } from './types/Calling';
import { ColorType } from './types/Colors';
import { CustomColorType } from './types/Colors';
import {
ConversationType,
MessageType,
@ -193,6 +193,9 @@ export type ConversationAttributesType = {
addedBy?: string;
capabilities?: CapabilitiesType;
color?: string;
conversationColor?: string;
customColor?: CustomColorType;
customColorId?: string;
discoveredUnregisteredAt?: number;
draftAttachments?: Array<{
path?: string;

View file

@ -4,7 +4,7 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable camelcase */
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
import { compact } from 'lodash';
import { compact, sample } from 'lodash';
import {
MessageModelCollectionType,
WhatIsThis,
@ -20,7 +20,11 @@ import {
SendOptionsType,
} from '../textsecure/SendMessage';
import { ConversationType } from '../state/ducks/conversations';
import { ColorType } from '../types/Colors';
import {
AvatarColorType,
AvatarColors,
ConversationColorType,
} from '../types/Colors';
import { MessageModel } from './messages';
import { isMuted } from '../util/isMuted';
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
@ -75,21 +79,6 @@ const {
} = window.Signal.Migrations;
const { addStickerPackReference } = window.Signal.Data;
const COLORS = [
'red',
'deep_orange',
'brown',
'pink',
'purple',
'indigo',
'blue',
'teal',
'green',
'light_green',
'blue_grey',
'ultramarine',
];
const THREE_HOURS = 3 * 60 * 60 * 1000;
const FIVE_MINUTES = 1000 * 60 * 5;
@ -105,7 +94,7 @@ type CustomError = Error & {
type CachedIdenticon = {
readonly url: string;
readonly content: string;
readonly color: ColorType;
readonly color: AvatarColorType;
};
export class ConversationModel extends window.Backbone
@ -318,6 +307,12 @@ export class ConversationModel extends window.Backbone
this.fetchSMSOnlyUUID,
FIVE_MINUTES
);
// Ensure each contact has a an avatar color associated with it
if (!this.get('color')) {
this.set('color', sample(AvatarColors));
window.Signal.Data.updateConversation(this.attributes);
}
}
isMe(): boolean {
@ -1452,6 +1447,9 @@ export class ConversationModel extends window.Backbone
avatarPath: this.getAbsoluteAvatarPath(),
unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(),
color,
conversationColor: this.getConversationColor(),
customColor: this.get('customColor'),
customColorId: this.get('customColorId'),
discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'),
draftBodyRanges,
draftPreview,
@ -4675,14 +4673,19 @@ export class ConversationModel extends window.Backbone
return this.get('type') === 'private';
}
getColor(): ColorType {
getColor(): AvatarColorType {
if (!this.isPrivate()) {
return 'signal-blue';
return 'ultramarine';
}
return migrateColor(this.get('color'));
}
getConversationColor(): ConversationColorType {
return (this.get('conversationColor') ||
'ultramarine') as ConversationColorType;
}
private getAvatarPath(): undefined | string {
const avatar = this.isMe()
? this.get('profileAvatar') || this.get('avatar')
@ -5187,10 +5190,6 @@ window.Whisper.ConversationCollection = window.Backbone.Collection.extend({
},
});
window.Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(
' '
);
// This is a wrapper model used to display group members in the member list view, within
// the world of backbone, but layering another bit of group-specific data top of base
// conversation data.

View file

@ -29,7 +29,7 @@ import {
import { CallbackResultType } from '../textsecure/SendMessage';
import * as expirationTimer from '../util/expirationTimer';
import { missingCaseError } from '../util/missingCaseError';
import { ColorType } from '../types/Colors';
import { ConversationColorType } from '../types/Colors';
import { CallMode } from '../types/Calling';
import { BodyRangesType } from '../types/Util';
import { ReactionType } from '../types/Reactions';
@ -919,6 +919,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
.map(attachment => this.getPropsForAttachment(attachment));
}
getConversationColor(): ConversationColorType {
const conversation = this.getConversation();
return conversation?.getConversationColor() || ('ultramarine' as const);
}
// Note: interactionMode is mixed in via selectors/conversations._messageSelector
getPropsForMessage(): PropsForMessage {
const sourceId = this.getContactId();
@ -958,6 +963,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
text: this.createNonBreakingLastSeparator(this.get('body')),
textPending: this.get('bodyPending'),
id: this.id,
conversationColor: this.getConversationColor(),
customColor: conversation?.get('customColor'),
conversationId: this.get('conversationId'),
isSticker: Boolean(sticker),
direction: this.isIncoming() ? 'incoming' : 'outgoing',
@ -1252,7 +1259,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
}
let authorColor: ColorType;
let authorId: string;
let authorName: undefined | string;
let authorPhoneNumber: undefined | string;
@ -1263,7 +1269,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (contact && contact.isPrivate()) {
const contactPhoneNumber = contact.get('e164');
authorColor = contact.getColor();
authorId = contact.id;
authorName = contact.get('name');
authorPhoneNumber = contactPhoneNumber
@ -1279,7 +1284,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
'getPropsForQuote: contact was missing. This may indicate a bookkeeping error or bad data from another client. Returning a placeholder contact.'
);
authorColor = 'grey';
authorId = 'placeholder-contact';
authorTitle = window.i18n('unknownContact');
isFromMe = false;
@ -1288,13 +1292,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const firstAttachment = quote.attachments && quote.attachments[0];
return {
authorColor,
authorId,
authorName,
authorPhoneNumber,
authorProfileName,
authorTitle,
bodyRanges: this.processBodyRanges(bodyRanges),
conversationColor: this.getConversationColor(),
customColor: this.getConversation()?.get('customColor'),
isFromMe,
rawAttachment: firstAttachment
? this.processQuoteAttachment(firstAttachment)

9
ts/shims/getUserTheme.ts Normal file
View file

@ -0,0 +1,9 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ThemeType } from '../types/Util';
import { getTheme } from '../state/selectors/user';
export function getUserTheme(): ThemeType {
return getTheme(window.reduxStore.getState());
}

View file

@ -29,6 +29,7 @@ import { createBatcher } from '../util/batcher';
import { assert } from '../util/assert';
import { cleanDataForIpc } from './cleanDataForIpc';
import { ReactionType } from '../types/Reactions';
import { ConversationColorType, CustomColorType } from '../types/Colors';
import {
ConversationModelCollectionType,
@ -157,6 +158,7 @@ const dataInterface: ClientInterface = {
updateConversation,
updateConversations,
removeConversation,
updateAllConversationColors,
eraseStorageServiceStateFromConversations,
getAllConversations,
@ -1549,3 +1551,16 @@ function insertJob(job: Readonly<StoredJob>): Promise<void> {
function deleteJob(id: string): Promise<void> {
return channels.deleteJob(id);
}
async function updateAllConversationColors(
conversationColor?: ConversationColorType,
customColorData?: {
id: string;
value: CustomColorType;
}
): Promise<void> {
return channels.updateAllConversationColors(
conversationColor,
customColorData
);
}

View file

@ -14,6 +14,7 @@ import { MessageModel } from '../models/messages';
import { ConversationModel } from '../models/conversations';
import { StoredJob } from '../jobs/types';
import { ReactionType } from '../types/Reactions';
import { ConversationColorType, CustomColorType } from '../types/Colors';
export type AttachmentDownloadJobType = {
id: string;
@ -310,6 +311,14 @@ export type DataInterface = {
getJobsInQueue(queueType: string): Promise<Array<StoredJob>>;
insertJob(job: Readonly<StoredJob>): Promise<void>;
deleteJob(id: string): Promise<void>;
updateAllConversationColors: (
conversationColor?: ConversationColorType,
customColorData?: {
id: string;
value: CustomColorType;
}
) => Promise<void>;
};
// The reason for client/server divergence is the need to inject Backbone models and

View file

@ -36,6 +36,7 @@ import { combineNames } from '../util/combineNames';
import { getExpiresAt } from '../services/MessageUpdater';
import { isNormalNumber } from '../util/isNormalNumber';
import { isNotNil } from '../util/isNotNil';
import { ConversationColorType, CustomColorType } from '../types/Colors';
import {
AttachmentDownloadJobType,
@ -153,6 +154,7 @@ const dataInterface: ServerInterface = {
getAllConversationIds,
getAllPrivateConversations,
getAllGroupsInvolvingId,
updateAllConversationColors,
searchConversations,
searchMessages,
@ -5328,3 +5330,26 @@ async function deleteJob(id: string): Promise<void> {
db.prepare<Query>('DELETE FROM jobs WHERE id = $id').run({ id });
}
async function updateAllConversationColors(
conversationColor?: ConversationColorType,
customColorData?: {
id: string;
value: CustomColorType;
}
): Promise<void> {
const db = getInstance();
db.prepare<Query>(
`
UPDATE conversations
SET json = JSON_PATCH(json, $patch);
`
).run({
patch: JSON.stringify({
conversationColor: conversationColor || null,
customColor: customColorData?.value || null,
customColorId: customColorData?.id || null,
}),
});
}

View file

@ -6,6 +6,7 @@ import { actions as calling } from './ducks/calling';
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
import { actions as globalModals } from './ducks/globalModals';
import { actions as items } from './ducks/items';
import { actions as linkPreviews } from './ducks/linkPreviews';
import { actions as network } from './ducks/network';
@ -21,6 +22,7 @@ export const mapDispatchToProps = {
...conversations,
...emojis,
...expiration,
...globalModals,
...items,
...linkPreviews,
...network,

View file

@ -22,7 +22,12 @@ import { getOwn } from '../../util/getOwn';
import { assert } from '../../util/assert';
import { trigger } from '../../shims/events';
import { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Colors';
import {
AvatarColorType,
ConversationColorType,
CustomColorType,
} from '../../types/Colors';
import { ConversationAttributesType } from '../../model-types.d';
import { BodyRangeType } from '../../types/Util';
import { CallMode, CallHistoryDetailsFromDiskType } from '../../types/Calling';
import { MediaItemType } from '../../components/LightboxGallery';
@ -67,7 +72,10 @@ export type ConversationType = {
areWePendingApproval?: boolean;
canChangeTimer?: boolean;
canEditGroupInfo?: boolean;
color?: ColorType;
color?: AvatarColorType;
conversationColor?: ConversationColorType;
customColor?: CustomColorType;
customColorId?: string;
discoveredUnregisteredAt?: number;
isArchived?: boolean;
isBlocked?: boolean;
@ -117,7 +125,6 @@ export type ConversationType = {
isFetchingUUID?: boolean;
typingContact?: {
avatarPath?: string;
color?: ColorType;
name?: string;
phoneNumber?: string;
profileName?: string;
@ -322,6 +329,9 @@ export const getConversationCallMode = (
// Actions
const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
type CantAddContactToGroupActionType = {
type: 'CANT_ADD_CONTACT_TO_GROUP';
payload: {
@ -344,6 +354,20 @@ type CloseMaximumGroupSizeModalActionType = {
type CloseRecommendedGroupSizeModalActionType = {
type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL';
};
type ColorsChangedActionType = {
type: typeof COLORS_CHANGED;
payload: {
conversationColor?: ConversationColorType;
customColorData?: {
id: string;
value: CustomColorType;
};
};
};
type CustomColorRemovedActionType = {
type: typeof CUSTOM_COLOR_REMOVED;
payload: string;
};
type SetPreJoinConversationActionType = {
type: 'SET_PRE_JOIN_CONVERSATION';
payload: {
@ -584,6 +608,8 @@ export type ConversationActionType =
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationUnloadedActionType
| ColorsChangedActionType
| CustomColorRemovedActionType
| CreateGroupFulfilledActionType
| CreateGroupPendingActionType
| CreateGroupRejectedActionType
@ -643,11 +669,14 @@ export const actions = {
openConversationExternal,
openConversationInternal,
removeAllConversations,
removeCustomColorOnConversations,
repairNewestMessage,
repairOldestMessage,
resetAllChatColors,
reviewMessageRequestNameCollision,
scrollToMessage,
selectMessage,
setAllConversationColors,
setComposeGroupAvatar,
setComposeGroupName,
setComposeSearchTerm,
@ -667,6 +696,101 @@ export const actions = {
toggleConversationInChooseMembers,
};
function removeCustomColorOnConversations(
colorId: string
): ThunkAction<void, RootStateType, unknown, CustomColorRemovedActionType> {
return async dispatch => {
const conversationsToUpdate: Array<ConversationAttributesType> = [];
// We don't want to trigger a model change because we're updating redux
// here manually ourselves. Au revoir Backbone!
window.getConversations().forEach(conversation => {
if (conversation.get('customColorId') === colorId) {
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.conversationColor;
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.customColor;
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.customColorId;
conversationsToUpdate.push(conversation.attributes);
}
});
if (conversationsToUpdate.length) {
await window.Signal.Data.updateConversations(conversationsToUpdate);
}
dispatch({
type: CUSTOM_COLOR_REMOVED,
payload: colorId,
});
};
}
function resetAllChatColors(): ThunkAction<
void,
RootStateType,
unknown,
ColorsChangedActionType
> {
return async dispatch => {
// Calling this with no args unsets all the colors in the db
await window.Signal.Data.updateAllConversationColors();
// We don't want to trigger a model change because we're updating redux
// here manually ourselves. Au revoir Backbone!
window.getConversations().forEach(conversation => {
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.conversationColor;
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.customColor;
// eslint-disable-next-line no-param-reassign
delete conversation.attributes.customColorId;
});
dispatch({
type: COLORS_CHANGED,
payload: {
conversationColor: undefined,
customColorData: undefined,
},
});
};
}
function setAllConversationColors(
conversationColor: ConversationColorType,
customColorData?: {
id: string;
value: CustomColorType;
}
): ThunkAction<void, RootStateType, unknown, ColorsChangedActionType> {
return async dispatch => {
await window.Signal.Data.updateAllConversationColors(
conversationColor,
customColorData
);
// We don't want to trigger a model change because we're updating redux
// here manually ourselves. Au revoir Backbone!
window.getConversations().forEach(conversation => {
Object.assign(conversation.attributes, {
conversationColor,
customColor: customColorData?.value,
customColorId: customColorData?.id,
});
});
dispatch({
type: COLORS_CHANGED,
payload: {
conversationColor,
customColorData,
},
});
};
}
function cantAddContactToGroup(
conversationId: string
): CantAddContactToGroupActionType {
@ -2346,5 +2470,73 @@ export function reducer(
};
}
if (action.type === COLORS_CHANGED) {
const { conversationLookup } = state;
const { conversationColor, customColorData } = action.payload;
const nextState = {
...state,
};
Object.keys(conversationLookup).forEach(id => {
const existing = conversationLookup[id];
const added = {
...existing,
conversationColor,
customColor: customColorData?.value,
customColorId: customColorData?.id,
};
Object.assign(
nextState,
updateConversationLookups(added, existing, nextState),
{
conversationLookup: {
...nextState.conversationLookup,
[id]: added,
},
}
);
});
return nextState;
}
if (action.type === CUSTOM_COLOR_REMOVED) {
const { conversationLookup } = state;
const colorId = action.payload;
const nextState = {
...state,
};
Object.keys(conversationLookup).forEach(id => {
const existing = conversationLookup[id];
if (existing.customColorId !== colorId) {
return;
}
const changed = omit(existing, [
'conversationColor',
'customColor',
'customColorId',
]);
Object.assign(
nextState,
updateConversationLookups(changed, existing, nextState),
{
conversationLookup: {
...nextState.conversationLookup,
[id]: changed,
},
}
);
});
return nextState;
}
return state;
}

View file

@ -0,0 +1,50 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// State
export type GlobalModalsStateType = {
readonly isChatColorEditorVisible: boolean;
};
// Actions
const TOGGLE_CHAT_COLOR_EDITOR = 'globalModals/TOGGLE_CHAT_COLOR_EDITOR';
type ToggleChatColorEditorActionType = {
type: typeof TOGGLE_CHAT_COLOR_EDITOR;
};
export type GlobalModalsActionType = ToggleChatColorEditorActionType;
// Action Creators
export const actions = {
toggleChatColorEditor,
};
function toggleChatColorEditor(): ToggleChatColorEditorActionType {
return { type: TOGGLE_CHAT_COLOR_EDITOR };
}
// Reducer
export function getEmptyState(): GlobalModalsStateType {
return {
isChatColorEditorVisible: false,
};
}
export function reducer(
state: Readonly<GlobalModalsStateType> = getEmptyState(),
action: Readonly<GlobalModalsActionType>
): GlobalModalsStateType {
if (action.type === TOGGLE_CHAT_COLOR_EDITOR) {
return {
...state,
isChatColorEditorVisible: !state.isChatColorEditorVisible,
};
}
return state;
}

View file

@ -2,13 +2,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { omit } from 'lodash';
import { v4 as getGuid } from 'uuid';
import { ThunkAction } from 'redux-thunk';
import { StateType as RootStateType } from '../reducer';
import * as storageShim from '../../shims/storage';
import { useBoundActions } from '../../util/hooks';
import { CustomColorType } from '../../types/Colors';
// State
export type ItemsStateType = {
readonly [key: string]: unknown;
readonly customColors?: {
readonly colors: Record<string, CustomColorType>;
readonly version: number;
};
};
// Actions
@ -50,6 +58,9 @@ export type ItemsActionType =
// Action Creators
export const actions = {
addCustomColor,
editCustomColor,
removeCustomColor,
onSetSkinTone,
putItem,
putItemExternal,
@ -103,6 +114,74 @@ function resetItems(): ItemsResetAction {
return { type: 'items/RESET' };
}
function getDefaultCustomColorData() {
return {
colors: {},
version: 1,
};
}
function addCustomColor(
payload: CustomColorType
): ThunkAction<void, RootStateType, unknown, ItemPutAction> {
return (dispatch, getState) => {
const { customColors = getDefaultCustomColorData() } = getState().items;
let uuid = getGuid();
while (customColors.colors[uuid]) {
uuid = getGuid();
}
const nextCustomColors = {
...customColors,
colors: {
...customColors.colors,
[uuid]: payload,
},
};
dispatch(putItem('customColors', nextCustomColors));
};
}
function editCustomColor(
colorId: string,
color: CustomColorType
): ThunkAction<void, RootStateType, unknown, ItemPutAction> {
return (dispatch, getState) => {
const { customColors = getDefaultCustomColorData() } = getState().items;
if (!customColors.colors[colorId]) {
return;
}
const nextCustomColors = {
...customColors,
colors: {
...customColors.colors,
[colorId]: color,
},
};
dispatch(putItem('customColors', nextCustomColors));
};
}
function removeCustomColor(
payload: string
): ThunkAction<void, RootStateType, unknown, ItemPutAction> {
return (dispatch, getState) => {
const { customColors = getDefaultCustomColorData() } = getState().items;
const nextCustomColors = {
...customColors,
colors: omit(customColors.colors, payload),
};
dispatch(putItem('customColors', nextCustomColors));
};
}
// Reducer
function getEmptyState(): ItemsStateType {

View file

@ -8,6 +8,7 @@ import { reducer as calling } from './ducks/calling';
import { reducer as conversations } from './ducks/conversations';
import { reducer as emojis } from './ducks/emojis';
import { reducer as expiration } from './ducks/expiration';
import { reducer as globalModals } from './ducks/globalModals';
import { reducer as items } from './ducks/items';
import { reducer as linkPreviews } from './ducks/linkPreviews';
import { reducer as network } from './ducks/network';
@ -23,6 +24,7 @@ export const reducer = combineReducers({
conversations,
emojis,
expiration,
globalModals,
items,
linkPreviews,
network,

View file

@ -0,0 +1,21 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import {
SmartChatColorPicker,
SmartChatColorPickerProps,
} from '../smart/ChatColorPicker';
export const createChatColorPicker = (
store: Store,
props: SmartChatColorPickerProps
): React.ReactElement => (
<Provider store={store}>
<SmartChatColorPicker {...props} />
</Provider>
);

View file

@ -0,0 +1,17 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { SmartGlobalModalContainer } from '../smart/GlobalModalContainer';
export const createGlobalModalContainer = (
store: Store
): React.ReactElement => (
<Provider store={store}>
<SmartGlobalModalContainer />
</Provider>
);

View file

@ -28,6 +28,7 @@ import { TimelineItemType } from '../../components/conversation/TimelineItem';
import { assert } from '../../util/assert';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations';
import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
import {
getInteractionMode,
@ -850,3 +851,62 @@ export const getInvitedContactsForNewlyCreatedGroup = createSelector(
invitedConversationIdsForNewlyCreatedGroup
)
);
const getCachedConversationMemberColorsSelector = createSelector(
getConversationSelector,
(conversationSelector: GetConversationByIdType) => {
return memoizee(
(conversationId: string) => {
const contactNameColors: Map<string, ContactNameColorType> = new Map();
const { sortedGroupMembers = [] } = conversationSelector(
conversationId
);
[...sortedGroupMembers]
.sort((left, right) =>
String(left.uuid) > String(right.uuid) ? 1 : -1
)
.forEach((member, i) => {
contactNameColors.set(
member.id,
ContactNameColors[i % ContactNameColors.length]
);
});
return contactNameColors;
},
{ max: 100 }
);
}
);
export const getContactNameColorSelector = createSelector(
getCachedConversationMemberColorsSelector,
conversationMemberColorsSelector => {
return (
conversationId: string,
contactId: string
): ContactNameColorType => {
const contactNameColors = conversationMemberColorsSelector(
conversationId
);
const color = contactNameColors.get(contactId);
if (!color) {
assert(false, `No color generated for contact ${contactId}`);
return ContactNameColors[0];
}
return color;
};
}
);
export const getConversationsWithCustomColorSelector = createSelector(
getAllConversations,
conversations => {
return (colorId: string): Array<ConversationType> => {
return conversations.filter(
conversation => conversation.customColorId === colorId
);
};
}
);

View file

@ -0,0 +1,60 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import {
ChatColorPicker,
PropsDataType,
} from '../../components/ChatColorPicker';
import { ConversationColorType, CustomColorType } from '../../types/Colors';
import { StateType } from '../reducer';
import {
getConversationSelector,
getConversationsWithCustomColorSelector,
getMe,
} from '../selectors/conversations';
import { getIntl } from '../selectors/user';
export type SmartChatColorPickerProps = {
conversationId?: string;
isInModal?: boolean;
onChatColorReset?: () => unknown;
onSelectColor: (
color: ConversationColorType,
customColorData?: {
id: string;
value: CustomColorType;
}
) => unknown;
};
const mapStateToProps = (
state: StateType,
props: SmartChatColorPickerProps
): PropsDataType => {
const conversation = props.conversationId
? getConversationSelector(state)(props.conversationId)
: getMe(state);
const { customColors } = state.items;
return {
...props,
customColors: customColors ? customColors.colors : {},
getConversationsWithCustomColor: getConversationsWithCustomColorSelector(
state
),
i18n: getIntl(state),
selectedColor: conversation.conversationColor,
selectedCustomColor: {
id: conversation.customColorId,
value: conversation.customColor,
},
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartChatColorPicker = smart(ChatColorPicker);

View file

@ -30,5 +30,14 @@ export const SmartContactName: React.ComponentType<ExternalProps> = props => {
title: i18n('unknownContact'),
};
return <ContactName i18n={i18n} {...conversation} />;
return (
<ContactName
firstName={conversation.firstName}
i18n={i18n}
name={conversation.name}
phoneNumber={conversation.phoneNumber}
profileName={conversation.profileName}
title={conversation.title}
/>
);
};

View file

@ -25,6 +25,7 @@ export type SmartConversationDetailsProps = {
setDisappearingMessages: (seconds: number) => void;
showAllMedia: () => void;
showContactModal: (conversationId: string) => void;
showGroupChatColorEditor: () => void;
showGroupLinkManagement: () => void;
showGroupV2Permissions: () => void;
showPendingInvites: () => void;

View file

@ -34,6 +34,7 @@ export type OwnProps = {
onSetMuteNotifications: (seconds: number) => void;
onSetPin: (value: boolean) => void;
onShowAllMedia: () => void;
onShowChatColorEditor: () => void;
onShowContactModal: (contactId: string) => void;
onShowGroupMembers: () => void;

View file

@ -0,0 +1,33 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { SmartChatColorPicker } from './ChatColorPicker';
import { ConversationColorType } from '../../types/Colors';
function renderChatColorPicker({
setAllConversationColors,
}: {
setAllConversationColors: (color: ConversationColorType) => unknown;
}): JSX.Element {
return (
<SmartChatColorPicker isInModal onSelectColor={setAllConversationColors} />
);
}
const mapStateToProps = (state: StateType) => {
return {
...state.globalModals,
i18n: getIntl(state),
renderChatColorPicker,
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartGlobalModalContainer = smart(GlobalModalContainer);

View file

@ -10,6 +10,8 @@ import { StateType } from '../reducer';
import { TimelineItem } from '../../components/conversation/TimelineItem';
import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
import {
getContactNameColorSelector,
getConversationSelector,
getMessageSelector,
getSelectedMessage,
} from '../selectors/conversations';
@ -37,13 +39,25 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const messageSelector = getMessageSelector(state);
const item = messageSelector(id);
if (item?.type === 'message' && item.data.conversationType === 'group') {
const { author } = item.data;
item.data.contactNameColor = getContactNameColorSelector(state)(
conversationId,
author.id
);
}
const selectedMessage = getSelectedMessage(state);
const isSelected = Boolean(selectedMessage && id === selectedMessage.id);
const conversation = getConversationSelector(state)(conversationId);
return {
item,
id,
conversationId,
conversationColor: conversation?.conversationColor,
customColor: conversation?.customColor,
isSelected,
renderContact,
i18n: getIntl(state),

View file

@ -6,6 +6,7 @@ import { actions as calling } from './ducks/calling';
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
import { actions as globalModals } from './ducks/globalModals';
import { actions as items } from './ducks/items';
import { actions as linkPreviews } from './ducks/linkPreviews';
import { actions as network } from './ducks/network';
@ -21,6 +22,7 @@ export type ReduxActions = {
conversations: typeof conversations;
emojis: typeof emojis;
expiration: typeof expiration;
globalModals: typeof globalModals;
items: typeof items;
linkPreviews: typeof linkPreviews;
network: typeof network;

View file

@ -0,0 +1,27 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
actions,
getEmptyState,
reducer,
} from '../../../state/ducks/globalModals';
describe('both/state/ducks/globalModals', () => {
describe('toggleChatColorEditor', () => {
const { toggleChatColorEditor } = actions;
it('toggles isChatColorEditorVisible', () => {
const state = getEmptyState();
const nextState = reducer(state, toggleChatColorEditor());
assert.isTrue(nextState.isChatColorEditorVisible);
const nextNextState = reducer(nextState, toggleChatColorEditor());
assert.isFalse(nextNextState.isChatColorEditorVisible);
});
});
});

View file

@ -23,6 +23,7 @@ import {
getComposerConversationSearchTerm,
getComposerStep,
getComposeSelectedContacts,
getContactNameColorSelector,
getConversationByIdSelector,
getConversationsByTitleSelector,
getConversationSelector,
@ -1211,7 +1212,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1236,7 +1236,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1261,7 +1260,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1286,7 +1284,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1311,7 +1308,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1360,7 +1356,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1386,7 +1381,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1412,7 +1406,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1463,7 +1456,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1488,7 +1480,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1513,7 +1504,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1539,7 +1529,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1564,7 +1553,6 @@ describe('both/state/selectors/conversations', () => {
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
@ -1842,4 +1830,44 @@ describe('both/state/selectors/conversations', () => {
assert.strictEqual(getSelectedConversation(state), conversation);
});
});
describe('#getContactNameColorSelector', () => {
function makeConversationWithUuid(id: string): ConversationType {
const convo = makeConversation(id);
convo.uuid = id;
return convo;
}
it('returns the right color order sorted by UUID ASC', () => {
const group = makeConversation('group');
group.sortedGroupMembers = [
makeConversationWithUuid('zyx'),
makeConversationWithUuid('vut'),
makeConversationWithUuid('srq'),
makeConversationWithUuid('pon'),
makeConversationWithUuid('mlk'),
makeConversationWithUuid('jih'),
makeConversationWithUuid('gfe'),
];
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
group,
},
},
};
const contactNameColorSelector = getContactNameColorSelector(state);
assert.equal(contactNameColorSelector('group', 'gfe'), '000');
assert.equal(contactNameColorSelector('group', 'jih'), '120');
assert.equal(contactNameColorSelector('group', 'mlk'), '240');
assert.equal(contactNameColorSelector('group', 'pon'), '040');
assert.equal(contactNameColorSelector('group', 'srq'), '160');
assert.equal(contactNameColorSelector('group', 'vut'), '280');
assert.equal(contactNameColorSelector('group', 'zyx'), '080');
});
});
});

View file

@ -0,0 +1,44 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
describe('getCustomColorStyle', () => {
it('returns undefined if no color passed in', () => {
assert.isUndefined(getCustomColorStyle());
});
it('returns backgroundColor for solid colors', () => {
const color = {
start: {
hue: 90,
saturation: 100,
},
};
assert.deepEqual(getCustomColorStyle(color), {
backgroundColor: 'hsl(90, 100%, 30%)',
});
});
it('returns backgroundImage with linear-gradient for gradients', () => {
const color = {
start: {
hue: 90,
saturation: 100,
},
end: {
hue: 180,
saturation: 50,
},
deg: 270,
};
assert.deepEqual(getCustomColorStyle(color), {
backgroundImage:
'linear-gradient(0deg, hsl(90, 100%, 30%), hsl(180, 50%, 30%))',
});
});
});

View file

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { getHSL } from '../../util/getHSL';
describe('getHSL', () => {
it('returns expected lightness values', () => {
const saturation = 100;
assert.equal(getHSL({ hue: 0, saturation }), 'hsl(0, 100%, 45%)');
assert.equal(getHSL({ hue: 60, saturation }), 'hsl(60, 100%, 30%)');
assert.equal(getHSL({ hue: 90, saturation }), 'hsl(90, 100%, 30%)');
assert.equal(getHSL({ hue: 180, saturation }), 'hsl(180, 100%, 30%)');
assert.equal(getHSL({ hue: 240, saturation }), 'hsl(240, 100%, 50%)');
assert.equal(getHSL({ hue: 300, saturation }), 'hsl(300, 100%, 40%)');
assert.equal(getHSL({ hue: 360, saturation }), 'hsl(360, 100%, 45%)');
});
it('calculates lightness between values', () => {
assert.equal(getHSL({ hue: 210, saturation: 100 }), 'hsl(210, 100%, 40%)');
});
});

View file

@ -40,6 +40,7 @@ const {
openConversationInternal,
repairNewestMessage,
repairOldestMessage,
setAllConversationColors,
setComposeGroupAvatar,
setComposeGroupName,
setComposeSearchTerm,
@ -49,6 +50,7 @@ const {
startComposing,
showChooseGroupMembers,
startSettingGroupMetadata,
resetAllChatColors,
reviewMessageRequestNameCollision,
toggleConversationInChooseMembers,
} = actions;
@ -1977,4 +1979,133 @@ describe('both/state/ducks/conversations', () => {
});
});
});
describe('COLORS_CHANGED', () => {
const abc = getDefaultConversation({
id: 'abc',
uuid: 'abc',
conversationColor: 'wintergreen',
});
const def = getDefaultConversation({
id: 'def',
uuid: 'def',
conversationColor: 'infrared',
});
const ghi = getDefaultConversation({
id: 'ghi',
e164: 'ghi',
conversationColor: 'ember',
});
const jkl = getDefaultConversation({
id: 'jkl',
groupId: 'jkl',
conversationColor: 'plum',
});
const getState = () => ({
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
abc,
def,
ghi,
jkl,
},
conversationsByUuid: {
abc,
def,
},
conversationsByE164: {
ghi,
},
conversationsByGroupId: {
jkl,
},
},
});
it('setAllConversationColors', async () => {
const dispatch = sinon.spy();
await setAllConversationColors('crimson')(dispatch, getState, null);
const [action] = dispatch.getCall(0).args;
const nextState = reducer(getState().conversations, action);
sinon.assert.calledOnce(dispatch);
assert.equal(
nextState.conversationLookup.abc.conversationColor,
'crimson'
);
assert.equal(
nextState.conversationLookup.def.conversationColor,
'crimson'
);
assert.equal(
nextState.conversationLookup.ghi.conversationColor,
'crimson'
);
assert.equal(
nextState.conversationLookup.jkl.conversationColor,
'crimson'
);
assert.equal(
nextState.conversationsByUuid.abc.conversationColor,
'crimson'
);
assert.equal(
nextState.conversationsByUuid.def.conversationColor,
'crimson'
);
assert.equal(
nextState.conversationsByE164.ghi.conversationColor,
'crimson'
);
assert.equal(
nextState.conversationsByGroupId.jkl.conversationColor,
'crimson'
);
});
it('resetAllChatColors', async () => {
const dispatch = sinon.spy();
await resetAllChatColors()(dispatch, getState, null);
const [action] = dispatch.getCall(0).args;
const nextState = reducer(getState().conversations, action);
sinon.assert.calledOnce(dispatch);
assert.isUndefined(
nextState.conversationLookup.abc.conversationColor,
'crimson'
);
assert.isUndefined(
nextState.conversationLookup.def.conversationColor,
'crimson'
);
assert.isUndefined(
nextState.conversationLookup.ghi.conversationColor,
'crimson'
);
assert.isUndefined(
nextState.conversationLookup.jkl.conversationColor,
'crimson'
);
assert.isUndefined(
nextState.conversationsByUuid.abc.conversationColor,
'crimson'
);
assert.isUndefined(
nextState.conversationsByUuid.def.conversationColor,
'crimson'
);
assert.isUndefined(
nextState.conversationsByE164.ghi.conversationColor,
'crimson'
);
assert.isUndefined(
nextState.conversationsByGroupId.jkl.conversationColor,
'crimson'
);
});
});
});

View file

@ -1,21 +1,95 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const Colors = [
'red',
'deep_orange',
'brown',
'pink',
'purple',
'indigo',
'blue',
export const AvatarColors = [
'crimson',
'vermilion',
'burlap',
'forest',
'wintergreen',
'teal',
'green',
'light_green',
'blue_grey',
'grey',
'blue',
'indigo',
'violet',
'plum',
'taupe',
'steel',
'ultramarine',
'signal-blue',
] as const;
export type ColorType = typeof Colors[number];
export const ConversationColors = [
'ultramarine',
'crimson',
'vermilion',
'burlap',
'forest',
'wintergreen',
'teal',
'blue',
'indigo',
'violet',
'plum',
'taupe',
'steel',
'ember',
'midnight',
'infrared',
'lagoon',
'fluorescent',
'basil',
'sublime',
'sea',
'tangerine',
] as const;
export const ContactNameColors = [
'000',
'120',
'240',
'040',
'160',
'280',
'080',
'200',
'320',
'020',
'140',
'260',
'060',
'180',
'300',
'100',
'220',
'340',
'010',
'130',
'250',
'050',
'170',
'290',
'090',
'210',
'330',
'030',
'150',
'270',
'070',
'190',
'310',
'110',
'230',
'350',
];
export type ContactNameColorType = typeof ContactNameColors[number];
export type CustomColorType = {
start: { hue: number; saturation: number };
end?: { hue: number; saturation: number };
deg?: number;
};
export type AvatarColorType = typeof AvatarColors[number];
export type ConversationColorType =
| typeof ConversationColors[number]
| 'custom';

View file

@ -0,0 +1,53 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CustomColorType } from '../types/Colors';
import { ThemeType } from '../types/Util';
import { getHSL } from './getHSL';
import { getUserTheme } from '../shims/getUserTheme';
type ExtraQuotePropsType = {
borderLeftColor?: string;
};
type BackgroundPropertyType =
| { backgroundColor: string }
| { backgroundImage: string }
| undefined;
export function getCustomColorStyle(
color?: CustomColorType,
isQuote = false
): BackgroundPropertyType {
if (!color) {
return undefined;
}
const extraQuoteProps: ExtraQuotePropsType = {};
let adjustedLightness = 0;
if (isQuote) {
const theme = getUserTheme();
if (theme === ThemeType.light) {
adjustedLightness = 0.6;
}
if (theme === ThemeType.dark) {
adjustedLightness = -0.4;
}
extraQuoteProps.borderLeftColor = getHSL(color.start);
}
if (!color.end) {
return {
...extraQuoteProps,
backgroundColor: getHSL(color.start, adjustedLightness),
};
}
return {
...extraQuoteProps,
backgroundImage: `linear-gradient(${270 - (color.deg || 0)}deg, ${getHSL(
color.start,
adjustedLightness
)}, ${getHSL(color.end, adjustedLightness)})`,
};
}

59
ts/util/getHSL.ts Normal file
View file

@ -0,0 +1,59 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const LIGHTNESS_TABLE: Record<number, number> = {
0: 45,
60: 30,
180: 30,
240: 50,
300: 40,
360: 45,
};
function getLightnessFromHue(hue: number, min: number, max: number) {
const percentage = ((hue - min) * 100) / (max - min);
const minValue = LIGHTNESS_TABLE[min];
const maxValue = LIGHTNESS_TABLE[max];
return (percentage * (maxValue - minValue)) / 100 + minValue;
}
function calculateLightness(hue: number): number {
let lightness = 45;
if (hue < 60) {
lightness = getLightnessFromHue(hue, 0, 60);
} else if (hue < 180) {
lightness = 30;
} else if (hue < 240) {
lightness = getLightnessFromHue(hue, 180, 240);
} else if (hue < 300) {
lightness = getLightnessFromHue(hue, 240, 300);
} else {
lightness = getLightnessFromHue(hue, 300, 360);
}
return lightness;
}
function adjustLightnessValue(
lightness: number,
percentIncrease: number
): number {
return lightness + lightness * percentIncrease;
}
export function getHSL(
{
hue,
saturation,
}: {
hue: number;
saturation: number;
},
adjustedLightness = 0
): string {
return `hsl(${hue}, ${saturation}%, ${adjustLightnessValue(
calculateLightness(hue),
adjustedLightness
)}%)`;
}

View file

@ -13557,6 +13557,13 @@
"updated": "2019-03-09T00:08:44.242Z",
"reasonDetail": "Used only to set focus"
},
{
"rule": "React-useRef",
"path": "ts/components/ChatColorPicker.js",
"line": " const menuRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-25T18:25:53.896Z"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.js",
@ -13697,6 +13704,13 @@
"reasonCategory": "usageTrusted",
"updated": "2021-04-19T18:13:21.664Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GradientDial.js",
"line": " const containerRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-25T18:25:53.896Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GroupCallOverflowArea.js",
@ -13814,6 +13828,27 @@
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Only used to focus the element."
},
{
"rule": "React-useRef",
"path": "ts/components/Slider.js",
"line": " const diff = react_1.useRef(0);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-25T18:25:53.896Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Slider.js",
"line": " const handleRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-25T18:25:53.896Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Slider.js",
"line": " const sliderRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-05-25T18:25:53.896Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Tooltip.js",
@ -14241,4 +14276,4 @@
"updated": "2021-03-18T21:41:28.361Z",
"reasonDetail": "A generic hook. Typically not to be used with non-DOM values."
}
]
]

View file

@ -1,42 +1,53 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ColorType } from '../types/Colors';
import { AvatarColorType } from '../types/Colors';
export function migrateColor(color?: string): ColorType {
export function migrateColor(color?: string): AvatarColorType {
switch (color) {
// These colors no longer exist
case 'orange':
case 'amber':
return 'deep_orange';
return 'vermilion';
case 'yellow':
return 'brown';
return 'burlap';
case 'deep_purple':
return 'purple';
return 'violet';
case 'light_blue':
return 'blue';
case 'cyan':
return 'teal';
case 'lime':
return 'light_green';
return 'wintergreen';
// Actual color names
case 'red':
return 'crimson';
case 'deep_orange':
return 'vermilion';
case 'brown':
return 'burlap';
case 'pink':
return 'plum';
case 'purple':
return 'violet';
case 'green':
return 'forest';
case 'light_green':
return 'wintergreen';
case 'blue_grey':
return 'steel';
case 'grey':
return 'steel';
// These can stay as they are
case 'red':
case 'deep_orange':
case 'brown':
case 'pink':
case 'purple':
case 'indigo':
case 'blue':
case 'indigo':
case 'teal':
case 'green':
case 'light_green':
case 'blue_grey':
case 'grey':
case 'ultramarine':
return color;
default:
return 'grey';
return 'steel';
}
}

View file

@ -4,6 +4,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AttachmentType } from '../types/Attachment';
import { ConversationColorType, CustomColorType } from '../types/Colors';
import { ConversationModel } from '../models/conversations';
import { GroupV2PendingMemberType } from '../model-types.d';
import { LinkPreviewType } from '../types/message/LinkPreviews';
@ -551,6 +552,9 @@ Whisper.ConversationView = Whisper.View.extend({
}
},
onShowChatColorEditor: () => {
this.showChatColorEditor();
},
onShowConversationDetails: () => {
this.showConversationDetails();
},
@ -3141,6 +3145,44 @@ Whisper.ConversationView = Whisper.View.extend({
view.render();
},
showChatColorEditor() {
const conversation: ConversationModel = this.model;
const view = new Whisper.ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createChatColorPicker(window.reduxStore, {
conversationId: conversation.get('id'),
onSelectColor: (
color: ConversationColorType,
customColorData?: {
id: string;
value: CustomColorType;
}
) => {
conversation.set('conversationColor', color);
if (customColorData) {
conversation.set('customColor', customColorData.value);
conversation.set('customColorId', customColorData.id);
} else {
conversation.unset('customColor');
conversation.unset('customColorId');
}
window.Signal.Data.updateConversation(conversation.attributes);
},
onChatColorReset: () => {
conversation.set('conversationColor', undefined);
conversation.unset('customColor');
window.Signal.Data.updateConversation(conversation.attributes);
},
}),
});
view.headerTitle = window.i18n('ChatColorPicker__menu-title');
this.listenBack(view);
view.render();
},
showConversationDetails() {
const conversation: ConversationModel = this.model;
@ -3177,6 +3219,7 @@ Whisper.ConversationView = Whisper.View.extend({
setDisappearingMessages: this.setDisappearingMessages.bind(this),
showAllMedia: this.showAllMedia.bind(this),
showContactModal: this.showContactModal.bind(this),
showGroupChatColorEditor: this.showChatColorEditor.bind(this),
showGroupLinkManagement: this.showGroupLinkManagement.bind(this),
showGroupV2Permissions: this.showGroupV2Permissions.bind(this),
showPendingInvites: this.showPendingInvites.bind(this),

7
ts/window.d.ts vendored
View file

@ -36,7 +36,6 @@ import { getEnvironment } from './environment';
import * as zkgroup from './util/zkgroup';
import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
import * as Attachment from './types/Attachment';
import { ColorType } from './types/Colors';
import * as MIME from './types/MIME';
import * as Contact from './types/Contact';
import * as Errors from '../js/modules/types/errors';
@ -44,11 +43,13 @@ import { ConversationController } from './ConversationController';
import { ReduxActions } from './state/types';
import { createStore } from './state/createStore';
import { createCallManager } from './state/roots/createCallManager';
import { createChatColorPicker } from './state/roots/createChatColorPicker';
import { createCompositionArea } from './state/roots/createCompositionArea';
import { createContactModal } from './state/roots/createContactModal';
import { createConversationDetails } from './state/roots/createConversationDetails';
import { createConversationHeader } from './state/roots/createConversationHeader';
import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
import { createGlobalModalContainer } from './state/roots/createGlobalModalContainer';
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
@ -89,6 +90,7 @@ import {
PropsType as CallingScreenSharingControllerProps,
} from './components/CallingScreenSharingController';
import { CaptionEditor } from './components/CaptionEditor';
import { ChatColorPicker } from './components/ChatColorPicker';
import { ConfirmationDialog } from './components/ConfirmationDialog';
import { ContactDetail } from './components/conversation/ContactDetail';
import { ContactModal } from './components/conversation/ContactModal';
@ -473,6 +475,7 @@ declare global {
Components: {
AttachmentList: typeof AttachmentList;
CaptionEditor: typeof CaptionEditor;
ChatColorPicker: typeof ChatColorPicker;
ConfirmationDialog: typeof ConfirmationDialog;
ContactDetail: typeof ContactDetail;
ContactModal: typeof ContactModal;
@ -500,11 +503,13 @@ declare global {
createStore: typeof createStore;
Roots: {
createCallManager: typeof createCallManager;
createChatColorPicker: typeof createChatColorPicker;
createCompositionArea: typeof createCompositionArea;
createContactModal: typeof createContactModal;
createConversationDetails: typeof createConversationDetails;
createConversationHeader: typeof createConversationHeader;
createForwardMessageModal: typeof createForwardMessageModal;
createGlobalModalContainer: typeof createGlobalModalContainer;
createGroupLinkManagement: typeof createGroupLinkManagement;
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
createGroupV2JoinModal: typeof createGroupV2JoinModal;