Support for creating New Groups
This commit is contained in:
parent
1934120e46
commit
5de4babc0d
56 changed files with 6222 additions and 526 deletions
32
ts/components/Alert.tsx
Normal file
32
ts/components/Alert.tsx
Normal 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>
|
||||
);
|
69
ts/components/AvatarInput.stories.tsx
Normal file
69
ts/components/AvatarInput.stories.tsx
Normal 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} />;
|
||||
});
|
213
ts/components/AvatarInput.tsx
Normal file
213
ts/components/AvatarInput.tsx
Normal 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');
|
||||
});
|
||||
}
|
74
ts/components/ContactPill.tsx
Normal file
74
ts/components/ContactPill.tsx
Normal 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>
|
||||
);
|
||||
};
|
87
ts/components/ContactPills.stories.tsx
Normal file
87
ts/components/ContactPills.stories.tsx
Normal 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>
|
||||
));
|
38
ts/components/ContactPills.tsx
Normal file
38
ts/components/ContactPills.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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> = {}
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
120
ts/components/GroupDialog.tsx
Normal file
120
ts/components/GroupDialog.tsx
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
));
|
80
ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx
Normal file
80
ts/components/NewlyCreatedGroupInvitedContactsDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
97
ts/components/conversationList/ContactCheckbox.tsx
Normal file
97
ts/components/conversationList/ContactCheckbox.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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}
|
||||
|
|
32
ts/components/conversationList/CreateNewGroupButton.tsx
Normal file
32
ts/components/conversationList/CreateNewGroupButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
304
ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx
Normal file
304
ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
218
ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
Normal file
218
ts/components/leftPane/LeftPaneSetGroupMetadataHelper.tsx
Normal 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();
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue