Avatar defaults and colors

This commit is contained in:
Josh Perez 2021-08-05 20:17:05 -04:00 committed by GitHub
parent a001882d58
commit 12d2b1bf7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
140 changed files with 4212 additions and 1084 deletions

View file

@ -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,

View file

@ -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}`
)}
/>

View 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],
})}
/>
));

View 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>
</>
);
};

View 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',
})}
/>
));

View 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>;
};

View 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],
}),
})}
/>
));

View 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,
})
}
/>
</>
);
};

View file

@ -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} />;
});

View file

@ -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);
}

View file

@ -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}
/>
));

View file

@ -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}
/>
);
};

View 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',
})}
/>
));

View 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>
);
};

View 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()} />);

View 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>
);
};

View file

@ -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,

View 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 },
})}
/>
));

View 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>
);
};

View 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: '‱௸𒈙',
},
})}
/>
));

View 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>
</>
);
};

View file

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { 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()} />);

View 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"
/>
</>
);
};

View 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],
}),
})}
/>
));

View 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>
);
};

View 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],
})}
/>
));

View 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>
);
};

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { 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()} />);

View 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>
);
};

View file

@ -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"

View file

@ -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,

View file

@ -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}

View file

@ -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}

View file

@ -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',

View file

@ -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}

View file

@ -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: [],
},
})}
/>

View file

@ -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);
},

View file

@ -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>
));

View file

@ -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}>

View file

@ -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>
));

View file

@ -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>
);

View file

@ -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', () => {

View file

@ -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>
</>

View file

@ -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}
/>

View file

@ -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,

View file

@ -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,

View file

@ -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
)

View file

@ -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',

View file

@ -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,

View file

@ -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}

View file

@ -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: '🔥',
},

View file

@ -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

View file

@ -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', () => {

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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,
});

View file

@ -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>
);
};

View file

@ -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}

View file

@ -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}

View file

@ -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 '';

View file

@ -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;

View file

@ -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