From 88fd42a46b2e88a0e114c35f14a83ff68dbd68dc Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Tue, 12 Dec 2023 11:11:39 -0500 Subject: [PATCH] Add contextMenu for deleting call events on right-click --- stylesheets/_modules.scss | 8 + .../CallingNotification.stories.tsx | 4 + .../conversation/CallingNotification.tsx | 104 ++++++-- .../conversation/MessageContextMenu.tsx | 244 +++++++++++++++++ ts/components/conversation/TimelineItem.tsx | 2 + .../conversation/TimelineMessage.tsx | 251 +----------------- ts/hooks/useKeyboardShortcuts.tsx | 5 +- ts/state/selectors/message.ts | 12 + ts/test-both/util/callingNotification_test.ts | 10 + ts/util/callingNotification.ts | 2 + ts/util/getNotificationDataForMessage.ts | 8 +- ts/util/lint/exceptions.json | 13 +- 12 files changed, 394 insertions(+), 269 deletions(-) create mode 100644 ts/components/conversation/MessageContextMenu.tsx diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 3d1f1bda4c4..7babf85dd5f 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7114,6 +7114,14 @@ button.module-image__border-overlay:focus { user-select: none; visibility: hidden; + // style a menu with only one option + &:not(:has(:nth-child(2))) { + padding-block: 0; + .react-contextmenu-item { + padding-block: 9px; + border-radius: 4px; + } + } @include light-theme { background-color: $color-white; } diff --git a/ts/components/conversation/CallingNotification.stories.tsx b/ts/components/conversation/CallingNotification.stories.tsx index af184815dd0..565ebf8b0c9 100644 --- a/ts/components/conversation/CallingNotification.stories.tsx +++ b/ts/components/conversation/CallingNotification.stories.tsx @@ -58,6 +58,7 @@ const getCommonProps = (options: { mode === CallMode.Group ? getDefaultGroup() : getDefaultConversation(); return { + id: 'message-id', conversationId: conversation.id, i18n, isNextItemCallingNotification: false, @@ -67,6 +68,7 @@ const getCommonProps = (options: { onOutgoingVideoCallInConversation: action( 'onOutgoingVideoCallInConversation' ), + toggleDeleteMessagesModal: action('toggleDeleteMessagesModal'), returnToActiveCall: action('returnToActiveCall'), callHistory: { callId: '123', @@ -83,6 +85,8 @@ const getCommonProps = (options: { groupCallEnded, maxDevices, deviceCount, + isSelectMode: false, + isTargeted: false, }; }; diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index bf5dd95ebfd..b5977f07820 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import React from 'react'; import { noop } from 'lodash'; +import { ContextMenuTrigger } from 'react-contextmenu'; import { SystemMessage, SystemMessageKind } from './SystemMessage'; import { Button, ButtonSize, ButtonVariant } from '../Button'; @@ -24,15 +25,27 @@ import { DirectCallStatus, GroupCallStatus, } from '../../types/CallDisposition'; +import { + type ContextMenuTriggerType, + MessageContextMenu, + useHandleMessageContextMenu, +} from './MessageContextMenu'; +import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals'; +import { + useKeyboardShortcutsConditionally, + useOpenContextMenu, +} from '../../hooks/useKeyboardShortcuts'; export type PropsActionsType = { onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void; returnToActiveCall: () => void; + toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void; }; type PropsHousekeeping = { i18n: LocalizerType; + id: string; conversationId: string; isNextItemCallingNotification: boolean; }; @@ -43,36 +56,81 @@ export type PropsType = CallingNotificationType & export const CallingNotification: React.FC = React.memo( function CallingNotificationInner(props) { + const menuTriggerRef = React.useRef(null); + const handleContextMenu = useHandleMessageContextMenu(menuTriggerRef); + const openContextMenuKeyboard = useOpenContextMenu(handleContextMenu); + useKeyboardShortcutsConditionally( + !props.isSelectMode && props.isTargeted, + openContextMenuKeyboard + ); const { i18n } = props; if (props.callHistory == null) { return null; } + const { type, direction, status, timestamp } = props.callHistory; const icon = getCallingIcon(type, direction, status); return ( - - {getCallingNotificationText(props, i18n)} ·{' '} - - - } - icon={icon} - kind={ - status === DirectCallStatus.Missed || - status === GroupCallStatus.Missed - ? SystemMessageKind.Danger - : SystemMessageKind.Normal - } - /> + <> +
+ + {getCallingNotificationText(props, i18n)} ·{' '} + + + } + icon={icon} + kind={ + status === DirectCallStatus.Missed || + status === GroupCallStatus.Missed + ? SystemMessageKind.Danger + : SystemMessageKind.Normal + } + /> +
+ { + // react-contextmenu's typings are incorrect here + menuTriggerRef.current = ref as unknown as ContextMenuTriggerType; + }} + /> + { + props.toggleDeleteMessagesModal({ + conversationId: props.conversationId, + messageIds: [props.id], + }); + }} + shouldShowAdditional={false} + onDownload={undefined} + onEdit={undefined} + onReplyToMessage={undefined} + onReact={undefined} + onRetryMessageSend={undefined} + onRetryDeleteForEveryone={undefined} + onCopy={undefined} + onSelect={undefined} + onForward={undefined} + onMoreInfo={undefined} + /> + ); } ); diff --git a/ts/components/conversation/MessageContextMenu.tsx b/ts/components/conversation/MessageContextMenu.tsx new file mode 100644 index 00000000000..9d7c073860c --- /dev/null +++ b/ts/components/conversation/MessageContextMenu.tsx @@ -0,0 +1,244 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { type RefObject } from 'react'; +import { ContextMenu, MenuItem } from 'react-contextmenu'; +import ReactDOM from 'react-dom'; +import type { LocalizerType } from '../../types/I18N'; + +export type ContextMenuTriggerType = { + handleContextClick: ( + event: React.MouseEvent | MouseEvent + ) => void; +}; + +type MessageContextProps = { + i18n: LocalizerType; + triggerId: string; + shouldShowAdditional: boolean; + + onDownload: (() => void) | undefined; + onEdit: (() => void) | undefined; + onReplyToMessage: (() => void) | undefined; + onReact: (() => void) | undefined; + onRetryMessageSend: (() => void) | undefined; + onRetryDeleteForEveryone: (() => void) | undefined; + onCopy: (() => void) | undefined; + onForward: (() => void) | undefined; + onDeleteMessage: () => void; + onMoreInfo: (() => void) | undefined; + onSelect: (() => void) | undefined; +}; +export const MessageContextMenu = ({ + i18n, + triggerId, + shouldShowAdditional, + onDownload, + onEdit, + onReplyToMessage, + onReact, + onMoreInfo, + onCopy, + onSelect, + onRetryMessageSend, + onRetryDeleteForEveryone, + onForward, + onDeleteMessage, +}: MessageContextProps): JSX.Element => { + const menu = ( + + {shouldShowAdditional && ( + <> + {onDownload && ( + + {i18n('icu:MessageContextMenu__download')} + + )} + {onReplyToMessage && ( + { + event.stopPropagation(); + event.preventDefault(); + + onReplyToMessage(); + }} + > + {i18n('icu:MessageContextMenu__reply')} + + )} + {onReact && ( + { + event.stopPropagation(); + event.preventDefault(); + + onReact(); + }} + > + {i18n('icu:MessageContextMenu__react')} + + )} + + )} + {onForward && ( + { + event.stopPropagation(); + event.preventDefault(); + + onForward(); + }} + > + {i18n('icu:MessageContextMenu__forward')} + + )} + {onEdit && ( + { + event.stopPropagation(); + event.preventDefault(); + + onEdit(); + }} + > + {i18n('icu:edit')} + + )} + {onSelect && ( + { + onSelect(); + }} + > + {i18n('icu:MessageContextMenu__select')} + + )} + {onCopy && ( + { + onCopy(); + }} + > + {i18n('icu:copy')} + + )} + {onMoreInfo && ( + { + event.stopPropagation(); + event.preventDefault(); + + onMoreInfo(); + }} + > + {i18n('icu:MessageContextMenu__info')} + + )} + { + event.stopPropagation(); + event.preventDefault(); + + onDeleteMessage(); + }} + > + {i18n('icu:MessageContextMenu__deleteMessage')} + + {onRetryMessageSend && ( + { + event.stopPropagation(); + event.preventDefault(); + + onRetryMessageSend(); + }} + > + {i18n('icu:retrySend')} + + )} + {onRetryDeleteForEveryone && ( + { + event.stopPropagation(); + event.preventDefault(); + + onRetryDeleteForEveryone(); + }} + > + {i18n('icu:retryDeleteForEveryone')} + + )} + + ); + + return ReactDOM.createPortal(menu, document.body); +}; + +export function useHandleMessageContextMenu( + menuTriggerRef: RefObject +): ContextMenuTriggerType['handleContextClick'] { + return React.useCallback( + (event: React.MouseEvent | MouseEvent): void => { + const selection = window.getSelection(); + + if (selection && !selection.isCollapsed) { + return; + } + if (event && event.target instanceof HTMLAnchorElement) { + return; + } + if (menuTriggerRef.current) { + menuTriggerRef.current.handleContextClick( + event ?? new MouseEvent('click') + ); + } + }, + [menuTriggerRef] + ); +} diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 6918bb6688a..a62441c9332 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -253,11 +253,13 @@ export const TimelineItem = memo(function TimelineItem({ } else if (item.type === 'callHistory') { notification = ( diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index d62e9c03fd3..d1f2343a2b4 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -5,8 +5,8 @@ import classNames from 'classnames'; import { noop } from 'lodash'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { Ref } from 'react'; -import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; -import ReactDOM, { createPortal } from 'react-dom'; +import { ContextMenuTrigger } from 'react-contextmenu'; +import { 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'; @@ -34,6 +34,11 @@ import { import { PanelType } from '../../types/Panels'; import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals'; import { useScrollerLock } from '../../hooks/useScrollLock'; +import { + type ContextMenuTriggerType, + MessageContextMenu, + useHandleMessageContextMenu, +} from './MessageContextMenu'; export type PropsData = { canDownload: boolean; @@ -77,12 +82,6 @@ export type Props = PropsData & ) => JSX.Element; }; -type Trigger = { - handleContextClick: ( - event: React.MouseEvent | MouseEvent - ) => void; -}; - /** * Message with menu/context-menu (as necessary for rendering in the timeline) */ @@ -132,7 +131,7 @@ export function TimelineMessage(props: Props): JSX.Element { const [reactionPickerRoot, setReactionPickerRoot] = useState< HTMLDivElement | undefined >(undefined); - const menuTriggerRef = useRef(null); + const menuTriggerRef = useRef(null); const isWindowWidthNotNarrow = containerWidthBreakpoint !== WidthBreakpoint.Narrow; @@ -228,22 +227,7 @@ export function TimelineMessage(props: Props): JSX.Element { [kickOffAttachmentDownload, saveAttachment, attachments, id, timestamp] ); - const handleContextMenu = React.useCallback( - (event: React.MouseEvent): void => { - const selection = window.getSelection(); - if (selection && !selection.isCollapsed) { - return; - } - if (event.target instanceof HTMLAnchorElement) { - return; - } - if (menuTriggerRef.current) { - menuTriggerRef.current.handleContextClick(event); - } - }, - [menuTriggerRef] - ); - + const handleContextMenu = useHandleMessageContextMenu(menuTriggerRef); const canForward = !isTapToView && !deletedForEveryone && !giftBadge && !contact && !payment; @@ -280,15 +264,7 @@ export function TimelineMessage(props: Props): JSX.Element { handleReact || noop ); - const handleOpenContextMenu = useCallback(() => { - if (!menuTriggerRef.current) { - return; - } - const event = new MouseEvent('click'); - menuTriggerRef.current.handleContextClick(event); - }, []); - - const openContextMenuKeyboard = useOpenContextMenu(handleOpenContextMenu); + const openContextMenuKeyboard = useOpenContextMenu(handleContextMenu); useKeyboardShortcutsConditionally( Boolean(isTargeted), @@ -419,7 +395,7 @@ type MessageMenuProps = { i18n: LocalizerType; triggerId: string; isWindowWidthNotNarrow: boolean; - menuTriggerRef: Ref; + menuTriggerRef: Ref; showMenu: (event: React.MouseEvent) => void; onDownload: (() => void) | undefined; onReplyToMessage: (() => void) | undefined; @@ -569,208 +545,3 @@ function MessageMenu({ ); } - -type MessageContextProps = { - i18n: LocalizerType; - triggerId: string; - shouldShowAdditional: boolean; - - onDownload: (() => void) | undefined; - onEdit: (() => void) | undefined; - onReplyToMessage: (() => void) | undefined; - onReact: (() => void) | undefined; - onRetryMessageSend: (() => void) | undefined; - onRetryDeleteForEveryone: (() => void) | undefined; - onCopy: (() => void) | undefined; - onForward: (() => void) | undefined; - onDeleteMessage: () => void; - onMoreInfo: () => void; - onSelect: () => void; -}; - -const MessageContextMenu = ({ - i18n, - triggerId, - shouldShowAdditional, - onDownload, - onEdit, - onReplyToMessage, - onReact, - onMoreInfo, - onCopy, - onSelect, - onRetryMessageSend, - onRetryDeleteForEveryone, - onForward, - onDeleteMessage, -}: MessageContextProps): JSX.Element => { - const menu = ( - - {shouldShowAdditional && ( - <> - {onDownload && ( - - {i18n('icu:MessageContextMenu__download')} - - )} - {onReplyToMessage && ( - { - event.stopPropagation(); - event.preventDefault(); - - onReplyToMessage(); - }} - > - {i18n('icu:MessageContextMenu__reply')} - - )} - {onReact && ( - { - event.stopPropagation(); - event.preventDefault(); - - onReact(); - }} - > - {i18n('icu:MessageContextMenu__react')} - - )} - - )} - {onForward && ( - { - event.stopPropagation(); - event.preventDefault(); - - onForward(); - }} - > - {i18n('icu:MessageContextMenu__forward')} - - )} - {onEdit && ( - { - event.stopPropagation(); - event.preventDefault(); - - onEdit(); - }} - > - {i18n('icu:edit')} - - )} - { - onSelect(); - }} - > - {i18n('icu:MessageContextMenu__select')} - - {onCopy && ( - { - onCopy(); - }} - > - {i18n('icu:copy')} - - )} - { - event.stopPropagation(); - event.preventDefault(); - - onMoreInfo(); - }} - > - {i18n('icu:MessageContextMenu__info')} - - { - event.stopPropagation(); - event.preventDefault(); - - onDeleteMessage(); - }} - > - {i18n('icu:MessageContextMenu__deleteMessage')} - - {onRetryMessageSend && ( - { - event.stopPropagation(); - event.preventDefault(); - - onRetryMessageSend(); - }} - > - {i18n('icu:retrySend')} - - )} - {onRetryDeleteForEveryone && ( - { - event.stopPropagation(); - event.preventDefault(); - - onRetryDeleteForEveryone(); - }} - > - {i18n('icu:retryDeleteForEveryone')} - - )} - - ); - - return ReactDOM.createPortal(menu, document.body); -}; diff --git a/ts/hooks/useKeyboardShortcuts.tsx b/ts/hooks/useKeyboardShortcuts.tsx index 24e57c07b63..f32fb69421d 100644 --- a/ts/hooks/useKeyboardShortcuts.tsx +++ b/ts/hooks/useKeyboardShortcuts.tsx @@ -10,6 +10,7 @@ import * as KeyboardLayout from '../services/keyboardLayout'; import { getHasPanelOpen } from '../state/selectors/conversations'; import { isInFullScreenCall } from '../state/selectors/calling'; import { isShowingAnyModal } from '../state/selectors/globalModals'; +import type { ContextMenuTriggerType } from '../components/conversation/MessageContextMenu'; type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean; @@ -225,7 +226,7 @@ export function useToggleReactionPicker( } export function useOpenContextMenu( - openContextMenu: () => unknown + openContextMenu: ContextMenuTriggerType['handleContextClick'] | undefined ): KeyboardShortcutHandlerType { const hasOverlay = useHasAnyOverlay(); @@ -247,7 +248,7 @@ export function useOpenContextMenu( ev.preventDefault(); ev.stopPropagation(); - openContextMenu(); + openContextMenu?.(new MouseEvent('click')); return true; } diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index b0279ac2c93..9756151e9a5 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -1328,6 +1328,8 @@ export type GetPropsForCallHistoryOptions = Pick< | 'callHistorySelector' | 'conversationSelector' | 'ourConversationId' + | 'selectedMessageIds' + | 'targetedMessageId' >; const emptyCallNotification: CallingNotificationType = { @@ -1337,6 +1339,8 @@ const emptyCallNotification: CallingNotificationType = { groupCallEnded: null, maxDevices: Infinity, deviceCount: 0, + isSelectMode: false, + isTargeted: false, }; export function getPropsForCallHistory( @@ -1347,6 +1351,8 @@ export function getPropsForCallHistory( activeCall, conversationSelector, ourConversationId, + selectedMessageIds, + targetedMessageId, }: GetPropsForCallHistoryOptions ): CallingNotificationType { const { callId } = message; @@ -1368,6 +1374,8 @@ export function getPropsForCallHistory( 'getPropsForCallHistory: Missing conversation' ); + const isSelectMode = selectedMessageIds != null; + let callCreator: ConversationType | null = null; if (callHistory.ringerId) { callCreator = conversationSelector(callHistory.ringerId); @@ -1383,6 +1391,8 @@ export function getPropsForCallHistory( groupCallEnded: false, deviceCount: 0, maxDevices: Infinity, + isSelectMode, + isTargeted: message.id === targetedMessageId, }; } @@ -1410,6 +1420,8 @@ export function getPropsForCallHistory( groupCallEnded: callId !== conversationCallId || deviceCount === 0, deviceCount, maxDevices, + isSelectMode, + isTargeted: message.id === targetedMessageId, }; } diff --git a/ts/test-both/util/callingNotification_test.ts b/ts/test-both/util/callingNotification_test.ts index 1d68802b930..918dce9f1a4 100644 --- a/ts/test-both/util/callingNotification_test.ts +++ b/ts/test-both/util/callingNotification_test.ts @@ -43,6 +43,8 @@ describe('calling notification helpers', () => { groupCallEnded: true, deviceCount: 1, maxDevices: 23, + isSelectMode: false, + isTargeted: false, }, i18n ), @@ -72,6 +74,8 @@ describe('calling notification helpers', () => { groupCallEnded: false, deviceCount: 1, maxDevices: 23, + isSelectMode: false, + isTargeted: false, }, i18n ), @@ -102,6 +106,8 @@ describe('calling notification helpers', () => { groupCallEnded: false, deviceCount: 1, maxDevices: 23, + isSelectMode: false, + isTargeted: false, }, i18n ), @@ -131,6 +137,8 @@ describe('calling notification helpers', () => { groupCallEnded: false, deviceCount: 1, maxDevices: 23, + isSelectMode: false, + isTargeted: false, }, i18n ), @@ -157,6 +165,8 @@ describe('calling notification helpers', () => { groupCallEnded: false, deviceCount: 1, maxDevices: 23, + isSelectMode: false, + isTargeted: false, }, i18n ), diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts index effdb4b47bb..bb590b827d4 100644 --- a/ts/util/callingNotification.ts +++ b/ts/util/callingNotification.ts @@ -22,6 +22,8 @@ export type CallingNotificationType = Readonly<{ groupCallEnded: boolean | null; deviceCount: number; maxDevices: number; + isSelectMode: boolean; + isTargeted: boolean; }>; function getDirectCallNotificationText( diff --git a/ts/util/getNotificationDataForMessage.ts b/ts/util/getNotificationDataForMessage.ts index 58fd7484f5a..562dc0e928e 100644 --- a/ts/util/getNotificationDataForMessage.ts +++ b/ts/util/getNotificationDataForMessage.ts @@ -16,7 +16,11 @@ import { dropNull } from './dropNull'; import { getCallHistorySelector } from '../state/selectors/callHistory'; import { getCallSelector, getActiveCall } from '../state/selectors/calling'; import { getCallingNotificationText } from './callingNotification'; -import { getConversationSelector } from '../state/selectors/conversations'; +import { + getConversationSelector, + getSelectedMessageIds, + getTargetedMessage, +} from '../state/selectors/conversations'; import { getStringForConversationMerge } from './getStringForConversationMerge'; import { getStringForProfileChange } from './getStringForProfileChange'; import { getTitleNoDefault, getNumber } from './getTitle'; @@ -376,6 +380,8 @@ export function getNotificationDataForMessage( activeCall: getActiveCall(state), callHistorySelector: getCallHistorySelector(state), conversationSelector: getConversationSelector(state), + selectedMessageIds: getSelectedMessageIds(state), + targetedMessageId: getTargetedMessage(state)?.id, }); if (callingNotification) { const text = getCallingNotificationText(callingNotification, window.i18n); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index d2e526d5289..ba522b70bd2 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -3677,10 +3677,17 @@ }, { "rule": "React-useRef", - "path": "ts/components/conversation/TimelineMessage.tsx", - "line": " const menuTriggerRef = useRef(null);", + "path": "ts/components/conversation/CallingNotification.tsx", + "line": " const menuTriggerRef = React.useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2022-11-03T14:21:47.456Z" + "updated": "2023-12-08T20:28:57.595Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/TimelineMessage.tsx", + "line": " const menuTriggerRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2023-12-08T20:28:57.595Z" }, { "rule": "React-useRef",