diff --git a/ts/components/Modal.stories.tsx b/ts/components/Modal.stories.tsx
index 04138001505e..54e0bdbf6a19 100644
--- a/ts/components/Modal.stories.tsx
+++ b/ts/components/Modal.stories.tsx
@@ -106,3 +106,45 @@ story.add('Long body with long title and X button', () => (
{LOREM_IPSUM}
));
+
+story.add('With sticky buttons long body', () => (
+
+ {LOREM_IPSUM}
+ {LOREM_IPSUM}
+ {LOREM_IPSUM}
+ {LOREM_IPSUM}
+
+ Okay
+ Okay
+
+
+));
+
+story.add('With sticky buttons short body', () => (
+
+ {LOREM_IPSUM.slice(0, 140)}
+
+ Okay
+ Okay
+
+
+));
+
+story.add('Sticky footer, Lots of buttons', () => (
+
+ {LOREM_IPSUM}
+
+ Okay
+ Okay
+ Okay
+
+ This is a button with a fairly large amount of text
+
+ Okay
+
+ This is a button with a fairly large amount of text
+
+ Okay
+
+
+));
diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx
index 882817bd0d6c..2174d7facd88 100644
--- a/ts/components/Modal.tsx
+++ b/ts/components/Modal.tsx
@@ -1,7 +1,8 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React, { useState, ReactElement, ReactNode } from 'react';
+import React, { useRef, useState, ReactElement, ReactNode } from 'react';
+import Measure, { ContentRect, MeasuredComponentProps } from 'react-measure';
import classNames from 'classnames';
import { noop } from 'lodash';
@@ -13,6 +14,7 @@ import { useHasWrapped } from '../util/hooks';
type PropsType = {
children: ReactNode;
+ hasStickyButtons?: boolean;
hasXButton?: boolean;
i18n: LocalizerType;
moduleClassName?: string;
@@ -26,6 +28,7 @@ const BASE_CLASS_NAME = 'module-Modal';
export function Modal({
children,
+ hasStickyButtons,
hasXButton,
i18n,
moduleClassName,
@@ -34,18 +37,32 @@ export function Modal({
title,
theme,
}: Readonly
): ReactElement {
+ const modalRef = useRef(null);
const [scrolled, setScrolled] = useState(false);
+ const [hasOverflow, setHasOverflow] = useState(false);
const hasHeader = Boolean(hasXButton || title);
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
+ function handleResize({ scroll }: ContentRect) {
+ const modalNode = modalRef?.current;
+ if (!modalNode) {
+ return;
+ }
+ if (scroll) {
+ setHasOverflow(scroll.height > modalNode.clientHeight);
+ }
+ }
+
return (
{hasHeader && (
@@ -72,17 +89,25 @@ export function Modal({
)}
)}
-
+ {({ measureRef }: MeasuredComponentProps) => (
+
{
+ setScrolled((event.target as HTMLDivElement).scrollTop > 2);
+ }}
+ ref={measureRef}
+ >
+ {children}
+
)}
- onScroll={event => {
- setScrolled((event.target as HTMLDivElement).scrollTop > 2);
- }}
- >
- {children}
-
+
);
diff --git a/ts/components/ProfileEditor.stories.tsx b/ts/components/ProfileEditor.stories.tsx
index 8308a4cac322..9ab8c33550b2 100644
--- a/ts/components/ProfileEditor.stories.tsx
+++ b/ts/components/ProfileEditor.stories.tsx
@@ -14,6 +14,7 @@ import {
getFirstName,
getLastName,
} from '../test-both/helpers/getDefaultConversation';
+import { getRandomColor } from '../test-both/helpers/getRandomColor';
const i18n = setupI18n('en', enMessages);
@@ -23,6 +24,9 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({
aboutEmoji: overrideProps.aboutEmoji,
aboutText: text('about', overrideProps.aboutText || ''),
avatarPath: overrideProps.avatarPath,
+ conversationId: '123',
+ color: overrideProps.color || getRandomColor(),
+ deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
familyName: overrideProps.familyName,
firstName: text('firstName', overrideProps.firstName || getFirstName()),
i18n,
@@ -30,7 +34,10 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({
onProfileChanged: action('onProfileChanged'),
onSetSkinTone: overrideProps.onSetSkinTone || action('onSetSkinTone'),
recentEmojis: [],
+ replaceAvatar: action('replaceAvatar'),
+ saveAvatarToDisk: action('saveAvatarToDisk'),
skinTone: overrideProps.skinTone || 0,
+ userAvatarData: [],
});
stories.add('Full Set', () => {
diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx
index a8d9cadfed12..2e52b0840378 100644
--- a/ts/components/ProfileEditor.tsx
+++ b/ts/components/ProfileEditor.tsx
@@ -3,16 +3,24 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { AvatarInputContainer } from './AvatarInputContainer';
-import { AvatarInputType } from './AvatarInput';
+import { AvatarColors, AvatarColorType } from '../types/Colors';
+import {
+ AvatarDataType,
+ DeleteAvatarFromDiskActionType,
+ ReplaceAvatarActionType,
+ SaveAvatarToDiskActionType,
+} from '../types/Avatar';
+import { AvatarEditor } from './AvatarEditor';
+import { AvatarPreview } from './AvatarPreview';
import { Button, ButtonVariant } from './Button';
-import { ConfirmationDialog } from './ConfirmationDialog';
+import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
import { Emoji } from './emoji/Emoji';
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
import { EmojiPickDataType } from './emoji/EmojiPicker';
import { Input } from './Input';
import { Intl } from './Intl';
import { LocalizerType } from '../types/Util';
+import { Modal } from './Modal';
import { PanelRow } from './conversation/conversation-details/PanelRow';
import { ProfileDataType } from '../state/ducks/conversations';
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
@@ -20,6 +28,7 @@ import { missingCaseError } from '../util/missingCaseError';
export enum EditState {
None = 'None',
+ BetterAvatar = 'BetterAvatar',
ProfileName = 'ProfileName',
Bio = 'Bio',
}
@@ -28,7 +37,7 @@ type PropsExternalType = {
onEditStateChanged: (editState: EditState) => unknown;
onProfileChanged: (
profileData: ProfileDataType,
- avatarData?: ArrayBuffer
+ avatarBuffer?: ArrayBuffer
) => unknown;
};
@@ -36,13 +45,19 @@ export type PropsDataType = {
aboutEmoji?: string;
aboutText?: string;
avatarPath?: string;
+ color?: AvatarColorType;
+ conversationId: string;
familyName?: string;
firstName: string;
i18n: LocalizerType;
+ userAvatarData: Array;
} & Pick;
type PropsActionType = {
+ deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
onSetSkinTone: (tone: number) => unknown;
+ replaceAvatar: ReplaceAvatarActionType;
+ saveAvatarToDisk: SaveAvatarToDiskActionType;
};
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
@@ -79,6 +94,9 @@ export const ProfileEditor = ({
aboutEmoji,
aboutText,
avatarPath,
+ color,
+ conversationId,
+ deleteAvatarFromDisk,
familyName,
firstName,
i18n,
@@ -86,7 +104,10 @@ export const ProfileEditor = ({
onProfileChanged,
onSetSkinTone,
recentEmojis,
+ replaceAvatar,
+ saveAvatarToDisk,
skinTone,
+ userAvatarData,
}: PropsType): JSX.Element => {
const focusInputRef = useRef(null);
const [editState, setEditState] = useState(EditState.None);
@@ -105,7 +126,7 @@ export const ProfileEditor = ({
aboutText,
});
- const [avatarData, setAvatarData] = useState(
+ const [avatarBuffer, setAvatarBuffer] = useState(
undefined
);
const [stagedProfile, setStagedProfile] = useState({
@@ -115,8 +136,6 @@ export const ProfileEditor = ({
firstName,
});
- let content: JSX.Element;
-
const handleBack = useCallback(() => {
setEditState(EditState.None);
onEditStateChanged(EditState.None);
@@ -135,9 +154,11 @@ export const ProfileEditor = ({
const handleAvatarChanged = useCallback(
(avatar: ArrayBuffer | undefined) => {
- setAvatarData(avatar);
+ setAvatarBuffer(avatar);
+ setEditState(EditState.None);
+ onProfileChanged(stagedProfile, avatar);
},
- [setAvatarData]
+ [onProfileChanged, stagedProfile]
);
const getTextEncoder = useCallback(() => new TextEncoder(), []);
@@ -154,6 +175,10 @@ export const ProfileEditor = ({
[countByteLength]
);
+ const getFullNameText = () => {
+ return [fullName.firstName, fullName.familyName].filter(Boolean).join(' ');
+ };
+
useEffect(() => {
const focusNode = focusInputRef.current;
if (!focusNode) {
@@ -163,7 +188,34 @@ export const ProfileEditor = ({
focusNode.focus();
}, [editState]);
- if (editState === EditState.ProfileName) {
+ useEffect(() => {
+ onEditStateChanged(editState);
+ }, [editState, onEditStateChanged]);
+
+ const handleAvatarLoaded = useCallback(avatar => {
+ setAvatarBuffer(avatar);
+ }, []);
+
+ let content: JSX.Element;
+
+ if (editState === EditState.BetterAvatar) {
+ content = (
+
+ );
+ } else if (editState === EditState.ProfileName) {
const shouldDisableSave =
!stagedProfile.firstName ||
(stagedProfile.firstName === fullName.firstName &&
@@ -200,7 +252,7 @@ export const ProfileEditor = ({
placeholder={i18n('ProfileEditor--last-name')}
value={stagedProfile.familyName}
/>
-
+
{
const handleCancel = () => {
@@ -236,13 +288,13 @@ export const ProfileEditor = ({
familyName: stagedProfile.familyName,
});
- onProfileChanged(stagedProfile, avatarData);
+ onProfileChanged(stagedProfile, avatarBuffer);
handleBack();
}}
>
{i18n('save')}
-
+
>
);
} else if (editState === EditState.Bio) {
@@ -314,7 +366,7 @@ export const ProfileEditor = ({
/>
))}
-
+
{
const handleCancel = () => {
@@ -346,32 +398,32 @@ export const ProfileEditor = ({
aboutText: stagedProfile.aboutText,
});
- onProfileChanged(stagedProfile, avatarData);
+ onProfileChanged(stagedProfile, avatarBuffer);
handleBack();
}}
>
{i18n('save')}
-
+
>
);
} else if (editState === EditState.None) {
- const fullNameText = [fullName.firstName, fullName.familyName]
- .filter(Boolean)
- .join(' ');
-
content = (
<>
- {
- handleAvatarChanged(avatar);
- onProfileChanged(stagedProfile, avatar);
+ onAvatarLoaded={handleAvatarLoaded}
+ onClick={() => {
+ setEditState(EditState.BetterAvatar);
+ }}
+ style={{
+ height: 96,
+ width: 96,
}}
- onAvatarLoaded={handleAvatarChanged}
- type={AvatarInputType.Profile}
/>
@@ -381,10 +433,9 @@ export const ProfileEditor = ({
icon={
}
- label={fullNameText}
+ label={getFullNameText()}
onClick={() => {
setEditState(EditState.ProfileName);
- onEditStateChanged(EditState.ProfileName);
}}
/>
@@ -402,7 +453,6 @@ export const ProfileEditor = ({
label={fullBio.aboutText || i18n('ProfileEditor--about')}
onClick={() => {
setEditState(EditState.Bio);
- onEditStateChanged(EditState.Bio);
}}
/>
@@ -434,19 +484,11 @@ export const ProfileEditor = ({
return (
<>
{confirmDiscardAction && (
- setConfirmDiscardAction(undefined)}
- >
- {i18n('ProfileEditor--discard')}
-
+ />
)}
{content}
>
diff --git a/ts/components/ProfileEditorModal.tsx b/ts/components/ProfileEditorModal.tsx
index e2fb00e5ce4f..7701f604b577 100644
--- a/ts/components/ProfileEditorModal.tsx
+++ b/ts/components/ProfileEditorModal.tsx
@@ -18,7 +18,7 @@ export type PropsDataType = {
type PropsType = {
myProfileChanged: (
profileData: ProfileDataType,
- avatarData?: ArrayBuffer
+ avatarBuffer?: ArrayBuffer
) => unknown;
toggleProfileEditor: () => unknown;
toggleProfileEditorHasError: () => unknown;
@@ -57,6 +57,7 @@ export const ProfileEditorModal = ({
return (
<>
{
- myProfileChanged(profileData, avatarData);
+ onProfileChanged={(profileData, avatarBuffer) => {
+ myProfileChanged(profileData, avatarBuffer);
}}
onSetSkinTone={onSetSkinTone}
/>
diff --git a/ts/components/SafetyNumberChangeDialog.stories.tsx b/ts/components/SafetyNumberChangeDialog.stories.tsx
index 80421eba216f..bd383f9165dd 100644
--- a/ts/components/SafetyNumberChangeDialog.stories.tsx
+++ b/ts/components/SafetyNumberChangeDialog.stories.tsx
@@ -15,7 +15,6 @@ const i18n = setupI18n('en', enMessages);
const contactWithAllData = getDefaultConversation({
id: 'abc',
avatarPath: undefined,
- color: 'ultramarine',
profileName: '-*Smartest Dude*-',
title: 'Rick Sanchez',
name: 'Rick Sanchez',
@@ -25,7 +24,6 @@ const contactWithAllData = getDefaultConversation({
const contactWithJustProfile = getDefaultConversation({
id: 'def',
avatarPath: undefined,
- color: 'ultramarine',
title: '-*Smartest Dude*-',
profileName: '-*Smartest Dude*-',
name: undefined,
@@ -35,7 +33,6 @@ const contactWithJustProfile = getDefaultConversation({
const contactWithJustNumber = getDefaultConversation({
id: 'xyz',
avatarPath: undefined,
- color: 'ultramarine',
profileName: undefined,
name: undefined,
title: '(305) 123-4567',
@@ -45,7 +42,6 @@ const contactWithJustNumber = getDefaultConversation({
const contactWithNothing = getDefaultConversation({
id: 'some-guid',
avatarPath: undefined,
- color: 'ultramarine',
profileName: undefined,
name: undefined,
phoneNumber: undefined,
diff --git a/ts/components/SafetyNumberViewer.stories.tsx b/ts/components/SafetyNumberViewer.stories.tsx
index c67496fda9d7..8448bb2236b1 100644
--- a/ts/components/SafetyNumberViewer.stories.tsx
+++ b/ts/components/SafetyNumberViewer.stories.tsx
@@ -7,46 +7,43 @@ import { boolean, text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { PropsType, SafetyNumberViewer } from './SafetyNumberViewer';
-import { ConversationType } from '../state/ducks/conversations';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
+import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
-const contactWithAllData = {
+const contactWithAllData = getDefaultConversation({
title: 'Summer Smith',
name: 'Summer Smith',
phoneNumber: '(305) 123-4567',
isVerified: true,
-} as ConversationType;
+});
-const contactWithJustProfile = {
+const contactWithJustProfile = getDefaultConversation({
avatarPath: undefined,
- color: 'ultramarine',
title: '-*Smartest Dude*-',
profileName: '-*Smartest Dude*-',
name: undefined,
phoneNumber: '(305) 123-4567',
-} as ConversationType;
+});
-const contactWithJustNumber = {
+const contactWithJustNumber = getDefaultConversation({
avatarPath: undefined,
- color: 'ultramarine',
profileName: undefined,
name: undefined,
title: '(305) 123-4567',
phoneNumber: '(305) 123-4567',
-} as ConversationType;
+});
-const contactWithNothing = {
+const contactWithNothing = getDefaultConversation({
id: 'some-guid',
avatarPath: undefined,
- color: 'ultramarine',
profileName: undefined,
title: 'Unknown contact',
name: undefined,
phoneNumber: undefined,
-} as ConversationType;
+});
const createProps = (overrideProps: Partial = {}): PropsType => ({
contact: overrideProps.contact || contactWithAllData,
diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx
index 31e83bf847f3..1ae8507dcea7 100644
--- a/ts/components/conversation/ContactModal.tsx
+++ b/ts/components/conversation/ContactModal.tsx
@@ -1,14 +1,15 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React, { ReactPortal } from 'react';
+import React, { ReactPortal, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-import { ConversationType } from '../../state/ducks/conversations';
import { About } from './About';
import { Avatar } from '../Avatar';
-import { SharedGroupNames } from '../SharedGroupNames';
+import { AvatarLightbox } from '../AvatarLightbox';
+import { ConversationType } from '../../state/ducks/conversations';
import { LocalizerType } from '../../types/Util';
+import { SharedGroupNames } from '../SharedGroupNames';
export type PropsType = {
areWeAdmin: boolean;
@@ -41,11 +42,13 @@ export const ContactModal = ({
throw new Error('Contact modal opened without a matching contact');
}
- const [root, setRoot] = React.useState(null);
- const overlayRef = React.useRef(null);
- const closeButtonRef = React.useRef(null);
+ const [root, setRoot] = useState(null);
+ const overlayRef = useRef(null);
+ const closeButtonRef = useRef(null);
- React.useEffect(() => {
+ const [showingAvatar, setShowingAvatar] = useState(false);
+
+ useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
@@ -56,18 +59,18 @@ export const ContactModal = ({
};
}, []);
- React.useEffect(() => {
+ useEffect(() => {
// Kick off the expensive hydration of the current sharedGroupNames
updateSharedGroups();
}, [updateSharedGroups]);
- React.useEffect(() => {
+ useEffect(() => {
if (root !== null && closeButtonRef.current) {
closeButtonRef.current.focus();
}
}, [root]);
- React.useEffect(() => {
+ useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
@@ -92,6 +95,115 @@ export const ContactModal = ({
}
};
+ let content: JSX.Element;
+ if (showingAvatar) {
+ content = (
+ setShowingAvatar(false)}
+ />
+ );
+ } else {
+ content = (
+
+
{
+ closeButtonRef.current = r;
+ }}
+ type="button"
+ className="module-contact-modal__close-button"
+ onClick={onClose}
+ aria-label={i18n('close')}
+ />
+ setShowingAvatar(true)}
+ />
+ {contact.title}
+
+ {contact.phoneNumber && (
+
+ {contact.phoneNumber}
+
+ )}
+
+
+
+
+
openConversation(contact.id)}
+ >
+
+ {i18n('ContactModal--message')}
+
+ {!contact.isMe && (
+
showSafetyNumber(contact.id)}
+ >
+
+ {i18n('showSafetyNumber')}
+
+ )}
+ {!contact.isMe && areWeAdmin && isMember && (
+ <>
+
toggleAdmin(contact.id)}
+ >
+
+ {isAdmin ? (
+ {i18n('ContactModal--rm-admin')}
+ ) : (
+ {i18n('ContactModal--make-admin')}
+ )}
+
+
removeMember(contact.id)}
+ >
+
+ {i18n('ContactModal--remove-from-group')}
+
+ >
+ )}
+
+
+ );
+ }
+
return root
? createPortal(
-
-
{
- closeButtonRef.current = r;
- }}
- type="button"
- className="module-contact-modal__close-button"
- onClick={onClose}
- aria-label={i18n('close')}
- />
-
- {contact.title}
-
- {contact.phoneNumber && (
-
- {contact.phoneNumber}
-
- )}
-
-
-
-
-
openConversation(contact.id)}
- >
-
- {i18n('ContactModal--message')}
-
- {!contact.isMe && (
-
showSafetyNumber(contact.id)}
- >
-
- {i18n('showSafetyNumber')}
-
- )}
- {!contact.isMe && areWeAdmin && isMember && (
- <>
-
toggleAdmin(contact.id)}
- >
-
- {isAdmin ? (
- {i18n('ContactModal--rm-admin')}
- ) : (
- {i18n('ContactModal--make-admin')}
- )}
-
-
removeMember(contact.id)}
- >
-
- {i18n('ContactModal--remove-from-group')}
-
- >
- )}
-
-
+ {content}
,
root
)
diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx
index c8b330856362..427d089f3a32 100644
--- a/ts/components/conversation/ConversationHeader.stories.tsx
+++ b/ts/components/conversation/ConversationHeader.stories.tsx
@@ -7,6 +7,7 @@ import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
+import { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import {
@@ -71,7 +72,7 @@ const stories: Array = [
title: 'With name and profile, verified',
props: {
...commonProps,
- color: 'crimson',
+ color: getRandomColor(),
isVerified: true,
avatarPath: gifUrl,
title: 'Someone 🔥 Somewhere',
@@ -87,7 +88,7 @@ const stories: Array = [
title: 'With name, not verified, no avatar',
props: {
...commonProps,
- color: 'blue',
+ color: getRandomColor(),
isVerified: false,
title: 'Someone 🔥 Somewhere',
name: 'Someone 🔥 Somewhere',
@@ -101,7 +102,7 @@ const stories: Array = [
title: 'With name, not verified, descenders',
props: {
...commonProps,
- color: 'blue',
+ color: getRandomColor(),
isVerified: false,
title: 'Joyrey 🔥 Leppey',
name: 'Joyrey 🔥 Leppey',
@@ -115,7 +116,7 @@ const stories: Array = [
title: 'Profile, no name',
props: {
...commonProps,
- color: 'wintergreen',
+ color: getRandomColor(),
isVerified: false,
phoneNumber: '(202) 555-0003',
type: 'direct',
@@ -141,7 +142,7 @@ const stories: Array = [
props: {
...commonProps,
showBackButton: true,
- color: 'vermilion',
+ color: getRandomColor(),
phoneNumber: '(202) 555-0004',
title: '(202) 555-0004',
type: 'direct',
@@ -153,7 +154,7 @@ const stories: Array = [
title: 'Disappearing messages set',
props: {
...commonProps,
- color: 'indigo',
+ color: getRandomColor(),
title: '(202) 555-0005',
phoneNumber: '(202) 555-0005',
type: 'direct',
@@ -166,7 +167,7 @@ const stories: Array = [
title: 'Disappearing messages + verified',
props: {
...commonProps,
- color: 'indigo',
+ color: getRandomColor(),
title: '(202) 555-0005',
phoneNumber: '(202) 555-0005',
type: 'direct',
@@ -181,7 +182,7 @@ const stories: Array = [
title: 'Muting Conversation',
props: {
...commonProps,
- color: 'ultramarine',
+ color: getRandomColor(),
title: '(202) 555-0006',
phoneNumber: '(202) 555-0006',
type: 'direct',
@@ -194,7 +195,7 @@ const stories: Array = [
title: 'SMS-only conversation',
props: {
...commonProps,
- color: 'ultramarine',
+ color: getRandomColor(),
title: '(202) 555-0006',
phoneNumber: '(202) 555-0006',
type: 'direct',
@@ -214,7 +215,7 @@ const stories: Array = [
title: 'Basic',
props: {
...commonProps,
- color: 'ultramarine',
+ color: getRandomColor(),
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@@ -229,7 +230,7 @@ const stories: Array = [
title: 'In a group you left - no disappearing messages',
props: {
...commonProps,
- color: 'ultramarine',
+ color: getRandomColor(),
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@@ -245,7 +246,7 @@ const stories: Array = [
title: 'In a group with an active group call',
props: {
...commonProps,
- color: 'ultramarine',
+ color: getRandomColor(),
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@@ -260,7 +261,7 @@ const stories: Array = [
title: 'In a forever muted group',
props: {
...commonProps,
- color: 'ultramarine',
+ color: getRandomColor(),
title: 'Way too many messages',
name: 'Way too many messages',
phoneNumber: '',
@@ -282,7 +283,7 @@ const stories: Array = [
title: 'In chat with yourself',
props: {
...commonProps,
- color: 'blue',
+ color: getRandomColor(),
title: '(202) 555-0007',
phoneNumber: '(202) 555-0007',
id: '15',
@@ -302,7 +303,7 @@ const stories: Array = [
title: '1:1 conversation',
props: {
...commonProps,
- color: 'blue',
+ color: getRandomColor(),
title: '(202) 555-0007',
phoneNumber: '(202) 555-0007',
id: '16',
diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx
index 5fc5d38477f8..e05623c047cf 100644
--- a/ts/components/conversation/MessageDetail.stories.tsx
+++ b/ts/components/conversation/MessageDetail.stories.tsx
@@ -44,7 +44,6 @@ const createProps = (overrideProps: Partial = {}): Props => ({
contacts: overrideProps.contacts || [
{
...getDefaultConversation({
- color: 'indigo',
title: 'Just Max',
}),
isOutgoingKeyError: false,
@@ -124,7 +123,6 @@ story.add('Message Statuses', () => {
contacts: [
{
...getDefaultConversation({
- color: 'forest',
title: 'Max',
}),
isOutgoingKeyError: false,
@@ -133,7 +131,6 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
- color: 'blue',
title: 'Sally',
}),
isOutgoingKeyError: false,
@@ -142,7 +139,6 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
- color: 'burlap',
title: 'Terry',
}),
isOutgoingKeyError: false,
@@ -151,7 +147,6 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
- color: 'wintergreen',
title: 'Theo',
}),
isOutgoingKeyError: false,
@@ -160,7 +155,6 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
- color: 'steel',
title: 'Nikki',
}),
isOutgoingKeyError: false,
@@ -217,7 +211,6 @@ story.add('All Errors', () => {
contacts: [
{
...getDefaultConversation({
- color: 'forest',
title: 'Max',
}),
isOutgoingKeyError: true,
@@ -226,7 +219,6 @@ story.add('All Errors', () => {
},
{
...getDefaultConversation({
- color: 'blue',
title: 'Sally',
}),
errors: [
@@ -241,7 +233,6 @@ story.add('All Errors', () => {
},
{
...getDefaultConversation({
- color: 'taupe',
title: 'Terry',
}),
isOutgoingKeyError: true,
diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx
index b0cce695e216..cc1a4fbcebb3 100644
--- a/ts/components/conversation/Timeline.stories.tsx
+++ b/ts/components/conversation/Timeline.stories.tsx
@@ -15,6 +15,7 @@ import { PropsType, Timeline } from './Timeline';
import { TimelineItem, TimelineItemType } from './TimelineItem';
import { ConversationHero } from './ConversationHero';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
+import { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { LastSeenIndicator } from './LastSeenIndicator';
import { TimelineLoadingRow } from './TimelineLoadingRow';
import { TypingBubble } from './TypingBubble';
@@ -38,7 +39,6 @@ const items: Record = {
data: {
author: getDefaultConversation({
phoneNumber: '(202) 555-2001',
- color: 'forest',
}),
canDeleteForEveryone: false,
canDownload: true,
@@ -58,7 +58,7 @@ const items: Record = {
'id-2': {
type: 'message',
data: {
- author: getDefaultConversation({ color: 'forest' }),
+ author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@@ -90,7 +90,7 @@ const items: Record = {
'id-3': {
type: 'message',
data: {
- author: getDefaultConversation({ color: 'crimson' }),
+ author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@@ -188,7 +188,7 @@ const items: Record = {
'id-10': {
type: 'message',
data: {
- author: getDefaultConversation({ color: 'plum' }),
+ author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@@ -208,7 +208,7 @@ const items: Record = {
'id-11': {
type: 'message',
data: {
- author: getDefaultConversation({ color: 'plum' }),
+ author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@@ -228,7 +228,7 @@ const items: Record = {
'id-12': {
type: 'message',
data: {
- author: getDefaultConversation({ color: 'crimson' }),
+ author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@@ -248,7 +248,7 @@ const items: Record = {
'id-13': {
type: 'message',
data: {
- author: getDefaultConversation({ color: 'blue' }),
+ author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@@ -269,7 +269,7 @@ const items: Record = {
'id-14': {
type: 'message',
data: {
- author: getDefaultConversation({ color: 'crimson' }),
+ author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@@ -418,7 +418,7 @@ const renderLoadingRow = () => ;
const renderTypingBubble = () => (
({
},
onBlock: action('onBlock'),
onLeave: action('onLeave'),
+ deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
+ replaceAvatar: action('replaceAvatar'),
+ saveAvatarToDisk: action('saveAvatarToDisk'),
+ userAvatarData: [],
});
story.add('Basic', () => {
diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx
index 2a5ab2cf7da8..9455bf7a25cb 100644
--- a/ts/components/conversation/conversation-details/ConversationDetails.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx
@@ -32,6 +32,12 @@ import { EditConversationAttributesModal } from './EditConversationAttributesMod
import { RequestState } from './util';
import { getCustomColorStyle } from '../../../util/getCustomColorStyle';
import { ConfirmationDialog } from '../../ConfirmationDialog';
+import {
+ AvatarDataType,
+ DeleteAvatarFromDiskActionType,
+ ReplaceAvatarActionType,
+ SaveAvatarToDiskActionType,
+} from '../../../types/Avatar';
enum ModalState {
NothingOpen,
@@ -73,9 +79,16 @@ export type StateProps = {
) => Promise;
onBlock: () => void;
onLeave: () => void;
+ userAvatarData: Array;
};
-export type Props = StateProps;
+type ActionProps = {
+ deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
+ replaceAvatar: ReplaceAvatarActionType;
+ saveAvatarToDisk: SaveAvatarToDiskActionType;
+};
+
+export type Props = StateProps & ActionProps;
export const ConversationDetails: React.ComponentType = ({
addMembers,
@@ -101,6 +114,10 @@ export const ConversationDetails: React.ComponentType = ({
updateGroupAttributes,
onBlock,
onLeave,
+ deleteAvatarFromDisk,
+ replaceAvatar,
+ saveAvatarToDisk,
+ userAvatarData,
}) => {
const [modalState, setModalState] = useState(
ModalState.NothingOpen
@@ -141,7 +158,9 @@ export const ConversationDetails: React.ComponentType = ({
case ModalState.EditingGroupTitle:
modalNode = (
= ({
}}
requestState={editGroupAttributesRequestState}
title={conversation.title}
+ deleteAvatarFromDisk={deleteAvatarFromDisk}
+ replaceAvatar={replaceAvatar}
+ saveAvatarToDisk={saveAvatarToDisk}
+ userAvatarData={userAvatarData}
/>
);
break;
diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx
index 2800d54f48f3..533cdddba511 100644
--- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx
+++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx
@@ -1,14 +1,15 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React, { ReactNode } from 'react';
+import React, { ReactNode, useState } from 'react';
import { Avatar } from '../../Avatar';
-import { Emojify } from '../Emojify';
-import { LocalizerType } from '../../../types/Util';
+import { AvatarLightbox } from '../../AvatarLightbox';
import { ConversationType } from '../../../state/ducks/conversations';
+import { Emojify } from '../Emojify';
import { GroupDescription } from '../GroupDescription';
import { GroupV2Membership } from './ConversationDetailsMembershipList';
+import { LocalizerType } from '../../../types/Util';
import { bemGenerator } from './util';
export type Props = {
@@ -28,6 +29,8 @@ export const ConversationDetailsHeader: React.ComponentType = ({
memberships,
startEditing,
}) => {
+ const [showingAvatar, setShowingAvatar] = useState(false);
+
let subtitle: ReactNode;
if (conversation.groupDescription) {
subtitle = (
@@ -45,26 +48,41 @@ export const ConversationDetailsHeader: React.ComponentType = ({
]);
}
- const contents = (
- <>
-
-
- >
+ const avatar = (
+ setShowingAvatar(true)}
+ sharedGroupNames={[]}
+ />
);
+ const contents = (
+
+ );
+
+ const avatarLightbox = showingAvatar ? (
+ setShowingAvatar(false)}
+ />
+ ) : null;
+
if (canEdit) {
return (
+ {avatarLightbox}
+ {avatar}
{
@@ -95,5 +113,11 @@ export const ConversationDetailsHeader: React.ComponentType = ({
);
}
- return {contents}
;
+ return (
+
+ {avatarLightbox}
+ {avatar}
+ {contents}
+
+ );
};
diff --git a/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx b/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx
index abee115ca7ff..a49160e17080 100644
--- a/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx
+++ b/ts/components/conversation/conversation-details/EditConversationAttributesModal.stories.tsx
@@ -22,12 +22,17 @@ type PropsType = ComponentProps;
const createProps = (overrideProps: Partial = {}): PropsType => ({
avatarPath: undefined,
+ conversationId: '123',
i18n,
initiallyFocusDescription: false,
onClose: action('onClose'),
makeRequest: action('onMakeRequest'),
requestState: RequestState.Inactive,
title: 'Bing Bong Group',
+ deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
+ replaceAvatar: action('replaceAvatar'),
+ saveAvatarToDisk: action('saveAvatarToDisk'),
+ userAvatarData: [],
...overrideProps,
});
diff --git a/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx b/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx
index 54dfd5d02a63..c5c09bbc67ab 100644
--- a/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx
+++ b/ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx
@@ -4,25 +4,31 @@
import React, {
FormEventHandler,
FunctionComponent,
- useCallback,
useRef,
useState,
} from 'react';
import { LocalizerType } from '../../../types/Util';
import { Modal } from '../../Modal';
-import { AvatarInputContainer } from '../../AvatarInputContainer';
-import { AvatarInputVariant } from '../../AvatarInput';
+import { AvatarEditor } from '../../AvatarEditor';
+import { AvatarPreview } from '../../AvatarPreview';
import { Button, ButtonVariant } from '../../Button';
import { Spinner } from '../../Spinner';
import { GroupDescriptionInput } from '../../GroupDescriptionInput';
import { GroupTitleInput } from '../../GroupTitleInput';
import { RequestState } from './util';
-
-const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
+import {
+ AvatarDataType,
+ DeleteAvatarFromDiskActionType,
+ ReplaceAvatarActionType,
+ SaveAvatarToDiskActionType,
+} from '../../../types/Avatar';
+import { AvatarColorType } from '../../../types/Colors';
type PropsType = {
+ avatarColor?: AvatarColorType;
avatarPath?: string;
+ conversationId: string;
groupDescription?: string;
i18n: LocalizerType;
initiallyFocusDescription: boolean;
@@ -36,10 +42,16 @@ type PropsType = {
onClose: () => void;
requestState: RequestState;
title: string;
+ deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
+ replaceAvatar: ReplaceAvatarActionType;
+ saveAvatarToDisk: SaveAvatarToDiskActionType;
+ userAvatarData: Array;
};
export const EditConversationAttributesModal: FunctionComponent = ({
+ avatarColor,
avatarPath: externalAvatarPath,
+ conversationId,
groupDescription: externalGroupDescription = '',
i18n,
initiallyFocusDescription,
@@ -47,6 +59,10 @@ export const EditConversationAttributesModal: FunctionComponent = ({
onClose,
requestState,
title: externalTitle,
+ deleteAvatarFromDisk,
+ replaceAvatar,
+ saveAvatarToDisk,
+ userAvatarData,
}) => {
const focusDescriptionRef = useRef(
initiallyFocusDescription
@@ -56,9 +72,8 @@ export const EditConversationAttributesModal: FunctionComponent = ({
const startingTitleRef = useRef(externalTitle);
const startingAvatarPathRef = useRef(externalAvatarPath);
- const [avatar, setAvatar] = useState(
- externalAvatarPath ? TEMPORARY_AVATAR_VALUE : undefined
- );
+ const [editingAvatar, setEditingAvatar] = useState(false);
+ const [avatar, setAvatar] = useState();
const [rawTitle, setRawTitle] = useState(externalTitle);
const [rawGroupDescription, setRawGroupDescription] = useState(
externalGroupDescription
@@ -112,35 +127,55 @@ export const EditConversationAttributesModal: FunctionComponent = ({
makeRequest(request);
};
- const handleAvatarLoaded = useCallback(
- loadedAvatar => {
- setAvatar(loadedAvatar);
- },
- [setAvatar]
- );
+ const avatarPathForPreview = hasAvatarChanged
+ ? undefined
+ : externalAvatarPath;
- return (
-
+ let content: JSX.Element;
+ if (editingAvatar) {
+ content = (
+ {
+ setHasAvatarChanged(false);
+ setEditingAvatar(false);
+ }}
+ onSave={newAvatar => {
+ setAvatar(newAvatar);
+ setHasAvatarChanged(true);
+ setEditingAvatar(false);
+ }}
+ userAvatarData={userAvatarData}
+ replaceAvatar={replaceAvatar}
+ saveAvatarToDisk={saveAvatarToDisk}
+ />
+ );
+ } else {
+ content = (
+ );
+ }
+
+ return (
+
+ {content}
);
};
diff --git a/ts/components/conversationList/CreateNewGroupButton.tsx b/ts/components/conversationList/CreateNewGroupButton.tsx
index fde45b2e78fe..d703f1872dcb 100644
--- a/ts/components/conversationList/CreateNewGroupButton.tsx
+++ b/ts/components/conversationList/CreateNewGroupButton.tsx
@@ -5,6 +5,7 @@ import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { LocalizerType } from '../../types/Util';
+import { AvatarColors } from '../../types/Colors';
type PropsType = {
i18n: LocalizerType;
@@ -19,7 +20,7 @@ export const CreateNewGroupButton: FunctionComponent
= React.memo(
return (
= React.memo(
return (
8) {
return '';
diff --git a/ts/components/leftPane/LeftPaneHelper.tsx b/ts/components/leftPane/LeftPaneHelper.tsx
index ae12e8599ac9..92168ccd0a25 100644
--- a/ts/components/leftPane/LeftPaneHelper.tsx
+++ b/ts/components/leftPane/LeftPaneHelper.tsx
@@ -5,6 +5,11 @@ import { ChangeEvent, ReactChild } from 'react';
import { Row } from '../ConversationList';
import { LocalizerType } from '../../types/Util';
+import {
+ DeleteAvatarFromDiskActionType,
+ ReplaceAvatarActionType,
+ SaveAvatarToDiskActionType,
+} from '../../types/Avatar';
export enum FindDirection {
Up,
@@ -46,6 +51,9 @@ export abstract class LeftPaneHelper {
closeCantAddContactToGroupModal: () => unknown;
closeMaximumGroupSizeModal: () => unknown;
closeRecommendedGroupSizeModal: () => unknown;
+ composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
+ composeReplaceAvatar: ReplaceAvatarActionType;
+ composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => unknown;
i18n: LocalizerType;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
@@ -55,6 +63,7 @@ export abstract class LeftPaneHelper {
event: ChangeEvent
) => unknown;
removeSelectedContact: (_: string) => unknown;
+ toggleComposeEditingAvatar: () => unknown;
}>
): null | ReactChild {
return null;
diff --git a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
index 2af5b322feed..066d13e4ee2d 100644
--- a/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
+++ b/ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
@@ -8,11 +8,20 @@ import { Row, RowType } from '../ConversationList';
import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem';
import { DisappearingTimerSelect } from '../DisappearingTimerSelect';
import { LocalizerType } from '../../types/Util';
-import { AvatarInput } from '../AvatarInput';
import { Alert } from '../Alert';
+import { AvatarEditor } from '../AvatarEditor';
+import { AvatarPreview } from '../AvatarPreview';
import { Spinner } from '../Spinner';
import { Button } from '../Button';
+import { Modal } from '../Modal';
import { GroupTitleInput } from '../GroupTitleInput';
+import {
+ AvatarDataType,
+ DeleteAvatarFromDiskActionType,
+ ReplaceAvatarActionType,
+ SaveAvatarToDiskActionType,
+} from '../../types/Avatar';
+import { AvatarColors } from '../../types/Colors';
export type LeftPaneSetGroupMetadataPropsType = {
groupAvatar: undefined | ArrayBuffer;
@@ -20,7 +29,9 @@ export type LeftPaneSetGroupMetadataPropsType = {
groupExpireTimer: number;
hasError: boolean;
isCreating: boolean;
+ isEditingAvatar: boolean;
selectedContacts: ReadonlyArray;
+ userAvatarData: ReadonlyArray;
};
/* eslint-disable class-methods-use-this */
@@ -36,15 +47,21 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper;
+ private readonly userAvatarData: ReadonlyArray;
+
constructor({
groupAvatar,
groupName,
groupExpireTimer,
- isCreating,
hasError,
+ isCreating,
+ isEditingAvatar,
selectedContacts,
+ userAvatarData,
}: Readonly) {
super();
@@ -53,7 +70,9 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper unknown;
+ composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
+ composeReplaceAvatar: ReplaceAvatarActionType;
+ composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => unknown;
i18n: LocalizerType;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
setComposeGroupExpireTimer: (_: number) => void;
setComposeGroupName: (_: string) => unknown;
+ toggleComposeEditingAvatar: () => unknown;
}>): ReactChild {
+ const [avatarColor] = AvatarColors;
const disabled = this.isCreating;
return (
@@ -121,12 +149,43 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper
-
+ {
+ setComposeGroupAvatar(newAvatar);
+ toggleComposeEditingAvatar();
+ }}
+ userAvatarData={this.userAvatarData}
+ replaceAvatar={composeReplaceAvatar}
+ saveAvatarToDisk={composeSaveAvatarToDisk}
+ />
+
+ )}
+
;
+ avatars?: Array;
}>): Promise {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
@@ -1681,6 +1684,7 @@ export async function createGroupV2({
active_at: now,
addedBy: ourConversationId,
avatar: avatarAttribute,
+ avatars,
groupVersion: 2,
masterKey,
profileSharing: true,
diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts
index a2b68947b75a..8d15cd0345ef 100644
--- a/ts/model-types.d.ts
+++ b/ts/model-types.d.ts
@@ -24,6 +24,7 @@ import { ConversationColorType } from './types/Colors';
import { AttachmentType, ThumbnailType } from './types/Attachment';
import { ContactType } from './types/Contact';
import { SignalService as Proto } from './protobuf';
+import { AvatarDataType } from './types/Avatar';
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
import MemberRoleEnum = Proto.Member.Role;
@@ -299,6 +300,7 @@ export type ConversationAttributesType = {
path: string;
hash?: string;
} | null;
+ avatars?: Array;
description?: string;
expireTimer?: number;
membersV2?: Array;
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index 54cb8aac248c..f4b5f223b13a 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -3,7 +3,7 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable camelcase */
-import { compact, sample } from 'lodash';
+import { compact } from 'lodash';
import {
ConversationAttributesType,
MessageAttributesType,
@@ -21,7 +21,6 @@ import { CallbackResultType } from '../textsecure/Types.d';
import { ConversationType } from '../state/ducks/conversations';
import {
AvatarColorType,
- AvatarColors,
ConversationColorType,
CustomColorType,
DEFAULT_CONVERSATION_COLOR,
@@ -82,6 +81,7 @@ import { Reactions, ReactionModel } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
import { getProfile } from '../util/getProfile';
import { SEALED_SENDER } from '../types/SealedSender';
+import { getAvatarData } from '../util/getAvatarData';
// TODO: remove once we move away from ArrayBuffers
const FIXMEU8 = Uint8Array;
@@ -311,10 +311,13 @@ export class ConversationModel extends window.Backbone
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);
+ const migratedColor = this.getColor();
+ if (this.get('color') !== migratedColor) {
+ this.set('color', migratedColor);
+ // Not saving the conversation here we're hoping it'll be saved elsewhere
+ // this may cause some color thrashing if Signal is restarted without
+ // the convo saving. If that is indeed the case and it's too disruptive
+ // we should add batched saving.
}
}
@@ -1395,6 +1398,7 @@ export class ConversationModel extends window.Backbone
ourConversationId && this.isMemberAwaitingApproval(ourConversationId)
),
areWeAdmin: this.areWeAdmin(),
+ avatars: getAvatarData(this.attributes),
canChangeTimer: this.canChangeTimer(),
canEditGroupInfo: this.canEditGroupInfo(),
avatarPath: this.getAbsoluteAvatarPath(),
@@ -4673,10 +4677,6 @@ export class ConversationModel extends window.Backbone
}
getColor(): AvatarColorType {
- if (!isDirectConversation(this.attributes)) {
- return 'ultramarine';
- }
-
return migrateColor(this.get('color'));
}
diff --git a/ts/services/writeProfile.ts b/ts/services/writeProfile.ts
index a758a1f3df91..8693a55377e1 100644
--- a/ts/services/writeProfile.ts
+++ b/ts/services/writeProfile.ts
@@ -10,7 +10,7 @@ import { handleMessageSend } from '../util/handleMessageSend';
export async function writeProfile(
conversation: ConversationType,
- avatarData?: ArrayBuffer
+ avatarBuffer?: ArrayBuffer
): Promise {
// Before we write anything we request the user's profile so that we can
// have an up-to-date paymentAddress to be able to include it when we write
@@ -32,7 +32,7 @@ export async function writeProfile(
const [profileData, encryptedAvatarData] = await encryptProfileData(
conversation,
- avatarData
+ avatarBuffer
);
const avatarRequestHeaders = await window.textsecure.messaging.putProfile(
profileData
@@ -47,17 +47,17 @@ export async function writeProfile(
path: string;
}
| undefined;
- if (avatarRequestHeaders && encryptedAvatarData && avatarData) {
+ if (avatarRequestHeaders && encryptedAvatarData && avatarBuffer) {
await window.textsecure.messaging.uploadAvatar(
avatarRequestHeaders,
encryptedAvatarData
);
- const hash = await computeHash(avatarData);
+ const hash = await computeHash(avatarBuffer);
if (hash !== avatarHash) {
const [path] = await Promise.all([
- window.Signal.Migrations.writeNewAttachmentData(avatarData),
+ window.Signal.Migrations.writeNewAttachmentData(avatarBuffer),
avatarPath
? window.Signal.Migrations.deleteAttachmentData(avatarPath)
: undefined,
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index 9a29439e54bd..d085125e7e2c 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -20,7 +20,7 @@ import * as groups from '../../groups';
import * as log from '../../logging/log';
import { calling } from '../../services/calling';
import { getOwn } from '../../util/getOwn';
-import { assert } from '../../util/assert';
+import { assert, strictAssert } from '../../util/assert';
import * as universalExpireTimer from '../../util/universalExpireTimer';
import { trigger } from '../../shims/events';
import {
@@ -53,6 +53,9 @@ import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCol
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { writeProfile } from '../../services/writeProfile';
import { getMe } from '../selectors/conversations';
+import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar';
+import { getAvatarData } from '../../util/getAvatarData';
+import { isSameAvatarData } from '../../util/isSameAvatarData';
import { NoopActionType } from './noop';
@@ -86,6 +89,7 @@ export type ConversationType = {
about?: string;
aboutText?: string;
aboutEmoji?: string;
+ avatars?: Array;
avatarPath?: string;
avatarHash?: string;
unblurredAvatarPath?: string;
@@ -244,9 +248,11 @@ type ComposerGroupCreationState = {
groupAvatar: undefined | ArrayBuffer;
groupName: string;
groupExpireTimer: number;
+ isEditingAvatar: boolean;
maximumGroupSizeModalState: OneTimeModalState;
recommendedGroupSizeModalState: OneTimeModalState;
selectedConversationIds: Array;
+ userAvatarData: Array;
};
type ComposerStateType =
@@ -325,9 +331,15 @@ export const getConversationCallMode = (
// Actions
-const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
+const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
+const COMPOSE_TOGGLE_EDITING_AVATAR =
+ 'conversations/compose/COMPOSE_TOGGLE_EDITING_AVATAR';
+const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR';
+const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR';
+const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR';
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
+const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
type CantAddContactToGroupActionType = {
type: 'CANT_ADD_CONTACT_TO_GROUP';
@@ -373,6 +385,21 @@ export type ColorSelectedActionType = {
type: typeof COLOR_SELECTED;
payload: ColorSelectedPayloadType;
};
+type ComposeDeleteAvatarActionType = {
+ type: typeof COMPOSE_REMOVE_AVATAR;
+ payload: AvatarDataType;
+};
+type ComposeReplaceAvatarsActionType = {
+ type: typeof COMPOSE_REPLACE_AVATAR;
+ payload: {
+ curr: AvatarDataType;
+ prev?: AvatarDataType;
+ };
+};
+type ComposeSaveAvatarActionType = {
+ type: typeof COMPOSE_ADD_AVATAR;
+ payload: AvatarDataType;
+};
type CustomColorRemovedActionType = {
type: typeof CUSTOM_COLOR_REMOVED;
payload: {
@@ -594,6 +621,9 @@ type SetRecentMediaItemsActionType = {
recentMediaItems: Array;
};
};
+type ToggleComposeEditingAvatarActionType = {
+ type: typeof COMPOSE_TOGGLE_EDITING_AVATAR;
+};
type StartComposingActionType = {
type: 'START_COMPOSING';
};
@@ -615,6 +645,14 @@ export type ToggleConversationInChooseMembersActionType = {
maxGroupSize: number;
};
};
+
+type ReplaceAvatarsActionType = {
+ type: typeof REPLACE_AVATARS;
+ payload: {
+ conversationId: string;
+ avatars: Array;
+ };
+};
export type ConversationActionType =
| CantAddContactToGroupActionType
| ClearChangedMessagesActionType
@@ -626,32 +664,36 @@ export type ConversationActionType =
| CloseContactSpoofingReviewActionType
| CloseMaximumGroupSizeModalActionType
| CloseRecommendedGroupSizeModalActionType
+ | ColorSelectedActionType
+ | ColorsChangedActionType
+ | ComposeDeleteAvatarActionType
+ | ComposeReplaceAvatarsActionType
+ | ComposeSaveAvatarActionType
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationUnloadedActionType
- | ColorsChangedActionType
- | ColorSelectedActionType
- | CustomColorRemovedActionType
| CreateGroupFulfilledActionType
| CreateGroupPendingActionType
| CreateGroupRejectedActionType
+ | CustomColorRemovedActionType
| MessageChangedActionType
| MessageDeletedActionType
- | MessagesAddedActionType
| MessageSelectedActionType
| MessageSizeChangedActionType
+ | MessagesAddedActionType
| MessagesResetActionType
| RemoveAllConversationsActionType
| RepairNewestMessageActionType
| RepairOldestMessageActionType
+ | ReplaceAvatarsActionType
| ReviewGroupMemberNameCollisionActionType
| ReviewMessageRequestNameCollisionActionType
| ScrollToMessageActionType
| SelectedConversationChangedActionType
| SetComposeGroupAvatarActionType
- | SetComposeGroupNameActionType
| SetComposeGroupExpireTimerActionType
+ | SetComposeGroupNameActionType
| SetComposeSearchTermActionType
| SetConversationHeaderTitleActionType
| SetIsNearBottomActionType
@@ -661,37 +703,42 @@ export type ConversationActionType =
| SetRecentMediaItemsActionType
| SetSelectedConversationPanelDepthActionType
| ShowArchivedConversationsActionType
+ | ShowChooseGroupMembersActionType
| ShowInboxActionType
| StartComposingActionType
- | ShowChooseGroupMembersActionType
| StartSettingGroupMetadataActionType
| SwitchToAssociatedViewActionType
- | ToggleConversationInChooseMembersActionType;
+ | ToggleConversationInChooseMembersActionType
+ | ToggleComposeEditingAvatarActionType;
// Action Creators
export const actions = {
cantAddContactToGroup,
clearChangedMessages,
- clearInvitedConversationsForNewlyCreatedGroup,
clearGroupCreationError,
+ clearInvitedConversationsForNewlyCreatedGroup,
clearSelectedMessage,
clearUnreadMetrics,
closeCantAddContactToGroupModal,
closeContactSpoofingReview,
- closeRecommendedGroupSizeModal,
closeMaximumGroupSizeModal,
+ closeRecommendedGroupSizeModal,
+ colorSelected,
+ composeDeleteAvatarFromDisk,
+ composeReplaceAvatar,
+ composeSaveAvatarToDisk,
conversationAdded,
conversationChanged,
conversationRemoved,
conversationUnloaded,
- colorSelected,
createGroup,
+ deleteAvatarFromDisk,
doubleCheckMissingQuoteReference,
messageChanged,
messageDeleted,
- messagesAdded,
messageSizeChanged,
+ messagesAdded,
messagesReset,
myProfileChanged,
openConversationExternal,
@@ -700,14 +747,16 @@ export const actions = {
removeCustomColorOnConversations,
repairNewestMessage,
repairOldestMessage,
+ replaceAvatar,
resetAllChatColors,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision,
+ saveAvatarToDisk,
scrollToMessage,
selectMessage,
setComposeGroupAvatar,
- setComposeGroupName,
setComposeGroupExpireTimer,
+ setComposeGroupName,
setComposeSearchTerm,
setIsNearBottom,
setLoadCountdownStart,
@@ -717,17 +766,166 @@ export const actions = {
setSelectedConversationHeaderTitle,
setSelectedConversationPanelDepth,
showArchivedConversations,
+ showChooseGroupMembers,
showInbox,
startComposing,
- showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startSettingGroupMetadata,
toggleConversationInChooseMembers,
+ toggleComposeEditingAvatar,
};
+function filterAvatarData(
+ avatars: ReadonlyArray,
+ data: AvatarDataType
+): Array {
+ return avatars.filter(avatarData => !isSameAvatarData(data, avatarData));
+}
+
+function getNextAvatarId(avatars: Array): number {
+ return Math.max(...avatars.map(x => Number(x.id))) + 1;
+}
+
+async function getAvatarsAndUpdateConversation(
+ conversations: ConversationsStateType,
+ conversationId: string,
+ getNextAvatarsData: (
+ avatars: Array,
+ nextId: number
+ ) => Array
+): Promise> {
+ const conversation = window.ConversationController.get(conversationId);
+ if (!conversation) {
+ throw new Error('No conversation found');
+ }
+
+ const { conversationLookup } = conversations;
+ const conversationAttrs = conversationLookup[conversationId];
+ const avatars =
+ conversationAttrs.avatars || getAvatarData(conversation.attributes);
+
+ const nextAvatarId = getNextAvatarId(avatars);
+ const nextAvatars = getNextAvatarsData(avatars, nextAvatarId);
+ // We don't save buffers to the db, but we definitely want it in-memory so
+ // we don't have to re-generate them.
+ //
+ // Mutating here because we don't want to trigger a model change
+ // because we're updating redux here manually ourselves. Au revoir Backbone!
+ conversation.attributes.avatars = nextAvatars.map(avatarData =>
+ omit(avatarData, ['buffer'])
+ );
+ await window.Signal.Data.updateConversation(conversation.attributes);
+
+ return nextAvatars;
+}
+
+function deleteAvatarFromDisk(
+ avatarData: AvatarDataType,
+ conversationId?: string
+): ThunkAction {
+ return async (dispatch, getState) => {
+ if (avatarData.imagePath) {
+ await window.Signal.Migrations.deleteAvatar(avatarData.imagePath);
+ } else {
+ window.log.info(
+ 'No imagePath for avatarData. Removing from userAvatarData, but not disk'
+ );
+ }
+
+ strictAssert(conversationId, 'conversationId not provided');
+
+ const avatars = await getAvatarsAndUpdateConversation(
+ getState().conversations,
+ conversationId,
+ prevAvatarsData => filterAvatarData(prevAvatarsData, avatarData)
+ );
+
+ dispatch({
+ type: REPLACE_AVATARS,
+ payload: {
+ conversationId,
+ avatars,
+ },
+ });
+ };
+}
+
+function replaceAvatar(
+ curr: AvatarDataType,
+ prev?: AvatarDataType,
+ conversationId?: string
+): ThunkAction {
+ return async (dispatch, getState) => {
+ strictAssert(conversationId, 'conversationId not provided');
+
+ const avatars = await getAvatarsAndUpdateConversation(
+ getState().conversations,
+ conversationId,
+ (prevAvatarsData, nextId) => {
+ const newAvatarData = {
+ ...curr,
+ id: prev?.id ?? nextId,
+ };
+ const existingAvatarsData = prev
+ ? filterAvatarData(prevAvatarsData, prev)
+ : prevAvatarsData;
+
+ return [newAvatarData, ...existingAvatarsData];
+ }
+ );
+
+ dispatch({
+ type: REPLACE_AVATARS,
+ payload: {
+ conversationId,
+ avatars,
+ },
+ });
+ };
+}
+
+function saveAvatarToDisk(
+ avatarData: AvatarDataType,
+ conversationId?: string
+): ThunkAction {
+ return async (dispatch, getState) => {
+ if (!avatarData.buffer) {
+ throw new Error('No avatar ArrayBuffer provided');
+ }
+
+ strictAssert(conversationId, 'conversationId not provided');
+
+ const imagePath = await window.Signal.Migrations.writeNewAvatarData(
+ avatarData.buffer
+ );
+
+ const avatars = await getAvatarsAndUpdateConversation(
+ getState().conversations,
+ conversationId,
+ (prevAvatarsData, id) => {
+ const newAvatarData = {
+ ...avatarData,
+ imagePath,
+ id,
+ };
+
+ return [newAvatarData, ...prevAvatarsData];
+ }
+ );
+
+ dispatch({
+ type: REPLACE_AVATARS,
+ payload: {
+ conversationId,
+ avatars,
+ },
+ });
+ };
+}
+
function myProfileChanged(
profileData: ProfileDataType,
- avatarData?: ArrayBuffer
+ avatarBuffer?: ArrayBuffer
): ThunkAction<
void,
RootStateType,
@@ -743,7 +941,7 @@ function myProfileChanged(
...conversation,
...profileData,
},
- avatarData
+ avatarBuffer
);
// writeProfile above updates the backbone model which in turn updates
@@ -879,6 +1077,66 @@ function colorSelected({
};
}
+function toggleComposeEditingAvatar(): ToggleComposeEditingAvatarActionType {
+ return {
+ type: COMPOSE_TOGGLE_EDITING_AVATAR,
+ };
+}
+
+function composeSaveAvatarToDisk(
+ avatarData: AvatarDataType
+): ThunkAction {
+ return async dispatch => {
+ if (!avatarData.buffer) {
+ throw new Error('No avatar ArrayBuffer provided');
+ }
+
+ const imagePath = await window.Signal.Migrations.writeNewAvatarData(
+ avatarData.buffer
+ );
+
+ dispatch({
+ type: COMPOSE_ADD_AVATAR,
+ payload: {
+ ...avatarData,
+ imagePath,
+ },
+ });
+ };
+}
+
+function composeDeleteAvatarFromDisk(
+ avatarData: AvatarDataType
+): ThunkAction {
+ return async dispatch => {
+ if (avatarData.imagePath) {
+ await window.Signal.Migrations.deleteAvatar(avatarData.imagePath);
+ } else {
+ window.log.info(
+ 'No imagePath for avatarData. Removing from userAvatarData, but not disk'
+ );
+ }
+
+ dispatch({
+ type: COMPOSE_REMOVE_AVATAR,
+ payload: avatarData,
+ });
+ };
+}
+
+function composeReplaceAvatar(
+ curr: AvatarDataType,
+ prev?: AvatarDataType
+): ComposeReplaceAvatarsActionType {
+ return {
+ type: COMPOSE_REPLACE_AVATAR,
+ payload: {
+ curr,
+ prev,
+ },
+ };
+}
+
function cantAddContactToGroup(
conversationId: string
): CantAddContactToGroupActionType {
@@ -967,6 +1225,9 @@ function createGroup(): ThunkAction<
const conversation = await groups.createGroupV2({
name: composer.groupName.trim(),
avatar: composer.groupAvatar,
+ avatars: composer.userAvatarData.map(avatarData =>
+ omit(avatarData, ['buffer'])
+ ),
expireTimer: composer.groupExpireTimer,
conversationIds: composer.selectedConversationIds,
});
@@ -2421,6 +2682,8 @@ export function reducer(
let groupName: string;
let groupAvatar: undefined | ArrayBuffer;
let groupExpireTimer: number;
+ let isEditingAvatar = false;
+ let userAvatarData = getDefaultAvatars(true);
switch (state.composer?.step) {
case ComposerStep.ChooseGroupMembers:
@@ -2433,6 +2696,8 @@ export function reducer(
groupName,
groupAvatar,
groupExpireTimer,
+ isEditingAvatar,
+ userAvatarData,
} = state.composer);
break;
default:
@@ -2457,6 +2722,8 @@ export function reducer(
groupName,
groupAvatar,
groupExpireTimer,
+ isEditingAvatar,
+ userAvatarData,
},
};
}
@@ -2477,9 +2744,11 @@ export function reducer(
'groupAvatar',
'groupName',
'groupExpireTimer',
+ 'isEditingAvatar',
'maximumGroupSizeModalState',
'recommendedGroupSizeModalState',
'selectedConversationIds',
+ 'userAvatarData',
]),
},
};
@@ -2574,6 +2843,99 @@ export function reducer(
};
}
+ if (action.type === COMPOSE_TOGGLE_EDITING_AVATAR) {
+ const { composer } = state;
+
+ switch (composer?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ isEditingAvatar: !composer?.isEditingAvatar,
+ },
+ };
+ default:
+ assert(false, 'Setting editing avatar at this step is a no-op');
+ return state;
+ }
+ }
+
+ if (action.type === COMPOSE_ADD_AVATAR) {
+ const { payload } = action;
+ const { composer } = state;
+
+ switch (composer?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ userAvatarData: [
+ {
+ ...payload,
+ id: getNextAvatarId(composer.userAvatarData),
+ },
+ ...composer.userAvatarData,
+ ],
+ },
+ };
+ default:
+ assert(false, 'Adding an avatar at this step is a no-op');
+ return state;
+ }
+ }
+
+ if (action.type === COMPOSE_REMOVE_AVATAR) {
+ const { payload } = action;
+ const { composer } = state;
+
+ switch (composer?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ userAvatarData: filterAvatarData(composer.userAvatarData, payload),
+ },
+ };
+ default:
+ assert(false, 'Removing an avatar at this step is a no-op');
+ return state;
+ }
+ }
+
+ if (action.type === COMPOSE_REPLACE_AVATAR) {
+ const { curr, prev } = action.payload;
+ const { composer } = state;
+
+ switch (composer?.step) {
+ case ComposerStep.ChooseGroupMembers:
+ case ComposerStep.SetGroupMetadata:
+ return {
+ ...state,
+ composer: {
+ ...composer,
+ userAvatarData: [
+ {
+ ...curr,
+ id: prev?.id ?? getNextAvatarId(composer.userAvatarData),
+ },
+ ...(prev
+ ? filterAvatarData(composer.userAvatarData, prev)
+ : composer.userAvatarData),
+ ],
+ },
+ };
+ default:
+ assert(false, 'Replacing an avatar at this step is a no-op');
+ return state;
+ }
+ }
+
if (action.type === 'SWITCH_TO_ASSOCIATED_VIEW') {
const conversation = getOwn(
state.conversationLookup,
@@ -2718,5 +3080,29 @@ export function reducer(
return nextState;
}
+ if (action.type === REPLACE_AVATARS) {
+ const { conversationLookup } = state;
+ const { conversationId, avatars } = action.payload;
+
+ const conversation = conversationLookup[conversationId];
+ if (!conversation) {
+ return state;
+ }
+
+ const changed = {
+ ...conversation,
+ avatars,
+ };
+
+ return {
+ ...state,
+ conversationLookup: {
+ ...conversationLookup,
+ [conversationId]: changed,
+ },
+ ...updateConversationLookups(changed, conversation, state),
+ };
+ }
+
return state;
}
diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts
index 08b0f0920c75..7b4bb8d808eb 100644
--- a/ts/state/selectors/conversations.ts
+++ b/ts/state/selectors/conversations.ts
@@ -25,6 +25,7 @@ import { assert } from '../../util/assert';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations';
import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
+import { AvatarDataType } from '../../types/Avatar';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { isGroupV2 } from '../../util/whatTypeOfConversation';
@@ -189,6 +190,21 @@ export const isCreatingGroup = createSelector(
composerState.isCreating
);
+export const isEditingAvatar = createSelector(
+ getComposerState,
+ (composerState): boolean =>
+ composerState?.step === ComposerStep.SetGroupMetadata &&
+ composerState.isEditingAvatar
+);
+
+export const getComposeAvatarData = createSelector(
+ getComposerState,
+ (composerState): ReadonlyArray =>
+ composerState?.step === ComposerStep.SetGroupMetadata
+ ? composerState.userAvatarData
+ : []
+);
+
export const getMessages = createSelector(
getConversations,
(state: ConversationsStateType): MessageLookupType => {
diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx
index 222cbf3269a7..6d14b73f53a1 100644
--- a/ts/state/smart/ConversationDetails.tsx
+++ b/ts/state/smart/ConversationDetails.tsx
@@ -4,6 +4,7 @@
import { connect } from 'react-redux';
import { StateType } from '../reducer';
+import { mapDispatchToProps } from '../actions';
import {
ConversationDetails,
StateProps,
@@ -71,9 +72,10 @@ const mapStateToProps = (
i18n: getIntl(state),
isAdmin,
...getGroupMemberships(conversation, conversationSelector),
+ userAvatarData: conversation.avatars || [],
};
};
-const smart = connect(mapStateToProps);
+const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartConversationDetails = smart(ConversationDetails);
diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx
index c3a75ead4fcf..ad6ec6a4e8b7 100644
--- a/ts/state/smart/LeftPane.tsx
+++ b/ts/state/smart/LeftPane.tsx
@@ -17,16 +17,17 @@ import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
import { getSearchResults, isSearching } from '../selectors/search';
import { getIntl, getRegionCode } from '../selectors/user';
import {
- getFilteredCandidateContactsForNewGroup,
getCantAddContactForModal,
- getFilteredComposeContacts,
- getFilteredComposeGroups,
+ getComposeAvatarData,
getComposeGroupAvatar,
- getComposeGroupName,
getComposeGroupExpireTimer,
+ getComposeGroupName,
getComposeSelectedContacts,
getComposerConversationSearchTerm,
getComposerStep,
+ getFilteredCandidateContactsForNewGroup,
+ getFilteredComposeContacts,
+ getFilteredComposeGroups,
getLeftPaneLists,
getMaximumGroupSizeModalState,
getRecommendedGroupSizeModalState,
@@ -35,6 +36,7 @@ import {
getShowArchived,
hasGroupCreationError,
isCreatingGroup,
+ isEditingAvatar,
} from '../selectors/conversations';
import { SmartExpiredBuildDialog } from './ExpiredBuildDialog';
@@ -133,7 +135,9 @@ const getModeSpecificProps = (
groupExpireTimer: getComposeGroupExpireTimer(state),
hasError: hasGroupCreationError(state),
isCreating: isCreatingGroup(state),
+ isEditingAvatar: isEditingAvatar(state),
selectedContacts: getComposeSelectedContacts(state),
+ userAvatarData: getComposeAvatarData(state),
};
default:
throw missingCaseError(composerStep);
diff --git a/ts/state/smart/ProfileEditorModal.ts b/ts/state/smart/ProfileEditorModal.ts
index 4423d19849bc..f1f07b79292e 100644
--- a/ts/state/smart/ProfileEditorModal.ts
+++ b/ts/state/smart/ProfileEditorModal.ts
@@ -17,9 +17,16 @@ import { selectRecentEmojis } from '../selectors/emojis';
function mapStateToProps(
state: StateType
): PropsDataType & ProfileEditorModalPropsType {
- const { avatarPath, aboutText, aboutEmoji, firstName, familyName } = getMe(
- state
- );
+ const {
+ avatarPath,
+ avatars: userAvatarData = [],
+ aboutText,
+ aboutEmoji,
+ color,
+ firstName,
+ familyName,
+ id: conversationId,
+ } = getMe(state);
const recentEmojis = selectRecentEmojis(state);
const skinTone = get(state, ['items', 'skinTone'], 0);
@@ -27,12 +34,15 @@ function mapStateToProps(
aboutEmoji,
aboutText,
avatarPath,
+ color,
+ conversationId,
familyName,
firstName: String(firstName),
hasError: state.globalModals.profileEditorHasError,
i18n: getIntl(state),
recentEmojis,
skinTone,
+ userAvatarData,
};
}
diff --git a/ts/test-both/helpers/getDefaultConversation.ts b/ts/test-both/helpers/getDefaultConversation.ts
index d28fdac52643..a567175c07b2 100644
--- a/ts/test-both/helpers/getDefaultConversation.ts
+++ b/ts/test-both/helpers/getDefaultConversation.ts
@@ -4,6 +4,7 @@
import { v4 as generateUuid } from 'uuid';
import { sample } from 'lodash';
import { ConversationType } from '../../state/ducks/conversations';
+import { getRandomColor } from './getRandomColor';
const FIRST_NAMES = [
'James',
@@ -323,6 +324,7 @@ export function getDefaultConversation(
return {
acceptedMessageRequest: true,
e164: '+1300555000',
+ color: getRandomColor(),
firstName,
id: generateUuid(),
isGroupV2Capable: true,
diff --git a/ts/test-both/helpers/getRandomColor.ts b/ts/test-both/helpers/getRandomColor.ts
new file mode 100644
index 000000000000..8456845fe624
--- /dev/null
+++ b/ts/test-both/helpers/getRandomColor.ts
@@ -0,0 +1,9 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { sample } from 'lodash';
+import { AvatarColors, AvatarColorType } from '../../types/Colors';
+
+export function getRandomColor(): AvatarColorType {
+ return sample(AvatarColors) || AvatarColors[0];
+}
diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts
index 3658307243d0..08c64ce810cd 100644
--- a/ts/test-both/state/selectors/conversations_test.ts
+++ b/ts/test-both/state/selectors/conversations_test.ts
@@ -45,6 +45,13 @@ import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../helpers/getDefaultConversation';
+function getDefaultComposeState() {
+ return {
+ isEditingAvatar: false,
+ userAvatarData: [],
+ };
+}
+
describe('both/state/selectors/conversations', () => {
const getEmptyRootState = (): StateType => {
return rootReducer(undefined, noopAction());
@@ -317,6 +324,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: 'foo',
selectedConversationIds: ['abc'],
@@ -340,6 +348,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
@@ -384,6 +393,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -406,6 +416,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -447,6 +458,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -469,6 +481,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -1078,6 +1091,7 @@ describe('both/state/selectors/conversations', () => {
},
},
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm,
selectedConversationIds: ['abc'],
@@ -1139,6 +1153,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: undefined,
searchTerm: '',
groupAvatar: undefined,
@@ -1164,6 +1179,7 @@ describe('both/state/selectors/conversations', () => {
...getEmptyState(),
conversationLookup: { abc123: conversation },
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: 'abc123',
searchTerm: '',
groupAvatar: undefined,
@@ -1602,6 +1618,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: undefined,
searchTerm: 'to be cleared',
groupAvatar: undefined,
@@ -1628,6 +1645,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: undefined,
searchTerm: 'to be cleared',
groupAvatar: undefined,
@@ -1654,6 +1672,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
@@ -1676,6 +1695,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
@@ -1703,6 +1723,7 @@ describe('both/state/selectors/conversations', () => {
conversations: {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc'],
cantAddContactIdForModal: undefined,
@@ -1737,6 +1758,7 @@ describe('both/state/selectors/conversations', () => {
},
},
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['convo-2', 'convo-1'],
cantAddContactIdForModal: undefined,
diff --git a/ts/test-both/types/Avatar_test.ts b/ts/test-both/types/Avatar_test.ts
new file mode 100644
index 000000000000..5f11481379c5
--- /dev/null
+++ b/ts/test-both/types/Avatar_test.ts
@@ -0,0 +1,27 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { getDefaultAvatars } from '../../types/Avatar';
+
+describe('Avatar', () => {
+ describe('getDefaultAvatars', () => {
+ it('returns an array of valid avatars for direct conversations', () => {
+ assert.isNotEmpty(getDefaultAvatars(false));
+ });
+
+ it('returns an array of valid avatars for group conversations', () => {
+ assert.isNotEmpty(getDefaultAvatars(true));
+ });
+
+ it('defaults to returning avatars for direct conversations', () => {
+ const defaultResult = getDefaultAvatars();
+ const directResult = getDefaultAvatars(false);
+ const groupResult = getDefaultAvatars(true);
+
+ assert.deepEqual(defaultResult, directResult);
+ assert.notDeepEqual(defaultResult, groupResult);
+ });
+ });
+});
diff --git a/ts/test-both/util/getAvatarData_test.ts b/ts/test-both/util/getAvatarData_test.ts
new file mode 100644
index 000000000000..cda22c887ded
--- /dev/null
+++ b/ts/test-both/util/getAvatarData_test.ts
@@ -0,0 +1,35 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import { v4 as uuid } from 'uuid';
+import { getRandomColor } from '../helpers/getRandomColor';
+
+import { getAvatarData } from '../../util/getAvatarData';
+
+describe('getAvatarData', () => {
+ it('returns existing avatars if present', () => {
+ const avatars = [
+ {
+ id: uuid(),
+ color: getRandomColor(),
+ text: 'Avatar A',
+ },
+ {
+ id: uuid(),
+ color: getRandomColor(),
+ text: 'Avatar B',
+ },
+ ];
+
+ assert.strictEqual(getAvatarData({ avatars, type: 'private' }), avatars);
+ assert.strictEqual(getAvatarData({ avatars, type: 'group' }), avatars);
+ });
+
+ it('returns a non-empty array if no avatars are provided', () => {
+ assert.isNotEmpty(getAvatarData({ type: 'private' }));
+ assert.isNotEmpty(getAvatarData({ type: 'group' }));
+ assert.isNotEmpty(getAvatarData({ avatars: [], type: 'private' }));
+ assert.isNotEmpty(getAvatarData({ avatars: [], type: 'group' }));
+ });
+});
diff --git a/ts/test-electron/models/conversations_test.ts b/ts/test-electron/models/conversations_test.ts
index 0a5fe19ddf28..7368b0b24387 100644
--- a/ts/test-electron/models/conversations_test.ts
+++ b/ts/test-electron/models/conversations_test.ts
@@ -20,6 +20,7 @@ describe('Conversations', () => {
// Creating a fake conversation
const conversation = new window.Whisper.Conversation({
+ avatars: [],
id: window.getGuid(),
e164: '+15551234567',
uuid: window.getGuid(),
diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts
index 370e2980ccdd..388e3b406688 100644
--- a/ts/test-electron/state/ducks/conversations_test.ts
+++ b/ts/test-electron/state/ducks/conversations_test.ts
@@ -27,6 +27,7 @@ import { ContactSpoofingType } from '../../../util/contactSpoofing';
import { CallMode } from '../../../types/Calling';
import * as groups from '../../../groups';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
+import { getDefaultAvatars } from '../../../types/Avatar';
const {
cantAddContactToGroup,
@@ -56,6 +57,13 @@ const {
toggleConversationInChooseMembers,
} = actions;
+function getDefaultComposeState() {
+ return {
+ isEditingAvatar: false,
+ userAvatarData: [],
+ };
+}
+
describe('both/state/ducks/conversations', () => {
const getEmptyRootState = () => rootReducer(undefined, noopAction());
@@ -451,6 +459,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: undefined,
searchTerm: '',
groupAvatar: undefined,
@@ -477,6 +486,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -516,6 +526,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: 'abc123',
searchTerm: '',
groupAvatar: undefined,
@@ -567,6 +578,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: 'abc123',
searchTerm: '',
groupAvatar: undefined,
@@ -593,6 +605,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: 'abc123',
searchTerm: '',
groupAvatar: undefined,
@@ -614,6 +627,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: 'abc123',
searchTerm: '',
groupAvatar: undefined,
@@ -637,6 +651,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: 'abc123',
searchTerm: '',
groupAvatar: undefined,
@@ -663,6 +678,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: 'abc123',
searchTerm: '',
groupAvatar: undefined,
@@ -684,6 +700,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: 'abc123',
searchTerm: '',
groupAvatar: undefined,
@@ -706,6 +723,7 @@ describe('both/state/ducks/conversations', () => {
const conversationsState = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: ['abc123'],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -758,6 +776,7 @@ describe('both/state/ducks/conversations', () => {
sinon.assert.calledWith(createGroupStub, {
name: 'Foo Bar Group',
avatar: new Uint8Array([1, 2, 3]).buffer,
+ avatars: [],
expireTimer: 0,
conversationIds: ['abc123'],
});
@@ -1210,6 +1229,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -1236,6 +1256,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -1262,6 +1283,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -1435,6 +1457,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
cantAddContactIdForModal: undefined,
searchTerm: 'to be cleared',
groupAvatar: undefined,
@@ -1460,6 +1483,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -1523,6 +1547,7 @@ describe('both/state/ducks/conversations', () => {
assert.isFalse(result.showArchived);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: [],
@@ -1532,6 +1557,7 @@ describe('both/state/ducks/conversations', () => {
groupName: '',
groupAvatar: undefined,
groupExpireTimer: 0,
+ userAvatarData: getDefaultAvatars(true),
});
});
@@ -1539,6 +1565,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: 'foo bar',
selectedConversationIds: [],
@@ -1560,6 +1587,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
cantAddContactIdForModal: undefined,
@@ -1577,6 +1605,7 @@ describe('both/state/ducks/conversations', () => {
assert.isFalse(result.showArchived);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: [],
@@ -1596,6 +1625,7 @@ describe('both/state/ducks/conversations', () => {
assert.isFalse(result.showArchived);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: [],
@@ -1605,6 +1635,7 @@ describe('both/state/ducks/conversations', () => {
groupName: '',
groupAvatar: undefined,
groupExpireTimer: 0,
+ userAvatarData: getDefaultAvatars(true),
});
});
@@ -1618,6 +1649,7 @@ describe('both/state/ducks/conversations', () => {
assert.isFalse(result.showArchived);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: [],
@@ -1627,6 +1659,7 @@ describe('both/state/ducks/conversations', () => {
groupName: '',
groupAvatar: undefined,
groupExpireTimer: 0,
+ userAvatarData: getDefaultAvatars(true),
});
});
});
@@ -1636,6 +1669,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: 'foo bar',
selectedConversationIds: ['abc', 'def'],
@@ -1651,6 +1685,7 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata,
selectedConversationIds: ['abc', 'def'],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -1667,6 +1702,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: 'foo bar',
selectedConversationIds: ['abc', 'def'],
@@ -1682,6 +1718,7 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata,
selectedConversationIds: ['abc', 'def'],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -1698,6 +1735,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.SetGroupMetadata as const,
selectedConversationIds: [],
recommendedGroupSizeModalState: OneTimeModalState.NeverShown,
@@ -1750,6 +1788,7 @@ describe('both/state/ducks/conversations', () => {
const zero = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: [],
@@ -1765,6 +1804,7 @@ describe('both/state/ducks/conversations', () => {
const two = reducer(one, getAction('def', one));
assert.deepEqual(two.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: ['abc', 'def'],
@@ -1781,6 +1821,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: ['abc', 'def'],
@@ -1796,6 +1837,7 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: ['def'],
@@ -1815,6 +1857,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: oldSelectedConversationIds,
@@ -1830,6 +1873,7 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: [...oldSelectedConversationIds, newUuid],
@@ -1849,6 +1893,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: oldSelectedConversationIds,
@@ -1864,6 +1909,7 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: [...oldSelectedConversationIds, newUuid],
@@ -1885,6 +1931,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: [],
@@ -1909,6 +1956,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: oldSelectedConversationIds,
@@ -1924,6 +1972,7 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: [...oldSelectedConversationIds, newUuid],
@@ -1943,6 +1992,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: oldSelectedConversationIds,
@@ -1958,6 +2008,7 @@ describe('both/state/ducks/conversations', () => {
const result = reducer(state, action);
assert.deepEqual(result.composer, {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers,
searchTerm: '',
selectedConversationIds: [...oldSelectedConversationIds, newUuid],
@@ -1974,6 +2025,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: times(1000, () => uuid()),
@@ -2002,6 +2054,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: [],
@@ -2029,6 +2082,7 @@ describe('both/state/ducks/conversations', () => {
const state = {
...getEmptyState(),
composer: {
+ ...getDefaultComposeState(),
step: ComposerStep.ChooseGroupMembers as const,
searchTerm: '',
selectedConversationIds: [],
diff --git a/ts/test-electron/util/canvasToArrayBuffer_test.ts b/ts/test-electron/util/canvasToArrayBuffer_test.ts
index 5df574c857b6..b1f533a2e797 100644
--- a/ts/test-electron/util/canvasToArrayBuffer_test.ts
+++ b/ts/test-electron/util/canvasToArrayBuffer_test.ts
@@ -2,12 +2,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
+import { IMAGE_JPEG, IMAGE_PNG } from '../../types/MIME';
+import { sniffImageMimeType } from '../../util/sniffImageMimeType';
import { canvasToArrayBuffer } from '../../util/canvasToArrayBuffer';
describe('canvasToArrayBuffer', () => {
- it('converts a canvas to an ArrayBuffer', async () => {
- const canvas = document.createElement('canvas');
+ let canvas: HTMLCanvasElement;
+ beforeEach(() => {
+ canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 200;
@@ -17,11 +20,21 @@ describe('canvasToArrayBuffer', () => {
}
context.fillStyle = '#ff9900';
context.fillRect(10, 10, 20, 20);
+ });
+ it('converts a canvas to an ArrayBuffer, JPEG by default', async () => {
const result = await canvasToArrayBuffer(canvas);
+ assert.strictEqual(sniffImageMimeType(result), IMAGE_JPEG);
+
// These are just smoke tests.
assert.instanceOf(result, ArrayBuffer);
assert.isAtLeast(result.byteLength, 50);
});
+
+ it('can convert a canvas to a PNG ArrayBuffer', async () => {
+ const result = await canvasToArrayBuffer(canvas, IMAGE_PNG);
+
+ assert.strictEqual(sniffImageMimeType(result), IMAGE_PNG);
+ });
});
diff --git a/ts/test-electron/util/canvasToBlob_test.ts b/ts/test-electron/util/canvasToBlob_test.ts
index 0513915b7f7f..8e7af9a95e4c 100644
--- a/ts/test-electron/util/canvasToBlob_test.ts
+++ b/ts/test-electron/util/canvasToBlob_test.ts
@@ -2,12 +2,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
+import { IMAGE_JPEG, IMAGE_PNG } from '../../types/MIME';
+import { sniffImageMimeType } from '../../util/sniffImageMimeType';
import { canvasToBlob } from '../../util/canvasToBlob';
describe('canvasToBlob', () => {
- it('converts a canvas to an Blob', async () => {
- const canvas = document.createElement('canvas');
+ let canvas: HTMLCanvasElement;
+ beforeEach(() => {
+ canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 200;
@@ -17,11 +20,27 @@ describe('canvasToBlob', () => {
}
context.fillStyle = '#ff9900';
context.fillRect(10, 10, 20, 20);
+ });
+ it('converts a canvas to an Blob, JPEG by default', async () => {
const result = await canvasToBlob(canvas);
+ assert.strictEqual(
+ sniffImageMimeType(await result.arrayBuffer()),
+ IMAGE_JPEG
+ );
+
// These are just smoke tests.
assert.instanceOf(result, Blob);
assert.isAtLeast(result.size, 50);
});
+
+ it('can convert a canvas to a PNG Blob', async () => {
+ const result = await canvasToBlob(canvas, IMAGE_PNG);
+
+ assert.strictEqual(
+ sniffImageMimeType(await result.arrayBuffer()),
+ IMAGE_PNG
+ );
+ });
});
diff --git a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts
index 4fbff026390f..8b3f9d4ffc2d 100644
--- a/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts
+++ b/ts/test-node/components/leftPane/LeftPaneSetGroupMetadataHelper_test.ts
@@ -8,17 +8,25 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon
import { LeftPaneSetGroupMetadataHelper } from '../../../components/leftPane/LeftPaneSetGroupMetadataHelper';
+function getComposeState() {
+ return {
+ groupAvatar: undefined,
+ groupExpireTimer: 0,
+ groupName: '',
+ hasError: false,
+ isCreating: false,
+ isEditingAvatar: false,
+ selectedContacts: [],
+ userAvatarData: [],
+ };
+}
+
describe('LeftPaneSetGroupMetadataHelper', () => {
describe('getBackAction', () => {
it('returns the "show composer" action if a request is not active', () => {
const showChooseGroupMembers = sinon.fake();
const helper = new LeftPaneSetGroupMetadataHelper({
- groupAvatar: undefined,
- groupExpireTimer: 0,
- groupName: '',
- hasError: false,
- isCreating: false,
- selectedContacts: [],
+ ...getComposeState(),
});
assert.strictEqual(
@@ -29,12 +37,9 @@ describe('LeftPaneSetGroupMetadataHelper', () => {
it("returns undefined (i.e., you can't go back) if a request is active", () => {
const helper = new LeftPaneSetGroupMetadataHelper({
- groupAvatar: undefined,
- groupExpireTimer: 0,
+ ...getComposeState(),
groupName: 'Foo Bar',
- hasError: false,
isCreating: true,
- selectedContacts: [],
});
assert.isUndefined(
@@ -47,12 +52,7 @@ describe('LeftPaneSetGroupMetadataHelper', () => {
it('returns 0 if there are no contacts', () => {
assert.strictEqual(
new LeftPaneSetGroupMetadataHelper({
- groupAvatar: undefined,
- groupExpireTimer: 0,
- groupName: '',
- hasError: false,
- isCreating: false,
- selectedContacts: [],
+ ...getComposeState(),
}).getRowCount(),
0
);
@@ -61,11 +61,7 @@ describe('LeftPaneSetGroupMetadataHelper', () => {
it('returns the number of candidate contacts + 2 if there are any', () => {
assert.strictEqual(
new LeftPaneSetGroupMetadataHelper({
- groupAvatar: undefined,
- groupExpireTimer: 0,
- groupName: '',
- hasError: false,
- isCreating: false,
+ ...getComposeState(),
selectedContacts: [
getDefaultConversation(),
getDefaultConversation(),
@@ -80,12 +76,7 @@ describe('LeftPaneSetGroupMetadataHelper', () => {
it('returns undefined if there are no contacts', () => {
assert.isUndefined(
new LeftPaneSetGroupMetadataHelper({
- groupAvatar: undefined,
- groupExpireTimer: 0,
- groupName: '',
- hasError: false,
- isCreating: false,
- selectedContacts: [],
+ ...getComposeState(),
}).getRow(0)
);
});
@@ -96,11 +87,7 @@ describe('LeftPaneSetGroupMetadataHelper', () => {
getDefaultConversation(),
];
const helper = new LeftPaneSetGroupMetadataHelper({
- groupAvatar: undefined,
- groupExpireTimer: 0,
- groupName: '',
- hasError: false,
- isCreating: false,
+ ...getComposeState(),
selectedContacts,
});
diff --git a/ts/types/Avatar.ts b/ts/types/Avatar.ts
new file mode 100644
index 000000000000..da1a289ef7c9
--- /dev/null
+++ b/ts/types/Avatar.ts
@@ -0,0 +1,122 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { AvatarColorType } from './Colors';
+import { strictAssert } from '../util/assert';
+
+export const PersonalAvatarIcons = [
+ 'abstract_01',
+ 'abstract_02',
+ 'abstract_03',
+ 'cat',
+ 'dog',
+ 'fox',
+ 'tucan',
+ 'pig',
+ 'dinosour',
+ 'sloth',
+ 'incognito',
+ 'ghost',
+] as const;
+
+export const GroupAvatarIcons = [
+ 'balloon',
+ 'book',
+ 'briefcase',
+ 'celebration',
+ 'drink',
+ 'football',
+ 'heart',
+ 'house',
+ 'melon',
+ 'soccerball',
+ 'sunset',
+ 'surfboard',
+] as const;
+
+type GroupAvatarIconType = typeof GroupAvatarIcons[number];
+
+type PersonalAvatarIconType = typeof PersonalAvatarIcons[number];
+
+export type AvatarIconType = GroupAvatarIconType | PersonalAvatarIconType;
+
+export type AvatarDataType = {
+ id: number | string;
+ buffer?: ArrayBuffer;
+ color?: AvatarColorType;
+ icon?: AvatarIconType;
+ imagePath?: string;
+ text?: string;
+};
+
+export type DeleteAvatarFromDiskActionType = (
+ avatarData: AvatarDataType,
+ conversationId?: string
+) => unknown;
+
+export type ReplaceAvatarActionType = (
+ curr: AvatarDataType,
+ prev?: AvatarDataType,
+ conversationId?: string
+) => unknown;
+
+export type SaveAvatarToDiskActionType = (
+ avatarData: AvatarDataType,
+ conversationId?: string
+) => unknown;
+
+const groupIconColors = [
+ 'A180',
+ 'A120',
+ 'A110',
+ 'A170',
+ 'A100',
+ 'A210',
+ 'A100',
+ 'A180',
+ 'A120',
+ 'A110',
+ 'A130',
+ 'A210',
+];
+
+const personalIconColors = [
+ 'A130',
+ 'A120',
+ 'A170',
+ 'A190',
+ 'A140',
+ 'A190',
+ 'A120',
+ 'A160',
+ 'A130',
+ 'A180',
+ 'A210',
+ 'A100',
+];
+
+strictAssert(
+ groupIconColors.length === GroupAvatarIcons.length &&
+ personalIconColors.length === PersonalAvatarIcons.length,
+ 'colors.length !== icons.length'
+);
+
+const groupDefaultAvatars = GroupAvatarIcons.map((icon, index) => ({
+ id: index,
+ color: groupIconColors[index],
+ icon,
+}));
+
+const personalDefaultAvatars = PersonalAvatarIcons.map((icon, index) => ({
+ id: index,
+ color: personalIconColors[index],
+ icon,
+}));
+
+export function getDefaultAvatars(isGroup?: boolean): Array {
+ if (isGroup) {
+ return groupDefaultAvatars;
+ }
+
+ return personalDefaultAvatars;
+}
diff --git a/ts/types/Colors.ts b/ts/types/Colors.ts
index 25a7fd8955a9..841b5abeebe5 100644
--- a/ts/types/Colors.ts
+++ b/ts/types/Colors.ts
@@ -1,21 +1,94 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-export const AvatarColors = [
- 'crimson',
- 'vermilion',
- 'burlap',
- 'forest',
- 'wintergreen',
- 'teal',
- 'blue',
- 'indigo',
- 'violet',
- 'plum',
- 'taupe',
- 'steel',
- 'ultramarine',
-] as const;
+export const AvatarColorMap = new Map([
+ [
+ 'A100',
+ {
+ bg: '#e3e3fe',
+ fg: '#3838f5',
+ },
+ ],
+ [
+ 'A110',
+ {
+ bg: '#dde7fc',
+ fg: '#1251d3',
+ },
+ ],
+ [
+ 'A120',
+ {
+ bg: '#d8e8f0',
+ fg: '#086da0',
+ },
+ ],
+ [
+ 'A130',
+ {
+ bg: '#cde4cd',
+ fg: '#067906',
+ },
+ ],
+ [
+ 'A140',
+ {
+ bg: '#eae0fd',
+ fg: '#661aff',
+ },
+ ],
+ [
+ 'A150',
+ {
+ bg: '#f5e3fe',
+ fg: '#9f00f0',
+ },
+ ],
+ [
+ 'A160',
+ {
+ bg: '#f6d8ec',
+ fg: '#b8057c',
+ },
+ ],
+ [
+ 'A170',
+ {
+ bg: '#f5d7d7',
+ fg: '#be0404',
+ },
+ ],
+ [
+ 'A180',
+ {
+ bg: '#fef5d0',
+ fg: '#836b01',
+ },
+ ],
+ [
+ 'A190',
+ {
+ bg: '#eae6d5',
+ fg: '#7d6f40',
+ },
+ ],
+ [
+ 'A200',
+ {
+ bg: '#d2d2dc',
+ fg: '#4f4f6d',
+ },
+ ],
+ [
+ 'A210',
+ {
+ bg: '#d7d7d9',
+ fg: '#5c5c5c',
+ },
+ ],
+]);
+
+export const AvatarColors = Array.from(AvatarColorMap.keys());
export const ConversationColors = [
'ultramarine',
@@ -90,6 +163,7 @@ export type CustomColorType = {
};
export type AvatarColorType = typeof AvatarColors[number];
+
export type ConversationColorType =
| typeof ConversationColors[number]
| 'custom';
diff --git a/ts/util/avatarDataToArrayBuffer.ts b/ts/util/avatarDataToArrayBuffer.ts
new file mode 100644
index 000000000000..3878f1feb945
--- /dev/null
+++ b/ts/util/avatarDataToArrayBuffer.ts
@@ -0,0 +1,121 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { AvatarColorMap, AvatarColorType } from '../types/Colors';
+import { AvatarDataType } from '../types/Avatar';
+import { canvasToArrayBuffer } from './canvasToArrayBuffer';
+import { getFittedFontSize } from './avatarTextSizeCalculator';
+
+const CANVAS_SIZE = 1024;
+
+function getAvatarColor(color: AvatarColorType): { bg: string; fg: string } {
+ return AvatarColorMap.get(color) || { bg: 'black', fg: 'white' };
+}
+
+function setCanvasBackground(
+ bg: string,
+ context: CanvasRenderingContext2D,
+ canvas: HTMLCanvasElement
+): void {
+ context.fillStyle = bg;
+ context.fillRect(0, 0, canvas.width, canvas.height);
+}
+
+async function drawImage(
+ src: string,
+ context: CanvasRenderingContext2D,
+ canvas: HTMLCanvasElement
+): Promise {
+ const image = new Image();
+ image.src = src;
+ await image.decode();
+ // eslint-disable-next-line no-param-reassign
+ canvas.width = image.width;
+ // eslint-disable-next-line no-param-reassign
+ canvas.height = image.height;
+ context.drawImage(image, 0, 0);
+}
+
+async function getFont(text: string): Promise {
+ const font = new window.FontFace(
+ 'Inter',
+ 'url("fonts/inter-v3.10/Inter-Regular.woff2")'
+ );
+ await font.load();
+
+ const measurerCanvas = document.createElement('canvas');
+ measurerCanvas.width = CANVAS_SIZE;
+ measurerCanvas.height = CANVAS_SIZE;
+
+ const measurerContext = measurerCanvas.getContext('2d');
+ if (!measurerContext) {
+ throw new Error('getFont: could not get canvas rendering context');
+ }
+
+ const fontSize = getFittedFontSize(CANVAS_SIZE, text, candidateFontSize => {
+ const candidateFont = `${candidateFontSize}px Inter`;
+ measurerContext.font = candidateFont;
+
+ const {
+ actualBoundingBoxLeft,
+ actualBoundingBoxRight,
+ actualBoundingBoxAscent,
+ actualBoundingBoxDescent,
+ } = measurerContext.measureText(text);
+
+ const width =
+ Math.abs(actualBoundingBoxLeft) + Math.abs(actualBoundingBoxRight);
+ const height =
+ Math.abs(actualBoundingBoxAscent) + Math.abs(actualBoundingBoxDescent);
+
+ return { height, width };
+ });
+
+ return `${fontSize}px Inter`;
+}
+
+export async function avatarDataToArrayBuffer(
+ avatarData: AvatarDataType
+): Promise {
+ const canvas = document.createElement('canvas');
+ canvas.width = CANVAS_SIZE;
+ canvas.height = CANVAS_SIZE;
+ const context = canvas.getContext('2d');
+
+ if (!context) {
+ throw new Error(
+ 'avatarDataToArrayBuffer: could not get canvas rendering context'
+ );
+ }
+
+ const { color, icon, imagePath, text } = avatarData;
+
+ if (imagePath) {
+ await drawImage(
+ window.Signal?.Migrations
+ ? window.Signal.Migrations.getAbsoluteAvatarPath(imagePath)
+ : imagePath,
+ context,
+ canvas
+ );
+ } else if (color && text) {
+ const { bg, fg } = getAvatarColor(color);
+ const textToWrite = text.toLocaleUpperCase();
+
+ setCanvasBackground(bg, context, canvas);
+ context.fillStyle = fg;
+ const font = await getFont(textToWrite);
+ context.font = font;
+ context.textBaseline = 'middle';
+ context.textAlign = 'center';
+ context.fillText(textToWrite, CANVAS_SIZE / 2, CANVAS_SIZE / 2 + 30);
+ } else if (color && icon) {
+ const iconPath = `images/avatars/avatar_${icon}.svg`;
+ await drawImage(iconPath, context, canvas);
+ context.globalCompositeOperation = 'destination-over';
+ const { bg } = getAvatarColor(color);
+ setCanvasBackground(bg, context, canvas);
+ }
+
+ return canvasToArrayBuffer(canvas);
+}
diff --git a/ts/util/avatarTextSizeCalculator.ts b/ts/util/avatarTextSizeCalculator.ts
new file mode 100644
index 000000000000..095b7d16c8c5
--- /dev/null
+++ b/ts/util/avatarTextSizeCalculator.ts
@@ -0,0 +1,52 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import * as grapheme from './grapheme';
+import { getEmojiCount } from '../components/emoji/lib';
+
+type FontSizes = {
+ diameter: number;
+ singleEmoji: number;
+ smol: number;
+ text: number;
+};
+
+type RectSize = {
+ height: number;
+ width: number;
+};
+
+export function getFontSizes(bubbleSize: number): FontSizes {
+ return {
+ diameter: Math.ceil(bubbleSize * 0.75),
+ singleEmoji: Math.ceil(bubbleSize * 0.6),
+ smol: Math.ceil(bubbleSize * 0.05),
+ text: Math.ceil(bubbleSize * 0.45),
+ };
+}
+
+export function getFittedFontSize(
+ bubbleSize: number,
+ text: string,
+ measure: (candidateFontSize: number) => RectSize
+): number {
+ const sizes = getFontSizes(bubbleSize);
+
+ let candidateFontSize = sizes.text;
+ if (grapheme.count(text) === 1 && getEmojiCount(text) === 1) {
+ candidateFontSize = sizes.singleEmoji;
+ }
+
+ for (
+ candidateFontSize;
+ candidateFontSize >= sizes.smol;
+ candidateFontSize -= 1
+ ) {
+ const { height, width } = measure(candidateFontSize);
+ if (width < sizes.diameter && height < sizes.diameter) {
+ return candidateFontSize;
+ }
+ }
+
+ return candidateFontSize;
+}
diff --git a/ts/util/canvasToArrayBuffer.ts b/ts/util/canvasToArrayBuffer.ts
index 33537ad08309..06bccb244dca 100644
--- a/ts/util/canvasToArrayBuffer.ts
+++ b/ts/util/canvasToArrayBuffer.ts
@@ -2,10 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { canvasToBlob } from './canvasToBlob';
+import { MIMEType } from '../types/MIME';
export async function canvasToArrayBuffer(
- canvas: HTMLCanvasElement
+ canvas: HTMLCanvasElement,
+ mimeType?: MIMEType,
+ quality?: number
): Promise {
- const blob = await canvasToBlob(canvas);
+ const blob = await canvasToBlob(canvas, mimeType, quality);
return blob.arrayBuffer();
}
diff --git a/ts/util/createAvatarData.ts b/ts/util/createAvatarData.ts
new file mode 100644
index 000000000000..073c2aa927f9
--- /dev/null
+++ b/ts/util/createAvatarData.ts
@@ -0,0 +1,14 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { v4 as uuid } from 'uuid';
+import { AvatarDataType } from '../types/Avatar';
+
+export function createAvatarData(
+ partialAvatarData: Readonly>
+): AvatarDataType {
+ return {
+ id: uuid(),
+ ...partialAvatarData,
+ };
+}
diff --git a/ts/util/encryptProfileData.ts b/ts/util/encryptProfileData.ts
index 947c344b3daf..e9fe9ee76eaf 100644
--- a/ts/util/encryptProfileData.ts
+++ b/ts/util/encryptProfileData.ts
@@ -16,7 +16,7 @@ const { encryptProfile, encryptProfileItemWithPadding } = Crypto;
export async function encryptProfileData(
conversation: ConversationType,
- avatarData?: ArrayBuffer
+ avatarBuffer?: ArrayBuffer
): Promise<[ProfileRequestDataType, ArrayBuffer | undefined]> {
const {
aboutEmoji,
@@ -59,7 +59,7 @@ export async function encryptProfileData(
PaddedLengths.AboutEmoji
)
: null,
- avatarData ? encryptProfile(avatarData, keyBuffer) : undefined,
+ avatarBuffer ? encryptProfile(avatarBuffer, keyBuffer) : undefined,
]);
const profileData = {
@@ -68,7 +68,7 @@ export async function encryptProfileData(
about: bytesAbout ? arrayBufferToBase64(bytesAbout) : null,
aboutEmoji: bytesAboutEmoji ? arrayBufferToBase64(bytesAboutEmoji) : null,
paymentAddress: window.storage.get('paymentAddress') || null,
- avatar: Boolean(avatarData),
+ avatar: Boolean(avatarBuffer),
commitment: deriveProfileKeyCommitment(profileKey, uuid),
};
diff --git a/ts/util/getAvatarData.ts b/ts/util/getAvatarData.ts
new file mode 100644
index 000000000000..0aca8d276aa7
--- /dev/null
+++ b/ts/util/getAvatarData.ts
@@ -0,0 +1,20 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { AvatarDataType, getDefaultAvatars } from '../types/Avatar';
+import { isDirectConversation } from './whatTypeOfConversation';
+import { ConversationAttributesType } from '../model-types.d';
+
+export function getAvatarData(
+ conversationAttrs: Pick
+): Array {
+ const { avatars } = conversationAttrs;
+
+ if (avatars && avatars.length) {
+ return avatars;
+ }
+
+ const isGroup = !isDirectConversation(conversationAttrs);
+
+ return getDefaultAvatars(isGroup);
+}
diff --git a/ts/util/isSameAvatarData.ts b/ts/util/isSameAvatarData.ts
new file mode 100644
index 000000000000..c1744cfcb63f
--- /dev/null
+++ b/ts/util/isSameAvatarData.ts
@@ -0,0 +1,20 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { AvatarDataType } from '../types/Avatar';
+
+export function isSameAvatarData(
+ a?: AvatarDataType,
+ b?: AvatarDataType
+): boolean {
+ if (!a || !b) {
+ return false;
+ }
+ if (a.buffer && b.buffer) {
+ return a.buffer === b.buffer;
+ }
+ if (a.imagePath && b.imagePath) {
+ return a.imagePath === b.imagePath;
+ }
+ return a.id === b.id;
+}
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index 3a34ec8271b4..581abf988491 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -13221,47 +13221,75 @@
},
{
"rule": "React-useRef",
- "path": "ts/components/AvatarInput.js",
+ "path": "ts/components/AvatarPreview.js",
+ "line": " const startingAvatarPathRef = react_1.useRef(avatarValue ? undefined : avatarPath);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-03T21:17:38.615Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarPreview.tsx",
+ "line": " const startingAvatarPathRef = useRef(",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-03T21:17:38.615Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarTextEditor.js",
+ "line": " const measureElRef = react_1.useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-04T18:18:09.236Z",
+ "reasonDetail": "Only used for measurement. Doesn't modify the DOM."
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarTextEditor.js",
+ "line": " const inputRef = react_1.useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-04T22:02:17.074Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarTextEditor.js",
+ "line": " const onDoneRef = react_1.useRef(onDone);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-05T23:40:55.699Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarTextEditor.tsx",
+ "line": " const measureElRef = useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-04T18:18:09.236Z",
+ "reasonDetail": "Only used for measurement. Doesn't modify the DOM."
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarTextEditor.tsx",
+ "line": " const inputRef = useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-04T22:02:17.074Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarTextEditor.tsx",
+ "line": " const onDoneRef = useRef(onDone);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-05T23:40:55.699Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/AvatarUploadButton.js",
"line": " const fileInputRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
- "updated": "2021-03-01T18:34:36.638Z",
- "reasonDetail": "Needed to trigger a click on a 'file' input. Doesn't update the DOM."
+ "updated": "2021-08-03T21:17:38.615Z"
},
{
"rule": "React-useRef",
- "path": "ts/components/AvatarInput.js",
- "line": " const menuTriggerRef = react_1.useRef(null);",
- "reasonCategory": "usageTrusted",
- "updated": "2021-03-01T18:34:36.638Z",
- "reasonDetail": "Used to reference popup menu"
- },
- {
- "rule": "React-useRef",
- "path": "ts/components/AvatarInput.tsx",
+ "path": "ts/components/AvatarUploadButton.tsx",
"line": " const fileInputRef = useRef(null);",
"reasonCategory": "usageTrusted",
- "updated": "2021-07-30T16:57:33.618Z"
- },
- {
- "rule": "React-useRef",
- "path": "ts/components/AvatarInput.tsx",
- "line": " const menuTriggerRef = useRef(null);",
- "reasonCategory": "usageTrusted",
- "updated": "2021-07-30T16:57:33.618Z"
- },
- {
- "rule": "React-useRef",
- "path": "ts/components/AvatarInputContainer.js",
- "line": " const startingAvatarPathRef = react_1.useRef(avatarPath);",
- "reasonCategory": "usageTrusted",
- "updated": "2021-07-14T00:50:58.330Z"
- },
- {
- "rule": "React-useRef",
- "path": "ts/components/AvatarInputContainer.tsx",
- "line": " const startingAvatarPathRef = useRef(avatarPath);",
- "reasonCategory": "usageTrusted",
- "updated": "2021-07-30T16:57:33.618Z"
+ "updated": "2021-08-03T21:17:38.615Z"
},
{
"rule": "React-useRef",
@@ -13847,6 +13875,20 @@
"updated": "2020-02-14T20:02:37.507Z",
"reasonDetail": "Used only to set focus"
},
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/Modal.js",
+ "line": " const modalRef = react_1.useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-05T00:22:31.660Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/Modal.tsx",
+ "line": " const modalRef = useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-05T00:22:31.660Z"
+ },
{
"rule": "React-useRef",
"path": "ts/components/ProfileEditor.js",
@@ -13929,30 +13971,30 @@
{
"rule": "React-useRef",
"path": "ts/components/conversation/ContactModal.js",
- "line": " const overlayRef = react_1.default.useRef(null);",
+ "line": " const overlayRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
- "updated": "2020-11-09T17:48:12.173Z"
+ "updated": "2021-08-03T21:17:38.615Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/ContactModal.js",
- "line": " const closeButtonRef = react_1.default.useRef(null);",
+ "line": " const closeButtonRef = react_1.useRef(null);",
"reasonCategory": "usageTrusted",
- "updated": "2020-11-10T21:27:04.909Z"
+ "updated": "2021-08-03T21:17:38.615Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/ContactModal.tsx",
- "line": " const overlayRef = React.useRef(null);",
+ "line": " const overlayRef = useRef(null);",
"reasonCategory": "usageTrusted",
- "updated": "2021-07-30T16:57:33.618Z"
+ "updated": "2021-08-03T21:17:38.615Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/ContactModal.tsx",
- "line": " const closeButtonRef = React.useRef(null);",
+ "line": " const closeButtonRef = useRef(null);",
"reasonCategory": "usageTrusted",
- "updated": "2021-07-30T16:57:33.618Z"
+ "updated": "2021-08-03T21:17:38.615Z"
},
{
"rule": "React-createRef",
@@ -14382,6 +14424,20 @@
"reasonCategory": "falseMatch",
"updated": "2019-04-26T17:48:30.675Z"
},
+ {
+ "rule": "jQuery-load(",
+ "path": "ts/util/avatarDataToArrayBuffer.js",
+ "line": " await font.load();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-03T21:17:38.615Z"
+ },
+ {
+ "rule": "jQuery-load(",
+ "path": "ts/util/avatarDataToArrayBuffer.ts",
+ "line": " await font.load();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2021-08-03T21:17:38.615Z"
+ },
{
"rule": "React-useRef",
"path": "ts/util/hooks/index.js",
diff --git a/ts/util/migrateColor.ts b/ts/util/migrateColor.ts
index b0181edea281..2385184c13ae 100644
--- a/ts/util/migrateColor.ts
+++ b/ts/util/migrateColor.ts
@@ -1,53 +1,15 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import { AvatarColorType } from '../types/Colors';
+import { sample } from 'lodash';
+import { AvatarColors, AvatarColorType } from '../types/Colors';
+
+const NEW_COLOR_NAMES = new Set(AvatarColors);
export function migrateColor(color?: string): AvatarColorType {
- switch (color) {
- // These colors no longer exist
- case 'orange':
- case 'amber':
- return 'vermilion';
- case 'yellow':
- return 'burlap';
- case 'deep_purple':
- return 'violet';
- case 'light_blue':
- return 'blue';
- case 'cyan':
- return 'teal';
- case 'lime':
- 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 'blue':
- case 'indigo':
- case 'teal':
- case 'ultramarine':
- return color;
-
- default:
- return 'steel';
+ if (color && NEW_COLOR_NAMES.has(color)) {
+ return color;
}
+
+ return sample(AvatarColors) || AvatarColors[0];
}
diff --git a/ts/util/processImageFile.ts b/ts/util/processImageFile.ts
new file mode 100644
index 000000000000..b6ad7737ea30
--- /dev/null
+++ b/ts/util/processImageFile.ts
@@ -0,0 +1,30 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import loadImage, { LoadImageOptions } from 'blueimp-load-image';
+import { canvasToArrayBuffer } from './canvasToArrayBuffer';
+
+export async function processImageFile(file: File): Promise {
+ const { image } = await loadImage(file, {
+ canvas: true,
+ cover: true,
+ crop: true,
+ imageSmoothingQuality: 'medium',
+ maxHeight: 512,
+ maxWidth: 512,
+ minHeight: 2,
+ minWidth: 2,
+ // `imageSmoothingQuality` is not present in `loadImage`'s types, but it is
+ // documented and supported. Updating DefinitelyTyped is the long-term solution
+ // here.
+ } as LoadImageOptions);
+
+ // NOTE: The types for `loadImage` say this can never be a canvas, but it will be if
+ // `canvas: true`, at least in our case. Again, updating DefinitelyTyped should
+ // address this.
+ if (!(image instanceof HTMLCanvasElement)) {
+ throw new Error('Loaded image was not a canvas');
+ }
+
+ return canvasToArrayBuffer(image);
+}
diff --git a/ts/util/whatTypeOfConversation.ts b/ts/util/whatTypeOfConversation.ts
index db818837c220..c3e35c9b61e2 100644
--- a/ts/util/whatTypeOfConversation.ts
+++ b/ts/util/whatTypeOfConversation.ts
@@ -12,7 +12,7 @@ export enum ConversationTypes {
}
export function isDirectConversation(
- conversationAttrs: ConversationAttributesType
+ conversationAttrs: Pick
): boolean {
return conversationAttrs.type === 'private';
}
diff --git a/ts/window.d.ts b/ts/window.d.ts
index c249a981e0e4..706b258a83af 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -135,12 +135,27 @@ type ConfirmationDialogViewProps = {
resolve: () => void;
};
+// This is the subset of `window.FontFace` that we need. We should delete this after
+// upgrading to TypeScript 4.4, which will include a full declaration in [its official
+// DOM type definitions][0].
+//
+// [0]: https://github.com/microsoft/TypeScript/blob/03dff41c9f2038f66fb358e5c23ebd7271145978/lib/lib.dom.d.ts#L5343-L5364
+declare class FontFace {
+ constructor(
+ family: string,
+ source: string | ArrayBuffer | ArrayBufferView,
+ descriptors?: unknown
+ );
+ load(): Promise;
+}
+
declare global {
// We want to extend `window`'s properties, so we need an interface.
// eslint-disable-next-line no-restricted-syntax
interface Window {
startApp: () => void;
+ FontFace: typeof FontFace;
_: typeof Underscore;
$: typeof jQuery;
@@ -339,6 +354,9 @@ declare global {
readDraftData: any;
saveAttachmentToDisk: any;
writeNewDraftData: any;
+ deleteAvatar: (path: string) => Promise;
+ getAbsoluteAvatarPath: (src: string) => string;
+ writeNewAvatarData: (data: ArrayBuffer) => Promise;
};
Types: {
Attachment: typeof Attachment;