Conversation Colors
This commit is contained in:
parent
b63d8e908c
commit
28f016ce48
128 changed files with 3997 additions and 1207 deletions
|
@ -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;
|
||||
|
|
|
@ -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} />);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 || ''),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
68
ts/components/ChatColorPicker.stories.tsx
Normal file
68
ts/components/ChatColorPicker.stories.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
));
|
386
ts/components/ChatColorPicker.tsx
Normal file
386
ts/components/ChatColorPicker.tsx
Normal 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;
|
||||
};
|
|
@ -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,
|
||||
|
|
23
ts/components/CustomColorEditor.stories.tsx
Normal file
23
ts/components/CustomColorEditor.stories.tsx
Normal 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()} />);
|
182
ts/components/CustomColorEditor.tsx
Normal file
182
ts/components/CustomColorEditor.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
43
ts/components/GlobalModalContainer.tsx
Normal file
43
ts/components/GlobalModalContainer.tsx
Normal 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;
|
||||
};
|
309
ts/components/GradientDial.tsx
Normal file
309
ts/components/GradientDial.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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(''),
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
112
ts/components/SampleMessageBubbles.tsx
Normal file
112
ts/components/SampleMessageBubbles.tsx
Normal 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' }} />
|
||||
</>
|
||||
);
|
||||
};
|
29
ts/components/Slider.stories.tsx
Normal file
29
ts/components/Slider.stories.tsx
Normal 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
126
ts/components/Slider.tsx
Normal 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
68
ts/components/Tabs.tsx
Normal 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 })}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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: '',
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
));
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
));
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -86,7 +86,7 @@ storiesOf('Components/Conversation/TimelineItem', module)
|
|||
timestamp: Date.now(),
|
||||
author: {
|
||||
phoneNumber: '(202) 555-2001',
|
||||
color: 'green',
|
||||
color: 'forest',
|
||||
},
|
||||
text: '🔥',
|
||||
},
|
||||
|
|
|
@ -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: '',
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -48,7 +48,7 @@ export function renderAvatar({
|
|||
acceptedMessageRequest={false}
|
||||
avatarPath={avatarPath}
|
||||
blur={AvatarBlur.NoBlur}
|
||||
color="grey"
|
||||
color="steel"
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
5
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
9
ts/shims/getUserTheme.ts
Normal 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());
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
50
ts/state/ducks/globalModals.ts
Normal file
50
ts/state/ducks/globalModals.ts
Normal 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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
21
ts/state/roots/createChatColorPicker.tsx
Normal file
21
ts/state/roots/createChatColorPicker.tsx
Normal 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>
|
||||
);
|
17
ts/state/roots/createGlobalModalContainer.tsx
Normal file
17
ts/state/roots/createGlobalModalContainer.tsx
Normal 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>
|
||||
);
|
|
@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
60
ts/state/smart/ChatColorPicker.tsx
Normal file
60
ts/state/smart/ChatColorPicker.tsx
Normal 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);
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
33
ts/state/smart/GlobalModalContainer.tsx
Normal file
33
ts/state/smart/GlobalModalContainer.tsx
Normal 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);
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
|
27
ts/test-both/state/ducks/globalModals_test.ts
Normal file
27
ts/test-both/state/ducks/globalModals_test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
44
ts/test-both/util/getCustomColorStyle.ts
Normal file
44
ts/test-both/util/getCustomColorStyle.ts
Normal 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%))',
|
||||
});
|
||||
});
|
||||
});
|
23
ts/test-both/util/getHSL_test.ts
Normal file
23
ts/test-both/util/getHSL_test.ts
Normal 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%)');
|
||||
});
|
||||
});
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
53
ts/util/getCustomColorStyle.ts
Normal file
53
ts/util/getCustomColorStyle.ts
Normal 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
59
ts/util/getHSL.ts
Normal 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
|
||||
)}%)`;
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
7
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue