Adds message forwarding
This commit is contained in:
parent
cd489a35fd
commit
d203f125c6
42 changed files with 1638 additions and 139 deletions
|
@ -33,3 +33,11 @@ story.add('Kitchen sink', () => (
|
|||
))}
|
||||
</>
|
||||
));
|
||||
|
||||
story.add('aria-label', () => (
|
||||
<Button
|
||||
aria-label="hello"
|
||||
className="module-ForwardMessageModal__header--back"
|
||||
onClick={action('onClick')}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -15,7 +15,6 @@ export enum ButtonVariant {
|
|||
}
|
||||
|
||||
type PropsType = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
variant?: ButtonVariant;
|
||||
|
@ -26,7 +25,21 @@ type PropsType = {
|
|||
| {
|
||||
type: 'submit';
|
||||
}
|
||||
);
|
||||
) &
|
||||
(
|
||||
| {
|
||||
'aria-label': string;
|
||||
children: ReactNode;
|
||||
}
|
||||
| {
|
||||
'aria-label'?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
| {
|
||||
'aria-label': string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
);
|
||||
|
||||
const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
|
||||
[ButtonVariant.Primary, 'module-Button--primary'],
|
||||
|
@ -50,6 +63,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
|||
disabled = false,
|
||||
variant = ButtonVariant.Primary,
|
||||
} = props;
|
||||
const ariaLabel = props['aria-label'];
|
||||
|
||||
let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
|
||||
let type: 'button' | 'submit';
|
||||
|
@ -66,6 +80,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
|||
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('module-Button', variantClassName, className)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
|
|
|
@ -63,6 +63,7 @@ export type Props = {
|
|||
readonly skinTone?: EmojiPickDataType['skinTone'];
|
||||
readonly draftText?: string;
|
||||
readonly draftBodyRanges?: Array<BodyRangeType>;
|
||||
readonly moduleClassName?: string;
|
||||
sortedGroupMembers?: Array<ConversationType>;
|
||||
onDirtyChange?(dirty: boolean): unknown;
|
||||
onEditorStateChange?(
|
||||
|
@ -79,12 +80,24 @@ export type Props = {
|
|||
|
||||
const MAX_LENGTH = 64 * 1024;
|
||||
|
||||
function getClassName(
|
||||
moduleClassName?: string,
|
||||
modifier?: string | null
|
||||
): string | undefined {
|
||||
if (!moduleClassName || !modifier) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${moduleClassName}${modifier}`;
|
||||
}
|
||||
|
||||
export const CompositionInput: React.ComponentType<Props> = props => {
|
||||
const {
|
||||
i18n,
|
||||
disabled,
|
||||
large,
|
||||
inputApi,
|
||||
moduleClassName,
|
||||
onPickEmoji,
|
||||
onSubmit,
|
||||
skinTone,
|
||||
|
@ -240,12 +253,12 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
propsRef.current = props;
|
||||
}, [props]);
|
||||
|
||||
const onShortKeyEnter = () => {
|
||||
const onShortKeyEnter = (): boolean => {
|
||||
submit();
|
||||
return false;
|
||||
};
|
||||
|
||||
const onEnter = () => {
|
||||
const onEnter = (): boolean => {
|
||||
const quill = quillRef.current;
|
||||
const emojiCompletion = emojiCompletionRef.current;
|
||||
const mentionCompletion = mentionCompletionRef.current;
|
||||
|
@ -277,7 +290,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
return false;
|
||||
};
|
||||
|
||||
const onTab = () => {
|
||||
const onTab = (): boolean => {
|
||||
const quill = quillRef.current;
|
||||
const emojiCompletion = emojiCompletionRef.current;
|
||||
const mentionCompletion = mentionCompletionRef.current;
|
||||
|
@ -303,7 +316,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const onEscape = () => {
|
||||
const onEscape = (): boolean => {
|
||||
const quill = quillRef.current;
|
||||
|
||||
if (quill === undefined) {
|
||||
|
@ -335,7 +348,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const onBackspace = () => {
|
||||
const onBackspace = (): boolean => {
|
||||
const quill = quillRef.current;
|
||||
|
||||
if (quill === undefined) {
|
||||
|
@ -361,7 +374,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
return false;
|
||||
};
|
||||
|
||||
const onChange = () => {
|
||||
const onChange = (): void => {
|
||||
const quill = quillRef.current;
|
||||
|
||||
const [text, mentions] = getTextAndMentions();
|
||||
|
@ -471,6 +484,22 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(memberIds)]);
|
||||
|
||||
// Placing all of these callbacks inside of a ref since Quill is not able
|
||||
// to re-render. We want to make sure that all these callbacks are fresh
|
||||
// so that the consumers of this component won't deal with stale props or
|
||||
// stale state as the result of calling them.
|
||||
const unstaleCallbacks = {
|
||||
onBackspace,
|
||||
onChange,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onPickEmoji,
|
||||
onShortKeyEnter,
|
||||
onTab,
|
||||
};
|
||||
const callbacksRef = React.useRef(unstaleCallbacks);
|
||||
callbacksRef.current = unstaleCallbacks;
|
||||
|
||||
const reactQuill = React.useMemo(
|
||||
() => {
|
||||
const delta = generateDelta(draftText || '', draftBodyRanges || []);
|
||||
|
@ -478,7 +507,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
return (
|
||||
<ReactQuill
|
||||
className="module-composition-input__quill"
|
||||
onChange={onChange}
|
||||
onChange={() => callbacksRef.current.onChange()}
|
||||
defaultValue={delta}
|
||||
modules={{
|
||||
toolbar: false,
|
||||
|
@ -494,19 +523,29 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
},
|
||||
keyboard: {
|
||||
bindings: {
|
||||
onEnter: { key: 13, handler: onEnter }, // 13 = Enter
|
||||
onEnter: {
|
||||
key: 13,
|
||||
handler: () => callbacksRef.current.onEnter(),
|
||||
}, // 13 = Enter
|
||||
onShortKeyEnter: {
|
||||
key: 13, // 13 = Enter
|
||||
shortKey: true,
|
||||
handler: onShortKeyEnter,
|
||||
handler: () => callbacksRef.current.onShortKeyEnter(),
|
||||
},
|
||||
onEscape: { key: 27, handler: onEscape }, // 27 = Escape
|
||||
onBackspace: { key: 8, handler: onBackspace }, // 8 = Backspace
|
||||
onEscape: {
|
||||
key: 27,
|
||||
handler: () => callbacksRef.current.onEscape(),
|
||||
}, // 27 = Escape
|
||||
onBackspace: {
|
||||
key: 8,
|
||||
handler: () => callbacksRef.current.onBackspace(),
|
||||
}, // 8 = Backspace
|
||||
},
|
||||
},
|
||||
emojiCompletion: {
|
||||
setEmojiPickerElement: setEmojiCompletionElement,
|
||||
onPickEmoji,
|
||||
onPickEmoji: (emoji: EmojiPickDataType) =>
|
||||
callbacksRef.current.onPickEmoji(emoji),
|
||||
skinTone,
|
||||
},
|
||||
mentionCompletion: {
|
||||
|
@ -528,7 +567,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
|
||||
// force the tab handler to be prepended, otherwise it won't be
|
||||
// executed: https://github.com/quilljs/quill/issues/1967
|
||||
keyboard.bindings[9].unshift({ key: 9, handler: onTab }); // 9 = Tab
|
||||
keyboard.bindings[9].unshift({
|
||||
key: 9,
|
||||
handler: () => callbacksRef.current.onTab(),
|
||||
}); // 9 = Tab
|
||||
// also, remove the default \t insertion binding
|
||||
keyboard.bindings[9].pop();
|
||||
|
||||
|
@ -583,7 +625,13 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div className="module-composition-input__input" ref={ref}>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-input__input',
|
||||
getClassName(moduleClassName, '__input')
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onClick={focus}
|
||||
|
@ -591,6 +639,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
|||
'module-composition-input__input__scroller',
|
||||
large
|
||||
? 'module-composition-input__input__scroller--large'
|
||||
: null,
|
||||
getClassName(moduleClassName, '__scroller'),
|
||||
large
|
||||
? getClassName(moduleClassName, '__scroller--large')
|
||||
: null
|
||||
)}
|
||||
>
|
||||
|
|
119
ts/components/ForwardMessageModal.stories.tsx
Normal file
119
ts/components/ForwardMessageModal.stories.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { AttachmentType } from '../types/Attachment';
|
||||
import { ForwardMessageModal, PropsType } from './ForwardMessageModal';
|
||||
import { IMAGE_JPEG, MIMEType, VIDEO_MP4 } from '../types/MIME';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
|
||||
const createAttachment = (
|
||||
props: Partial<AttachmentType> = {}
|
||||
): AttachmentType => ({
|
||||
contentType: text(
|
||||
'attachment contentType',
|
||||
props.contentType || ''
|
||||
) as MIMEType,
|
||||
fileName: text('attachment fileName', props.fileName || ''),
|
||||
screenshot: props.screenshot,
|
||||
url: text('attachment url', props.url || ''),
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/ForwardMessageModal', module);
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const LONG_TITLE =
|
||||
"This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?";
|
||||
const LONG_DESCRIPTION =
|
||||
"You're gonna love this description. Not only does it have a lot of characters, but it will also be truncated in the UI. How cool is that??";
|
||||
const candidateConversations = Array.from(Array(100), () =>
|
||||
getDefaultConversation()
|
||||
);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
attachments: overrideProps.attachments,
|
||||
candidateConversations,
|
||||
doForwardMessage: action('doForwardMessage'),
|
||||
i18n,
|
||||
isSticker: Boolean(overrideProps.isSticker),
|
||||
linkPreview: overrideProps.linkPreview,
|
||||
messageBody: text('messageBody', overrideProps.messageBody || ''),
|
||||
onClose: action('onClose'),
|
||||
onEditorStateChange: action('onEditorStateChange'),
|
||||
onPickEmoji: action('onPickEmoji'),
|
||||
onTextTooLong: action('onTextTooLong'),
|
||||
onSetSkinTone: action('onSetSkinTone'),
|
||||
recentEmojis: [],
|
||||
removeLinkPreview: action('removeLinkPreview'),
|
||||
skinTone: 0,
|
||||
});
|
||||
|
||||
story.add('Modal', () => {
|
||||
return <ForwardMessageModal {...createProps()} />;
|
||||
});
|
||||
|
||||
story.add('with text', () => {
|
||||
return <ForwardMessageModal {...createProps({ messageBody: 'sup' })} />;
|
||||
});
|
||||
|
||||
story.add('a sticker', () => {
|
||||
return <ForwardMessageModal {...createProps({ isSticker: true })} />;
|
||||
});
|
||||
|
||||
story.add('link preview', () => {
|
||||
return (
|
||||
<ForwardMessageModal
|
||||
{...createProps({
|
||||
linkPreview: {
|
||||
description: LONG_DESCRIPTION,
|
||||
date: Date.now(),
|
||||
domain: 'https://www.signal.org',
|
||||
url: 'signal.org',
|
||||
image: createAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
title: LONG_TITLE,
|
||||
},
|
||||
messageBody: 'signal.org',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
story.add('media attachments', () => {
|
||||
return (
|
||||
<ForwardMessageModal
|
||||
{...createProps({
|
||||
attachments: [
|
||||
createAttachment({
|
||||
contentType: IMAGE_JPEG,
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
}),
|
||||
createAttachment({
|
||||
contentType: VIDEO_MP4,
|
||||
fileName: 'pixabay-Soap-Bubble-7141.mp4',
|
||||
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
screenshot: {
|
||||
height: 112,
|
||||
width: 112,
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
},
|
||||
}),
|
||||
],
|
||||
messageBody: 'cats',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
409
ts/components/ForwardMessageModal.tsx
Normal file
409
ts/components/ForwardMessageModal.tsx
Normal file
|
@ -0,0 +1,409 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import Measure, { MeasuredComponentProps } from 'react-measure';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { AttachmentList } from './conversation/AttachmentList';
|
||||
import { AttachmentType } from '../types/Attachment';
|
||||
import { Button } from './Button';
|
||||
import { CompositionInput, InputApi } from './CompositionInput';
|
||||
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
|
||||
import { ConversationList, Row, RowType } from './ConversationList';
|
||||
import { ConversationType } from '../state/ducks/conversations';
|
||||
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
|
||||
import { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import { BodyRangeType, LocalizerType } from '../types/Util';
|
||||
import { ModalHost } from './ModalHost';
|
||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||
import { assert } from '../util/assert';
|
||||
import { filterAndSortConversations } from '../util/filterAndSortConversations';
|
||||
|
||||
export type DataPropsType = {
|
||||
attachments?: Array<AttachmentType>;
|
||||
candidateConversations: ReadonlyArray<ConversationType>;
|
||||
doForwardMessage: (
|
||||
selectedContacts: Array<string>,
|
||||
messageBody?: string,
|
||||
attachments?: Array<AttachmentType>,
|
||||
linkPreview?: LinkPreviewType
|
||||
) => void;
|
||||
i18n: LocalizerType;
|
||||
isSticker: boolean;
|
||||
linkPreview?: LinkPreviewType;
|
||||
messageBody?: string;
|
||||
onClose: () => void;
|
||||
onEditorStateChange: (
|
||||
messageText: string,
|
||||
bodyRanges: Array<BodyRangeType>,
|
||||
caretLocation?: number
|
||||
) => unknown;
|
||||
onTextTooLong: () => void;
|
||||
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
|
||||
|
||||
type ActionPropsType = Pick<
|
||||
EmojiButtonProps,
|
||||
'onPickEmoji' | 'onSetSkinTone'
|
||||
> & {
|
||||
removeLinkPreview: () => void;
|
||||
};
|
||||
|
||||
export type PropsType = DataPropsType & ActionPropsType;
|
||||
|
||||
const MAX_FORWARD = 5;
|
||||
|
||||
export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
||||
attachments,
|
||||
candidateConversations,
|
||||
doForwardMessage,
|
||||
i18n,
|
||||
isSticker,
|
||||
linkPreview,
|
||||
messageBody,
|
||||
onClose,
|
||||
onEditorStateChange,
|
||||
onPickEmoji,
|
||||
onSetSkinTone,
|
||||
onTextTooLong,
|
||||
recentEmojis,
|
||||
removeLinkPreview,
|
||||
skinTone,
|
||||
}) => {
|
||||
const inputRef = useRef<null | HTMLInputElement>(null);
|
||||
const inputApiRef = React.useRef<InputApi | undefined>();
|
||||
const [selectedContacts, setSelectedContacts] = useState<
|
||||
Array<ConversationType>
|
||||
>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filteredContacts, setFilteredContacts] = useState(
|
||||
filterAndSortConversations(candidateConversations, '')
|
||||
);
|
||||
const [attachmentsToForward, setAttachmentsToForward] = useState(attachments);
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
||||
const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
|
||||
|
||||
const isMessageEditable = !isSticker;
|
||||
|
||||
const hasSelectedMaximumNumberOfContacts =
|
||||
selectedContacts.length >= MAX_FORWARD;
|
||||
|
||||
const selectedConversationIdsSet: Set<string> = useMemo(
|
||||
() => new Set(selectedContacts.map(contact => contact.id)),
|
||||
[selectedContacts]
|
||||
);
|
||||
|
||||
const focusTextEditInput = React.useCallback(() => {
|
||||
if (inputApiRef.current) {
|
||||
inputApiRef.current.focus();
|
||||
}
|
||||
}, [inputApiRef]);
|
||||
|
||||
const insertEmoji = React.useCallback(
|
||||
(e: EmojiPickDataType) => {
|
||||
if (inputApiRef.current) {
|
||||
inputApiRef.current.insertEmoji(e);
|
||||
onPickEmoji(e);
|
||||
}
|
||||
},
|
||||
[inputApiRef, onPickEmoji]
|
||||
);
|
||||
|
||||
const forwardMessage = React.useCallback(() => {
|
||||
if (!messageBodyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
doForwardMessage(
|
||||
selectedContacts.map(contact => contact.id),
|
||||
messageBodyText,
|
||||
attachmentsToForward,
|
||||
linkPreview
|
||||
);
|
||||
}, [
|
||||
attachmentsToForward,
|
||||
doForwardMessage,
|
||||
linkPreview,
|
||||
messageBodyText,
|
||||
selectedContacts,
|
||||
]);
|
||||
|
||||
const hasContactsSelected = Boolean(selectedContacts.length);
|
||||
|
||||
const canForwardMessage =
|
||||
hasContactsSelected &&
|
||||
(Boolean(messageBodyText) ||
|
||||
isSticker ||
|
||||
(attachmentsToForward && attachmentsToForward.length));
|
||||
|
||||
const normalizedSearchTerm = searchTerm.trim();
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setFilteredContacts(
|
||||
filterAndSortConversations(candidateConversations, normalizedSearchTerm)
|
||||
);
|
||||
}, 200);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [candidateConversations, normalizedSearchTerm, setFilteredContacts]);
|
||||
|
||||
const contactLookup = useMemo(() => {
|
||||
const map = new Map();
|
||||
candidateConversations.forEach(contact => {
|
||||
map.set(contact.id, contact);
|
||||
});
|
||||
return map;
|
||||
}, [candidateConversations]);
|
||||
|
||||
const toggleSelectedContact = useCallback(
|
||||
(conversationId: string) => {
|
||||
let removeContact = false;
|
||||
const nextSelectedContacts = selectedContacts.filter(contact => {
|
||||
if (contact.id === conversationId) {
|
||||
removeContact = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (removeContact) {
|
||||
setSelectedContacts(nextSelectedContacts);
|
||||
return;
|
||||
}
|
||||
const selectedContact = contactLookup.get(conversationId);
|
||||
if (selectedContact) {
|
||||
setSelectedContacts([...nextSelectedContacts, selectedContact]);
|
||||
}
|
||||
},
|
||||
[contactLookup, selectedContacts, setSelectedContacts]
|
||||
);
|
||||
|
||||
const rowCount = filteredContacts.length;
|
||||
const getRow = (index: number): undefined | Row => {
|
||||
const contact = filteredContacts[index];
|
||||
if (!contact) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isSelected = selectedConversationIdsSet.has(contact.id);
|
||||
|
||||
let disabledReason: undefined | ContactCheckboxDisabledReason;
|
||||
if (hasSelectedMaximumNumberOfContacts && !isSelected) {
|
||||
disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
|
||||
}
|
||||
|
||||
return {
|
||||
type: RowType.ContactCheckbox,
|
||||
contact,
|
||||
isChecked: isSelected,
|
||||
disabledReason,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalHost onClose={onClose}>
|
||||
<div className="module-ForwardMessageModal">
|
||||
<div
|
||||
className={classNames('module-ForwardMessageModal__header', {
|
||||
'module-ForwardMessageModal__header--edit': isEditingMessage,
|
||||
})}
|
||||
>
|
||||
{isEditingMessage ? (
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
className="module-ForwardMessageModal__header--back"
|
||||
onClick={() => setIsEditingMessage(false)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={i18n('cancel')}
|
||||
className="module-ForwardMessageModal__header--cancel"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
{i18n('cancel')}
|
||||
</button>
|
||||
)}
|
||||
<h1>{i18n('forwardMessage')}</h1>
|
||||
</div>
|
||||
{isEditingMessage ? (
|
||||
<div className="module-ForwardMessageModal__main-body">
|
||||
{linkPreview ? (
|
||||
<div className="module-ForwardMessageModal--link-preview">
|
||||
<StagedLinkPreview
|
||||
date={linkPreview.date || null}
|
||||
description={linkPreview.description || ''}
|
||||
domain={linkPreview.url}
|
||||
i18n={i18n}
|
||||
image={linkPreview.image}
|
||||
isLoaded
|
||||
onClose={() => removeLinkPreview()}
|
||||
title={linkPreview.title}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{attachmentsToForward && attachmentsToForward.length ? (
|
||||
<AttachmentList
|
||||
attachments={attachmentsToForward}
|
||||
i18n={i18n}
|
||||
onCloseAttachment={(attachment: AttachmentType) => {
|
||||
const newAttachments = attachmentsToForward.filter(
|
||||
currentAttachment => currentAttachment !== attachment
|
||||
);
|
||||
setAttachmentsToForward(newAttachments);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="module-ForwardMessageModal__text-edit-area">
|
||||
<CompositionInput
|
||||
clearQuotedMessage={shouldNeverBeCalled}
|
||||
draftText={messageBodyText}
|
||||
getQuotedMessage={noop}
|
||||
i18n={i18n}
|
||||
inputApi={inputApiRef}
|
||||
large
|
||||
moduleClassName="module-ForwardMessageModal__input"
|
||||
onEditorStateChange={(
|
||||
messageText,
|
||||
bodyRanges,
|
||||
caretLocation
|
||||
) => {
|
||||
setMessageBodyText(messageText);
|
||||
onEditorStateChange(messageText, bodyRanges, caretLocation);
|
||||
}}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onSubmit={forwardMessage}
|
||||
onTextTooLong={onTextTooLong}
|
||||
/>
|
||||
<div className="module-ForwardMessageModal__emoji">
|
||||
<EmojiButton
|
||||
doSend={noop}
|
||||
i18n={i18n}
|
||||
onClose={focusTextEditInput}
|
||||
onPickEmoji={insertEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="module-ForwardMessageModal__main-body">
|
||||
<div className="module-ForwardMessageModal__search">
|
||||
<i className="module-ForwardMessageModal__search--icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="module-ForwardMessageModal__search--input"
|
||||
disabled={candidateConversations.length === 0}
|
||||
placeholder={i18n('contactSearchPlaceholder')}
|
||||
onChange={event => {
|
||||
setSearchTerm(event.target.value);
|
||||
}}
|
||||
ref={inputRef}
|
||||
value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
{candidateConversations.length ? (
|
||||
<Measure bounds>
|
||||
{({ contentRect, measureRef }: MeasuredComponentProps) => {
|
||||
// We disable this ESLint rule because we're capturing a bubbled keydown
|
||||
// event. See [this note in the jsx-a11y docs][0].
|
||||
//
|
||||
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
return (
|
||||
<div
|
||||
className="module-ForwardMessageModal__list-wrapper"
|
||||
ref={measureRef}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ConversationList
|
||||
dimensions={contentRect.bounds}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={shouldNeverBeCalled}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason:
|
||||
| undefined
|
||||
| ContactCheckboxDisabledReason
|
||||
) => {
|
||||
if (
|
||||
disabledReason !==
|
||||
ContactCheckboxDisabledReason.MaximumContactsSelected
|
||||
) {
|
||||
toggleSelectedContact(conversationId);
|
||||
}
|
||||
}}
|
||||
onSelectConversation={shouldNeverBeCalled}
|
||||
renderMessageSearchResult={() => {
|
||||
shouldNeverBeCalled();
|
||||
return <div />;
|
||||
}}
|
||||
rowCount={rowCount}
|
||||
shouldRecomputeRowHeights={false}
|
||||
showChooseGroupMembers={shouldNeverBeCalled}
|
||||
startNewConversationFromPhoneNumber={
|
||||
shouldNeverBeCalled
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
}}
|
||||
</Measure>
|
||||
) : (
|
||||
<div className="module-ForwardMessageModal__no-candidate-contacts">
|
||||
{i18n('noContactsFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="module-ForwardMessageModal__footer">
|
||||
<div>
|
||||
{Boolean(selectedContacts.length) &&
|
||||
selectedContacts.map(contact => contact.title).join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
{isEditingMessage || !isMessageEditable ? (
|
||||
<Button
|
||||
aria-label={i18n('ForwardMessageModal--continue')}
|
||||
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
|
||||
disabled={!canForwardMessage}
|
||||
onClick={forwardMessage}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={i18n('forwardMessage')}
|
||||
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
|
||||
disabled={!hasContactsSelected}
|
||||
onClick={() => setIsEditingMessage(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalHost>
|
||||
);
|
||||
};
|
||||
|
||||
function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): void {
|
||||
assert(false, 'This should never be called. Doing nothing');
|
||||
}
|
|
@ -18,10 +18,10 @@ import {
|
|||
export type Props = {
|
||||
attachments: Array<AttachmentType>;
|
||||
i18n: LocalizerType;
|
||||
onClickAttachment: (attachment: AttachmentType) => void;
|
||||
onAddAttachment?: () => void;
|
||||
onClickAttachment?: (attachment: AttachmentType) => void;
|
||||
onClose?: () => void;
|
||||
onCloseAttachment: (attachment: AttachmentType) => void;
|
||||
onAddAttachment: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const IMAGE_WIDTH = 120;
|
||||
|
@ -47,7 +47,7 @@ export const AttachmentList = ({
|
|||
|
||||
return (
|
||||
<div className="module-attachments">
|
||||
{attachments.length > 1 ? (
|
||||
{onClose && attachments.length > 1 ? (
|
||||
<div className="module-attachments__header">
|
||||
<button
|
||||
type="button"
|
||||
|
@ -105,7 +105,7 @@ export const AttachmentList = ({
|
|||
/>
|
||||
);
|
||||
})}
|
||||
{allVisualAttachments ? (
|
||||
{allVisualAttachments && onAddAttachment ? (
|
||||
<StagedPlaceholderAttachment onClick={onAddAttachment} i18n={i18n} />
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
@ -130,6 +130,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
showExpiredOutgoingTapToViewToast: action(
|
||||
'showExpiredOutgoingTapToViewToast'
|
||||
),
|
||||
showForwardMessageModal: action('showForwardMessageModal'),
|
||||
showMessageDetail: action('showMessageDetail'),
|
||||
showVisualAttachment: action('showVisualAttachment'),
|
||||
status: overrideProps.status || 'sent',
|
||||
|
|
|
@ -170,6 +170,7 @@ export type PropsActions = {
|
|||
) => void;
|
||||
replyToMessage: (id: string) => void;
|
||||
retrySend: (id: string) => void;
|
||||
showForwardMessageModal: (id: string) => void;
|
||||
deleteMessage: (id: string) => void;
|
||||
deleteMessageForEveryone: (id: string) => void;
|
||||
showMessageDetail: (id: string) => void;
|
||||
|
@ -1401,6 +1402,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
canReply,
|
||||
deleteMessage,
|
||||
deleteMessageForEveryone,
|
||||
deletedForEveryone,
|
||||
direction,
|
||||
i18n,
|
||||
id,
|
||||
|
@ -1408,10 +1410,13 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
isTapToView,
|
||||
replyToMessage,
|
||||
retrySend,
|
||||
showForwardMessageModal,
|
||||
showMessageDetail,
|
||||
status,
|
||||
} = this.props;
|
||||
|
||||
const canForward = !isTapToView && !deletedForEveryone;
|
||||
|
||||
const { canDeleteForEveryone } = this.state;
|
||||
|
||||
const showRetry =
|
||||
|
@ -1499,6 +1504,22 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
{i18n('retrySend')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{canForward ? (
|
||||
<MenuItem
|
||||
attributes={{
|
||||
className:
|
||||
'module-message__context--icon module-message__context__forward-message',
|
||||
}}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
showForwardMessageModal(id);
|
||||
}}
|
||||
>
|
||||
{i18n('forwardMessage')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
attributes={{
|
||||
className:
|
||||
|
|
|
@ -72,6 +72,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
showContactModal: () => null,
|
||||
showExpiredIncomingTapToViewToast: () => null,
|
||||
showExpiredOutgoingTapToViewToast: () => null,
|
||||
showForwardMessageModal: () => null,
|
||||
showVisualAttachment: () => null,
|
||||
});
|
||||
|
||||
|
|
|
@ -65,6 +65,7 @@ export type Props = {
|
|||
| 'showContactModal'
|
||||
| 'showExpiredIncomingTapToViewToast'
|
||||
| 'showExpiredOutgoingTapToViewToast'
|
||||
| 'showForwardMessageModal'
|
||||
| 'showVisualAttachment'
|
||||
>;
|
||||
|
||||
|
@ -235,6 +236,7 @@ export class MessageDetail extends React.Component<Props> {
|
|||
showContactModal,
|
||||
showExpiredIncomingTapToViewToast,
|
||||
showExpiredOutgoingTapToViewToast,
|
||||
showForwardMessageModal,
|
||||
showVisualAttachment,
|
||||
} = this.props;
|
||||
|
||||
|
@ -263,6 +265,7 @@ export class MessageDetail extends React.Component<Props> {
|
|||
renderEmojiPicker={renderEmojiPicker}
|
||||
replyToMessage={replyToMessage}
|
||||
retrySend={retrySend}
|
||||
showForwardMessageModal={showForwardMessageModal}
|
||||
scrollToQuotedMessage={() => {
|
||||
assert(
|
||||
false,
|
||||
|
|
|
@ -61,6 +61,7 @@ const defaultMessageProps: MessagesProps = {
|
|||
showContactModal: () => null,
|
||||
showExpiredIncomingTapToViewToast: () => null,
|
||||
showExpiredOutgoingTapToViewToast: () => null,
|
||||
showForwardMessageModal: () => null,
|
||||
showMessageDetail: () => null,
|
||||
showVisualAttachment: () => null,
|
||||
status: 'sent',
|
||||
|
|
|
@ -249,6 +249,7 @@ const actions = () => ({
|
|||
showExpiredOutgoingTapToViewToast: action(
|
||||
'showExpiredOutgoingTapToViewToast'
|
||||
),
|
||||
showForwardMessageModal: action('showForwardMessageModal'),
|
||||
|
||||
showIdentity: action('showIdentity'),
|
||||
|
||||
|
|
|
@ -53,6 +53,7 @@ const getDefaultProps = () => ({
|
|||
openConversation: action('openConversation'),
|
||||
showContactDetail: action('showContactDetail'),
|
||||
showContactModal: action('showContactModal'),
|
||||
showForwardMessageModal: action('showForwardMessageModal'),
|
||||
showVisualAttachment: action('showVisualAttachment'),
|
||||
downloadAttachment: action('downloadAttachment'),
|
||||
displayTapToViewMessage: action('displayTapToViewMessage'),
|
||||
|
|
|
@ -22,6 +22,7 @@ export type PropsDataType = {
|
|||
color?: ColorType;
|
||||
disabledReason?: ContactCheckboxDisabledReason;
|
||||
id: string;
|
||||
isMe?: boolean;
|
||||
isChecked: boolean;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
|
@ -49,6 +50,7 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
|
|||
i18n,
|
||||
id,
|
||||
isChecked,
|
||||
isMe,
|
||||
name,
|
||||
onClick,
|
||||
phoneNumber,
|
||||
|
@ -58,7 +60,9 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
|
|||
}) => {
|
||||
const disabled = Boolean(disabledReason);
|
||||
|
||||
const headerName = (
|
||||
const headerName = isMe ? (
|
||||
i18n('noteToSelf')
|
||||
) : (
|
||||
<ContactName
|
||||
phoneNumber={phoneNumber}
|
||||
name={name}
|
||||
|
@ -91,6 +95,7 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
|
|||
headerName={headerName}
|
||||
i18n={i18n}
|
||||
id={id}
|
||||
isMe={isMe}
|
||||
isSelected={false}
|
||||
messageText={messageText}
|
||||
name={name}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue