Avatar defaults and colors
This commit is contained in:
parent
a001882d58
commit
12d2b1bf7c
140 changed files with 4212 additions and 1084 deletions
|
@ -36,7 +36,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
: true,
|
||||
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
|
||||
blur: overrideProps.blur,
|
||||
color: select('color', colorMap, overrideProps.color || 'blue'),
|
||||
color: select('color', colorMap, overrideProps.color || AvatarColors[0]),
|
||||
conversationType: select(
|
||||
'conversationType',
|
||||
conversationTypeMap,
|
||||
|
|
|
@ -14,7 +14,7 @@ import { Spinner } from './Spinner';
|
|||
|
||||
import { getInitials } from '../util/getInitials';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { AvatarColorType } from '../types/Colors';
|
||||
import { AvatarColors, AvatarColorType } from '../types/Colors';
|
||||
import * as log from '../logging/log';
|
||||
import { assert } from '../util/assert';
|
||||
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
|
||||
|
@ -70,7 +70,7 @@ export const Avatar: FunctionComponent<Props> = ({
|
|||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
className,
|
||||
color,
|
||||
color = AvatarColors[0],
|
||||
conversationType,
|
||||
i18n,
|
||||
isMe,
|
||||
|
@ -160,6 +160,7 @@ export const Avatar: FunctionComponent<Props> = ({
|
|||
<div
|
||||
className={classNames(
|
||||
'module-Avatar__icon',
|
||||
`module-Avatar--${color}--icon`,
|
||||
'module-Avatar__icon--note-to-self'
|
||||
)}
|
||||
/>
|
||||
|
@ -179,6 +180,7 @@ export const Avatar: FunctionComponent<Props> = ({
|
|||
<div
|
||||
className={classNames(
|
||||
'module-Avatar__icon',
|
||||
`module-Avatar--${color}--icon`,
|
||||
`module-Avatar__icon--${conversationType}`
|
||||
)}
|
||||
/>
|
||||
|
|
32
ts/components/AvatarColorPicker.stories.tsx
Normal file
32
ts/components/AvatarColorPicker.stories.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarColorPicker, PropsType } from './AvatarColorPicker';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
i18n,
|
||||
onColorSelected: action('onColorSelected'),
|
||||
selectedColor: overrideProps.selectedColor,
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/AvatarColorPicker', module);
|
||||
|
||||
story.add('Default', () => <AvatarColorPicker {...createProps()} />);
|
||||
|
||||
story.add('Selected', () => (
|
||||
<AvatarColorPicker
|
||||
{...createProps({
|
||||
selectedColor: AvatarColors[7],
|
||||
})}
|
||||
/>
|
||||
));
|
40
ts/components/AvatarColorPicker.tsx
Normal file
40
ts/components/AvatarColorPicker.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { AvatarColors, AvatarColorType } from '../types/Colors';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { BetterAvatarBubble } from './BetterAvatarBubble';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
onColorSelected: (color: AvatarColorType) => unknown;
|
||||
selectedColor?: AvatarColorType;
|
||||
};
|
||||
|
||||
export const AvatarColorPicker = ({
|
||||
i18n,
|
||||
onColorSelected,
|
||||
selectedColor,
|
||||
}: PropsType): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
<div className="AvatarEditor__avatar-selector-title">
|
||||
{i18n('AvatarColorPicker--choose')}
|
||||
</div>
|
||||
<div className="AvatarEditor__avatars">
|
||||
{AvatarColors.map(color => (
|
||||
<BetterAvatarBubble
|
||||
color={color}
|
||||
i18n={i18n}
|
||||
isSelected={selectedColor === color}
|
||||
key={color}
|
||||
onSelect={() => {
|
||||
onColorSelected(color);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
98
ts/components/AvatarEditor.stories.tsx
Normal file
98
ts/components/AvatarEditor.stories.tsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { AvatarEditor, PropsType } from './AvatarEditor';
|
||||
import { getDefaultAvatars } from '../types/Avatar';
|
||||
import { createAvatarData } from '../util/createAvatarData';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
avatarColor: overrideProps.avatarColor || AvatarColors[9],
|
||||
avatarPath: overrideProps.avatarPath,
|
||||
conversationId: '123',
|
||||
conversationTitle: overrideProps.conversationTitle || 'Default Title',
|
||||
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
|
||||
i18n,
|
||||
isGroup: Boolean(overrideProps.isGroup),
|
||||
onCancel: action('onCancel'),
|
||||
onSave: action('onSave'),
|
||||
replaceAvatar: action('replaceAvatar'),
|
||||
saveAvatarToDisk: action('saveAvatarToDisk'),
|
||||
userAvatarData: overrideProps.userAvatarData || [
|
||||
createAvatarData({
|
||||
imagePath: '/fixtures/kitten-3-64-64.jpg',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A110',
|
||||
text: 'YA',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A120',
|
||||
text: 'OK',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A130',
|
||||
text: 'F',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A140',
|
||||
text: '🏄💣',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A150',
|
||||
text: '😇🙃😆',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A160',
|
||||
text: '🦊F💦',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A170',
|
||||
text: 'J',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A180',
|
||||
text: 'ZAP',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A190',
|
||||
text: '🍍P',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A200',
|
||||
text: '🌵',
|
||||
}),
|
||||
createAvatarData({
|
||||
color: 'A210',
|
||||
text: 'NAP',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/AvatarEditor', module);
|
||||
|
||||
story.add('No Avatar (group)', () => (
|
||||
<AvatarEditor
|
||||
{...createProps({ isGroup: true, userAvatarData: getDefaultAvatars(true) })}
|
||||
/>
|
||||
));
|
||||
story.add('No Avatar (me)', () => (
|
||||
<AvatarEditor {...createProps({ userAvatarData: getDefaultAvatars() })} />
|
||||
));
|
||||
|
||||
story.add('Has Avatar', () => (
|
||||
<AvatarEditor
|
||||
{...createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
})}
|
||||
/>
|
||||
));
|
298
ts/components/AvatarEditor.tsx
Normal file
298
ts/components/AvatarEditor.tsx
Normal file
|
@ -0,0 +1,298 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AvatarColorType } from '../types/Colors';
|
||||
import {
|
||||
AvatarDataType,
|
||||
DeleteAvatarFromDiskActionType,
|
||||
ReplaceAvatarActionType,
|
||||
SaveAvatarToDiskActionType,
|
||||
} from '../types/Avatar';
|
||||
import { AvatarIconEditor } from './AvatarIconEditor';
|
||||
import { AvatarModalButtons } from './AvatarModalButtons';
|
||||
import { AvatarPreview } from './AvatarPreview';
|
||||
import { AvatarTextEditor } from './AvatarTextEditor';
|
||||
import { AvatarUploadButton } from './AvatarUploadButton';
|
||||
import { BetterAvatar } from './BetterAvatar';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
|
||||
import { createAvatarData } from '../util/createAvatarData';
|
||||
import { isSameAvatarData } from '../util/isSameAvatarData';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
export type PropsType = {
|
||||
avatarColor?: AvatarColorType;
|
||||
avatarPath?: string;
|
||||
avatarValue?: ArrayBuffer;
|
||||
conversationId?: string;
|
||||
conversationTitle?: string;
|
||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||
i18n: LocalizerType;
|
||||
isGroup?: boolean;
|
||||
onCancel: () => unknown;
|
||||
onSave: (buffer: ArrayBuffer | undefined) => unknown;
|
||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||
replaceAvatar: ReplaceAvatarActionType;
|
||||
saveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||
};
|
||||
|
||||
enum EditMode {
|
||||
Main = 'Main',
|
||||
Custom = 'Custom',
|
||||
Text = 'Text',
|
||||
}
|
||||
|
||||
export const AvatarEditor = ({
|
||||
avatarColor,
|
||||
avatarPath,
|
||||
avatarValue,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
deleteAvatarFromDisk,
|
||||
i18n,
|
||||
isGroup,
|
||||
onCancel,
|
||||
onSave,
|
||||
userAvatarData,
|
||||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [provisionalSelectedAvatar, setProvisionalSelectedAvatar] = useState<
|
||||
AvatarDataType | undefined
|
||||
>();
|
||||
const [avatarPreview, setAvatarPreview] = useState<ArrayBuffer | undefined>(
|
||||
avatarValue
|
||||
);
|
||||
const [initialAvatar, setInitialAvatar] = useState<ArrayBuffer | undefined>(
|
||||
avatarValue
|
||||
);
|
||||
const [localAvatarData, setLocalAvatarData] = useState<Array<AvatarDataType>>(
|
||||
userAvatarData.slice()
|
||||
);
|
||||
|
||||
const [editMode, setEditMode] = useState<EditMode>(EditMode.Main);
|
||||
|
||||
const getSelectedAvatar = useCallback(
|
||||
avatarToFind =>
|
||||
localAvatarData.find(avatarData =>
|
||||
isSameAvatarData(avatarData, avatarToFind)
|
||||
),
|
||||
[localAvatarData]
|
||||
);
|
||||
|
||||
const selectedAvatar = getSelectedAvatar(provisionalSelectedAvatar);
|
||||
|
||||
// Caching the ArrayBuffer produced into avatarData as buffer because
|
||||
// that function is a little expensive to run and so we don't flicker the UI.
|
||||
useEffect(() => {
|
||||
let shouldCancel = false;
|
||||
|
||||
async function cacheAvatars() {
|
||||
const newAvatarData = await Promise.all(
|
||||
userAvatarData.map(async avatarData => {
|
||||
if (avatarData.buffer) {
|
||||
return avatarData;
|
||||
}
|
||||
const buffer = await avatarDataToArrayBuffer(avatarData);
|
||||
return {
|
||||
...avatarData,
|
||||
buffer,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (!shouldCancel) {
|
||||
setLocalAvatarData(newAvatarData);
|
||||
}
|
||||
}
|
||||
|
||||
cacheAvatars();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [setLocalAvatarData, userAvatarData]);
|
||||
|
||||
// This function optimistcally updates userAvatarData so we don't have to
|
||||
// wait for saveAvatarToDisk to finish before displaying something to the
|
||||
// user. As a bonus the component fully works in storybook!
|
||||
const updateAvatarDataList = useCallback(
|
||||
(newAvatarData?: AvatarDataType, staleAvatarData?: AvatarDataType) => {
|
||||
const existingAvatarData = staleAvatarData
|
||||
? localAvatarData.filter(avatarData => avatarData !== staleAvatarData)
|
||||
: localAvatarData;
|
||||
|
||||
if (newAvatarData) {
|
||||
setAvatarPreview(newAvatarData.buffer);
|
||||
setLocalAvatarData([newAvatarData, ...existingAvatarData]);
|
||||
setProvisionalSelectedAvatar(newAvatarData);
|
||||
} else {
|
||||
setLocalAvatarData(existingAvatarData);
|
||||
if (isSameAvatarData(selectedAvatar, staleAvatarData)) {
|
||||
setAvatarPreview(undefined);
|
||||
setProvisionalSelectedAvatar(undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
localAvatarData,
|
||||
selectedAvatar,
|
||||
setAvatarPreview,
|
||||
setLocalAvatarData,
|
||||
setProvisionalSelectedAvatar,
|
||||
]
|
||||
);
|
||||
|
||||
const handleAvatarLoaded = useCallback(avatarBuffer => {
|
||||
setAvatarPreview(avatarBuffer);
|
||||
setInitialAvatar(avatarBuffer);
|
||||
}, []);
|
||||
|
||||
const hasChanges = initialAvatar !== avatarPreview;
|
||||
|
||||
let content: JSX.Element | undefined;
|
||||
|
||||
if (editMode === EditMode.Main) {
|
||||
content = (
|
||||
<>
|
||||
<div className="AvatarEditor__preview">
|
||||
<AvatarPreview
|
||||
avatarColor={avatarColor}
|
||||
avatarPath={avatarPath}
|
||||
avatarValue={avatarPreview}
|
||||
conversationTitle={conversationTitle}
|
||||
i18n={i18n}
|
||||
isGroup={isGroup}
|
||||
onAvatarLoaded={handleAvatarLoaded}
|
||||
onClear={() => {
|
||||
setAvatarPreview(undefined);
|
||||
setProvisionalSelectedAvatar(undefined);
|
||||
}}
|
||||
/>
|
||||
<div className="AvatarEditor__top-buttons">
|
||||
<AvatarUploadButton
|
||||
className="AvatarEditor__button AvatarEditor__button--photo"
|
||||
i18n={i18n}
|
||||
onChange={newAvatar => {
|
||||
const avatarData = createAvatarData({
|
||||
buffer: newAvatar,
|
||||
// This is so that the newly created avatar gets an X
|
||||
imagePath: 'TMP',
|
||||
});
|
||||
saveAvatarToDisk(avatarData, conversationId);
|
||||
updateAvatarDataList(avatarData);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="AvatarEditor__button AvatarEditor__button--text"
|
||||
onClick={() => {
|
||||
setProvisionalSelectedAvatar(undefined);
|
||||
setEditMode(EditMode.Text);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{i18n('text')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="AvatarEditor__divider" />
|
||||
<div className="AvatarEditor__avatar-selector-title">
|
||||
{i18n('AvatarEditor--choose')}
|
||||
</div>
|
||||
<div className="AvatarEditor__avatars">
|
||||
{localAvatarData.map(avatarData => (
|
||||
<BetterAvatar
|
||||
avatarData={avatarData}
|
||||
key={avatarData.id}
|
||||
i18n={i18n}
|
||||
isSelected={isSameAvatarData(avatarData, selectedAvatar)}
|
||||
onClick={avatarBuffer => {
|
||||
if (isSameAvatarData(avatarData, selectedAvatar)) {
|
||||
if (avatarData.text) {
|
||||
setEditMode(EditMode.Text);
|
||||
} else if (avatarData.icon) {
|
||||
setEditMode(EditMode.Custom);
|
||||
}
|
||||
} else {
|
||||
setAvatarPreview(avatarBuffer);
|
||||
setProvisionalSelectedAvatar(avatarData);
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
updateAvatarDataList(undefined, avatarData);
|
||||
deleteAvatarFromDisk(avatarData, conversationId);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<AvatarModalButtons
|
||||
hasChanges={hasChanges}
|
||||
i18n={i18n}
|
||||
onCancel={onCancel}
|
||||
onSave={() => {
|
||||
if (selectedAvatar) {
|
||||
replaceAvatar(selectedAvatar, selectedAvatar, conversationId);
|
||||
}
|
||||
onSave(avatarPreview);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (editMode === EditMode.Text) {
|
||||
content = (
|
||||
<AvatarTextEditor
|
||||
avatarData={selectedAvatar}
|
||||
i18n={i18n}
|
||||
onCancel={() => {
|
||||
setEditMode(EditMode.Main);
|
||||
if (selectedAvatar) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The selected avatar was cleared when we entered text mode so we
|
||||
// need to find if one is actually selected if it matches the current
|
||||
// preview.
|
||||
const actualAvatarSelected = localAvatarData.find(
|
||||
avatarData => avatarData.buffer === avatarPreview
|
||||
);
|
||||
if (actualAvatarSelected) {
|
||||
setProvisionalSelectedAvatar(actualAvatarSelected);
|
||||
}
|
||||
}}
|
||||
onDone={(avatarBuffer, avatarData) => {
|
||||
const newAvatarData = {
|
||||
...avatarData,
|
||||
buffer: avatarBuffer,
|
||||
};
|
||||
updateAvatarDataList(newAvatarData, selectedAvatar);
|
||||
setEditMode(EditMode.Main);
|
||||
replaceAvatar(newAvatarData, selectedAvatar, conversationId);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (editMode === EditMode.Custom) {
|
||||
if (!selectedAvatar) {
|
||||
throw new Error('No selected avatar and editMode is custom');
|
||||
}
|
||||
|
||||
content = (
|
||||
<AvatarIconEditor
|
||||
avatarData={selectedAvatar}
|
||||
i18n={i18n}
|
||||
onClose={avatarData => {
|
||||
if (avatarData) {
|
||||
updateAvatarDataList(avatarData, selectedAvatar);
|
||||
replaceAvatar(avatarData, selectedAvatar, conversationId);
|
||||
}
|
||||
setEditMode(EditMode.Main);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
throw missingCaseError(editMode);
|
||||
}
|
||||
|
||||
return <div className="AvatarEditor">{content}</div>;
|
||||
};
|
46
ts/components/AvatarIconEditor.stories.tsx
Normal file
46
ts/components/AvatarIconEditor.stories.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarIconEditor, PropsType } from './AvatarIconEditor';
|
||||
import { GroupAvatarIcons, PersonalAvatarIcons } from '../types/Avatar';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { createAvatarData } from '../util/createAvatarData';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
avatarData: overrideProps.avatarData || createAvatarData({}),
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/AvatarIconEditor', module);
|
||||
|
||||
story.add('Personal Icon', () => (
|
||||
<AvatarIconEditor
|
||||
{...createProps({
|
||||
avatarData: createAvatarData({
|
||||
color: AvatarColors[3],
|
||||
icon: PersonalAvatarIcons[0],
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Group Icon', () => (
|
||||
<AvatarIconEditor
|
||||
{...createProps({
|
||||
avatarData: createAvatarData({
|
||||
color: AvatarColors[3],
|
||||
icon: GroupAvatarIcons[0],
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
));
|
81
ts/components/AvatarIconEditor.tsx
Normal file
81
ts/components/AvatarIconEditor.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AvatarColorPicker } from './AvatarColorPicker';
|
||||
import { AvatarColorType } from '../types/Colors';
|
||||
import { AvatarDataType } from '../types/Avatar';
|
||||
import { AvatarModalButtons } from './AvatarModalButtons';
|
||||
import { AvatarPreview } from './AvatarPreview';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
|
||||
|
||||
export type PropsType = {
|
||||
avatarData: AvatarDataType;
|
||||
i18n: LocalizerType;
|
||||
onClose: (avatarData?: AvatarDataType) => unknown;
|
||||
};
|
||||
|
||||
export const AvatarIconEditor = ({
|
||||
avatarData: initialAvatarData,
|
||||
i18n,
|
||||
onClose,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [avatarBuffer, setAvatarBuffer] = useState<ArrayBuffer | undefined>();
|
||||
const [avatarData, setAvatarData] = useState<AvatarDataType>(
|
||||
initialAvatarData
|
||||
);
|
||||
|
||||
const onColorSelected = useCallback(
|
||||
(color: AvatarColorType) => {
|
||||
setAvatarData({
|
||||
...avatarData,
|
||||
color,
|
||||
});
|
||||
},
|
||||
[avatarData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let shouldCancel = false;
|
||||
|
||||
async function loadAvatar() {
|
||||
const buffer = await avatarDataToArrayBuffer(avatarData);
|
||||
if (!shouldCancel) {
|
||||
setAvatarBuffer(buffer);
|
||||
}
|
||||
}
|
||||
loadAvatar();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [avatarData, setAvatarBuffer]);
|
||||
|
||||
const hasChanges = avatarData !== initialAvatarData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AvatarPreview
|
||||
avatarColor={avatarData.color}
|
||||
avatarValue={avatarBuffer}
|
||||
conversationTitle={avatarData.text}
|
||||
i18n={i18n}
|
||||
/>
|
||||
<hr className="AvatarEditor__divider" />
|
||||
<AvatarColorPicker i18n={i18n} onColorSelected={onColorSelected} />
|
||||
<AvatarModalButtons
|
||||
hasChanges={hasChanges}
|
||||
i18n={i18n}
|
||||
onCancel={onClose}
|
||||
onSave={() =>
|
||||
onClose({
|
||||
...avatarData,
|
||||
buffer: avatarBuffer,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,74 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { chunk, noop } from 'lodash';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarInput, AvatarInputVariant } from './AvatarInput';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/AvatarInput', module);
|
||||
|
||||
const TEST_IMAGE = new Uint8Array(
|
||||
chunk(
|
||||
'89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082',
|
||||
2
|
||||
).map(bytePair => parseInt(bytePair.join(''), 16))
|
||||
).buffer;
|
||||
|
||||
const Wrapper = ({
|
||||
startValue,
|
||||
variant,
|
||||
}: {
|
||||
startValue: undefined | ArrayBuffer;
|
||||
variant?: AvatarInputVariant;
|
||||
}) => {
|
||||
const [value, setValue] = useState<undefined | ArrayBuffer>(startValue);
|
||||
const [objectUrl, setObjectUrl] = useState<undefined | string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setObjectUrl(undefined);
|
||||
return noop;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([value]));
|
||||
setObjectUrl(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AvatarInput
|
||||
contextMenuId={uuid()}
|
||||
i18n={i18n}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variant={variant}
|
||||
/>
|
||||
<figure>
|
||||
<figcaption>Processed image (if it exists)</figcaption>
|
||||
{objectUrl && <img src={objectUrl} alt="" />}
|
||||
</figure>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
story.add('No start state', () => {
|
||||
return <Wrapper startValue={undefined} />;
|
||||
});
|
||||
|
||||
story.add('Starting with a value', () => {
|
||||
return <Wrapper startValue={TEST_IMAGE} />;
|
||||
});
|
||||
|
||||
story.add('Dark variant', () => {
|
||||
return <Wrapper startValue={undefined} variant={AvatarInputVariant.Dark} />;
|
||||
});
|
|
@ -1,225 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
ChangeEventHandler,
|
||||
MouseEventHandler,
|
||||
FunctionComponent,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu';
|
||||
import loadImage, { LoadImageOptions } from 'blueimp-load-image';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Spinner } from './Spinner';
|
||||
import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer';
|
||||
|
||||
export type PropsType = {
|
||||
// This ID needs to be globally unique across the app.
|
||||
contextMenuId: string;
|
||||
disabled?: boolean;
|
||||
i18n: LocalizerType;
|
||||
onChange: (value: undefined | ArrayBuffer) => unknown;
|
||||
type?: AvatarInputType;
|
||||
value: undefined | ArrayBuffer;
|
||||
variant?: AvatarInputVariant;
|
||||
};
|
||||
|
||||
enum ImageStatus {
|
||||
Nothing = 'nothing',
|
||||
Loading = 'loading',
|
||||
HasImage = 'has-image',
|
||||
}
|
||||
|
||||
export enum AvatarInputType {
|
||||
Profile = 'Profile',
|
||||
Group = 'Group',
|
||||
}
|
||||
|
||||
export enum AvatarInputVariant {
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
}
|
||||
|
||||
export const AvatarInput: FunctionComponent<PropsType> = ({
|
||||
contextMenuId,
|
||||
disabled,
|
||||
i18n,
|
||||
onChange,
|
||||
type,
|
||||
value,
|
||||
variant = AvatarInputVariant.Light,
|
||||
}) => {
|
||||
const fileInputRef = useRef<null | HTMLInputElement>(null);
|
||||
// Comes from a third-party dependency
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const menuTriggerRef = useRef<null | any>(null);
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<undefined | string>();
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setObjectUrl(undefined);
|
||||
return noop;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([value]));
|
||||
setObjectUrl(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const [processingFile, setProcessingFile] = useState<undefined | File>(
|
||||
undefined
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!processingFile) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
let shouldCancel = false;
|
||||
|
||||
(async () => {
|
||||
let newValue: ArrayBuffer;
|
||||
try {
|
||||
newValue = await processFile(processingFile);
|
||||
} catch (err) {
|
||||
// Processing errors should be rare; if they do, we silently fail. In an ideal
|
||||
// world, we may want to show a toast instead.
|
||||
return;
|
||||
}
|
||||
if (shouldCancel) {
|
||||
return;
|
||||
}
|
||||
setProcessingFile(undefined);
|
||||
onChange(newValue);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [processingFile, onChange]);
|
||||
|
||||
let buttonLabel = i18n('AvatarInput--change-photo-label');
|
||||
if (!value) {
|
||||
if (type === AvatarInputType.Profile) {
|
||||
buttonLabel = i18n('AvatarInput--no-photo-label--profile');
|
||||
} else {
|
||||
buttonLabel = i18n('AvatarInput--no-photo-label--group');
|
||||
}
|
||||
}
|
||||
|
||||
const startUpload = () => {
|
||||
const fileInput = fileInputRef.current;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
onChange(undefined);
|
||||
};
|
||||
|
||||
const onClick: MouseEventHandler<unknown> = value
|
||||
? event => {
|
||||
const menuTrigger = menuTriggerRef.current;
|
||||
if (!menuTrigger) {
|
||||
return;
|
||||
}
|
||||
menuTrigger.handleContextClick(event);
|
||||
}
|
||||
: startUpload;
|
||||
|
||||
const onInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (file) {
|
||||
setProcessingFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
let imageStatus: ImageStatus;
|
||||
if (processingFile || (value && !objectUrl)) {
|
||||
imageStatus = ImageStatus.Loading;
|
||||
} else if (objectUrl) {
|
||||
imageStatus = ImageStatus.HasImage;
|
||||
} else {
|
||||
imageStatus = ImageStatus.Nothing;
|
||||
}
|
||||
|
||||
const isLoading = imageStatus === ImageStatus.Loading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuTrigger id={contextMenuId} ref={menuTriggerRef}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isLoading}
|
||||
className={classNames(
|
||||
'module-AvatarInput',
|
||||
`module-AvatarInput--${variant}`
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={`module-AvatarInput__avatar module-AvatarInput__avatar--${imageStatus}`}
|
||||
style={
|
||||
imageStatus === ImageStatus.HasImage
|
||||
? {
|
||||
backgroundImage: `url(${objectUrl})`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<Spinner size="70px" svgSize="normal" direction="on-avatar" />
|
||||
)}
|
||||
</div>
|
||||
<span className="module-AvatarInput__label">{buttonLabel}</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenu id={contextMenuId}>
|
||||
<MenuItem onClick={startUpload}>
|
||||
{i18n('AvatarInput--upload-photo-choice')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={clear}>
|
||||
{i18n('AvatarInput--remove-photo-choice')}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
<input
|
||||
accept=".gif,.jpg,.jpeg,.png,.webp,image/gif,image/jpeg/image/png,image/webp"
|
||||
hidden
|
||||
onChange={onInputChange}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
async function processFile(file: File): Promise<ArrayBuffer> {
|
||||
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);
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarInputContainer } from './AvatarInputContainer';
|
||||
import { AvatarInputType } from './AvatarInput';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/AvatarInputContainer', module);
|
||||
|
||||
story.add('No photo (group)', () => (
|
||||
<AvatarInputContainer
|
||||
contextMenuId={uuid()}
|
||||
i18n={i18n}
|
||||
onAvatarChanged={noop}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('No photo (profile)', () => (
|
||||
<AvatarInputContainer
|
||||
contextMenuId={uuid()}
|
||||
i18n={i18n}
|
||||
onAvatarChanged={noop}
|
||||
type={AvatarInputType.Profile}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Has photo', () => (
|
||||
<AvatarInputContainer
|
||||
avatarPath="/fixtures/kitten-3-64-64.jpg"
|
||||
contextMenuId={uuid()}
|
||||
i18n={i18n}
|
||||
onAvatarChanged={noop}
|
||||
/>
|
||||
));
|
|
@ -1,86 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { AvatarInput, PropsType as AvatarInputPropsType } from './AvatarInput';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { imagePathToArrayBuffer } from '../util/imagePathToArrayBuffer';
|
||||
|
||||
type PropsType = {
|
||||
avatarPath?: string;
|
||||
i18n: LocalizerType;
|
||||
onAvatarChanged: (avatar: ArrayBuffer | undefined) => unknown;
|
||||
onAvatarLoaded?: (avatar: ArrayBuffer | undefined) => unknown;
|
||||
} & Pick<
|
||||
AvatarInputPropsType,
|
||||
'contextMenuId' | 'disabled' | 'type' | 'variant'
|
||||
>;
|
||||
|
||||
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
|
||||
|
||||
export const AvatarInputContainer = ({
|
||||
avatarPath,
|
||||
contextMenuId,
|
||||
disabled,
|
||||
i18n,
|
||||
onAvatarChanged,
|
||||
onAvatarLoaded,
|
||||
type,
|
||||
variant,
|
||||
}: PropsType): JSX.Element => {
|
||||
const startingAvatarPathRef = useRef<undefined | string>(avatarPath);
|
||||
|
||||
const [avatar, setAvatar] = useState<undefined | ArrayBuffer>(
|
||||
avatarPath ? TEMPORARY_AVATAR_VALUE : undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const startingAvatarPath = startingAvatarPathRef.current;
|
||||
if (!startingAvatarPath) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
let shouldCancel = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const buffer = await imagePathToArrayBuffer(startingAvatarPath);
|
||||
if (shouldCancel) {
|
||||
return;
|
||||
}
|
||||
setAvatar(buffer);
|
||||
if (onAvatarLoaded) {
|
||||
onAvatarLoaded(buffer);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`Failed to convert image URL to array buffer. Error message: ${
|
||||
err && err.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [onAvatarLoaded]);
|
||||
|
||||
return (
|
||||
<AvatarInput
|
||||
contextMenuId={contextMenuId}
|
||||
disabled={disabled}
|
||||
i18n={i18n}
|
||||
onChange={newAvatar => {
|
||||
setAvatar(newAvatar);
|
||||
onAvatarChanged(newAvatar);
|
||||
}}
|
||||
type={type}
|
||||
value={avatar}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
};
|
59
ts/components/AvatarLightbox.stories.tsx
Normal file
59
ts/components/AvatarLightbox.stories.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { select } from '@storybook/addon-knobs';
|
||||
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { AvatarLightbox, PropsType } from './AvatarLightbox';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
avatarColor: select(
|
||||
'Color',
|
||||
AvatarColors,
|
||||
overrideProps.avatarColor || AvatarColors[0]
|
||||
),
|
||||
avatarPath: overrideProps.avatarPath,
|
||||
conversationTitle: overrideProps.conversationTitle,
|
||||
i18n,
|
||||
isGroup: Boolean(overrideProps.isGroup),
|
||||
onClose: action('onClose'),
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/AvatarLightbox', module);
|
||||
|
||||
story.add('Group', () => (
|
||||
<AvatarLightbox
|
||||
{...createProps({
|
||||
isGroup: true,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Person', () => {
|
||||
const conversation = getDefaultConversation();
|
||||
return (
|
||||
<AvatarLightbox
|
||||
{...createProps({
|
||||
avatarColor: conversation.color,
|
||||
conversationTitle: conversation.title,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
story.add('Photo', () => (
|
||||
<AvatarLightbox
|
||||
{...createProps({
|
||||
avatarPath: '/fixtures/kitten-1-64-64.jpg',
|
||||
})}
|
||||
/>
|
||||
));
|
64
ts/components/AvatarLightbox.tsx
Normal file
64
ts/components/AvatarLightbox.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AvatarColorType } from '../types/Colors';
|
||||
import { AvatarPreview } from './AvatarPreview';
|
||||
import { IMAGE_JPEG } from '../types/MIME';
|
||||
import { Lightbox } from './Lightbox';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export type PropsType = {
|
||||
avatarColor?: AvatarColorType;
|
||||
avatarPath?: string;
|
||||
conversationTitle?: string;
|
||||
i18n: LocalizerType;
|
||||
isGroup?: boolean;
|
||||
onClose: () => unknown;
|
||||
};
|
||||
|
||||
export const AvatarLightbox = ({
|
||||
avatarColor,
|
||||
avatarPath,
|
||||
conversationTitle,
|
||||
i18n,
|
||||
isGroup,
|
||||
onClose,
|
||||
}: PropsType): JSX.Element => {
|
||||
if (avatarPath) {
|
||||
return (
|
||||
<Lightbox
|
||||
// We don't know that the avatar is a JPEG, but any image `contentType` will cause
|
||||
// it to be rendered as an image, which is what we want.
|
||||
contentType={IMAGE_JPEG}
|
||||
close={onClose}
|
||||
i18n={i18n}
|
||||
isViewOnce={false}
|
||||
objectURL={avatarPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Lightbox
|
||||
contentType={undefined}
|
||||
close={onClose}
|
||||
i18n={i18n}
|
||||
isViewOnce={false}
|
||||
objectURL=""
|
||||
>
|
||||
<AvatarPreview
|
||||
avatarColor={avatarColor}
|
||||
conversationTitle={conversationTitle}
|
||||
i18n={i18n}
|
||||
isGroup={isGroup}
|
||||
style={{
|
||||
fontSize: '16em',
|
||||
height: '2em',
|
||||
width: '2em',
|
||||
}}
|
||||
/>
|
||||
</Lightbox>
|
||||
);
|
||||
};
|
32
ts/components/AvatarModalButtons.stories.tsx
Normal file
32
ts/components/AvatarModalButtons.stories.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { AvatarModalButtons, PropsType } from './AvatarModalButtons';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
hasChanges: Boolean(overrideProps.hasChanges),
|
||||
i18n,
|
||||
onCancel: action('onCancel'),
|
||||
onSave: action('onSave'),
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/AvatarModalButtons', module);
|
||||
|
||||
story.add('Has changes', () => (
|
||||
<AvatarModalButtons
|
||||
{...createProps({
|
||||
hasChanges: true,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('No changes', () => <AvatarModalButtons {...createProps()} />);
|
54
ts/components/AvatarModalButtons.tsx
Normal file
54
ts/components/AvatarModalButtons.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
export type PropsType = {
|
||||
hasChanges: boolean;
|
||||
i18n: LocalizerType;
|
||||
onCancel: () => unknown;
|
||||
onSave: () => unknown;
|
||||
};
|
||||
|
||||
export const AvatarModalButtons = ({
|
||||
hasChanges,
|
||||
i18n,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [confirmDiscardAction, setConfirmDiscardAction] = useState<
|
||||
(() => unknown) | undefined
|
||||
>(undefined);
|
||||
|
||||
return (
|
||||
<Modal.ButtonFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (hasChanges) {
|
||||
setConfirmDiscardAction(() => onCancel);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('cancel')}
|
||||
</Button>
|
||||
<Button disabled={!hasChanges} onClick={onSave}>
|
||||
{i18n('save')}
|
||||
</Button>
|
||||
{confirmDiscardAction && (
|
||||
<ConfirmDiscardDialog
|
||||
i18n={i18n}
|
||||
onDiscard={confirmDiscardAction}
|
||||
onClose={() => setConfirmDiscardAction(undefined)}
|
||||
/>
|
||||
)}
|
||||
</Modal.ButtonFooter>
|
||||
);
|
||||
};
|
|
@ -30,7 +30,7 @@ const conversationTypeMap: Record<string, Props['conversationType']> = {
|
|||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
acceptedMessageRequest: true,
|
||||
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
|
||||
color: select('color', colorMap, overrideProps.color || 'blue'),
|
||||
color: select('color', colorMap, overrideProps.color || AvatarColors[0]),
|
||||
conversationType: select(
|
||||
'conversationType',
|
||||
conversationTypeMap,
|
||||
|
|
94
ts/components/AvatarPreview.stories.tsx
Normal file
94
ts/components/AvatarPreview.stories.tsx
Normal file
|
@ -0,0 +1,94 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { chunk } from 'lodash';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { AvatarPreview, PropsType } from './AvatarPreview';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const TEST_IMAGE = new Uint8Array(
|
||||
chunk(
|
||||
'89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082',
|
||||
2
|
||||
).map(bytePair => parseInt(bytePair.join(''), 16))
|
||||
).buffer;
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
avatarColor: overrideProps.avatarColor,
|
||||
avatarPath: overrideProps.avatarPath,
|
||||
avatarValue: overrideProps.avatarValue,
|
||||
conversationTitle: overrideProps.conversationTitle,
|
||||
i18n,
|
||||
isEditable: Boolean(overrideProps.isEditable),
|
||||
isGroup: Boolean(overrideProps.isGroup),
|
||||
onAvatarLoaded: action('onAvatarLoaded'),
|
||||
onClear: action('onClear'),
|
||||
onClick: action('onClick'),
|
||||
style: overrideProps.style,
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/AvatarPreview', module);
|
||||
|
||||
story.add('No state (personal)', () => (
|
||||
<AvatarPreview
|
||||
{...createProps({
|
||||
avatarColor: AvatarColors[0],
|
||||
conversationTitle: 'Just Testing',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('No state (group)', () => (
|
||||
<AvatarPreview
|
||||
{...createProps({
|
||||
avatarColor: AvatarColors[1],
|
||||
isGroup: true,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('No state (group) + upload me', () => (
|
||||
<AvatarPreview
|
||||
{...createProps({
|
||||
avatarColor: AvatarColors[1],
|
||||
isEditable: true,
|
||||
isGroup: true,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('value', () => (
|
||||
<AvatarPreview {...createProps({ avatarValue: TEST_IMAGE })} />
|
||||
));
|
||||
|
||||
story.add('path', () => (
|
||||
<AvatarPreview
|
||||
{...createProps({ avatarPath: '/fixtures/kitten-3-64-64.jpg' })}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('value & path', () => (
|
||||
<AvatarPreview
|
||||
{...createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarValue: TEST_IMAGE,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('style', () => (
|
||||
<AvatarPreview
|
||||
{...createProps({
|
||||
avatarValue: TEST_IMAGE,
|
||||
style: { height: 100, width: 100 },
|
||||
})}
|
||||
/>
|
||||
));
|
184
ts/components/AvatarPreview.tsx
Normal file
184
ts/components/AvatarPreview.tsx
Normal file
|
@ -0,0 +1,184 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Spinner } from './Spinner';
|
||||
import { AvatarColors, AvatarColorType } from '../types/Colors';
|
||||
import { getInitials } from '../util/getInitials';
|
||||
import { imagePathToArrayBuffer } from '../util/imagePathToArrayBuffer';
|
||||
|
||||
export type PropsType = {
|
||||
avatarColor?: AvatarColorType;
|
||||
avatarPath?: string;
|
||||
avatarValue?: ArrayBuffer;
|
||||
conversationTitle?: string;
|
||||
i18n: LocalizerType;
|
||||
isEditable?: boolean;
|
||||
isGroup?: boolean;
|
||||
onAvatarLoaded?: (avatarBuffer: ArrayBuffer) => unknown;
|
||||
onClear?: () => unknown;
|
||||
onClick?: () => unknown;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
enum ImageStatus {
|
||||
Nothing = 'nothing',
|
||||
Loading = 'loading',
|
||||
HasImage = 'has-image',
|
||||
}
|
||||
|
||||
export const AvatarPreview = ({
|
||||
avatarColor = AvatarColors[0],
|
||||
avatarPath,
|
||||
avatarValue,
|
||||
conversationTitle,
|
||||
i18n,
|
||||
isEditable,
|
||||
isGroup,
|
||||
onAvatarLoaded,
|
||||
onClear,
|
||||
onClick,
|
||||
style = {},
|
||||
}: PropsType): JSX.Element => {
|
||||
const startingAvatarPathRef = useRef<undefined | string>(
|
||||
avatarValue ? undefined : avatarPath
|
||||
);
|
||||
|
||||
const [avatarPreview, setAvatarPreview] = useState<ArrayBuffer | undefined>();
|
||||
|
||||
// Loads the initial avatarPath if one is provided.
|
||||
useEffect(() => {
|
||||
const startingAvatarPath = startingAvatarPathRef.current;
|
||||
if (!startingAvatarPath) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
let shouldCancel = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const buffer = await imagePathToArrayBuffer(startingAvatarPath);
|
||||
if (shouldCancel) {
|
||||
return;
|
||||
}
|
||||
setAvatarPreview(buffer);
|
||||
if (onAvatarLoaded) {
|
||||
onAvatarLoaded(buffer);
|
||||
}
|
||||
} catch (err) {
|
||||
if (shouldCancel) {
|
||||
return;
|
||||
}
|
||||
log.warn(
|
||||
`Failed to convert image URL to array buffer. Error message: ${
|
||||
err && err.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [onAvatarLoaded]);
|
||||
|
||||
// Ensures that when avatarValue changes we generate new URLs
|
||||
useEffect(() => {
|
||||
if (avatarValue) {
|
||||
setAvatarPreview(avatarValue);
|
||||
} else {
|
||||
setAvatarPreview(undefined);
|
||||
}
|
||||
}, [avatarValue]);
|
||||
|
||||
// Creates the object URL to render the ArrayBuffer image
|
||||
const [objectUrl, setObjectUrl] = useState<undefined | string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!avatarPreview) {
|
||||
setObjectUrl(undefined);
|
||||
return noop;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(new Blob([avatarPreview]));
|
||||
setObjectUrl(url);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [avatarPreview]);
|
||||
|
||||
let imageStatus: ImageStatus;
|
||||
if (avatarValue && !objectUrl) {
|
||||
imageStatus = ImageStatus.Loading;
|
||||
} else if (objectUrl) {
|
||||
imageStatus = ImageStatus.HasImage;
|
||||
} else {
|
||||
imageStatus = ImageStatus.Nothing;
|
||||
}
|
||||
|
||||
const isLoading = imageStatus === ImageStatus.Loading;
|
||||
|
||||
const clickProps = onClick ? { role: 'button', onClick } : {};
|
||||
const componentStyle = {
|
||||
...style,
|
||||
};
|
||||
if (onClick) {
|
||||
componentStyle.cursor = 'pointer';
|
||||
}
|
||||
|
||||
if (!avatarPreview) {
|
||||
return (
|
||||
<div className="AvatarPreview">
|
||||
<div
|
||||
className={`AvatarPreview__avatar BetterAvatarBubble--${avatarColor}`}
|
||||
{...clickProps}
|
||||
style={componentStyle}
|
||||
>
|
||||
{isGroup ? (
|
||||
<div
|
||||
className={`BetterAvatarBubble--${avatarColor}--icon AvatarPreview__group`}
|
||||
/>
|
||||
) : (
|
||||
getInitials(conversationTitle)
|
||||
)}
|
||||
{isEditable && <div className="AvatarPreview__upload" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="AvatarPreview">
|
||||
<div
|
||||
className={`AvatarPreview__avatar AvatarPreview__avatar--${imageStatus}`}
|
||||
{...clickProps}
|
||||
style={
|
||||
imageStatus === ImageStatus.HasImage
|
||||
? {
|
||||
...componentStyle,
|
||||
backgroundImage: `url(${objectUrl})`,
|
||||
}
|
||||
: componentStyle
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<Spinner size="70px" svgSize="normal" direction="on-avatar" />
|
||||
)}
|
||||
{imageStatus === ImageStatus.HasImage && onClear && (
|
||||
<button
|
||||
aria-label={i18n('delete')}
|
||||
className="AvatarPreview__clear"
|
||||
onClick={onClear}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
49
ts/components/AvatarTextEditor.stories.tsx
Normal file
49
ts/components/AvatarTextEditor.stories.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarTextEditor, PropsType } from './AvatarTextEditor';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
avatarData: overrideProps.avatarData,
|
||||
i18n,
|
||||
onCancel: action('onCancel'),
|
||||
onDone: action('onDone'),
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/AvatarTextEditor', module);
|
||||
|
||||
story.add('Empty', () => <AvatarTextEditor {...createProps()} />);
|
||||
|
||||
story.add('with Data', () => (
|
||||
<AvatarTextEditor
|
||||
{...createProps({
|
||||
avatarData: {
|
||||
id: '123',
|
||||
color: AvatarColors[6],
|
||||
text: 'SUP',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('with wide characters', () => (
|
||||
<AvatarTextEditor
|
||||
{...createProps({
|
||||
avatarData: {
|
||||
id: '123',
|
||||
color: AvatarColors[6],
|
||||
text: '‱௸𒈙',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
));
|
197
ts/components/AvatarTextEditor.tsx
Normal file
197
ts/components/AvatarTextEditor.tsx
Normal file
|
@ -0,0 +1,197 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import * as grapheme from '../util/grapheme';
|
||||
import { AvatarColorPicker } from './AvatarColorPicker';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { AvatarDataType } from '../types/Avatar';
|
||||
import { AvatarModalButtons } from './AvatarModalButtons';
|
||||
import { BetterAvatarBubble } from './BetterAvatarBubble';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
|
||||
import { createAvatarData } from '../util/createAvatarData';
|
||||
import {
|
||||
getFittedFontSize,
|
||||
getFontSizes,
|
||||
} from '../util/avatarTextSizeCalculator';
|
||||
|
||||
type DoneHandleType = (
|
||||
avatarBuffer: ArrayBuffer,
|
||||
avatarData: AvatarDataType
|
||||
) => unknown;
|
||||
|
||||
export type PropsType = {
|
||||
avatarData?: AvatarDataType;
|
||||
i18n: LocalizerType;
|
||||
onCancel: () => unknown;
|
||||
onDone: DoneHandleType;
|
||||
};
|
||||
|
||||
const BUBBLE_SIZE = 120;
|
||||
const MAX_LENGTH = 3;
|
||||
|
||||
export const AvatarTextEditor = ({
|
||||
avatarData,
|
||||
i18n,
|
||||
onCancel,
|
||||
onDone,
|
||||
}: PropsType): JSX.Element => {
|
||||
const initialText = useMemo(() => avatarData?.text || '', [avatarData]);
|
||||
const initialColor = useMemo(() => avatarData?.color || AvatarColors[0], [
|
||||
avatarData,
|
||||
]);
|
||||
|
||||
const [inputText, setInputText] = useState(initialText);
|
||||
const [fontSize, setFontSize] = useState(getFontSizes(BUBBLE_SIZE).text);
|
||||
const [selectedColor, setSelectedColor] = useState(initialColor);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
const inputEl = inputRef?.current;
|
||||
if (inputEl) {
|
||||
inputEl.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(ev: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = ev.target;
|
||||
if (grapheme.count(value) <= MAX_LENGTH) {
|
||||
setInputText(ev.target.value);
|
||||
}
|
||||
},
|
||||
[setInputText]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(ev: ClipboardEvent<HTMLInputElement>) => {
|
||||
const inputEl = ev.currentTarget;
|
||||
|
||||
const selectionStart = inputEl.selectionStart || 0;
|
||||
const selectionEnd = inputEl.selectionEnd || inputEl.selectionStart || 0;
|
||||
const textBeforeSelection = inputText.slice(0, selectionStart);
|
||||
const textAfterSelection = inputText.slice(selectionEnd);
|
||||
|
||||
const pastedText = ev.clipboardData.getData('Text');
|
||||
|
||||
const newGraphemeCount =
|
||||
grapheme.count(textBeforeSelection) +
|
||||
grapheme.count(pastedText) +
|
||||
grapheme.count(textAfterSelection);
|
||||
|
||||
if (newGraphemeCount > MAX_LENGTH) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
[inputText]
|
||||
);
|
||||
|
||||
const onDoneRef = useRef<DoneHandleType>(onDone);
|
||||
|
||||
// Make sure we keep onDoneRef up to date
|
||||
useEffect(() => {
|
||||
onDoneRef.current = onDone;
|
||||
}, [onDone]);
|
||||
|
||||
const handleDone = useCallback(async () => {
|
||||
const newAvatarData = createAvatarData({
|
||||
color: selectedColor,
|
||||
text: inputText,
|
||||
});
|
||||
|
||||
const buffer = await avatarDataToArrayBuffer(newAvatarData);
|
||||
|
||||
onDoneRef.current(buffer, newAvatarData);
|
||||
}, [inputText, selectedColor]);
|
||||
|
||||
// In case the component unmounts before we're able to create the avatar data
|
||||
// we set the done handler to a no-op.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onDoneRef.current = noop;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measureElRef = useRef<null | HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const measureEl = measureElRef.current;
|
||||
if (!measureEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFontSize = getFittedFontSize(
|
||||
BUBBLE_SIZE,
|
||||
inputText,
|
||||
candidateFontSize => {
|
||||
measureEl.style.fontSize = `${candidateFontSize}px`;
|
||||
const { width, height } = measureEl.getBoundingClientRect();
|
||||
return { height, width };
|
||||
}
|
||||
);
|
||||
|
||||
setFontSize(nextFontSize);
|
||||
}, [inputText]);
|
||||
|
||||
useEffect(() => {
|
||||
focusInput();
|
||||
}, [focusInput]);
|
||||
|
||||
const hasChanges =
|
||||
initialText !== inputText || selectedColor !== initialColor;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="AvatarEditor__preview">
|
||||
<BetterAvatarBubble
|
||||
color={selectedColor}
|
||||
i18n={i18n}
|
||||
onSelect={focusInput}
|
||||
style={{
|
||||
height: BUBBLE_SIZE,
|
||||
width: BUBBLE_SIZE,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="AvatarTextEditor__input"
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
ref={inputRef}
|
||||
style={{ fontSize }}
|
||||
type="text"
|
||||
value={inputText}
|
||||
/>
|
||||
</BetterAvatarBubble>
|
||||
</div>
|
||||
<hr className="AvatarEditor__divider" />
|
||||
<AvatarColorPicker
|
||||
i18n={i18n}
|
||||
onColorSelected={color => {
|
||||
setSelectedColor(color);
|
||||
focusInput();
|
||||
}}
|
||||
selectedColor={selectedColor}
|
||||
/>
|
||||
<AvatarModalButtons
|
||||
hasChanges={hasChanges}
|
||||
i18n={i18n}
|
||||
onCancel={onCancel}
|
||||
onSave={handleDone}
|
||||
/>
|
||||
<div className="AvatarTextEditor__measure" ref={measureElRef}>
|
||||
{inputText}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
23
ts/components/AvatarUploadButton.stories.tsx
Normal file
23
ts/components/AvatarUploadButton.stories.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { AvatarUploadButton, PropsType } from './AvatarUploadButton';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
className: overrideProps.className || '',
|
||||
i18n,
|
||||
onChange: action('onChange'),
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/AvatarUploadButton', module);
|
||||
|
||||
story.add('Default', () => <AvatarUploadButton {...createProps()} />);
|
86
ts/components/AvatarUploadButton.tsx
Normal file
86
ts/components/AvatarUploadButton.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { ChangeEventHandler, useEffect, useRef, useState } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { processImageFile } from '../util/processImageFile';
|
||||
|
||||
export type PropsType = {
|
||||
className: string;
|
||||
i18n: LocalizerType;
|
||||
onChange: (avatar: ArrayBuffer) => unknown;
|
||||
};
|
||||
|
||||
export const AvatarUploadButton = ({
|
||||
className,
|
||||
i18n,
|
||||
onChange,
|
||||
}: PropsType): JSX.Element => {
|
||||
const fileInputRef = useRef<null | HTMLInputElement>(null);
|
||||
|
||||
const [processingFile, setProcessingFile] = useState<File | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!processingFile) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
let shouldCancel = false;
|
||||
|
||||
(async () => {
|
||||
let newAvatar: ArrayBuffer;
|
||||
try {
|
||||
newAvatar = await processImageFile(processingFile);
|
||||
} catch (err) {
|
||||
// Processing errors should be rare; if they do, we silently fail. In an ideal
|
||||
// world, we may want to show a toast instead.
|
||||
return;
|
||||
}
|
||||
if (shouldCancel) {
|
||||
return;
|
||||
}
|
||||
setProcessingFile(undefined);
|
||||
onChange(newAvatar);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [onChange, processingFile]);
|
||||
|
||||
const onInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (file) {
|
||||
setProcessingFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={className}
|
||||
onClick={() => {
|
||||
const fileInput = fileInputRef.current;
|
||||
if (fileInput) {
|
||||
// Setting the value to empty so that onChange always fires in case
|
||||
// you add multiple photos.
|
||||
fileInput.value = '';
|
||||
fileInput.click();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{i18n('photo')}
|
||||
</button>
|
||||
<input
|
||||
accept=".gif,.jpg,.jpeg,.png,.webp,image/gif,image/jpeg,image/png,image/webp"
|
||||
hidden
|
||||
onChange={onInputChange}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
62
ts/components/BetterAvatar.stories.tsx
Normal file
62
ts/components/BetterAvatar.stories.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { GroupAvatarIcons, PersonalAvatarIcons } from '../types/Avatar';
|
||||
import { BetterAvatar, PropsType } from './BetterAvatar';
|
||||
import { createAvatarData } from '../util/createAvatarData';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
avatarData:
|
||||
overrideProps.avatarData ||
|
||||
createAvatarData({ color: AvatarColors[0], text: 'OOO' }),
|
||||
i18n,
|
||||
isSelected: Boolean(overrideProps.isSelected),
|
||||
onClick: action('onClick'),
|
||||
onDelete: action('onDelete'),
|
||||
size: 80,
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/BetterAvatar', module);
|
||||
|
||||
story.add('Text', () => (
|
||||
<BetterAvatar
|
||||
{...createProps({
|
||||
avatarData: createAvatarData({
|
||||
color: AvatarColors[0],
|
||||
text: 'AH',
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Personal Icon', () => (
|
||||
<BetterAvatar
|
||||
{...createProps({
|
||||
avatarData: createAvatarData({
|
||||
color: AvatarColors[1],
|
||||
icon: PersonalAvatarIcons[1],
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Group Icon', () => (
|
||||
<BetterAvatar
|
||||
{...createProps({
|
||||
avatarData: createAvatarData({
|
||||
color: AvatarColors[1],
|
||||
icon: GroupAvatarIcons[1],
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
));
|
117
ts/components/BetterAvatar.tsx
Normal file
117
ts/components/BetterAvatar.tsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { MouseEvent, useEffect, useState } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { AvatarDataType } from '../types/Avatar';
|
||||
import { BetterAvatarBubble } from './BetterAvatarBubble';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { Spinner } from './Spinner';
|
||||
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
|
||||
|
||||
type AvatarSize = 48 | 80;
|
||||
|
||||
export type PropsType = {
|
||||
avatarData: AvatarDataType;
|
||||
i18n: LocalizerType;
|
||||
isSelected?: boolean;
|
||||
onClick: (avatarBuffer: ArrayBuffer | undefined) => unknown;
|
||||
onDelete: () => unknown;
|
||||
size?: AvatarSize;
|
||||
};
|
||||
|
||||
export const BetterAvatar = ({
|
||||
avatarData,
|
||||
i18n,
|
||||
isSelected,
|
||||
onClick,
|
||||
onDelete,
|
||||
size = 48,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [avatarBuffer, setAvatarBuffer] = useState<ArrayBuffer | undefined>(
|
||||
avatarData.buffer
|
||||
);
|
||||
const [avatarURL, setAvatarURL] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let shouldCancel = false;
|
||||
|
||||
async function makeAvatar() {
|
||||
const buffer = await avatarDataToArrayBuffer(avatarData);
|
||||
if (!shouldCancel) {
|
||||
setAvatarBuffer(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have this we'll get lots of flashing because avatarData
|
||||
// changes too much. Once we have a buffer set we don't need to reload.
|
||||
if (avatarBuffer) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
makeAvatar();
|
||||
|
||||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [avatarBuffer, avatarData]);
|
||||
|
||||
// Convert avatar's ArrayBuffer to a URL object
|
||||
useEffect(() => {
|
||||
if (avatarBuffer) {
|
||||
const url = URL.createObjectURL(new Blob([avatarBuffer]));
|
||||
|
||||
setAvatarURL(url);
|
||||
}
|
||||
}, [avatarBuffer]);
|
||||
|
||||
// Clean up any remaining object URLs
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarURL) {
|
||||
URL.revokeObjectURL(avatarURL);
|
||||
}
|
||||
};
|
||||
}, [avatarURL]);
|
||||
|
||||
const isEditable = Boolean(avatarData.color);
|
||||
const handleDelete = !avatarData.icon
|
||||
? (ev: MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onDelete();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<BetterAvatarBubble
|
||||
i18n={i18n}
|
||||
isSelected={isSelected}
|
||||
onDelete={handleDelete}
|
||||
onSelect={() => {
|
||||
onClick(avatarBuffer);
|
||||
}}
|
||||
style={{
|
||||
backgroundImage: avatarURL ? `url(${avatarURL})` : undefined,
|
||||
backgroundSize: size,
|
||||
// +8 so that the size is the acutal size we want, 8 is the invisible
|
||||
// padding around the bubble to make room for the selection border
|
||||
height: size + 8,
|
||||
width: size + 8,
|
||||
}}
|
||||
>
|
||||
{isEditable && isSelected && (
|
||||
<div className="BetterAvatarBubble--editable" />
|
||||
)}
|
||||
{!avatarURL && (
|
||||
<div className="module-Avatar__spinner-container">
|
||||
<Spinner
|
||||
size={`${size - 8}px`}
|
||||
svgSize="small"
|
||||
direction="on-avatar"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</BetterAvatarBubble>
|
||||
);
|
||||
};
|
56
ts/components/BetterAvatarBubble.stories.tsx
Normal file
56
ts/components/BetterAvatarBubble.stories.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { BetterAvatarBubble, PropsType } from './BetterAvatarBubble';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
children: overrideProps.children,
|
||||
color: overrideProps.color,
|
||||
i18n,
|
||||
isSelected: Boolean(overrideProps.isSelected),
|
||||
onDelete: action('onDelete'),
|
||||
onSelect: action('onSelect'),
|
||||
style: overrideProps.style,
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/BetterAvatarBubble', module);
|
||||
|
||||
story.add('Children', () => (
|
||||
<BetterAvatarBubble
|
||||
{...createProps({
|
||||
children: <div>HI</div>,
|
||||
color: AvatarColors[8],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Selected', () => (
|
||||
<BetterAvatarBubble
|
||||
{...createProps({
|
||||
color: AvatarColors[1],
|
||||
isSelected: true,
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Style', () => (
|
||||
<BetterAvatarBubble
|
||||
{...createProps({
|
||||
style: {
|
||||
height: 120,
|
||||
width: 120,
|
||||
},
|
||||
color: AvatarColors[2],
|
||||
})}
|
||||
/>
|
||||
));
|
60
ts/components/BetterAvatarBubble.tsx
Normal file
60
ts/components/BetterAvatarBubble.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { CSSProperties, MouseEvent, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { AvatarColorType } from '../types/Colors';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export type PropsType = {
|
||||
children?: ReactNode;
|
||||
color?: AvatarColorType;
|
||||
i18n: LocalizerType;
|
||||
isSelected?: boolean;
|
||||
onDelete?: (ev: MouseEvent) => unknown;
|
||||
onSelect: () => unknown;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export const BetterAvatarBubble = ({
|
||||
children,
|
||||
color,
|
||||
i18n,
|
||||
isSelected,
|
||||
onDelete,
|
||||
onSelect,
|
||||
style,
|
||||
}: PropsType): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
{
|
||||
BetterAvatarBubble: true,
|
||||
'BetterAvatarBubble--selected': isSelected,
|
||||
},
|
||||
color && `BetterAvatarBubble--${color}`
|
||||
)}
|
||||
onKeyDown={ev => {
|
||||
if (ev.key === 'Enter') {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
onClick={onSelect}
|
||||
role="button"
|
||||
style={style}
|
||||
tabIndex={0}
|
||||
>
|
||||
{onDelete && (
|
||||
<button
|
||||
aria-label={i18n('delete')}
|
||||
className="BetterAvatarBubble__delete"
|
||||
onClick={onDelete}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { Avatar } from './Avatar';
|
||||
import { Intl } from './Intl';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
|
@ -46,7 +47,7 @@ export const CallNeedPermissionScreen: React.FC<Props> = ({
|
|||
<Avatar
|
||||
acceptedMessageRequest={conversation.acceptedMessageRequest}
|
||||
avatarPath={conversation.avatarPath}
|
||||
color={conversation.color || 'ultramarine'}
|
||||
color={conversation.color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
|
|
|
@ -24,7 +24,7 @@ import {
|
|||
PresentedSource,
|
||||
VideoFrameSource,
|
||||
} from '../types/Calling';
|
||||
import { AvatarColorType } from '../types/Colors';
|
||||
import { AvatarColors, AvatarColorType } from '../types/Colors';
|
||||
import { CallingToastManager } from './CallingToastManager';
|
||||
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
|
||||
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
|
||||
|
@ -343,7 +343,7 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={me.avatarPath}
|
||||
color={me.color || 'ultramarine'}
|
||||
color={me.color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
|
@ -418,7 +418,7 @@ export const CallScreen: React.FC<PropsType> = ({
|
|||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={me.avatarPath}
|
||||
color={me.color || 'ultramarine'}
|
||||
color={me.color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
GroupCallVideoRequest,
|
||||
VideoFrameSource,
|
||||
} from '../types/Calling';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { SetRendererCanvasType } from '../state/ducks/calling';
|
||||
import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer';
|
||||
import { usePageVisibility } from '../util/hooks';
|
||||
|
@ -50,7 +51,7 @@ const NoVideo = ({
|
|||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
color={color || 'ultramarine'}
|
||||
color={color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
|
|
23
ts/components/ConfirmDiscardDialog.stories.tsx
Normal file
23
ts/components/ConfirmDiscardDialog.stories.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { ConfirmDiscardDialog, PropsType } from './ConfirmDiscardDialog';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (): PropsType => ({
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
onDiscard: action('onDiscard'),
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/ConfirmDiscardDialog', module);
|
||||
|
||||
story.add('Default', () => <ConfirmDiscardDialog {...createProps()} />);
|
31
ts/components/ConfirmDiscardDialog.tsx
Normal file
31
ts/components/ConfirmDiscardDialog.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
onClose: () => unknown;
|
||||
onDiscard: () => unknown;
|
||||
};
|
||||
|
||||
export const ConfirmDiscardDialog = ({
|
||||
i18n,
|
||||
onClose,
|
||||
onDiscard,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: onDiscard,
|
||||
text: i18n('discard'),
|
||||
style: 'negative',
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
>
|
||||
{i18n('ConfirmDiscardDialog--discard')}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
};
|
|
@ -10,6 +10,7 @@ import { gifUrl } from '../storybook/Fixtures';
|
|||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { ContactListItem } from './ContactListItem';
|
||||
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
const onClick = action('onClick');
|
||||
|
@ -126,7 +127,7 @@ storiesOf('Components/ContactListItem', module)
|
|||
isMe={false}
|
||||
title="Someone 🔥 Somewhere"
|
||||
name="Someone 🔥 Somewhere"
|
||||
color="teal"
|
||||
color={getRandomColor()}
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
sharedGroupNames={[]}
|
||||
|
@ -140,7 +141,7 @@ storiesOf('Components/ContactListItem', module)
|
|||
<ContactListItem
|
||||
type="direct"
|
||||
acceptedMessageRequest
|
||||
color="blue"
|
||||
color={getRandomColor()}
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
phoneNumber="(202) 555-0011"
|
||||
|
|
|
@ -22,7 +22,6 @@ type ContactType = Omit<ContactPillPropsType, 'i18n' | 'onClickRemove'>;
|
|||
|
||||
const contacts: Array<ContactType> = times(50, index =>
|
||||
getDefaultConversation({
|
||||
color: 'crimson',
|
||||
id: `contact-${index}`,
|
||||
name: `Contact ${index}`,
|
||||
phoneNumber: '(202) 555-0001',
|
||||
|
@ -37,7 +36,6 @@ const contactPillProps = (
|
|||
...(overrideProps ||
|
||||
getDefaultConversation({
|
||||
avatarPath: gifUrl,
|
||||
color: 'crimson',
|
||||
firstName: 'John',
|
||||
id: 'abc123',
|
||||
isMe: false,
|
||||
|
|
|
@ -5,6 +5,7 @@ import React, { useRef, useEffect } from 'react';
|
|||
import { SetRendererCanvasType } from '../state/ducks/calling';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
type PropsType = {
|
||||
|
@ -69,7 +70,7 @@ function renderAvatar(
|
|||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
color={color || 'ultramarine'}
|
||||
color={color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
VideoFrameSource,
|
||||
} from '../types/Calling';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { CallBackgroundBlur } from './CallBackgroundBlur';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
@ -331,7 +332,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
color={color || 'ultramarine'}
|
||||
color={color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { AvatarColors } from '../types/Colors';
|
|||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { getRandomColor } from '../test-both/helpers/getRandomColor';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -25,7 +26,6 @@ const defaultProps = {
|
|||
conversation: getDefaultConversation({
|
||||
id: '3051234567',
|
||||
avatarPath: undefined,
|
||||
color: AvatarColors[0],
|
||||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
|
@ -37,7 +37,7 @@ const defaultProps = {
|
|||
|
||||
storiesOf('Components/IncomingCallBar', module)
|
||||
.add('Knobs Playground', () => {
|
||||
const color = select('color', AvatarColors, 'ultramarine');
|
||||
const color = select('color', AvatarColors, getRandomColor());
|
||||
const isVideoCall = boolean('isVideoCall', false);
|
||||
const name = text(
|
||||
'name',
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Tooltip } from './Tooltip';
|
|||
import { Theme } from '../util/theme';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { AcceptCallType, DeclineCallType } from '../state/ducks/calling';
|
||||
|
||||
|
@ -89,7 +90,7 @@ export const IncomingCallBar = ({
|
|||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
color={color || 'ultramarine'}
|
||||
color={color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
|
|
|
@ -82,6 +82,9 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'),
|
||||
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
|
||||
closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'),
|
||||
composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'),
|
||||
composeReplaceAvatar: action('composeReplaceAvatar'),
|
||||
composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'),
|
||||
createGroup: action('createGroup'),
|
||||
i18n,
|
||||
modeSpecificProps: defaultModeSpecificProps,
|
||||
|
@ -135,6 +138,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
'startNewConversationFromPhoneNumber'
|
||||
),
|
||||
startSettingGroupMetadata: action('startSettingGroupMetadata'),
|
||||
toggleComposeEditingAvatar: action('toggleComposeEditingAvatar'),
|
||||
toggleConversationInChooseMembers: action(
|
||||
'toggleConversationInChooseMembers'
|
||||
),
|
||||
|
@ -528,7 +532,9 @@ story.add('Group Metadata: No Timer', () => (
|
|||
groupExpireTimer: 0,
|
||||
hasError: false,
|
||||
isCreating: false,
|
||||
isEditingAvatar: false,
|
||||
selectedContacts: defaultConversations,
|
||||
userAvatarData: [],
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
@ -544,7 +550,9 @@ story.add('Group Metadata: Regular Timer', () => (
|
|||
groupExpireTimer: 24 * 3600,
|
||||
hasError: false,
|
||||
isCreating: false,
|
||||
isEditingAvatar: false,
|
||||
selectedContacts: defaultConversations,
|
||||
userAvatarData: [],
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
@ -560,7 +568,9 @@ story.add('Group Metadata: Custom Timer', () => (
|
|||
groupExpireTimer: 7 * 3600,
|
||||
hasError: false,
|
||||
isCreating: false,
|
||||
isEditingAvatar: false,
|
||||
selectedContacts: defaultConversations,
|
||||
userAvatarData: [],
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -43,6 +43,12 @@ import { missingCaseError } from '../util/missingCaseError';
|
|||
import { ConversationList } from './ConversationList';
|
||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||
|
||||
import {
|
||||
DeleteAvatarFromDiskActionType,
|
||||
ReplaceAvatarActionType,
|
||||
SaveAvatarToDiskActionType,
|
||||
} from '../types/Avatar';
|
||||
|
||||
export enum LeftPaneMode {
|
||||
Inbox,
|
||||
Search,
|
||||
|
@ -105,6 +111,10 @@ export type PropsType = {
|
|||
showChooseGroupMembers: () => void;
|
||||
startSettingGroupMetadata: () => void;
|
||||
toggleConversationInChooseMembers: (conversationId: string) => void;
|
||||
composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||
composeReplaceAvatar: ReplaceAvatarActionType;
|
||||
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||
toggleComposeEditingAvatar: () => unknown;
|
||||
|
||||
// Render Props
|
||||
renderExpiredBuildDialog: () => JSX.Element;
|
||||
|
@ -118,35 +128,39 @@ export type PropsType = {
|
|||
|
||||
export const LeftPane: React.FC<PropsType> = ({
|
||||
cantAddContactToGroup,
|
||||
challengeStatus,
|
||||
clearGroupCreationError,
|
||||
closeCantAddContactToGroupModal,
|
||||
closeMaximumGroupSizeModal,
|
||||
closeRecommendedGroupSizeModal,
|
||||
composeDeleteAvatarFromDisk,
|
||||
composeReplaceAvatar,
|
||||
composeSaveAvatarToDisk,
|
||||
createGroup,
|
||||
i18n,
|
||||
modeSpecificProps,
|
||||
challengeStatus,
|
||||
setChallengeStatus,
|
||||
openConversationInternal,
|
||||
renderCaptchaDialog,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
renderMessageSearchResult,
|
||||
renderNetworkStatus,
|
||||
renderRelinkDialog,
|
||||
renderUpdateDialog,
|
||||
renderCaptchaDialog,
|
||||
selectedConversationId,
|
||||
selectedMessageId,
|
||||
setComposeSearchTerm,
|
||||
setChallengeStatus,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupName,
|
||||
setComposeGroupExpireTimer,
|
||||
setComposeGroupName,
|
||||
setComposeSearchTerm,
|
||||
showArchivedConversations,
|
||||
showChooseGroupMembers,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
startNewConversationFromPhoneNumber,
|
||||
startSettingGroupMetadata,
|
||||
toggleComposeEditingAvatar,
|
||||
toggleConversationInChooseMembers,
|
||||
}) => {
|
||||
const previousModeSpecificProps = usePrevious(
|
||||
|
@ -340,11 +354,15 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
closeCantAddContactToGroupModal,
|
||||
closeMaximumGroupSizeModal,
|
||||
closeRecommendedGroupSizeModal,
|
||||
composeDeleteAvatarFromDisk,
|
||||
composeReplaceAvatar,
|
||||
composeSaveAvatarToDisk,
|
||||
createGroup,
|
||||
i18n,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupName,
|
||||
setComposeGroupExpireTimer,
|
||||
toggleComposeEditingAvatar,
|
||||
onChangeComposeSearchTerm: event => {
|
||||
setComposeSearchTerm(event.target.value);
|
||||
},
|
||||
|
|
|
@ -129,3 +129,18 @@ story.add('Including Next/Previous/Save Callbacks', () => {
|
|||
|
||||
return <Lightbox {...props} />;
|
||||
});
|
||||
|
||||
story.add('Custom children', () => (
|
||||
<Lightbox {...createProps({})} contentType={undefined}>
|
||||
<div
|
||||
style={{
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
I am middle child
|
||||
</div>
|
||||
</Lightbox>
|
||||
));
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2018-2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import is from '@sindresorhus/is';
|
||||
|
@ -25,6 +25,7 @@ const colorSVG = (url: string, color: string) => {
|
|||
};
|
||||
|
||||
export type Props = {
|
||||
children?: ReactNode;
|
||||
close: () => void;
|
||||
contentType: MIME.MIMEType | undefined;
|
||||
i18n: LocalizerType;
|
||||
|
@ -53,6 +54,7 @@ const styles = {
|
|||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
zIndex: 10,
|
||||
} as React.CSSProperties,
|
||||
buttonContainer: {
|
||||
backgroundColor: 'transparent',
|
||||
|
@ -298,6 +300,7 @@ export class Lightbox extends React.Component<Props, State> {
|
|||
public render(): JSX.Element {
|
||||
const {
|
||||
caption,
|
||||
children,
|
||||
contentType,
|
||||
i18n,
|
||||
isViewOnce,
|
||||
|
@ -329,7 +332,7 @@ export class Lightbox extends React.Component<Props, State> {
|
|||
isViewOnce,
|
||||
loop,
|
||||
})
|
||||
: null}
|
||||
: children}
|
||||
{caption ? <div style={styles.caption}>{caption}</div> : null}
|
||||
</div>
|
||||
<div style={styles.controls}>
|
||||
|
|
|
@ -106,3 +106,45 @@ story.add('Long body with long title and X button', () => (
|
|||
<p>{LOREM_IPSUM}</p>
|
||||
</Modal>
|
||||
));
|
||||
|
||||
story.add('With sticky buttons long body', () => (
|
||||
<Modal hasStickyButtons hasXButton i18n={i18n} onClose={onClose}>
|
||||
<p>{LOREM_IPSUM}</p>
|
||||
<p>{LOREM_IPSUM}</p>
|
||||
<p>{LOREM_IPSUM}</p>
|
||||
<p>{LOREM_IPSUM}</p>
|
||||
<Modal.ButtonFooter>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
</Modal.ButtonFooter>
|
||||
</Modal>
|
||||
));
|
||||
|
||||
story.add('With sticky buttons short body', () => (
|
||||
<Modal hasStickyButtons hasXButton i18n={i18n} onClose={onClose}>
|
||||
<p>{LOREM_IPSUM.slice(0, 140)}</p>
|
||||
<Modal.ButtonFooter>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
</Modal.ButtonFooter>
|
||||
</Modal>
|
||||
));
|
||||
|
||||
story.add('Sticky footer, Lots of buttons', () => (
|
||||
<Modal hasStickyButtons i18n={i18n} onClose={onClose} title="OK">
|
||||
<p>{LOREM_IPSUM}</p>
|
||||
<Modal.ButtonFooter>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
<Button onClick={noop}>
|
||||
This is a button with a fairly large amount of text
|
||||
</Button>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
<Button onClick={noop}>
|
||||
This is a button with a fairly large amount of text
|
||||
</Button>
|
||||
<Button onClick={noop}>Okay</Button>
|
||||
</Modal.ButtonFooter>
|
||||
</Modal>
|
||||
));
|
||||
|
|
|
@ -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<PropsType>): ReactElement {
|
||||
const modalRef = useRef<HTMLDivElement | null>(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 (
|
||||
<ModalHost noMouseClose={noMouseClose} onClose={onClose} theme={theme}>
|
||||
<div
|
||||
className={classNames(
|
||||
getClassName(''),
|
||||
getClassName(hasHeader ? '--has-header' : '--no-header')
|
||||
getClassName(hasHeader ? '--has-header' : '--no-header'),
|
||||
hasStickyButtons && getClassName('--sticky-buttons')
|
||||
)}
|
||||
ref={modalRef}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div className={getClassName('__header')}>
|
||||
|
@ -72,17 +89,25 @@ export function Modal({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
getClassName('__body'),
|
||||
scrolled ? getClassName('__body--scrolled') : null
|
||||
<Measure scroll onResize={handleResize}>
|
||||
{({ measureRef }: MeasuredComponentProps) => (
|
||||
<div
|
||||
className={classNames(
|
||||
getClassName('__body'),
|
||||
scrolled ? getClassName('__body--scrolled') : null,
|
||||
hasOverflow || scrolled
|
||||
? getClassName('__body--overflow')
|
||||
: null
|
||||
)}
|
||||
onScroll={event => {
|
||||
setScrolled((event.target as HTMLDivElement).scrollTop > 2);
|
||||
}}
|
||||
ref={measureRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
onScroll={event => {
|
||||
setScrolled((event.target as HTMLDivElement).scrollTop > 2);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Measure>
|
||||
</div>
|
||||
</ModalHost>
|
||||
);
|
||||
|
|
|
@ -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> = {}): 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> = {}): 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', () => {
|
||||
|
|
|
@ -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<AvatarDataType>;
|
||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||
|
||||
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<HTMLInputElement | null>(null);
|
||||
const [editState, setEditState] = useState<EditState>(EditState.None);
|
||||
|
@ -105,7 +126,7 @@ export const ProfileEditor = ({
|
|||
aboutText,
|
||||
});
|
||||
|
||||
const [avatarData, setAvatarData] = useState<ArrayBuffer | undefined>(
|
||||
const [avatarBuffer, setAvatarBuffer] = useState<ArrayBuffer | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [stagedProfile, setStagedProfile] = useState<ProfileDataType>({
|
||||
|
@ -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 = (
|
||||
<AvatarEditor
|
||||
avatarColor={color || AvatarColors[0]}
|
||||
avatarPath={avatarPath}
|
||||
avatarValue={avatarBuffer}
|
||||
conversationId={conversationId}
|
||||
conversationTitle={getFullNameText()}
|
||||
deleteAvatarFromDisk={deleteAvatarFromDisk}
|
||||
i18n={i18n}
|
||||
onCancel={handleBack}
|
||||
onSave={handleAvatarChanged}
|
||||
userAvatarData={userAvatarData}
|
||||
replaceAvatar={replaceAvatar}
|
||||
saveAvatarToDisk={saveAvatarToDisk}
|
||||
/>
|
||||
);
|
||||
} 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}
|
||||
/>
|
||||
<div className="ProfileEditor__buttons">
|
||||
<Modal.ButtonFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const handleCancel = () => {
|
||||
|
@ -236,13 +288,13 @@ export const ProfileEditor = ({
|
|||
familyName: stagedProfile.familyName,
|
||||
});
|
||||
|
||||
onProfileChanged(stagedProfile, avatarData);
|
||||
onProfileChanged(stagedProfile, avatarBuffer);
|
||||
handleBack();
|
||||
}}
|
||||
>
|
||||
{i18n('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.ButtonFooter>
|
||||
</>
|
||||
);
|
||||
} else if (editState === EditState.Bio) {
|
||||
|
@ -314,7 +366,7 @@ export const ProfileEditor = ({
|
|||
/>
|
||||
))}
|
||||
|
||||
<div className="ProfileEditor__buttons">
|
||||
<Modal.ButtonFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const handleCancel = () => {
|
||||
|
@ -346,32 +398,32 @@ export const ProfileEditor = ({
|
|||
aboutText: stagedProfile.aboutText,
|
||||
});
|
||||
|
||||
onProfileChanged(stagedProfile, avatarData);
|
||||
onProfileChanged(stagedProfile, avatarBuffer);
|
||||
handleBack();
|
||||
}}
|
||||
>
|
||||
{i18n('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.ButtonFooter>
|
||||
</>
|
||||
);
|
||||
} else if (editState === EditState.None) {
|
||||
const fullNameText = [fullName.firstName, fullName.familyName]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
content = (
|
||||
<>
|
||||
<AvatarInputContainer
|
||||
<AvatarPreview
|
||||
avatarColor={color}
|
||||
avatarPath={avatarPath}
|
||||
contextMenuId="edit-self-profile-avatar"
|
||||
avatarValue={avatarBuffer}
|
||||
conversationTitle={getFullNameText()}
|
||||
i18n={i18n}
|
||||
onAvatarChanged={avatar => {
|
||||
handleAvatarChanged(avatar);
|
||||
onProfileChanged(stagedProfile, avatar);
|
||||
onAvatarLoaded={handleAvatarLoaded}
|
||||
onClick={() => {
|
||||
setEditState(EditState.BetterAvatar);
|
||||
}}
|
||||
style={{
|
||||
height: 96,
|
||||
width: 96,
|
||||
}}
|
||||
onAvatarLoaded={handleAvatarChanged}
|
||||
type={AvatarInputType.Profile}
|
||||
/>
|
||||
|
||||
<hr className="ProfileEditor__divider" />
|
||||
|
@ -381,10 +433,9 @@ export const ProfileEditor = ({
|
|||
icon={
|
||||
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--name" />
|
||||
}
|
||||
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 && (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: confirmDiscardAction,
|
||||
text: i18n('discard'),
|
||||
style: 'negative',
|
||||
},
|
||||
]}
|
||||
<ConfirmDiscardDialog
|
||||
i18n={i18n}
|
||||
onDiscard={confirmDiscardAction}
|
||||
onClose={() => setConfirmDiscardAction(undefined)}
|
||||
>
|
||||
{i18n('ProfileEditor--discard')}
|
||||
</ConfirmationDialog>
|
||||
/>
|
||||
)}
|
||||
<div className="ProfileEditor">{content}</div>
|
||||
</>
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
<Modal
|
||||
hasStickyButtons
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={toggleProfileEditor}
|
||||
|
@ -74,8 +75,8 @@ export const ProfileEditorModal = ({
|
|||
setModalTitle(ModalTitles.Bio);
|
||||
}
|
||||
}}
|
||||
onProfileChanged={(profileData, avatarData) => {
|
||||
myProfileChanged(profileData, avatarData);
|
||||
onProfileChanged={(profileData, avatarBuffer) => {
|
||||
myProfileChanged(profileData, avatarBuffer);
|
||||
}}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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> = {}): PropsType => ({
|
||||
contact: overrideProps.contact || contactWithAllData,
|
||||
|
|
|
@ -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<HTMLElement | null>(null);
|
||||
const overlayRef = React.useRef<HTMLElement | null>(null);
|
||||
const closeButtonRef = React.useRef<HTMLElement | null>(null);
|
||||
const [root, setRoot] = useState<HTMLElement | null>(null);
|
||||
const overlayRef = useRef<HTMLElement | null>(null);
|
||||
const closeButtonRef = useRef<HTMLElement | null>(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 = (
|
||||
<AvatarLightbox
|
||||
avatarColor={contact.color}
|
||||
avatarPath={contact.avatarPath}
|
||||
conversationTitle={contact.title}
|
||||
i18n={i18n}
|
||||
onClose={() => setShowingAvatar(false)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="module-contact-modal">
|
||||
<button
|
||||
ref={r => {
|
||||
closeButtonRef.current = r;
|
||||
}}
|
||||
type="button"
|
||||
className="module-contact-modal__close-button"
|
||||
onClick={onClose}
|
||||
aria-label={i18n('close')}
|
||||
/>
|
||||
<Avatar
|
||||
acceptedMessageRequest={contact.acceptedMessageRequest}
|
||||
avatarPath={contact.avatarPath}
|
||||
color={contact.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={contact.isMe}
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
sharedGroupNames={contact.sharedGroupNames}
|
||||
size={96}
|
||||
title={contact.title}
|
||||
unblurredAvatarPath={contact.unblurredAvatarPath}
|
||||
onClick={() => setShowingAvatar(true)}
|
||||
/>
|
||||
<div className="module-contact-modal__name">{contact.title}</div>
|
||||
<div className="module-about__container">
|
||||
<About text={contact.about} />
|
||||
</div>
|
||||
{contact.phoneNumber && (
|
||||
<div className="module-contact-modal__info">
|
||||
{contact.phoneNumber}
|
||||
</div>
|
||||
)}
|
||||
<div className="module-contact-modal__info">
|
||||
<SharedGroupNames
|
||||
i18n={i18n}
|
||||
sharedGroupNames={contact.sharedGroupNames || []}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-contact-modal__button-container">
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__send-message"
|
||||
onClick={() => openConversation(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__send-message__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--message')}</span>
|
||||
</button>
|
||||
{!contact.isMe && (
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__safety-number"
|
||||
onClick={() => showSafetyNumber(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__safety-number__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('showSafetyNumber')}</span>
|
||||
</button>
|
||||
)}
|
||||
{!contact.isMe && areWeAdmin && isMember && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__make-admin"
|
||||
onClick={() => toggleAdmin(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__make-admin__bubble-icon" />
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<span>{i18n('ContactModal--rm-admin')}</span>
|
||||
) : (
|
||||
<span>{i18n('ContactModal--make-admin')}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__remove-from-group"
|
||||
onClick={() => removeMember(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__remove-from-group__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--remove-from-group')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return root
|
||||
? createPortal(
|
||||
<div
|
||||
|
@ -102,98 +214,7 @@ export const ContactModal = ({
|
|||
className="module-contact-modal__overlay"
|
||||
onClick={onClickOverlay}
|
||||
>
|
||||
<div className="module-contact-modal">
|
||||
<button
|
||||
ref={r => {
|
||||
closeButtonRef.current = r;
|
||||
}}
|
||||
type="button"
|
||||
className="module-contact-modal__close-button"
|
||||
onClick={onClose}
|
||||
aria-label={i18n('close')}
|
||||
/>
|
||||
<Avatar
|
||||
acceptedMessageRequest={contact.acceptedMessageRequest}
|
||||
avatarPath={contact.avatarPath}
|
||||
color={contact.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={contact.isMe}
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
sharedGroupNames={contact.sharedGroupNames}
|
||||
size={96}
|
||||
title={contact.title}
|
||||
unblurredAvatarPath={contact.unblurredAvatarPath}
|
||||
/>
|
||||
<div className="module-contact-modal__name">{contact.title}</div>
|
||||
<div className="module-about__container">
|
||||
<About text={contact.about} />
|
||||
</div>
|
||||
{contact.phoneNumber && (
|
||||
<div className="module-contact-modal__info">
|
||||
{contact.phoneNumber}
|
||||
</div>
|
||||
)}
|
||||
<div className="module-contact-modal__info">
|
||||
<SharedGroupNames
|
||||
i18n={i18n}
|
||||
sharedGroupNames={contact.sharedGroupNames || []}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-contact-modal__button-container">
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__send-message"
|
||||
onClick={() => openConversation(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__send-message__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--message')}</span>
|
||||
</button>
|
||||
{!contact.isMe && (
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__safety-number"
|
||||
onClick={() => showSafetyNumber(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__safety-number__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('showSafetyNumber')}</span>
|
||||
</button>
|
||||
)}
|
||||
{!contact.isMe && areWeAdmin && isMember && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__make-admin"
|
||||
onClick={() => toggleAdmin(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__make-admin__bubble-icon" />
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<span>{i18n('ContactModal--rm-admin')}</span>
|
||||
) : (
|
||||
<span>{i18n('ContactModal--make-admin')}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-modal__button module-contact-modal__remove-from-group"
|
||||
onClick={() => removeMember(contact.id)}
|
||||
>
|
||||
<div className="module-contact-modal__bubble-icon">
|
||||
<div className="module-contact-modal__remove-from-group__bubble-icon" />
|
||||
</div>
|
||||
<span>{i18n('ContactModal--remove-from-group')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</div>,
|
||||
root
|
||||
)
|
||||
|
|
|
@ -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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
title: 'Basic',
|
||||
props: {
|
||||
...commonProps,
|
||||
color: 'ultramarine',
|
||||
color: getRandomColor(),
|
||||
title: 'Typescript support group',
|
||||
name: 'Typescript support group',
|
||||
phoneNumber: '',
|
||||
|
@ -229,7 +230,7 @@ const stories: Array<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
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<ConversationHeaderStory> = [
|
|||
title: '1:1 conversation',
|
||||
props: {
|
||||
...commonProps,
|
||||
color: 'blue',
|
||||
color: getRandomColor(),
|
||||
title: '(202) 555-0007',
|
||||
phoneNumber: '(202) 555-0007',
|
||||
id: '16',
|
||||
|
|
|
@ -44,7 +44,6 @@ const createProps = (overrideProps: Partial<Props> = {}): 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,
|
||||
|
|
|
@ -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<string, TimelineItemType> = {
|
|||
data: {
|
||||
author: getDefaultConversation({
|
||||
phoneNumber: '(202) 555-2001',
|
||||
color: 'forest',
|
||||
}),
|
||||
canDeleteForEveryone: false,
|
||||
canDownload: true,
|
||||
|
@ -58,7 +58,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
'id-2': {
|
||||
type: 'message',
|
||||
data: {
|
||||
author: getDefaultConversation({ color: 'forest' }),
|
||||
author: getDefaultConversation({}),
|
||||
canDeleteForEveryone: false,
|
||||
canDownload: true,
|
||||
canReply: true,
|
||||
|
@ -90,7 +90,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
'id-3': {
|
||||
type: 'message',
|
||||
data: {
|
||||
author: getDefaultConversation({ color: 'crimson' }),
|
||||
author: getDefaultConversation({}),
|
||||
canDeleteForEveryone: false,
|
||||
canDownload: true,
|
||||
canReply: true,
|
||||
|
@ -188,7 +188,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
'id-10': {
|
||||
type: 'message',
|
||||
data: {
|
||||
author: getDefaultConversation({ color: 'plum' }),
|
||||
author: getDefaultConversation({}),
|
||||
canDeleteForEveryone: false,
|
||||
canDownload: true,
|
||||
canReply: true,
|
||||
|
@ -208,7 +208,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
'id-11': {
|
||||
type: 'message',
|
||||
data: {
|
||||
author: getDefaultConversation({ color: 'plum' }),
|
||||
author: getDefaultConversation({}),
|
||||
canDeleteForEveryone: false,
|
||||
canDownload: true,
|
||||
canReply: true,
|
||||
|
@ -228,7 +228,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
'id-12': {
|
||||
type: 'message',
|
||||
data: {
|
||||
author: getDefaultConversation({ color: 'crimson' }),
|
||||
author: getDefaultConversation({}),
|
||||
canDeleteForEveryone: false,
|
||||
canDownload: true,
|
||||
canReply: true,
|
||||
|
@ -248,7 +248,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
'id-13': {
|
||||
type: 'message',
|
||||
data: {
|
||||
author: getDefaultConversation({ color: 'blue' }),
|
||||
author: getDefaultConversation({}),
|
||||
canDeleteForEveryone: false,
|
||||
canDownload: true,
|
||||
canReply: true,
|
||||
|
@ -269,7 +269,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
'id-14': {
|
||||
type: 'message',
|
||||
data: {
|
||||
author: getDefaultConversation({ color: 'crimson' }),
|
||||
author: getDefaultConversation({}),
|
||||
canDeleteForEveryone: false,
|
||||
canDownload: true,
|
||||
canReply: true,
|
||||
|
@ -418,7 +418,7 @@ const renderLoadingRow = () => <TimelineLoadingRow state="loading" />;
|
|||
const renderTypingBubble = () => (
|
||||
<TypingBubble
|
||||
acceptedMessageRequest
|
||||
color="crimson"
|
||||
color={getRandomColor()}
|
||||
conversationType="direct"
|
||||
phoneNumber="+18005552222"
|
||||
i18n={i18n}
|
||||
|
|
|
@ -12,6 +12,7 @@ import enMessages from '../../../_locales/en/messages.json';
|
|||
import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem';
|
||||
import { UniversalTimerNotification } from './UniversalTimerNotification';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
import { AvatarColors } from '../../types/Colors';
|
||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
@ -97,7 +98,7 @@ storiesOf('Components/Conversation/TimelineItem', module)
|
|||
timestamp: Date.now(),
|
||||
author: {
|
||||
phoneNumber: '(202) 555-2001',
|
||||
color: 'forest',
|
||||
color: AvatarColors[0],
|
||||
},
|
||||
text: '🔥',
|
||||
},
|
||||
|
|
|
@ -8,6 +8,7 @@ import { Avatar, AvatarBlur } from '../Avatar';
|
|||
import { Spinner } from '../Spinner';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { AvatarColors } from '../../types/Colors';
|
||||
import { ContactType, getName } from '../../types/Contact';
|
||||
|
||||
// This file starts with _ to keep it from showing up in the StyleGuide.
|
||||
|
@ -48,7 +49,7 @@ export function renderAvatar({
|
|||
acceptedMessageRequest={false}
|
||||
avatarPath={avatarPath}
|
||||
blur={AvatarBlur.NoBlur}
|
||||
color="steel"
|
||||
color={AvatarColors[0]}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe
|
||||
|
|
|
@ -75,6 +75,10 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
|
|||
},
|
||||
onBlock: action('onBlock'),
|
||||
onLeave: action('onLeave'),
|
||||
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
|
||||
replaceAvatar: action('replaceAvatar'),
|
||||
saveAvatarToDisk: action('saveAvatarToDisk'),
|
||||
userAvatarData: [],
|
||||
});
|
||||
|
||||
story.add('Basic', () => {
|
||||
|
|
|
@ -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<void>;
|
||||
onBlock: () => void;
|
||||
onLeave: () => void;
|
||||
userAvatarData: Array<AvatarDataType>;
|
||||
};
|
||||
|
||||
export type Props = StateProps;
|
||||
type ActionProps = {
|
||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||
replaceAvatar: ReplaceAvatarActionType;
|
||||
saveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||
};
|
||||
|
||||
export type Props = StateProps & ActionProps;
|
||||
|
||||
export const ConversationDetails: React.ComponentType<Props> = ({
|
||||
addMembers,
|
||||
|
@ -101,6 +114,10 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
updateGroupAttributes,
|
||||
onBlock,
|
||||
onLeave,
|
||||
deleteAvatarFromDisk,
|
||||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
userAvatarData,
|
||||
}) => {
|
||||
const [modalState, setModalState] = useState<ModalState>(
|
||||
ModalState.NothingOpen
|
||||
|
@ -141,7 +158,9 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
case ModalState.EditingGroupTitle:
|
||||
modalNode = (
|
||||
<EditConversationAttributesModal
|
||||
avatarColor={conversation.color}
|
||||
avatarPath={conversation.avatarPath}
|
||||
conversationId={conversation.id}
|
||||
groupDescription={conversation.groupDescription}
|
||||
i18n={i18n}
|
||||
initiallyFocusDescription={
|
||||
|
@ -172,6 +191,10 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
}}
|
||||
requestState={editGroupAttributesRequestState}
|
||||
title={conversation.title}
|
||||
deleteAvatarFromDisk={deleteAvatarFromDisk}
|
||||
replaceAvatar={replaceAvatar}
|
||||
saveAvatarToDisk={saveAvatarToDisk}
|
||||
userAvatarData={userAvatarData}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
|
|
@ -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<Props> = ({
|
|||
memberships,
|
||||
startEditing,
|
||||
}) => {
|
||||
const [showingAvatar, setShowingAvatar] = useState(false);
|
||||
|
||||
let subtitle: ReactNode;
|
||||
if (conversation.groupDescription) {
|
||||
subtitle = (
|
||||
|
@ -45,26 +48,41 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
|||
]);
|
||||
}
|
||||
|
||||
const contents = (
|
||||
<>
|
||||
<Avatar
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
size={80}
|
||||
{...conversation}
|
||||
sharedGroupNames={[]}
|
||||
/>
|
||||
<div>
|
||||
<div className={bem('title')}>
|
||||
<Emojify text={conversation.title} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
const avatar = (
|
||||
<Avatar
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
size={80}
|
||||
{...conversation}
|
||||
onClick={() => setShowingAvatar(true)}
|
||||
sharedGroupNames={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
const contents = (
|
||||
<div>
|
||||
<div className={bem('title')}>
|
||||
<Emojify text={conversation.title} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const avatarLightbox = showingAvatar ? (
|
||||
<AvatarLightbox
|
||||
avatarColor={conversation.color}
|
||||
avatarPath={conversation.avatarPath}
|
||||
conversationTitle={conversation.title}
|
||||
i18n={i18n}
|
||||
isGroup
|
||||
onClose={() => setShowingAvatar(false)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (canEdit) {
|
||||
return (
|
||||
<div className={bem('root')}>
|
||||
{avatarLightbox}
|
||||
{avatar}
|
||||
<button
|
||||
type="button"
|
||||
onClick={ev => {
|
||||
|
@ -95,5 +113,11 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
|||
);
|
||||
}
|
||||
|
||||
return <div className={bem('root')}>{contents}</div>;
|
||||
return (
|
||||
<div className={bem('root')}>
|
||||
{avatarLightbox}
|
||||
{avatar}
|
||||
{contents}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -22,12 +22,17 @@ type PropsType = ComponentProps<typeof EditConversationAttributesModal>;
|
|||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): 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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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<AvatarDataType>;
|
||||
};
|
||||
|
||||
export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
||||
avatarColor,
|
||||
avatarPath: externalAvatarPath,
|
||||
conversationId,
|
||||
groupDescription: externalGroupDescription = '',
|
||||
i18n,
|
||||
initiallyFocusDescription,
|
||||
|
@ -47,6 +59,10 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
|||
onClose,
|
||||
requestState,
|
||||
title: externalTitle,
|
||||
deleteAvatarFromDisk,
|
||||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
userAvatarData,
|
||||
}) => {
|
||||
const focusDescriptionRef = useRef<undefined | boolean>(
|
||||
initiallyFocusDescription
|
||||
|
@ -56,9 +72,8 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
|||
const startingTitleRef = useRef<string>(externalTitle);
|
||||
const startingAvatarPathRef = useRef<undefined | string>(externalAvatarPath);
|
||||
|
||||
const [avatar, setAvatar] = useState<undefined | ArrayBuffer>(
|
||||
externalAvatarPath ? TEMPORARY_AVATAR_VALUE : undefined
|
||||
);
|
||||
const [editingAvatar, setEditingAvatar] = useState(false);
|
||||
const [avatar, setAvatar] = useState<undefined | ArrayBuffer>();
|
||||
const [rawTitle, setRawTitle] = useState(externalTitle);
|
||||
const [rawGroupDescription, setRawGroupDescription] = useState(
|
||||
externalGroupDescription
|
||||
|
@ -112,35 +127,55 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
|||
makeRequest(request);
|
||||
};
|
||||
|
||||
const handleAvatarLoaded = useCallback(
|
||||
loadedAvatar => {
|
||||
setAvatar(loadedAvatar);
|
||||
},
|
||||
[setAvatar]
|
||||
);
|
||||
const avatarPathForPreview = hasAvatarChanged
|
||||
? undefined
|
||||
: externalAvatarPath;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
title={i18n('updateGroupAttributes__title')}
|
||||
>
|
||||
let content: JSX.Element;
|
||||
if (editingAvatar) {
|
||||
content = (
|
||||
<AvatarEditor
|
||||
avatarColor={avatarColor}
|
||||
avatarPath={avatarPathForPreview}
|
||||
avatarValue={avatar}
|
||||
conversationId={conversationId}
|
||||
deleteAvatarFromDisk={deleteAvatarFromDisk}
|
||||
i18n={i18n}
|
||||
isGroup
|
||||
onCancel={() => {
|
||||
setHasAvatarChanged(false);
|
||||
setEditingAvatar(false);
|
||||
}}
|
||||
onSave={newAvatar => {
|
||||
setAvatar(newAvatar);
|
||||
setHasAvatarChanged(true);
|
||||
setEditingAvatar(false);
|
||||
}}
|
||||
userAvatarData={userAvatarData}
|
||||
replaceAvatar={replaceAvatar}
|
||||
saveAvatarToDisk={saveAvatarToDisk}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="module-EditConversationAttributesModal"
|
||||
>
|
||||
<AvatarInputContainer
|
||||
avatarPath={externalAvatarPath}
|
||||
contextMenuId="edit conversation attributes avatar input"
|
||||
disabled={isRequestActive}
|
||||
<AvatarPreview
|
||||
avatarColor={avatarColor}
|
||||
avatarPath={avatarPathForPreview}
|
||||
avatarValue={avatar}
|
||||
i18n={i18n}
|
||||
onAvatarChanged={newAvatar => {
|
||||
setAvatar(newAvatar);
|
||||
setHasAvatarChanged(true);
|
||||
isEditable
|
||||
isGroup
|
||||
onClick={() => {
|
||||
setEditingAvatar(true);
|
||||
}}
|
||||
style={{
|
||||
height: 96,
|
||||
width: 96,
|
||||
}}
|
||||
onAvatarLoaded={handleAvatarLoaded}
|
||||
variant={AvatarInputVariant.Dark}
|
||||
/>
|
||||
|
||||
<GroupTitleInput
|
||||
|
@ -191,6 +226,18 @@ export const EditConversationAttributesModal: FunctionComponent<PropsType> = ({
|
|||
</Button>
|
||||
</Modal.ButtonFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hasStickyButtons
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
title={i18n('updateGroupAttributes__title')}
|
||||
>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<PropsType> = React.memo(
|
|||
return (
|
||||
<BaseConversationListItem
|
||||
acceptedMessageRequest={false}
|
||||
color="steel"
|
||||
color={AvatarColors[0]}
|
||||
conversationType="group"
|
||||
headerName={title}
|
||||
i18n={i18n}
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from './BaseConversationListItem';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { AvatarColors } from '../../types/Colors';
|
||||
|
||||
const TEXT_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__start-new-conversation`;
|
||||
|
||||
|
@ -33,7 +34,7 @@ export const StartNewConversation: FunctionComponent<Props> = React.memo(
|
|||
return (
|
||||
<BaseConversationListItem
|
||||
acceptedMessageRequest={false}
|
||||
color="steel"
|
||||
color={AvatarColors[0]}
|
||||
conversationType="direct"
|
||||
headerName={phoneNumber}
|
||||
i18n={i18n}
|
||||
|
|
|
@ -290,7 +290,9 @@ export function emojiToData(emoji: string): EmojiData | undefined {
|
|||
return getOwn(dataByEmoji, emoji);
|
||||
}
|
||||
|
||||
function getCountOfAllMatches(str: string, regex: RegExp) {
|
||||
export function getEmojiCount(str: string): number {
|
||||
const regex = emojiRegex();
|
||||
|
||||
let match = regex.exec(str);
|
||||
let count = 0;
|
||||
|
||||
|
@ -312,7 +314,7 @@ export function getSizeClass(str: string): SizeClassType {
|
|||
return '';
|
||||
}
|
||||
|
||||
const emojiCount = getCountOfAllMatches(str, emojiRegex());
|
||||
const emojiCount = getEmojiCount(str);
|
||||
|
||||
if (emojiCount > 8) {
|
||||
return '';
|
||||
|
|
|
@ -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<T> {
|
|||
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<T> {
|
|||
event: ChangeEvent<HTMLInputElement>
|
||||
) => unknown;
|
||||
removeSelectedContact: (_: string) => unknown;
|
||||
toggleComposeEditingAvatar: () => unknown;
|
||||
}>
|
||||
): null | ReactChild {
|
||||
return null;
|
||||
|
|
|
@ -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<ContactListItemPropsType>;
|
||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||
};
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
@ -36,15 +47,21 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
|
|||
|
||||
private readonly isCreating: boolean;
|
||||
|
||||
private readonly isEditingAvatar: boolean;
|
||||
|
||||
private readonly selectedContacts: ReadonlyArray<ContactListItemPropsType>;
|
||||
|
||||
private readonly userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||
|
||||
constructor({
|
||||
groupAvatar,
|
||||
groupName,
|
||||
groupExpireTimer,
|
||||
isCreating,
|
||||
hasError,
|
||||
isCreating,
|
||||
isEditingAvatar,
|
||||
selectedContacts,
|
||||
userAvatarData,
|
||||
}: Readonly<LeftPaneSetGroupMetadataPropsType>) {
|
||||
super();
|
||||
|
||||
|
@ -53,7 +70,9 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
|
|||
this.groupExpireTimer = groupExpireTimer;
|
||||
this.hasError = hasError;
|
||||
this.isCreating = isCreating;
|
||||
this.isEditingAvatar = isEditingAvatar;
|
||||
this.selectedContacts = selectedContacts;
|
||||
this.userAvatarData = userAvatarData;
|
||||
}
|
||||
|
||||
getHeaderContents({
|
||||
|
@ -92,19 +111,28 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
|
|||
|
||||
getPreRowsNode({
|
||||
clearGroupCreationError,
|
||||
composeDeleteAvatarFromDisk,
|
||||
composeReplaceAvatar,
|
||||
composeSaveAvatarToDisk,
|
||||
createGroup,
|
||||
i18n,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupExpireTimer,
|
||||
setComposeGroupName,
|
||||
toggleComposeEditingAvatar,
|
||||
}: Readonly<{
|
||||
clearGroupCreationError: () => 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<LeftPaneSetGr
|
|||
createGroup();
|
||||
}}
|
||||
>
|
||||
<AvatarInput
|
||||
contextMenuId="left pane group avatar uploader"
|
||||
disabled={disabled}
|
||||
{this.isEditingAvatar && (
|
||||
<Modal
|
||||
hasStickyButtons
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={toggleComposeEditingAvatar}
|
||||
title={i18n('LeftPaneSetGroupMetadataHelper__avatar-modal-title')}
|
||||
>
|
||||
<AvatarEditor
|
||||
avatarColor={avatarColor}
|
||||
avatarValue={this.groupAvatar}
|
||||
deleteAvatarFromDisk={composeDeleteAvatarFromDisk}
|
||||
i18n={i18n}
|
||||
isGroup
|
||||
onCancel={toggleComposeEditingAvatar}
|
||||
onSave={newAvatar => {
|
||||
setComposeGroupAvatar(newAvatar);
|
||||
toggleComposeEditingAvatar();
|
||||
}}
|
||||
userAvatarData={this.userAvatarData}
|
||||
replaceAvatar={composeReplaceAvatar}
|
||||
saveAvatarToDisk={composeSaveAvatarToDisk}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
<AvatarPreview
|
||||
avatarColor={avatarColor}
|
||||
avatarValue={this.groupAvatar}
|
||||
i18n={i18n}
|
||||
onChange={setComposeGroupAvatar}
|
||||
value={this.groupAvatar}
|
||||
isEditable
|
||||
isGroup
|
||||
onClick={toggleComposeEditingAvatar}
|
||||
style={{
|
||||
height: 96,
|
||||
margin: 0,
|
||||
width: 96,
|
||||
}}
|
||||
/>
|
||||
<div className="module-GroupInput--container">
|
||||
<GroupTitleInput
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue