signal-desktop/ts/components/conversation/TimelineMessage.tsx

746 lines
21 KiB
TypeScript
Raw Normal View History

2022-11-04 13:22:07 +00:00
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
2022-12-03 00:40:33 +00:00
import { noop } from 'lodash';
2022-12-19 22:33:55 +00:00
import React, { useCallback, useEffect, useRef, useState } from 'react';
2022-11-04 13:22:07 +00:00
import type { Ref } from 'react';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import ReactDOM, { createPortal } from 'react-dom';
import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
import { isDownloaded } from '../../types/Attachment';
import type { LocalizerType } from '../../types/I18N';
import { handleOutsideClick } from '../../util/handleOutsideClick';
import { offsetDistanceModifier } from '../../util/popperUtil';
import { StopPropagation } from '../StopPropagation';
import { WidthBreakpoint } from '../_util';
import { Message } from './Message';
import type { SmartReactionPicker } from '../../state/smart/ReactionPicker';
import type {
Props as MessageProps,
PropsActions as MessagePropsActions,
PropsData as MessagePropsData,
PropsHousekeeping,
} from './Message';
import { doesMessageBodyOverflow } from './MessageBodyReadMore';
import type { Props as ReactionPickerProps } from './ReactionPicker';
import { ConfirmationDialog } from '../ConfirmationDialog';
2022-12-03 00:40:33 +00:00
import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts';
2022-11-04 13:22:07 +00:00
export type PropsData = {
canDownload: boolean;
canRetry: boolean;
canRetryDeleteForEveryone: boolean;
canReact: boolean;
canReply: boolean;
selectedReaction?: string;
2022-12-03 00:40:33 +00:00
isSelected?: boolean;
2022-11-04 13:22:07 +00:00
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
export type PropsActions = {
deleteMessage: (options: {
conversationId: string;
messageId: string;
}) => void;
2022-11-04 13:22:07 +00:00
deleteMessageForEveryone: (id: string) => void;
toggleForwardMessageModal: (id: string) => void;
2022-11-04 13:22:07 +00:00
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
) => void;
retryMessageSend: (id: string) => void;
2022-11-04 13:22:07 +00:00
retryDeleteForEveryone: (id: string) => void;
setQuoteByMessageId: (conversationId: string, messageId: string) => void;
2022-11-04 13:22:07 +00:00
} & MessagePropsActions;
export type Props = PropsData &
PropsActions &
Omit<PropsHousekeeping, 'isAttachmentPending'> &
Pick<ReactionPickerProps, 'renderEmojiPicker'> & {
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
};
type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
};
/**
* Message with menu/context-menu (as necessary for rendering in the timeline)
*/
2022-11-18 00:45:19 +00:00
export function TimelineMessage(props: Props): JSX.Element {
2022-11-04 13:22:07 +00:00
const {
i18n,
id,
author,
attachments,
canDownload,
canReact,
canReply,
canRetry,
canDeleteForEveryone,
canRetryDeleteForEveryone,
contact,
2022-11-30 21:47:54 +00:00
payment,
conversationId,
2022-11-04 13:22:07 +00:00
containerElementRef,
containerWidthBreakpoint,
deletedForEveryone,
deleteMessage,
deleteMessageForEveryone,
direction,
giftBadge,
2022-12-03 00:40:33 +00:00
isSelected,
2022-11-04 13:22:07 +00:00
isSticker,
isTapToView,
reactToMessage,
setQuoteByMessageId,
2022-11-04 13:22:07 +00:00
renderReactionPicker,
renderEmojiPicker,
retryMessageSend,
2022-11-04 13:22:07 +00:00
retryDeleteForEveryone,
selectedReaction,
toggleForwardMessageModal,
2022-11-04 13:22:07 +00:00
showMessageDetail,
text,
timestamp,
2022-12-19 22:33:55 +00:00
kickOffAttachmentDownload,
saveAttachment,
2022-11-04 13:22:07 +00:00
} = props;
const [reactionPickerRoot, setReactionPickerRoot] = useState<
HTMLDivElement | undefined
>(undefined);
const menuTriggerRef = useRef<Trigger | null>(null);
const isWindowWidthNotNarrow =
containerWidthBreakpoint !== WidthBreakpoint.Narrow;
2022-12-19 22:33:55 +00:00
const popperPreventOverflowModifier =
useCallback((): Partial<PreventOverflowModifier> => {
return {
name: 'preventOverflow',
options: {
altAxis: true,
boundary: containerElementRef.current || undefined,
padding: {
bottom: 16,
left: 8,
right: 8,
top: 16,
},
2022-11-04 13:22:07 +00:00
},
2022-12-19 22:33:55 +00:00
};
}, [containerElementRef]);
2022-11-04 13:22:07 +00:00
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
const triggerId = String(id || `${author.id}-${timestamp}`);
2022-12-19 22:33:55 +00:00
const toggleReactionPicker = useCallback(
2022-11-04 13:22:07 +00:00
(onlyRemove = false): void => {
if (reactionPickerRoot) {
document.body.removeChild(reactionPickerRoot);
setReactionPickerRoot(undefined);
return;
}
if (!onlyRemove) {
const root = document.createElement('div');
document.body.appendChild(root);
setReactionPickerRoot(root);
}
},
[reactionPickerRoot]
);
useEffect(() => {
let cleanUpHandler: (() => void) | undefined;
if (reactionPickerRoot) {
cleanUpHandler = handleOutsideClick(
() => {
toggleReactionPicker(true);
return true;
},
{
containerElements: [reactionPickerRoot],
name: 'Message.reactionPicker',
}
);
}
return () => {
cleanUpHandler?.();
};
});
2022-12-19 22:33:55 +00:00
const openGenericAttachment = useCallback(
(event?: React.MouseEvent): void => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
2022-11-04 13:22:07 +00:00
2022-12-19 22:33:55 +00:00
if (!attachments || attachments.length !== 1) {
return;
}
2022-11-04 13:22:07 +00:00
2022-12-19 22:33:55 +00:00
const attachment = attachments[0];
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
return;
}
2022-11-04 13:22:07 +00:00
2022-12-19 22:33:55 +00:00
saveAttachment(attachment, timestamp);
},
[kickOffAttachmentDownload, saveAttachment, attachments, id, timestamp]
);
2022-11-04 13:22:07 +00:00
2022-12-19 22:33:55 +00:00
const handleContextMenu = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>): void => {
const selection = window.getSelection();
if (selection && !selection.isCollapsed) {
return;
}
if (event.target instanceof HTMLAnchorElement) {
return;
}
if (menuTriggerRef.current) {
menuTriggerRef.current.handleContextClick(event);
}
},
[menuTriggerRef]
);
2022-11-04 13:22:07 +00:00
const canForward =
2022-11-30 21:47:54 +00:00
!isTapToView && !deletedForEveryone && !giftBadge && !contact && !payment;
2022-11-04 13:22:07 +00:00
const shouldShowAdditional =
doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow;
const multipleAttachments = attachments && attachments.length > 1;
const firstAttachment = attachments && attachments[0];
const handleDownload =
canDownload &&
!isSticker &&
!multipleAttachments &&
!isTapToView &&
firstAttachment &&
!firstAttachment.pending
? openGenericAttachment
: undefined;
2022-12-19 22:33:55 +00:00
const handleReplyToMessage = useCallback(() => {
if (!canReply) {
return;
}
setQuoteByMessageId(conversationId, id);
}, [canReply, conversationId, id, setQuoteByMessageId]);
2022-11-04 13:22:07 +00:00
2022-12-19 22:33:55 +00:00
const handleReact = useCallback(() => {
if (canReact) {
toggleReactionPicker();
}
}, [canReact, toggleReactionPicker]);
2022-11-04 13:22:07 +00:00
const [hasDOEConfirmation, setHasDOEConfirmation] = useState(false);
const [hasDeleteConfirmation, setHasDeleteConfirmation] = useState(false);
2022-12-03 00:40:33 +00:00
const toggleReactionPickerKeyboard = useToggleReactionPicker(
handleReact || noop
);
useEffect(() => {
if (isSelected) {
document.addEventListener('keydown', toggleReactionPickerKeyboard);
}
return () => {
document.removeEventListener('keydown', toggleReactionPickerKeyboard);
};
}, [isSelected, toggleReactionPickerKeyboard]);
2022-12-19 22:33:55 +00:00
const renderMenu = useCallback(() => {
return (
<Manager>
<MessageMenu
i18n={i18n}
triggerId={triggerId}
isWindowWidthNotNarrow={isWindowWidthNotNarrow}
direction={direction}
menuTriggerRef={menuTriggerRef}
showMenu={handleContextMenu}
onDownload={handleDownload}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
/>
{reactionPickerRoot &&
createPortal(
<Popper
placement="top"
modifiers={[
offsetDistanceModifier(4),
popperPreventOverflowModifier(),
]}
>
{({ ref, style }) =>
renderReactionPicker({
ref,
style,
selected: selectedReaction,
onClose: toggleReactionPicker,
onPick: emoji => {
toggleReactionPicker(true);
reactToMessage(id, {
emoji,
remove: emoji === selectedReaction,
});
},
renderEmojiPicker,
})
}
</Popper>,
reactionPickerRoot
)}
</Manager>
);
}, [
i18n,
triggerId,
isWindowWidthNotNarrow,
direction,
menuTriggerRef,
handleContextMenu,
handleDownload,
handleReplyToMessage,
handleReact,
reactionPickerRoot,
popperPreventOverflowModifier,
renderReactionPicker,
selectedReaction,
reactToMessage,
renderEmojiPicker,
toggleReactionPicker,
id,
]);
2022-11-04 13:22:07 +00:00
return (
<>
{hasDOEConfirmation && canDeleteForEveryone && (
<ConfirmationDialog
actions={[
{
action: () => deleteMessageForEveryone(id),
style: 'negative',
text: i18n('delete'),
},
]}
dialogName="TimelineMessage/deleteMessageForEveryone"
i18n={i18n}
onClose={() => setHasDOEConfirmation(false)}
>
{i18n('deleteForEveryoneWarning')}
</ConfirmationDialog>
)}
{hasDeleteConfirmation && (
<ConfirmationDialog
actions={[
{
action: () =>
deleteMessage({
conversationId,
messageId: id,
}),
style: 'negative',
text: i18n('delete'),
},
]}
dialogName="TimelineMessage/deleteMessage"
i18n={i18n}
onClose={() => setHasDeleteConfirmation(false)}
>
{i18n('deleteWarning')}
</ConfirmationDialog>
)}
<div
onDoubleClick={ev => {
if (!handleReplyToMessage) {
return;
}
ev.stopPropagation();
ev.preventDefault();
handleReplyToMessage();
}}
>
<Message
{...props}
renderingContext="conversation/TimelineItem"
onContextMenu={handleContextMenu}
2022-12-19 22:33:55 +00:00
renderMenu={renderMenu}
/>
</div>
2022-11-04 13:22:07 +00:00
<MessageContextMenu
i18n={i18n}
triggerId={triggerId}
shouldShowAdditional={shouldShowAdditional}
onDownload={handleDownload}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined}
2022-11-04 13:22:07 +00:00
onRetryDeleteForEveryone={
canRetryDeleteForEveryone
? () => retryDeleteForEveryone(id)
: undefined
}
onForward={canForward ? () => toggleForwardMessageModal(id) : undefined}
onDeleteForMe={() => setHasDeleteConfirmation(true)}
2022-11-04 13:22:07 +00:00
onDeleteForEveryone={
canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined
2022-11-04 13:22:07 +00:00
}
onMoreInfo={() => showMessageDetail(id)}
/>
</>
);
2022-11-18 00:45:19 +00:00
}
2022-11-04 13:22:07 +00:00
type MessageMenuProps = {
i18n: LocalizerType;
triggerId: string;
isWindowWidthNotNarrow: boolean;
menuTriggerRef: Ref<Trigger>;
showMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
onDownload: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined;
} & Pick<MessageProps, 'i18n' | 'direction'>;
2022-11-18 00:45:19 +00:00
function MessageMenu({
2022-11-04 13:22:07 +00:00
i18n,
triggerId,
direction,
isWindowWidthNotNarrow,
menuTriggerRef,
showMenu,
onDownload,
onReplyToMessage,
onReact,
2022-11-18 00:45:19 +00:00
}: MessageMenuProps) {
2022-11-04 13:22:07 +00:00
// This a menu meant for mouse use only
/* eslint-disable jsx-a11y/interactive-supports-focus */
/* eslint-disable jsx-a11y/click-events-have-key-events */
const menuButton = (
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the collapsed menu button if the reaction
// button is not visible (it is hidden when the timeline is narrow)
const maybePopperRef = !isWindowWidthNotNarrow ? popperRef : undefined;
return (
<StopPropagation className="module-message__buttons__menu--container">
<ContextMenuTrigger
id={triggerId}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={menuTriggerRef as any}
>
<div
ref={maybePopperRef}
role="button"
onClick={showMenu}
aria-label={i18n('messageContextMenuButton')}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
onDoubleClick={ev => {
// Prevent double click from triggering the replyToMessage action
ev.stopPropagation();
}}
2022-11-04 13:22:07 +00:00
/>
</ContextMenuTrigger>
</StopPropagation>
);
}}
</Reference>
);
/* eslint-enable jsx-a11y/interactive-supports-focus */
/* eslint-enable jsx-a11y/click-events-have-key-events */
return (
<div
className={classNames(
'module-message__buttons',
`module-message__buttons--${direction}`
)}
>
{isWindowWidthNotNarrow && (
<>
{onReact && (
<Reference>
{({ ref: popperRef }) => {
// Only attach the popper reference to the reaction button if it is
// visible (it is hidden when the timeline is narrow)
const maybePopperRef = isWindowWidthNotNarrow
? popperRef
: undefined;
return (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
ref={maybePopperRef}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onReact();
}}
role="button"
className="module-message__buttons__react"
aria-label={i18n('reactToMessage')}
onDoubleClick={ev => {
// Prevent double click from triggering the replyToMessage action
ev.stopPropagation();
}}
2022-11-04 13:22:07 +00:00
/>
);
}}
</Reference>
)}
{onDownload && (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
onClick={onDownload}
role="button"
aria-label={i18n('downloadAttachment')}
className={classNames(
'module-message__buttons__download',
`module-message__buttons__download--${direction}`
)}
onDoubleClick={ev => {
// Prevent double click from triggering the replyToMessage action
ev.stopPropagation();
}}
2022-11-04 13:22:07 +00:00
/>
)}
{onReplyToMessage && (
// This a menu meant for mouse use only
// eslint-disable-next-line max-len
// eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
<div
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onReplyToMessage();
}}
// This a menu meant for mouse use only
role="button"
aria-label={i18n('replyToMessage')}
className={classNames(
'module-message__buttons__reply',
`module-message__buttons__download--${direction}`
)}
onDoubleClick={ev => {
// Prevent double click from triggering the replyToMessage action
ev.stopPropagation();
}}
2022-11-04 13:22:07 +00:00
/>
)}
</>
)}
{menuButton}
</div>
);
2022-11-18 00:45:19 +00:00
}
2022-11-04 13:22:07 +00:00
type MessageContextProps = {
i18n: LocalizerType;
triggerId: string;
shouldShowAdditional: boolean;
onDownload: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined;
onRetryMessageSend: (() => void) | undefined;
2022-11-04 13:22:07 +00:00
onRetryDeleteForEveryone: (() => void) | undefined;
onForward: (() => void) | undefined;
onDeleteForMe: () => void;
onDeleteForEveryone: (() => void) | undefined;
onMoreInfo: () => void;
};
const MessageContextMenu = ({
i18n,
triggerId,
shouldShowAdditional,
onDownload,
onReplyToMessage,
onReact,
onMoreInfo,
onRetryMessageSend,
2022-11-04 13:22:07 +00:00
onRetryDeleteForEveryone,
onForward,
onDeleteForMe,
onDeleteForEveryone,
}: MessageContextProps): JSX.Element => {
const menu = (
<ContextMenu id={triggerId}>
{shouldShowAdditional && (
<>
{onDownload && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__download',
}}
onClick={onDownload}
>
{i18n('downloadAttachment')}
</MenuItem>
)}
{onReplyToMessage && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__reply',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onReplyToMessage();
}}
>
{i18n('replyToMessage')}
</MenuItem>
)}
{onReact && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__react',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onReact();
}}
>
{i18n('reactToMessage')}
</MenuItem>
)}
</>
)}
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__more-info',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onMoreInfo();
}}
>
{i18n('moreInfo')}
</MenuItem>
{onRetryMessageSend && (
2022-11-04 13:22:07 +00:00
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__retry-send',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onRetryMessageSend();
2022-11-04 13:22:07 +00:00
}}
>
{i18n('retrySend')}
</MenuItem>
)}
{onRetryDeleteForEveryone && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onRetryDeleteForEveryone();
}}
>
{i18n('retryDeleteForEveryone')}
</MenuItem>
)}
{onForward && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__forward-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onForward();
}}
>
{i18n('forwardMessage')}
</MenuItem>
)}
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onDeleteForMe();
}}
>
{i18n('deleteMessage')}
</MenuItem>
{onDeleteForEveryone && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onDeleteForEveryone();
}}
>
{i18n('deleteMessageForEveryone')}
</MenuItem>
)}
</ContextMenu>
);
return ReactDOM.createPortal(menu, document.body);
};