Support for creating New Groups

This commit is contained in:
Evan Hahn 2021-03-03 14:09:58 -06:00 committed by Josh Perez
parent 1934120e46
commit 5de4babc0d
56 changed files with 6222 additions and 526 deletions

32
ts/components/Alert.tsx Normal file
View file

@ -0,0 +1,32 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent } from 'react';
import { LocalizerType } from '../types/Util';
import { Button } from './Button';
import { ModalHost } from './ModalHost';
type PropsType = {
title?: string;
body: string;
i18n: LocalizerType;
onClose: () => void;
};
export const Alert: FunctionComponent<PropsType> = ({
body,
i18n,
onClose,
title,
}) => (
<ModalHost onClose={onClose}>
<div className="module-Alert">
{title && <h1 className="module-Alert__title">{title}</h1>}
<p className="module-Alert__body">{body}</p>
<div className="module-Alert__button-container">
<Button onClick={onClose}>{i18n('Confirmation--confirm')}</Button>
</div>
</div>
</ModalHost>
);

View file

@ -0,0 +1,69 @@
// 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 } 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 }: { startValue: undefined | ArrayBuffer }) => {
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 (
<>
<div
style={{
background: 'rgba(255, 0, 255, 0.1)',
}}
>
<AvatarInput
contextMenuId={uuid()}
i18n={i18n}
value={value}
onChange={setValue}
/>
</div>
<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} />;
});

View file

@ -0,0 +1,213 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useRef,
useState,
useEffect,
ChangeEventHandler,
MouseEventHandler,
FunctionComponent,
} from 'react';
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';
type PropsType = {
// This ID needs to be globally unique across the app.
contextMenuId: string;
disabled?: boolean;
i18n: LocalizerType;
onChange: (value: undefined | ArrayBuffer) => unknown;
value: undefined | ArrayBuffer;
};
enum ImageStatus {
Nothing = 'nothing',
Loading = 'loading',
HasImage = 'has-image',
}
export const AvatarInput: FunctionComponent<PropsType> = ({
contextMenuId,
disabled,
i18n,
onChange,
value,
}) => {
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]);
const buttonLabel = value
? i18n('AvatarInput--change-photo-label')
: 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="module-AvatarInput"
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 (await canvasToBlob(image)).arrayBuffer();
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Couldn't convert the canvas to a Blob"));
}
}, 'image/webp');
});
}

View file

@ -0,0 +1,74 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent } from 'react';
import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { ContactName } from './conversation/ContactName';
import { Avatar, AvatarSize } from './Avatar';
export type PropsType = {
avatarPath?: string;
color?: ColorType;
firstName?: string;
i18n: LocalizerType;
id: string;
isMe?: boolean;
name?: string;
onClickRemove: (id: string) => void;
phoneNumber?: string;
profileName?: string;
title: string;
};
export const ContactPill: FunctionComponent<PropsType> = ({
avatarPath,
color,
firstName,
i18n,
id,
name,
phoneNumber,
profileName,
title,
onClickRemove,
}) => {
const removeLabel = i18n('ContactPill--remove');
return (
<div className="module-ContactPill">
<Avatar
avatarPath={avatarPath}
color={color}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={AvatarSize.TWENTY_EIGHT}
/>
<ContactName
firstName={firstName}
i18n={i18n}
module="module-ContactPill__contact-name"
name={name}
phoneNumber={phoneNumber}
preferFirstName
profileName={profileName}
title={title}
/>
<button
aria-label={removeLabel}
className="module-ContactPill__remove"
onClick={() => {
onClickRemove(id);
}}
title={removeLabel}
type="button"
/>
</div>
);
};

View file

@ -0,0 +1,87 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { times } from 'lodash';
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 { ContactPills } from './ContactPills';
import { ContactPill, PropsType as ContactPillPropsType } from './ContactPill';
import { gifUrl } from '../storybook/Fixtures';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Contact Pills', module);
type ContactType = Omit<ContactPillPropsType, 'i18n' | 'onClickRemove'>;
const contacts: Array<ContactType> = times(50, index => ({
color: 'red',
id: `contact-${index}`,
isMe: false,
name: `Contact ${index}`,
phoneNumber: '(202) 555-0001',
profileName: `C${index}`,
title: `Contact ${index}`,
}));
const contactPillProps = (
overrideProps?: ContactType
): ContactPillPropsType => ({
...(overrideProps || {
avatarPath: gifUrl,
color: 'red',
firstName: 'John',
id: 'abc123',
isMe: false,
name: 'John Bon Bon Jovi',
phoneNumber: '(202) 555-0001',
profileName: 'JohnB',
title: 'John Bon Bon Jovi',
}),
i18n,
onClickRemove: action('onClickRemove'),
});
story.add('Empty list', () => <ContactPills />);
story.add('One contact', () => (
<ContactPills>
<ContactPill {...contactPillProps()} />
</ContactPills>
));
story.add('Three contacts', () => (
<ContactPills>
<ContactPill {...contactPillProps(contacts[0])} />
<ContactPill {...contactPillProps(contacts[1])} />
<ContactPill {...contactPillProps(contacts[2])} />
</ContactPills>
));
story.add('Four contacts, one with a long name', () => (
<ContactPills>
<ContactPill {...contactPillProps(contacts[0])} />
<ContactPill
{...contactPillProps({
...contacts[1],
title:
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
})}
/>
<ContactPill {...contactPillProps(contacts[2])} />
<ContactPill {...contactPillProps(contacts[3])} />
</ContactPills>
));
story.add('Fifty contacts', () => (
<ContactPills>
{contacts.map(contact => (
<ContactPill key={contact.id} {...contactPillProps(contact)} />
))}
</ContactPills>
));

View file

@ -0,0 +1,38 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useRef,
useEffect,
Children,
FunctionComponent,
ReactNode,
} from 'react';
type PropsType = {
children?: ReactNode;
};
export const ContactPills: FunctionComponent<PropsType> = ({ children }) => {
const elRef = useRef<null | HTMLDivElement>(null);
const childCount = Children.count(children);
const previousChildCountRef = useRef<number>(childCount);
const previousChildCount = previousChildCountRef.current;
previousChildCountRef.current = childCount;
useEffect(() => {
const hasAddedNewChild = childCount > previousChildCount;
const el = elRef.current;
if (!hasAddedNewChild || !el) {
return;
}
el.scrollTop = el.scrollHeight;
}, [childCount, previousChildCount]);
return (
<div className="module-ContactPills" ref={elRef}>
{children}
</div>
);
};

View file

@ -14,6 +14,7 @@ import {
PropsData as ConversationListItemPropsType,
MessageStatuses,
} from './conversationList/ConversationListItem';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
@ -39,6 +40,15 @@ const defaultConversations: Array<ConversationListItemPropsType> = [
title: 'Marc Barraca',
type: 'direct',
},
{
id: 'long-name-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title:
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
type: 'direct',
},
];
const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
@ -52,6 +62,7 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
i18n,
onSelectConversation: action('onSelectConversation'),
onClickArchiveButton: action('onClickArchiveButton'),
onClickContactCheckbox: action('onClickContactCheckbox'),
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
<MessageSearchResult
conversationId="marc-convo"
@ -65,6 +76,7 @@ const createProps = (rows: ReadonlyArray<Row>): PropsType => ({
to={defaultConversations[1]}
/>
),
showChooseGroupMembers: action('showChooseGroupMembers'),
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
@ -144,6 +156,56 @@ story.add('Contact: group', () => (
/>
));
story.add('Contact checkboxes', () => (
<ConversationList
{...createProps([
{
type: RowType.ContactCheckbox,
contact: defaultConversations[0],
isChecked: true,
},
{
type: RowType.ContactCheckbox,
contact: defaultConversations[1],
isChecked: false,
},
{
type: RowType.ContactCheckbox,
contact: {
...defaultConversations[2],
about: '😃 Hola',
},
isChecked: true,
},
])}
/>
));
story.add('Contact checkboxes: disabled', () => (
<ConversationList
{...createProps([
{
type: RowType.ContactCheckbox,
contact: defaultConversations[0],
isChecked: false,
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
},
{
type: RowType.ContactCheckbox,
contact: defaultConversations[1],
isChecked: false,
disabledReason: ContactCheckboxDisabledReason.NotCapable,
},
{
type: RowType.ContactCheckbox,
contact: defaultConversations[2],
isChecked: true,
disabledReason: ContactCheckboxDisabledReason.MaximumContactsSelected,
},
])}
/>
));
{
const createConversation = (
overrideProps: Partial<ConversationListItemPropsType> = {}

View file

@ -16,13 +16,21 @@ import {
ContactListItem,
PropsDataType as ContactListItemPropsType,
} from './conversationList/ContactListItem';
import {
ContactCheckbox as ContactCheckboxComponent,
ContactCheckboxDisabledReason,
} from './conversationList/ContactCheckbox';
import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { Spinner as SpinnerComponent } from './Spinner';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
export enum RowType {
ArchiveButton,
Blank,
Contact,
ContactCheckbox,
Conversation,
CreateNewGroup,
Header,
MessageSearchResult,
Spinner,
@ -34,9 +42,19 @@ type ArchiveButtonRowType = {
archivedConversationsCount: number;
};
type BlankRowType = { type: RowType.Blank };
type ContactRowType = {
type: RowType.Contact;
contact: ContactListItemPropsType;
isClickable?: boolean;
};
type ContactCheckboxRowType = {
type: RowType.ContactCheckbox;
contact: ContactListItemPropsType;
isChecked: boolean;
disabledReason?: ContactCheckboxDisabledReason;
};
type ConversationRowType = {
@ -44,6 +62,10 @@ type ConversationRowType = {
conversation: ConversationListItemPropsType;
};
type CreateNewGroupRowType = {
type: RowType.CreateNewGroup;
};
type MessageRowType = {
type: RowType.MessageSearchResult;
messageId: string;
@ -63,8 +85,11 @@ type StartNewConversationRowType = {
export type Row =
| ArchiveButtonRowType
| BlankRowType
| ContactRowType
| ContactCheckboxRowType
| ConversationRowType
| CreateNewGroupRowType
| MessageRowType
| HeaderRowType
| SpinnerRowType
@ -85,9 +110,14 @@ export type PropsType = {
i18n: LocalizerType;
onSelectConversation: (conversationId: string, messageId?: string) => void;
onClickArchiveButton: () => void;
onClickContactCheckbox: (
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => void;
onSelectConversation: (conversationId: string, messageId?: string) => void;
renderMessageSearchResult: (id: string, style: CSSProperties) => JSX.Element;
showChooseGroupMembers: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
};
@ -96,11 +126,13 @@ export const ConversationList: React.FC<PropsType> = ({
getRow,
i18n,
onClickArchiveButton,
onClickContactCheckbox,
onSelectConversation,
renderMessageSearchResult,
rowCount,
scrollToRowIndex,
shouldRecomputeRowHeights,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
}) => {
const listRef = useRef<null | List>(null);
@ -148,13 +180,29 @@ export const ConversationList: React.FC<PropsType> = ({
</span>
</button>
);
case RowType.Contact:
case RowType.Blank:
return <div key={key} style={style} />;
case RowType.Contact: {
const { isClickable = true } = row;
return (
<ContactListItem
{...row.contact}
key={key}
style={style}
onClick={onSelectConversation}
onClick={isClickable ? onSelectConversation : undefined}
i18n={i18n}
/>
);
}
case RowType.ContactCheckbox:
return (
<ContactCheckboxComponent
{...row.contact}
isChecked={row.isChecked}
disabledReason={row.disabledReason}
key={key}
style={style}
onClick={onClickContactCheckbox}
i18n={i18n}
/>
);
@ -168,6 +216,15 @@ export const ConversationList: React.FC<PropsType> = ({
i18n={i18n}
/>
);
case RowType.CreateNewGroup:
return (
<CreateNewGroupButton
i18n={i18n}
key={key}
onClick={showChooseGroupMembers}
style={style}
/>
);
case RowType.Header:
return (
<div
@ -214,8 +271,10 @@ export const ConversationList: React.FC<PropsType> = ({
getRow,
i18n,
onClickArchiveButton,
onClickContactCheckbox,
onSelectConversation,
renderMessageSearchResult,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
]
);

View file

@ -0,0 +1,120 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild, ReactNode } from 'react';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { ModalHost } from './ModalHost';
import { Button, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar';
import { ContactName } from './conversation/ContactName';
type PropsType = {
children: ReactNode;
i18n: LocalizerType;
onClickPrimaryButton: () => void;
onClose: () => void;
primaryButtonText: string;
title: string;
} & (
| // We use this empty type for an "all or nothing" setup.
// eslint-disable-next-line @typescript-eslint/ban-types
{}
| {
onClickSecondaryButton: () => void;
secondaryButtonText: string;
}
);
export function GroupDialog(props: Readonly<PropsType>): JSX.Element {
const {
children,
i18n,
onClickPrimaryButton,
onClose,
primaryButtonText,
title,
} = props;
let secondaryButton: undefined | ReactChild;
if ('secondaryButtonText' in props) {
const { onClickSecondaryButton, secondaryButtonText } = props;
secondaryButton = (
<Button
onClick={onClickSecondaryButton}
variant={ButtonVariant.Secondary}
>
{secondaryButtonText}
</Button>
);
}
return (
<ModalHost onClose={onClose}>
<div className="module-GroupDialog">
<button
aria-label={i18n('close')}
type="button"
className="module-GroupDialog__close-button"
onClick={() => {
onClose();
}}
/>
<h1 className="module-GroupDialog__title">{title}</h1>
<div className="module-GroupDialog__body">{children}</div>
<div className="module-GroupDialog__button-container">
{secondaryButton}
<Button
onClick={onClickPrimaryButton}
ref={focusRef}
variant={ButtonVariant.Primary}
>
{primaryButtonText}
</Button>
</div>
</div>
</ModalHost>
);
}
type ParagraphPropsType = {
children: ReactNode;
};
GroupDialog.Paragraph = ({
children,
}: Readonly<ParagraphPropsType>): JSX.Element => (
<p className="module-GroupDialog__paragraph">{children}</p>
);
type ContactsPropsType = {
contacts: Array<ConversationType>;
i18n: LocalizerType;
};
GroupDialog.Contacts = ({ contacts, i18n }: Readonly<ContactsPropsType>) => (
<ul className="module-GroupDialog__contacts">
{contacts.map(contact => (
<li key={contact.id} className="module-GroupDialog__contacts__contact">
<Avatar
{...contact}
conversationType={contact.type}
size={AvatarSize.TWENTY_EIGHT}
i18n={i18n}
/>
<ContactName
i18n={i18n}
module="module-GroupDialog__contacts__contact__name"
title={contact.title}
/>
</li>
))}
</ul>
);
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}

View file

@ -2,10 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { Avatar } from './Avatar';
import { GroupDialog } from './GroupDialog';
import { sortByTitle } from '../util/sortByTitle';
type CallbackType = () => unknown;
@ -25,61 +24,64 @@ export type HousekeepingPropsType = {
export type PropsType = DataPropsType & HousekeepingPropsType;
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const GroupV1MigrationDialog: React.FunctionComponent<PropsType> = React.memo(
(props: PropsType) => {
const {
areWeInvited,
droppedMembers,
hasMigrated,
i18n,
invitedMembers,
migrate,
onClose,
} = props;
export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
const {
areWeInvited,
droppedMembers,
hasMigrated,
i18n,
invitedMembers,
migrate,
onClose,
} = props;
const title = hasMigrated
? i18n('GroupV1--Migration--info--title')
: i18n('GroupV1--Migration--migrate--title');
const keepHistory = hasMigrated
? i18n('GroupV1--Migration--info--keep-history')
: i18n('GroupV1--Migration--migrate--keep-history');
const migrationKey = hasMigrated ? 'after' : 'before';
const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`;
const title = hasMigrated
? i18n('GroupV1--Migration--info--title')
: i18n('GroupV1--Migration--migrate--title');
const keepHistory = hasMigrated
? i18n('GroupV1--Migration--info--keep-history')
: i18n('GroupV1--Migration--migrate--keep-history');
const migrationKey = hasMigrated ? 'after' : 'before';
const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`;
let primaryButtonText: string;
let onClickPrimaryButton: () => void;
let secondaryButtonProps:
| undefined
| {
secondaryButtonText: string;
onClickSecondaryButton: () => void;
};
if (hasMigrated) {
primaryButtonText = i18n('Confirmation--confirm');
onClickPrimaryButton = onClose;
} else {
primaryButtonText = i18n('GroupV1--Migration--migrate');
onClickPrimaryButton = migrate;
secondaryButtonProps = {
secondaryButtonText: i18n('cancel'),
onClickSecondaryButton: onClose,
};
}
return (
<div className="module-group-v2-migration-dialog">
<button
aria-label={i18n('close')}
type="button"
className="module-group-v2-migration-dialog__close-button"
onClick={onClose}
/>
<div className="module-group-v2-migration-dialog__title">{title}</div>
<div className="module-group-v2-migration-dialog__scrollable">
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{i18n('GroupV1--Migration--info--summary')}
</div>
</div>
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{keepHistory}
</div>
</div>
return (
<GroupDialog
i18n={i18n}
onClickPrimaryButton={onClickPrimaryButton}
onClose={onClose}
primaryButtonText={primaryButtonText}
title={title}
{...secondaryButtonProps}
>
<GroupDialog.Paragraph>
{i18n('GroupV1--Migration--info--summary')}
</GroupDialog.Paragraph>
<GroupDialog.Paragraph>{keepHistory}</GroupDialog.Paragraph>
{areWeInvited ? (
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{i18n('GroupV1--Migration--info--invited--you')}
</div>
</div>
<GroupDialog.Paragraph>
{i18n('GroupV1--Migration--info--invited--you')}
</GroupDialog.Paragraph>
) : (
<>
{renderMembers(
@ -90,67 +92,16 @@ export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
{renderMembers(droppedMembers, droppedMembersKey, i18n)}
</>
)}
</div>
{renderButtons(hasMigrated, onClose, migrate, i18n)}
</div>
);
});
function renderButtons(
hasMigrated: boolean,
onClose: CallbackType,
migrate: CallbackType,
i18n: LocalizerType
) {
if (hasMigrated) {
return (
<div
className={classNames(
'module-group-v2-migration-dialog__buttons',
'module-group-v2-migration-dialog__buttons--narrow'
)}
>
<button
className="module-group-v2-migration-dialog__button"
ref={focusRef}
type="button"
onClick={onClose}
>
{i18n('Confirmation--confirm')}
</button>
</div>
</GroupDialog>
);
}
return (
<div className="module-group-v2-migration-dialog__buttons">
<button
className={classNames(
'module-group-v2-migration-dialog__button',
'module-group-v2-migration-dialog__button--secondary'
)}
type="button"
onClick={onClose}
>
{i18n('cancel')}
</button>
<button
className="module-group-v2-migration-dialog__button"
ref={focusRef}
type="button"
onClick={migrate}
>
{i18n('GroupV1--Migration--migrate')}
</button>
</div>
);
}
);
function renderMembers(
members: Array<ConversationType>,
prefix: string,
i18n: LocalizerType
): React.ReactElement | null {
): React.ReactNode {
if (!members.length) {
return null;
}
@ -159,27 +110,9 @@ function renderMembers(
const key = `${prefix}${postfix}`;
return (
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
<div>{i18n(key)}</div>
{sortByTitle(members).map(member => (
<div
key={member.id}
className="module-group-v2-migration-dialog__member"
>
<Avatar
{...member}
conversationType={member.type}
size={28}
i18n={i18n}
/>{' '}
<span className="module-group-v2-migration-dialog__member__name">
{member.title}
</span>
</div>
))}
</div>
</div>
<>
<GroupDialog.Paragraph>{i18n(key)}</GroupDialog.Paragraph>
<GroupDialog.Contacts contacts={sortByTitle(members)} i18n={i18n} />
</>
);
}

View file

@ -77,6 +77,12 @@ const defaultModeSpecificProps = {
const emptySearchResultsGroup = { isLoading: false, results: [] };
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
cantAddContactToGroup: action('cantAddContactToGroup'),
clearGroupCreationError: action('clearGroupCreationError'),
closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'),
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'),
createGroup: action('createGroup'),
i18n,
modeSpecificProps: defaultModeSpecificProps,
openConversationInternal: action('openConversationInternal'),
@ -102,12 +108,19 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
selectedConversationId: undefined,
selectedMessageId: undefined,
setComposeSearchTerm: action('setComposeSearchTerm'),
setComposeGroupAvatar: action('setComposeGroupAvatar'),
setComposeGroupName: action('setComposeGroupName'),
showArchivedConversations: action('showArchivedConversations'),
showInbox: action('showInbox'),
startComposing: action('startComposing'),
showChooseGroupMembers: action('showChooseGroupMembers'),
startNewConversationFromPhoneNumber: action(
'startNewConversationFromPhoneNumber'
),
startSettingGroupMetadata: action('startSettingGroupMetadata'),
toggleConversationInChooseMembers: action(
'toggleConversationInChooseMembers'
),
...overrideProps,
});

View file

@ -26,18 +26,29 @@ import {
LeftPaneComposeHelper,
LeftPaneComposePropsType,
} from './leftPane/LeftPaneComposeHelper';
import {
LeftPaneChooseGroupMembersHelper,
LeftPaneChooseGroupMembersPropsType,
} from './leftPane/LeftPaneChooseGroupMembersHelper';
import {
LeftPaneSetGroupMetadataHelper,
LeftPaneSetGroupMetadataPropsType,
} from './leftPane/LeftPaneSetGroupMetadataHelper';
import * as OS from '../OS';
import { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
export enum LeftPaneMode {
Inbox,
Search,
Archive,
Compose,
ChooseGroupMembers,
SetGroupMetadata,
}
export type PropsType = {
@ -56,23 +67,40 @@ export type PropsType = {
} & LeftPaneArchivePropsType)
| ({
mode: LeftPaneMode.Compose;
} & LeftPaneComposePropsType);
} & LeftPaneComposePropsType)
| ({
mode: LeftPaneMode.ChooseGroupMembers;
} & LeftPaneChooseGroupMembersPropsType)
| ({
mode: LeftPaneMode.SetGroupMetadata;
} & LeftPaneSetGroupMetadataPropsType);
i18n: LocalizerType;
selectedConversationId: undefined | string;
selectedMessageId: undefined | string;
regionCode: string;
// Action Creators
cantAddContactToGroup: (conversationId: string) => void;
clearGroupCreationError: () => void;
closeCantAddContactToGroupModal: () => void;
closeMaximumGroupSizeModal: () => void;
closeRecommendedGroupSizeModal: () => void;
createGroup: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
openConversationInternal: (_: {
conversationId: string;
messageId?: string;
switchToAssociatedView?: boolean;
}) => void;
setComposeSearchTerm: (composeSearchTerm: string) => void;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => void;
setComposeGroupName: (_: string) => void;
showArchivedConversations: () => void;
showInbox: () => void;
startComposing: () => void;
setComposeSearchTerm: (composeSearchTerm: string) => void;
showChooseGroupMembers: () => void;
startSettingGroupMetadata: () => void;
toggleConversationInChooseMembers: (conversationId: string) => void;
// Render Props
renderExpiredBuildDialog: () => JSX.Element;
@ -84,6 +112,12 @@ export type PropsType = {
};
export const LeftPane: React.FC<PropsType> = ({
cantAddContactToGroup,
clearGroupCreationError,
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
createGroup,
i18n,
modeSpecificProps,
openConversationInternal,
@ -96,10 +130,15 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId,
selectedMessageId,
setComposeSearchTerm,
setComposeGroupAvatar,
setComposeGroupName,
showArchivedConversations,
showInbox,
startComposing,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startSettingGroupMetadata,
toggleConversationInChooseMembers,
}) => {
const previousModeSpecificPropsRef = useRef(modeSpecificProps);
const previousModeSpecificProps = previousModeSpecificPropsRef.current;
@ -162,6 +201,32 @@ export const LeftPane: React.FC<PropsType> = ({
helper = composeHelper;
break;
}
case LeftPaneMode.ChooseGroupMembers: {
const chooseGroupMembersHelper = new LeftPaneChooseGroupMembersHelper(
modeSpecificProps
);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? chooseGroupMembersHelper.shouldRecomputeRowHeights(
previousModeSpecificProps
)
: true;
helper = chooseGroupMembersHelper;
break;
}
case LeftPaneMode.SetGroupMetadata: {
const setGroupMetadataHelper = new LeftPaneSetGroupMetadataHelper(
modeSpecificProps
);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? setGroupMetadataHelper.shouldRecomputeRowHeights(
previousModeSpecificProps
)
: true;
helper = setGroupMetadataHelper;
break;
}
default:
throw missingCaseError(modeSpecificProps);
}
@ -245,11 +310,25 @@ export const LeftPane: React.FC<PropsType> = ({
]);
const preRowsNode = helper.getPreRowsNode({
clearGroupCreationError,
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
createGroup,
i18n,
setComposeGroupAvatar,
setComposeGroupName,
onChangeComposeSearchTerm: event => {
setComposeSearchTerm(event.target.value);
},
removeSelectedContact: toggleConversationInChooseMembers,
});
const footerContents = helper.getFooterContents({
createGroup,
i18n,
startSettingGroupMetadata,
});
const getRow = useMemo(() => helper.getRow.bind(helper), [helper]);
// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
@ -261,7 +340,12 @@ export const LeftPane: React.FC<PropsType> = ({
return (
<div className="module-left-pane">
<div className="module-left-pane__header">
{helper.getHeaderContents({ i18n, showInbox }) || renderMainHeader()}
{helper.getHeaderContents({
i18n,
showInbox,
startComposing,
showChooseGroupMembers,
}) || renderMainHeader()}
</div>
{renderExpiredBuildDialog()}
{renderRelinkDialog()}
@ -288,6 +372,24 @@ export const LeftPane: React.FC<PropsType> = ({
getRow={getRow}
i18n={i18n}
onClickArchiveButton={showArchivedConversations}
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleConversationInChooseMembers(conversationId);
break;
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// This is a no-op.
break;
case ContactCheckboxDisabledReason.NotCapable:
cantAddContactToGroup(conversationId);
break;
default:
throw missingCaseError(disabledReason);
}
}}
onSelectConversation={(
conversationId: string,
messageId?: string
@ -304,6 +406,7 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId
)}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers}
startNewConversationFromPhoneNumber={
startNewConversationFromPhoneNumber
}
@ -313,6 +416,9 @@ export const LeftPane: React.FC<PropsType> = ({
</div>
)}
</Measure>
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)}
</div>
);
};

View file

@ -0,0 +1,54 @@
// 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 { NewlyCreatedGroupInvitedContactsDialog } from './NewlyCreatedGroupInvitedContactsDialog';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { ConversationType } from '../state/ducks/conversations';
const i18n = setupI18n('en', enMessages);
const conversations: Array<ConversationType> = [
{
id: 'fred-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Fred Willard',
type: 'direct',
},
{
id: 'marc-convo',
isSelected: true,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Marc Barraca',
type: 'direct',
},
];
const story = storiesOf(
'Components/NewlyCreatedGroupInvitedContactsDialog',
module
);
story.add('One contact', () => (
<NewlyCreatedGroupInvitedContactsDialog
contacts={[conversations[0]]}
i18n={i18n}
onClose={action('onClose')}
/>
));
story.add('Two contacts', () => (
<NewlyCreatedGroupInvitedContactsDialog
contacts={conversations}
i18n={i18n}
onClose={action('onClose')}
/>
));

View file

@ -0,0 +1,80 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent, ReactNode } from 'react';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName';
import { GroupDialog } from './GroupDialog';
type PropsType = {
contacts: Array<ConversationType>;
i18n: LocalizerType;
onClose: () => void;
};
export const NewlyCreatedGroupInvitedContactsDialog: FunctionComponent<PropsType> = ({
contacts,
i18n,
onClose,
}) => {
let title: string;
let body: ReactNode;
if (contacts.length === 1) {
const contact = contacts[0];
title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--one');
body = (
<>
<GroupDialog.Paragraph>
<Intl
i18n={i18n}
id="NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--one"
components={[<ContactName i18n={i18n} title={contact.title} />]}
/>
</GroupDialog.Paragraph>
<GroupDialog.Paragraph>
{i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')}
</GroupDialog.Paragraph>
</>
);
} else {
title = i18n('NewlyCreatedGroupInvitedContactsDialog--title--many', [
contacts.length.toString(),
]);
body = (
<>
<GroupDialog.Paragraph>
{i18n(
'NewlyCreatedGroupInvitedContactsDialog--body--user-paragraph--many'
)}
</GroupDialog.Paragraph>
<GroupDialog.Paragraph>
{i18n('NewlyCreatedGroupInvitedContactsDialog--body--info-paragraph')}
</GroupDialog.Paragraph>
<GroupDialog.Contacts contacts={contacts} i18n={i18n} />
</>
);
}
return (
<GroupDialog
i18n={i18n}
onClickPrimaryButton={onClose}
primaryButtonText={i18n('Confirmation--confirm')}
secondaryButtonText={i18n(
'NewlyCreatedGroupInvitedContactsDialog--body--learn-more'
)}
onClickSecondaryButton={() => {
window.location.href =
'https://support.signal.org/hc/articles/360007319331-Group-chats';
}}
onClose={onClose}
title={title}
>
{body}
</GroupDialog>
);
};

View file

@ -7,20 +7,34 @@ import { LocalizerType } from '../../types/Util';
import { Emojify } from './Emojify';
export type PropsType = {
firstName?: string;
i18n: LocalizerType;
title: string;
module?: string;
name?: string;
phoneNumber?: string;
preferFirstName?: boolean;
profileName?: string;
title: string;
};
export const ContactName = ({ module, title }: PropsType): JSX.Element => {
export const ContactName = ({
firstName,
module,
preferFirstName,
title,
}: PropsType): JSX.Element => {
const prefix = module || 'module-contact-name';
let text: string;
if (preferFirstName) {
text = firstName || title || '';
} else {
text = title || '';
}
return (
<span className={prefix} dir="auto">
<Emojify text={title || ''} />
<Emojify text={text} />
</span>
);
};

View file

@ -7,7 +7,6 @@ import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { Intl } from '../Intl';
import { ContactName } from './ContactName';
import { ModalHost } from '../ModalHost';
import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog';
export type PropsDataType = {
@ -58,19 +57,17 @@ export function GroupV1Migration(props: PropsType): React.ReactElement {
{i18n('GroupV1--Migration--learn-more')}
</button>
{showingDialog ? (
<ModalHost onClose={dismissDialog}>
<GroupV1MigrationDialog
areWeInvited={areWeInvited}
droppedMembers={droppedMembers}
hasMigrated
i18n={i18n}
invitedMembers={invitedMembers}
migrate={() =>
window.log.warn('GroupV1Migration: Modal called migrate()')
}
onClose={dismissDialog}
/>
</ModalHost>
<GroupV1MigrationDialog
areWeInvited={areWeInvited}
droppedMembers={droppedMembers}
hasMigrated
i18n={i18n}
invitedMembers={invitedMembers}
migrate={() =>
window.log.warn('GroupV1Migration: Modal called migrate()')
}
onClose={dismissDialog}
/>
) : null}
</div>
);

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -211,6 +211,9 @@ const items: Record<string, TimelineItemType> = {
const actions = () => ({
clearChangedMessages: action('clearChangedMessages'),
clearInvitedConversationsForNewlyCreatedGroup: action(
'clearInvitedConversationsForNewlyCreatedGroup'
),
setLoadCountdownStart: action('setLoadCountdownStart'),
setIsNearBottom: action('setIsNearBottom'),
loadAndScroll: action('loadAndScroll'),
@ -299,6 +302,8 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
oldestUnreadIndex:
number('oldestUnreadIndex', overrideProps.oldestUnreadIndex || 0) ||
undefined,
invitedContactsForNewlyCreatedGroup:
overrideProps.invitedContactsForNewlyCreatedGroup || [],
id: '',
renderItem,
@ -361,3 +366,22 @@ story.add('Without Oldest Message', () => {
return <Timeline {...props} />;
});
story.add('With invited contacts for a newly-created group', () => {
const props = createProps({
invitedContactsForNewlyCreatedGroup: [
{
id: 'abc123',
title: 'John Bon Bon Jovi',
type: 'direct',
},
{
id: 'def456',
title: 'Bon John Bon Jovi',
type: 'direct',
},
],
});
return <Timeline {...props} />;
});

View file

@ -15,9 +15,11 @@ import {
import { ScrollDownButton } from './ScrollDownButton';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog';
const AT_BOTTOM_THRESHOLD = 15;
const NEAR_BOTTOM_THRESHOLD = 15;
@ -48,6 +50,7 @@ type PropsHousekeepingType = {
isGroupV1AndDisabled?: boolean;
selectedMessageId?: string;
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
i18n: LocalizerType;
@ -68,6 +71,7 @@ type PropsHousekeepingType = {
type PropsActionsType = {
clearChangedMessages: (conversationId: string) => unknown;
clearInvitedConversationsForNewlyCreatedGroup: () => void;
setLoadCountdownStart: (
conversationId: string,
loadCountdownStart?: number
@ -1063,7 +1067,14 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
};
public render(): JSX.Element | null {
const { i18n, id, items, isGroupV1AndDisabled } = this.props;
const {
clearInvitedConversationsForNewlyCreatedGroup,
i18n,
id,
items,
isGroupV1AndDisabled,
invitedContactsForNewlyCreatedGroup,
} = this.props;
const {
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
@ -1077,60 +1088,70 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
}
return (
<div
className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
)}
role="presentation"
tabIndex={-1}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
>
<AutoSizer>
{({ height, width }) => {
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
this.resizeFlag = true;
<>
<div
className={classNames(
'module-timeline',
isGroupV1AndDisabled ? 'module-timeline--disabled' : null
)}
role="presentation"
tabIndex={-1}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
>
<AutoSizer>
{({ height, width }) => {
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
this.resizeFlag = true;
setTimeout(this.resize, 0);
} else if (
this.mostRecentHeight &&
this.mostRecentHeight !== height
) {
setTimeout(this.onHeightOnlyChange, 0);
}
setTimeout(this.resize, 0);
} else if (
this.mostRecentHeight &&
this.mostRecentHeight !== height
) {
setTimeout(this.onHeightOnlyChange, 0);
}
this.mostRecentWidth = width;
this.mostRecentHeight = height;
this.mostRecentWidth = width;
this.mostRecentHeight = height;
return (
<List
deferredMeasurementCache={this.cellSizeCache}
height={height}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScroll={this.onScroll as any}
overscanRowCount={10}
ref={this.listRef}
rowCount={rowCount}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer}
scrollToAlignment="start"
scrollToIndex={scrollToIndex}
tabIndex={-1}
width={width}
/>
);
}}
</AutoSizer>
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
return (
<List
deferredMeasurementCache={this.cellSizeCache}
height={height}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScroll={this.onScroll as any}
overscanRowCount={10}
ref={this.listRef}
rowCount={rowCount}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer}
scrollToAlignment="start"
scrollToIndex={scrollToIndex}
tabIndex={-1}
width={width}
/>
);
}}
</AutoSizer>
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
i18n={i18n}
/>
) : null}
</div>
{Boolean(invitedContactsForNewlyCreatedGroup.length) && (
<NewlyCreatedGroupInvitedContactsDialog
contacts={invitedContactsForNewlyCreatedGroup}
i18n={i18n}
onClose={clearInvitedConversationsForNewlyCreatedGroup}
/>
) : null}
</div>
)}
</>
);
}
}

View file

@ -20,11 +20,14 @@ export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`;
const TIMESTAMP_CLASS_NAME = `${DATE_CLASS_NAME}__timestamp`;
export const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
type PropsType = {
avatarPath?: string;
checked?: boolean;
color?: ColorType;
conversationType: 'group' | 'direct';
disabled?: boolean;
headerDate?: number;
headerName: ReactNode;
i18n: LocalizerType;
@ -37,7 +40,7 @@ type PropsType = {
messageStatusIcon?: ReactNode;
messageText?: ReactNode;
name?: string;
onClick: () => void;
onClick?: () => void;
phoneNumber?: string;
profileName?: string;
style: CSSProperties;
@ -48,8 +51,10 @@ type PropsType = {
export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo(
({
avatarPath,
checked,
color,
conversationType,
disabled,
headerDate,
headerName,
i18n,
@ -74,17 +79,32 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
? isNoteToSelf
: Boolean(isMe);
return (
<button
type="button"
onClick={onClick}
style={style}
className={classNames(BASE_CLASS_NAME, {
[`${BASE_CLASS_NAME}--has-unread`]: isUnread,
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
})}
data-id={id ? cleanId(id) : undefined}
>
const isCheckbox = isBoolean(checked);
let checkboxNode: ReactNode;
if (isCheckbox) {
let ariaLabel: string;
if (disabled) {
ariaLabel = i18n('cannotSelectContact');
} else if (checked) {
ariaLabel = i18n('deselectContact');
} else {
ariaLabel = i18n('selectContact');
}
checkboxNode = (
<input
aria-label={ariaLabel}
checked={checked}
className={CHECKBOX_CLASS_NAME}
disabled={disabled}
onChange={onClick}
type="checkbox"
/>
);
}
const contents = (
<>
<div className={`${BASE_CLASS_NAME}__avatar-container`}>
<Avatar
avatarPath={avatarPath}
@ -104,7 +124,12 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
</div>
)}
</div>
<div className={CONTENT_CLASS_NAME}>
<div
className={classNames(
CONTENT_CLASS_NAME,
disabled && `${CONTENT_CLASS_NAME}--disabled`
)}
>
<div className={HEADER_CLASS_NAME}>
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
{isNumber(headerDate) && (
@ -137,7 +162,61 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
</div>
) : null}
</div>
</button>
{checkboxNode}
</>
);
const commonClassNames = classNames(BASE_CLASS_NAME, {
[`${BASE_CLASS_NAME}--has-unread`]: isUnread,
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
});
if (isCheckbox) {
return (
<label
className={classNames(
commonClassNames,
`${BASE_CLASS_NAME}--is-checkbox`,
{ [`${BASE_CLASS_NAME}--is-checkbox--disabled`]: disabled }
)}
data-id={id ? cleanId(id) : undefined}
style={style}
// `onClick` is will double-fire if we're enabled. We want it to fire when we're
// disabled so we can show any "can't add contact" modals, etc. This won't
// work for keyboard users, though, because labels are not tabbable.
{...(disabled ? { onClick } : {})}
>
{contents}
</label>
);
}
if (onClick) {
return (
<button
className={classNames(
commonClassNames,
`${BASE_CLASS_NAME}--is-button`
)}
data-id={id ? cleanId(id) : undefined}
disabled={disabled}
onClick={onClick}
style={style}
type="button"
>
{contents}
</button>
);
}
return (
<div
className={commonClassNames}
data-id={id ? cleanId(id) : undefined}
style={style}
>
{contents}
</div>
);
}
);

View file

@ -0,0 +1,97 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ColorType } from '../../types/Colors';
import { LocalizerType } from '../../types/Util';
import { ContactName } from '../conversation/ContactName';
import { About } from '../conversation/About';
export enum ContactCheckboxDisabledReason {
// We start the enum at 1 because the default starting value of 0 is falsy.
MaximumContactsSelected = 1,
NotCapable,
}
export type PropsDataType = {
about?: string;
avatarPath?: string;
color?: ColorType;
disabledReason?: ContactCheckboxDisabledReason;
id: string;
isChecked: boolean;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
type PropsHousekeepingType = {
i18n: LocalizerType;
style: CSSProperties;
onClick: (
id: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => void;
};
type PropsType = PropsDataType & PropsHousekeepingType;
export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
({
about,
avatarPath,
color,
disabledReason,
i18n,
id,
isChecked,
name,
onClick,
phoneNumber,
profileName,
style,
title,
}) => {
const disabled = Boolean(disabledReason);
const headerName = (
<ContactName
phoneNumber={phoneNumber}
name={name}
profileName={profileName}
title={title}
i18n={i18n}
/>
);
const messageText = about ? <About className="" text={about} /> : null;
const onClickItem = () => {
onClick(id, disabledReason);
};
return (
<BaseConversationListItem
avatarPath={avatarPath}
checked={isChecked}
color={color}
conversationType="direct"
disabled={disabled}
headerName={headerName}
i18n={i18n}
id={id}
isSelected={false}
messageText={messageText}
name={name}
onClick={onClickItem}
phoneNumber={phoneNumber}
profileName={profileName}
style={style}
title={title}
/>
);
}
);

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, CSSProperties, FunctionComponent } from 'react';
import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ColorType } from '../../types/Colors';
@ -25,7 +25,7 @@ export type PropsDataType = {
type PropsHousekeepingType = {
i18n: LocalizerType;
style: CSSProperties;
onClick: (id: string) => void;
onClick?: (id: string) => void;
};
type PropsType = PropsDataType & PropsHousekeepingType;
@ -61,8 +61,6 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
const messageText =
about && !isMe ? <About className="" text={about} /> : null;
const onClickItem = useCallback(() => onClick(id), [onClick, id]);
return (
<BaseConversationListItem
avatarPath={avatarPath}
@ -75,7 +73,7 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
isSelected={false}
messageText={messageText}
name={name}
onClick={onClickItem}
onClick={onClick ? () => onClick(id) : undefined}
phoneNumber={phoneNumber}
profileName={profileName}
style={style}

View file

@ -0,0 +1,32 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { LocalizerType } from '../../types/Util';
type PropsType = {
i18n: LocalizerType;
onClick: () => void;
style: CSSProperties;
};
export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo(
({ i18n, onClick, style }) => {
const title = i18n('createNewGroupButton');
return (
<BaseConversationListItem
color="grey"
conversationType="group"
headerName={title}
i18n={i18n}
isSelected={false}
onClick={onClick}
style={style}
title={title}
/>
);
}
);

View file

@ -0,0 +1,304 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild, ChangeEvent } from 'react';
import { LeftPaneHelper } from './LeftPaneHelper';
import { Row, RowType } from '../ConversationList';
import { ConversationType } from '../../state/ducks/conversations';
import { ContactCheckboxDisabledReason } from '../conversationList/ContactCheckbox';
import { ContactPills } from '../ContactPills';
import { ContactPill } from '../ContactPill';
import { Alert } from '../Alert';
import { Button } from '../Button';
import { LocalizerType } from '../../types/Util';
import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
export type LeftPaneChooseGroupMembersPropsType = {
candidateContacts: ReadonlyArray<ConversationType>;
cantAddContactForModal: undefined | ConversationType;
isShowingRecommendedGroupSizeModal: boolean;
isShowingMaximumGroupSizeModal: boolean;
searchTerm: string;
selectedContacts: Array<ConversationType>;
};
/* eslint-disable class-methods-use-this */
export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<
LeftPaneChooseGroupMembersPropsType
> {
private readonly candidateContacts: ReadonlyArray<ConversationType>;
private readonly cantAddContactForModal:
| undefined
| Readonly<{ title: string }>;
private readonly isShowingMaximumGroupSizeModal: boolean;
private readonly isShowingRecommendedGroupSizeModal: boolean;
private readonly searchTerm: string;
private readonly selectedContacts: Array<ConversationType>;
private readonly selectedConversationIdsSet: Set<string>;
constructor({
candidateContacts,
cantAddContactForModal,
isShowingMaximumGroupSizeModal,
isShowingRecommendedGroupSizeModal,
searchTerm,
selectedContacts,
}: Readonly<LeftPaneChooseGroupMembersPropsType>) {
super();
this.candidateContacts = candidateContacts;
this.cantAddContactForModal = cantAddContactForModal;
this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal;
this.isShowingRecommendedGroupSizeModal = isShowingRecommendedGroupSizeModal;
this.searchTerm = searchTerm;
this.selectedContacts = selectedContacts;
this.selectedConversationIdsSet = new Set(
selectedContacts.map(contact => contact.id)
);
}
getHeaderContents({
i18n,
startComposing,
}: Readonly<{
i18n: LocalizerType;
startComposing: () => void;
}>): ReactChild {
const backButtonLabel = i18n('chooseGroupMembers__back-button');
return (
<div className="module-left-pane__header__contents">
<button
aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button"
onClick={startComposing}
title={backButtonLabel}
type="button"
/>
<div className="module-left-pane__header__contents__text">
{i18n('chooseGroupMembers__title')}
</div>
</div>
);
}
getPreRowsNode({
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
i18n,
onChangeComposeSearchTerm,
removeSelectedContact,
}: Readonly<{
closeCantAddContactToGroupModal: () => unknown;
closeMaximumGroupSizeModal: () => unknown;
closeRecommendedGroupSizeModal: () => unknown;
i18n: LocalizerType;
onChangeComposeSearchTerm: (
event: ChangeEvent<HTMLInputElement>
) => unknown;
removeSelectedContact: (conversationId: string) => unknown;
}>): ReactChild {
let modalDetails:
| undefined
| { title: string; body: string; onClose: () => void };
if (this.isShowingMaximumGroupSizeModal) {
modalDetails = {
title: i18n('chooseGroupMembers__maximum-group-size__title'),
body: i18n('chooseGroupMembers__maximum-group-size__body', [
this.getMaximumNumberOfContacts().toString(),
]),
onClose: closeMaximumGroupSizeModal,
};
} else if (this.isShowingRecommendedGroupSizeModal) {
modalDetails = {
title: i18n(
'chooseGroupMembers__maximum-recommended-group-size__title'
),
body: i18n('chooseGroupMembers__maximum-recommended-group-size__body', [
this.getRecommendedMaximumNumberOfContacts().toString(),
]),
onClose: closeRecommendedGroupSizeModal,
};
} else if (this.cantAddContactForModal) {
modalDetails = {
title: i18n('chooseGroupMembers__cant-add-member__title'),
body: i18n('chooseGroupMembers__cant-add-member__body', [
this.cantAddContactForModal.title,
]),
onClose: closeCantAddContactToGroupModal,
};
}
return (
<>
<div className="module-left-pane__compose-search-form">
<input
type="text"
ref={focusRef}
className="module-left-pane__compose-search-form__input"
placeholder={i18n('newConversationContactSearchPlaceholder')}
dir="auto"
value={this.searchTerm}
onChange={onChangeComposeSearchTerm}
/>
</div>
{Boolean(this.selectedContacts.length) && (
<ContactPills>
{this.selectedContacts.map(contact => (
<ContactPill
key={contact.id}
avatarPath={contact.avatarPath}
color={contact.color}
firstName={contact.firstName}
i18n={i18n}
id={contact.id}
name={contact.name}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
title={contact.title}
onClickRemove={removeSelectedContact}
/>
))}
</ContactPills>
)}
{this.getRowCount() ? null : (
<div className="module-left-pane__compose-no-contacts">
{i18n('newConversationNoContacts')}
</div>
)}
{modalDetails && (
<Alert
body={modalDetails.body}
i18n={i18n}
onClose={modalDetails.onClose}
title={modalDetails.title}
/>
)}
</>
);
}
getFooterContents({
i18n,
startSettingGroupMetadata,
}: Readonly<{
i18n: LocalizerType;
startSettingGroupMetadata: () => void;
}>): ReactChild {
return (
<Button
disabled={this.hasExceededMaximumNumberOfContacts()}
onClick={startSettingGroupMetadata}
>
{this.selectedContacts.length
? i18n('chooseGroupMembers__next')
: i18n('chooseGroupMembers__skip')}
</Button>
);
}
getRowCount(): number {
if (!this.candidateContacts.length) {
return 0;
}
return this.candidateContacts.length + 2;
}
getRow(rowIndex: number): undefined | Row {
if (!this.candidateContacts.length) {
return undefined;
}
if (rowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
// This puts a blank row for the footer.
if (rowIndex === this.candidateContacts.length + 1) {
return { type: RowType.Blank };
}
const contact = this.candidateContacts[rowIndex - 1];
if (!contact) {
return undefined;
}
const isChecked = this.selectedConversationIdsSet.has(contact.id);
let disabledReason: undefined | ContactCheckboxDisabledReason;
if (!isChecked) {
if (this.hasSelectedMaximumNumberOfContacts()) {
disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
} else if (!contact.isGroupV2Capable) {
disabledReason = ContactCheckboxDisabledReason.NotCapable;
}
}
return {
type: RowType.ContactCheckbox,
contact,
isChecked,
disabledReason,
};
}
// This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
// the composer. The same is true for the "in direction" function below.
getConversationAndMessageAtIndex(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
getConversationAndMessageInDirection(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
shouldRecomputeRowHeights(_old: unknown): boolean {
return false;
}
private hasSelectedMaximumNumberOfContacts(): boolean {
return this.selectedContacts.length >= this.getMaximumNumberOfContacts();
}
private hasExceededMaximumNumberOfContacts(): boolean {
// It should be impossible to reach this state. This is here as a failsafe.
return this.selectedContacts.length > this.getMaximumNumberOfContacts();
}
private getRecommendedMaximumNumberOfContacts(): number {
return getGroupSizeRecommendedLimit(151) - 1;
}
private getMaximumNumberOfContacts(): number {
return getGroupSizeHardLimit(1001) - 1;
}
}
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}

View file

@ -12,6 +12,9 @@ import {
instance as phoneNumberInstance,
PhoneNumberFormat,
} from '../../util/libphonenumberInstance';
import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { isStorageWriteFeatureEnabled } from '../../storage/isFeatureEnabled';
export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray<ContactListItemPropsType>;
@ -19,6 +22,12 @@ export type LeftPaneComposePropsType = {
searchTerm: string;
};
enum TopButton {
None,
CreateNewGroup,
StartNewConversation,
}
/* eslint-disable class-methods-use-this */
export class LeftPaneComposeHelper extends LeftPaneHelper<
@ -98,24 +107,53 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
}
getRowCount(): number {
return this.composeContacts.length + (this.phoneNumber ? 1 : 0);
let result = this.composeContacts.length;
if (this.hasTopButton()) {
result += 1;
}
if (this.hasContactsHeader()) {
result += 1;
}
return result;
}
getRow(rowIndex: number): undefined | Row {
let contactIndex = rowIndex;
if (this.phoneNumber) {
if (rowIndex === 0) {
return {
type: RowType.StartNewConversation,
phoneNumber: phoneNumberInstance.format(
if (rowIndex === 0) {
const topButton = this.getTopButton();
switch (topButton) {
case TopButton.None:
break;
case TopButton.StartNewConversation:
assert(
this.phoneNumber,
PhoneNumberFormat.E164
),
};
'LeftPaneComposeHelper: we should have a phone number if the top button is "Start new conversation"'
);
return {
type: RowType.StartNewConversation,
phoneNumber: phoneNumberInstance.format(
this.phoneNumber,
PhoneNumberFormat.E164
),
};
case TopButton.CreateNewGroup:
return { type: RowType.CreateNewGroup };
default:
throw missingCaseError(topButton);
}
}
contactIndex -= 1;
if (rowIndex === 1 && this.hasContactsHeader()) {
return {
type: RowType.Header,
i18nKey: 'contactsHeader',
};
}
let contactIndex: number;
if (this.hasTopButton()) {
contactIndex = rowIndex - 2;
} else {
contactIndex = rowIndex;
}
const contact = this.composeContacts[contactIndex];
@ -141,8 +179,29 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<
return undefined;
}
shouldRecomputeRowHeights(_old: unknown): boolean {
return false;
shouldRecomputeRowHeights(old: Readonly<LeftPaneComposePropsType>): boolean {
return (
this.hasContactsHeader() !==
new LeftPaneComposeHelper(old).hasContactsHeader()
);
}
private getTopButton(): TopButton {
if (this.phoneNumber) {
return TopButton.StartNewConversation;
}
if (this.searchTerm || !isStorageWriteFeatureEnabled()) {
return TopButton.None;
}
return TopButton.CreateNewGroup;
}
private hasTopButton(): boolean {
return this.getTopButton() !== TopButton.None;
}
private hasContactsHeader(): boolean {
return this.hasTopButton() && Boolean(this.composeContacts.length);
}
}

View file

@ -23,6 +23,8 @@ export abstract class LeftPaneHelper<T> {
_: Readonly<{
i18n: LocalizerType;
showInbox: () => void;
startComposing: () => void;
showChooseGroupMembers: () => void;
}>
): null | ReactChild {
return null;
@ -34,10 +36,28 @@ export abstract class LeftPaneHelper<T> {
getPreRowsNode(
_: Readonly<{
clearGroupCreationError: () => void;
closeCantAddContactToGroupModal: () => unknown;
closeMaximumGroupSizeModal: () => unknown;
closeRecommendedGroupSizeModal: () => unknown;
createGroup: () => unknown;
i18n: LocalizerType;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
setComposeGroupName: (_: string) => unknown;
onChangeComposeSearchTerm: (
event: ChangeEvent<HTMLInputElement>
) => unknown;
removeSelectedContact: (_: string) => unknown;
}>
): null | ReactChild {
return null;
}
getFooterContents(
_: Readonly<{
i18n: LocalizerType;
startSettingGroupMetadata: () => void;
createGroup: () => unknown;
}>
): null | ReactChild {
return null;

View file

@ -0,0 +1,218 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactChild } from 'react';
import { LeftPaneHelper } from './LeftPaneHelper';
import { Row, RowType } from '../ConversationList';
import { PropsDataType as ContactListItemPropsType } from '../conversationList/ContactListItem';
import { LocalizerType } from '../../types/Util';
import { AvatarInput } from '../AvatarInput';
import { Alert } from '../Alert';
import { Spinner } from '../Spinner';
import { Button } from '../Button';
export type LeftPaneSetGroupMetadataPropsType = {
groupAvatar: undefined | ArrayBuffer;
groupName: string;
hasError: boolean;
isCreating: boolean;
selectedContacts: ReadonlyArray<ContactListItemPropsType>;
};
/* eslint-disable class-methods-use-this */
export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<
LeftPaneSetGroupMetadataPropsType
> {
private readonly groupAvatar: undefined | ArrayBuffer;
private readonly groupName: string;
private readonly hasError: boolean;
private readonly isCreating: boolean;
private readonly selectedContacts: ReadonlyArray<ContactListItemPropsType>;
constructor({
groupAvatar,
groupName,
isCreating,
hasError,
selectedContacts,
}: Readonly<LeftPaneSetGroupMetadataPropsType>) {
super();
this.groupAvatar = groupAvatar;
this.groupName = groupName;
this.hasError = hasError;
this.isCreating = isCreating;
this.selectedContacts = selectedContacts;
}
getHeaderContents({
i18n,
showChooseGroupMembers,
}: Readonly<{
i18n: LocalizerType;
showChooseGroupMembers: () => void;
}>): ReactChild {
const backButtonLabel = i18n('setGroupMetadata__back-button');
return (
<div className="module-left-pane__header__contents">
<button
aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button"
disabled={this.isCreating}
onClick={showChooseGroupMembers}
title={backButtonLabel}
type="button"
/>
<div className="module-left-pane__header__contents__text">
{i18n('setGroupMetadata__title')}
</div>
</div>
);
}
getPreRowsNode({
clearGroupCreationError,
createGroup,
i18n,
setComposeGroupAvatar,
setComposeGroupName,
}: Readonly<{
clearGroupCreationError: () => unknown;
createGroup: () => unknown;
i18n: LocalizerType;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => unknown;
setComposeGroupName: (_: string) => unknown;
}>): ReactChild {
const disabled = this.isCreating;
return (
<form
className="module-left-pane__header__form"
onSubmit={event => {
event.preventDefault();
event.stopPropagation();
if (!this.canCreateGroup()) {
return;
}
createGroup();
}}
>
<AvatarInput
contextMenuId="left pane group avatar uploader"
disabled={disabled}
i18n={i18n}
onChange={setComposeGroupAvatar}
value={this.groupAvatar}
/>
<input
disabled={disabled}
className="module-left-pane__compose-input"
onChange={event => {
setComposeGroupName(event.target.value);
}}
placeholder={i18n('setGroupMetadata__group-name-placeholder')}
ref={focusRef}
type="text"
value={this.groupName}
/>
{this.hasError && (
<Alert
body={i18n('setGroupMetadata__error-message')}
i18n={i18n}
onClose={clearGroupCreationError}
/>
)}
</form>
);
}
getFooterContents({
createGroup,
i18n,
}: Readonly<{
createGroup: () => unknown;
i18n: LocalizerType;
}>): ReactChild {
return (
<Button disabled={!this.canCreateGroup()} onClick={createGroup}>
{this.isCreating ? (
<Spinner size="20px" svgSize="small" direction="on-avatar" />
) : (
i18n('setGroupMetadata__create-group')
)}
</Button>
);
}
getRowCount(): number {
if (!this.selectedContacts.length) {
return 0;
}
return this.selectedContacts.length + 2;
}
getRow(rowIndex: number): undefined | Row {
if (!this.selectedContacts.length) {
return undefined;
}
if (rowIndex === 0) {
return {
type: RowType.Header,
i18nKey: 'setGroupMetadata__members-header',
};
}
// This puts a blank row for the footer.
if (rowIndex === this.selectedContacts.length + 1) {
return { type: RowType.Blank };
}
const contact = this.selectedContacts[rowIndex - 1];
return contact
? {
type: RowType.Contact,
contact,
isClickable: false,
}
: undefined;
}
// This is deliberately unimplemented because these keyboard shortcuts shouldn't work in
// the composer. The same is true for the "in direction" function below.
getConversationAndMessageAtIndex(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
getConversationAndMessageInDirection(
..._args: ReadonlyArray<unknown>
): undefined {
return undefined;
}
shouldRecomputeRowHeights(_old: unknown): boolean {
return false;
}
private canCreateGroup(): boolean {
return !this.isCreating && Boolean(this.groupName.trim());
}
}
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}