Add contextMenu for deleting call events on right-click
This commit is contained in:
parent
7fb01f102d
commit
88fd42a46b
12 changed files with 394 additions and 269 deletions
|
@ -7114,6 +7114,14 @@ button.module-image__border-overlay:focus {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
visibility: hidden;
|
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 {
|
@include light-theme {
|
||||||
background-color: $color-white;
|
background-color: $color-white;
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ const getCommonProps = (options: {
|
||||||
mode === CallMode.Group ? getDefaultGroup() : getDefaultConversation();
|
mode === CallMode.Group ? getDefaultGroup() : getDefaultConversation();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: 'message-id',
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
i18n,
|
i18n,
|
||||||
isNextItemCallingNotification: false,
|
isNextItemCallingNotification: false,
|
||||||
|
@ -67,6 +68,7 @@ const getCommonProps = (options: {
|
||||||
onOutgoingVideoCallInConversation: action(
|
onOutgoingVideoCallInConversation: action(
|
||||||
'onOutgoingVideoCallInConversation'
|
'onOutgoingVideoCallInConversation'
|
||||||
),
|
),
|
||||||
|
toggleDeleteMessagesModal: action('toggleDeleteMessagesModal'),
|
||||||
returnToActiveCall: action('returnToActiveCall'),
|
returnToActiveCall: action('returnToActiveCall'),
|
||||||
callHistory: {
|
callHistory: {
|
||||||
callId: '123',
|
callId: '123',
|
||||||
|
@ -83,6 +85,8 @@ const getCommonProps = (options: {
|
||||||
groupCallEnded,
|
groupCallEnded,
|
||||||
maxDevices,
|
maxDevices,
|
||||||
deviceCount,
|
deviceCount,
|
||||||
|
isSelectMode: false,
|
||||||
|
isTargeted: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
import { ContextMenuTrigger } from 'react-contextmenu';
|
||||||
|
|
||||||
import { SystemMessage, SystemMessageKind } from './SystemMessage';
|
import { SystemMessage, SystemMessageKind } from './SystemMessage';
|
||||||
import { Button, ButtonSize, ButtonVariant } from '../Button';
|
import { Button, ButtonSize, ButtonVariant } from '../Button';
|
||||||
|
@ -24,15 +25,27 @@ import {
|
||||||
DirectCallStatus,
|
DirectCallStatus,
|
||||||
GroupCallStatus,
|
GroupCallStatus,
|
||||||
} from '../../types/CallDisposition';
|
} 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 = {
|
export type PropsActionsType = {
|
||||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
returnToActiveCall: () => void;
|
returnToActiveCall: () => void;
|
||||||
|
toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PropsHousekeeping = {
|
type PropsHousekeeping = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
id: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
isNextItemCallingNotification: boolean;
|
isNextItemCallingNotification: boolean;
|
||||||
};
|
};
|
||||||
|
@ -43,36 +56,81 @@ export type PropsType = CallingNotificationType &
|
||||||
|
|
||||||
export const CallingNotification: React.FC<PropsType> = React.memo(
|
export const CallingNotification: React.FC<PropsType> = React.memo(
|
||||||
function CallingNotificationInner(props) {
|
function CallingNotificationInner(props) {
|
||||||
|
const menuTriggerRef = React.useRef<ContextMenuTriggerType | null>(null);
|
||||||
|
const handleContextMenu = useHandleMessageContextMenu(menuTriggerRef);
|
||||||
|
const openContextMenuKeyboard = useOpenContextMenu(handleContextMenu);
|
||||||
|
useKeyboardShortcutsConditionally(
|
||||||
|
!props.isSelectMode && props.isTargeted,
|
||||||
|
openContextMenuKeyboard
|
||||||
|
);
|
||||||
const { i18n } = props;
|
const { i18n } = props;
|
||||||
if (props.callHistory == null) {
|
if (props.callHistory == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, direction, status, timestamp } = props.callHistory;
|
const { type, direction, status, timestamp } = props.callHistory;
|
||||||
const icon = getCallingIcon(type, direction, status);
|
const icon = getCallingIcon(type, direction, status);
|
||||||
return (
|
return (
|
||||||
<SystemMessage
|
<>
|
||||||
button={renderCallingNotificationButton(props)}
|
<div
|
||||||
contents={
|
onContextMenu={handleContextMenu}
|
||||||
<>
|
// @ts-expect-error -- React/TS doesn't know about inert
|
||||||
{getCallingNotificationText(props, i18n)} ·{' '}
|
// eslint-disable-next-line react/no-unknown-property
|
||||||
<MessageTimestamp
|
inert={props.isSelectMode ? '' : undefined}
|
||||||
direction="outgoing"
|
>
|
||||||
i18n={i18n}
|
<SystemMessage
|
||||||
timestamp={timestamp}
|
button={renderCallingNotificationButton(props)}
|
||||||
withImageNoCaption={false}
|
contents={
|
||||||
withSticker={false}
|
<>
|
||||||
withTapToViewExpired={false}
|
{getCallingNotificationText(props, i18n)} ·{' '}
|
||||||
/>
|
<MessageTimestamp
|
||||||
</>
|
direction="outgoing"
|
||||||
}
|
i18n={i18n}
|
||||||
icon={icon}
|
timestamp={timestamp}
|
||||||
kind={
|
withImageNoCaption={false}
|
||||||
status === DirectCallStatus.Missed ||
|
withSticker={false}
|
||||||
status === GroupCallStatus.Missed
|
withTapToViewExpired={false}
|
||||||
? SystemMessageKind.Danger
|
/>
|
||||||
: SystemMessageKind.Normal
|
</>
|
||||||
}
|
}
|
||||||
/>
|
icon={icon}
|
||||||
|
kind={
|
||||||
|
status === DirectCallStatus.Missed ||
|
||||||
|
status === GroupCallStatus.Missed
|
||||||
|
? SystemMessageKind.Danger
|
||||||
|
: SystemMessageKind.Normal
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContextMenuTrigger
|
||||||
|
id={props.id}
|
||||||
|
ref={ref => {
|
||||||
|
// react-contextmenu's typings are incorrect here
|
||||||
|
menuTriggerRef.current = ref as unknown as ContextMenuTriggerType;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MessageContextMenu
|
||||||
|
i18n={i18n}
|
||||||
|
triggerId={props.id}
|
||||||
|
onDeleteMessage={() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
244
ts/components/conversation/MessageContextMenu.tsx
Normal file
244
ts/components/conversation/MessageContextMenu.tsx
Normal file
|
@ -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<HTMLDivElement> | 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 = (
|
||||||
|
<ContextMenu id={triggerId}>
|
||||||
|
{shouldShowAdditional && (
|
||||||
|
<>
|
||||||
|
{onDownload && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__download',
|
||||||
|
}}
|
||||||
|
onClick={onDownload}
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageContextMenu__download')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onReplyToMessage && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__reply',
|
||||||
|
}}
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
onReplyToMessage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageContextMenu__reply')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onReact && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__react',
|
||||||
|
}}
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
onReact();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageContextMenu__react')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{onForward && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__forward-message',
|
||||||
|
}}
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
onForward();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageContextMenu__forward')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__edit-message',
|
||||||
|
}}
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:edit')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onSelect && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__select',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageContextMenu__select')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onCopy && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__copy-timestamp',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
onCopy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:copy')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onMoreInfo && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__more-info',
|
||||||
|
}}
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
onMoreInfo();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageContextMenu__info')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__delete-message',
|
||||||
|
}}
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
onDeleteMessage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageContextMenu__deleteMessage')}
|
||||||
|
</MenuItem>
|
||||||
|
{onRetryMessageSend && (
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__retry-send',
|
||||||
|
}}
|
||||||
|
onClick={(event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
onRetryMessageSend();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu: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('icu:retryDeleteForEveryone')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(menu, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useHandleMessageContextMenu(
|
||||||
|
menuTriggerRef: RefObject<ContextMenuTriggerType>
|
||||||
|
): ContextMenuTriggerType['handleContextClick'] {
|
||||||
|
return React.useCallback(
|
||||||
|
(event: React.MouseEvent<HTMLDivElement> | 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]
|
||||||
|
);
|
||||||
|
}
|
|
@ -253,11 +253,13 @@ export const TimelineItem = memo(function TimelineItem({
|
||||||
} else if (item.type === 'callHistory') {
|
} else if (item.type === 'callHistory') {
|
||||||
notification = (
|
notification = (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
|
id={id}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isNextItemCallingNotification={isNextItemCallingNotification}
|
isNextItemCallingNotification={isNextItemCallingNotification}
|
||||||
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
||||||
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
|
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
|
||||||
|
toggleDeleteMessagesModal={reducedProps.toggleDeleteMessagesModal}
|
||||||
returnToActiveCall={returnToActiveCall}
|
returnToActiveCall={returnToActiveCall}
|
||||||
{...item.data}
|
{...item.data}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -5,8 +5,8 @@ import classNames from 'classnames';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { Ref } from 'react';
|
import type { Ref } from 'react';
|
||||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
import { ContextMenuTrigger } from 'react-contextmenu';
|
||||||
import ReactDOM, { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Manager, Popper, Reference } from 'react-popper';
|
import { Manager, Popper, Reference } from 'react-popper';
|
||||||
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
|
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
|
||||||
import { isDownloaded } from '../../types/Attachment';
|
import { isDownloaded } from '../../types/Attachment';
|
||||||
|
@ -34,6 +34,11 @@ import {
|
||||||
import { PanelType } from '../../types/Panels';
|
import { PanelType } from '../../types/Panels';
|
||||||
import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals';
|
import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals';
|
||||||
import { useScrollerLock } from '../../hooks/useScrollLock';
|
import { useScrollerLock } from '../../hooks/useScrollLock';
|
||||||
|
import {
|
||||||
|
type ContextMenuTriggerType,
|
||||||
|
MessageContextMenu,
|
||||||
|
useHandleMessageContextMenu,
|
||||||
|
} from './MessageContextMenu';
|
||||||
|
|
||||||
export type PropsData = {
|
export type PropsData = {
|
||||||
canDownload: boolean;
|
canDownload: boolean;
|
||||||
|
@ -77,12 +82,6 @@ export type Props = PropsData &
|
||||||
) => JSX.Element;
|
) => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Trigger = {
|
|
||||||
handleContextClick: (
|
|
||||||
event: React.MouseEvent<HTMLDivElement> | MouseEvent
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message with menu/context-menu (as necessary for rendering in the timeline)
|
* 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<
|
const [reactionPickerRoot, setReactionPickerRoot] = useState<
|
||||||
HTMLDivElement | undefined
|
HTMLDivElement | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const menuTriggerRef = useRef<Trigger | null>(null);
|
const menuTriggerRef = useRef<ContextMenuTriggerType | null>(null);
|
||||||
|
|
||||||
const isWindowWidthNotNarrow =
|
const isWindowWidthNotNarrow =
|
||||||
containerWidthBreakpoint !== WidthBreakpoint.Narrow;
|
containerWidthBreakpoint !== WidthBreakpoint.Narrow;
|
||||||
|
@ -228,22 +227,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
||||||
[kickOffAttachmentDownload, saveAttachment, attachments, id, timestamp]
|
[kickOffAttachmentDownload, saveAttachment, attachments, id, timestamp]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContextMenu = React.useCallback(
|
const handleContextMenu = useHandleMessageContextMenu(menuTriggerRef);
|
||||||
(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]
|
|
||||||
);
|
|
||||||
|
|
||||||
const canForward =
|
const canForward =
|
||||||
!isTapToView && !deletedForEveryone && !giftBadge && !contact && !payment;
|
!isTapToView && !deletedForEveryone && !giftBadge && !contact && !payment;
|
||||||
|
|
||||||
|
@ -280,15 +264,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
||||||
handleReact || noop
|
handleReact || noop
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenContextMenu = useCallback(() => {
|
const openContextMenuKeyboard = useOpenContextMenu(handleContextMenu);
|
||||||
if (!menuTriggerRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const event = new MouseEvent('click');
|
|
||||||
menuTriggerRef.current.handleContextClick(event);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openContextMenuKeyboard = useOpenContextMenu(handleOpenContextMenu);
|
|
||||||
|
|
||||||
useKeyboardShortcutsConditionally(
|
useKeyboardShortcutsConditionally(
|
||||||
Boolean(isTargeted),
|
Boolean(isTargeted),
|
||||||
|
@ -419,7 +395,7 @@ type MessageMenuProps = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
triggerId: string;
|
triggerId: string;
|
||||||
isWindowWidthNotNarrow: boolean;
|
isWindowWidthNotNarrow: boolean;
|
||||||
menuTriggerRef: Ref<Trigger>;
|
menuTriggerRef: Ref<ContextMenuTriggerType>;
|
||||||
showMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
|
showMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
onDownload: (() => void) | undefined;
|
onDownload: (() => void) | undefined;
|
||||||
onReplyToMessage: (() => void) | undefined;
|
onReplyToMessage: (() => void) | undefined;
|
||||||
|
@ -569,208 +545,3 @@ function MessageMenu({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = (
|
|
||||||
<ContextMenu id={triggerId}>
|
|
||||||
{shouldShowAdditional && (
|
|
||||||
<>
|
|
||||||
{onDownload && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__download',
|
|
||||||
}}
|
|
||||||
onClick={onDownload}
|
|
||||||
>
|
|
||||||
{i18n('icu:MessageContextMenu__download')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{onReplyToMessage && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__reply',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
onReplyToMessage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu:MessageContextMenu__reply')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{onReact && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__react',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
onReact();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu:MessageContextMenu__react')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{onForward && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__forward-message',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
onForward();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu:MessageContextMenu__forward')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{onEdit && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__edit-message',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
onEdit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu:edit')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__select',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu:MessageContextMenu__select')}
|
|
||||||
</MenuItem>
|
|
||||||
{onCopy && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__copy-timestamp',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
onCopy();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu:copy')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__more-info',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
onMoreInfo();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu:MessageContextMenu__info')}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__delete-message',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
onDeleteMessage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu:MessageContextMenu__deleteMessage')}
|
|
||||||
</MenuItem>
|
|
||||||
{onRetryMessageSend && (
|
|
||||||
<MenuItem
|
|
||||||
attributes={{
|
|
||||||
className:
|
|
||||||
'module-message__context--icon module-message__context__retry-send',
|
|
||||||
}}
|
|
||||||
onClick={(event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
onRetryMessageSend();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n('icu: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('icu:retryDeleteForEveryone')}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
|
|
||||||
return ReactDOM.createPortal(menu, document.body);
|
|
||||||
};
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import * as KeyboardLayout from '../services/keyboardLayout';
|
||||||
import { getHasPanelOpen } from '../state/selectors/conversations';
|
import { getHasPanelOpen } from '../state/selectors/conversations';
|
||||||
import { isInFullScreenCall } from '../state/selectors/calling';
|
import { isInFullScreenCall } from '../state/selectors/calling';
|
||||||
import { isShowingAnyModal } from '../state/selectors/globalModals';
|
import { isShowingAnyModal } from '../state/selectors/globalModals';
|
||||||
|
import type { ContextMenuTriggerType } from '../components/conversation/MessageContextMenu';
|
||||||
|
|
||||||
type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean;
|
type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean;
|
||||||
|
|
||||||
|
@ -225,7 +226,7 @@ export function useToggleReactionPicker(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOpenContextMenu(
|
export function useOpenContextMenu(
|
||||||
openContextMenu: () => unknown
|
openContextMenu: ContextMenuTriggerType['handleContextClick'] | undefined
|
||||||
): KeyboardShortcutHandlerType {
|
): KeyboardShortcutHandlerType {
|
||||||
const hasOverlay = useHasAnyOverlay();
|
const hasOverlay = useHasAnyOverlay();
|
||||||
|
|
||||||
|
@ -247,7 +248,7 @@ export function useOpenContextMenu(
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
openContextMenu();
|
openContextMenu?.(new MouseEvent('click'));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1328,6 +1328,8 @@ export type GetPropsForCallHistoryOptions = Pick<
|
||||||
| 'callHistorySelector'
|
| 'callHistorySelector'
|
||||||
| 'conversationSelector'
|
| 'conversationSelector'
|
||||||
| 'ourConversationId'
|
| 'ourConversationId'
|
||||||
|
| 'selectedMessageIds'
|
||||||
|
| 'targetedMessageId'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const emptyCallNotification: CallingNotificationType = {
|
const emptyCallNotification: CallingNotificationType = {
|
||||||
|
@ -1337,6 +1339,8 @@ const emptyCallNotification: CallingNotificationType = {
|
||||||
groupCallEnded: null,
|
groupCallEnded: null,
|
||||||
maxDevices: Infinity,
|
maxDevices: Infinity,
|
||||||
deviceCount: 0,
|
deviceCount: 0,
|
||||||
|
isSelectMode: false,
|
||||||
|
isTargeted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPropsForCallHistory(
|
export function getPropsForCallHistory(
|
||||||
|
@ -1347,6 +1351,8 @@ export function getPropsForCallHistory(
|
||||||
activeCall,
|
activeCall,
|
||||||
conversationSelector,
|
conversationSelector,
|
||||||
ourConversationId,
|
ourConversationId,
|
||||||
|
selectedMessageIds,
|
||||||
|
targetedMessageId,
|
||||||
}: GetPropsForCallHistoryOptions
|
}: GetPropsForCallHistoryOptions
|
||||||
): CallingNotificationType {
|
): CallingNotificationType {
|
||||||
const { callId } = message;
|
const { callId } = message;
|
||||||
|
@ -1368,6 +1374,8 @@ export function getPropsForCallHistory(
|
||||||
'getPropsForCallHistory: Missing conversation'
|
'getPropsForCallHistory: Missing conversation'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isSelectMode = selectedMessageIds != null;
|
||||||
|
|
||||||
let callCreator: ConversationType | null = null;
|
let callCreator: ConversationType | null = null;
|
||||||
if (callHistory.ringerId) {
|
if (callHistory.ringerId) {
|
||||||
callCreator = conversationSelector(callHistory.ringerId);
|
callCreator = conversationSelector(callHistory.ringerId);
|
||||||
|
@ -1383,6 +1391,8 @@ export function getPropsForCallHistory(
|
||||||
groupCallEnded: false,
|
groupCallEnded: false,
|
||||||
deviceCount: 0,
|
deviceCount: 0,
|
||||||
maxDevices: Infinity,
|
maxDevices: Infinity,
|
||||||
|
isSelectMode,
|
||||||
|
isTargeted: message.id === targetedMessageId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1410,6 +1420,8 @@ export function getPropsForCallHistory(
|
||||||
groupCallEnded: callId !== conversationCallId || deviceCount === 0,
|
groupCallEnded: callId !== conversationCallId || deviceCount === 0,
|
||||||
deviceCount,
|
deviceCount,
|
||||||
maxDevices,
|
maxDevices,
|
||||||
|
isSelectMode,
|
||||||
|
isTargeted: message.id === targetedMessageId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,8 @@ describe('calling notification helpers', () => {
|
||||||
groupCallEnded: true,
|
groupCallEnded: true,
|
||||||
deviceCount: 1,
|
deviceCount: 1,
|
||||||
maxDevices: 23,
|
maxDevices: 23,
|
||||||
|
isSelectMode: false,
|
||||||
|
isTargeted: false,
|
||||||
},
|
},
|
||||||
i18n
|
i18n
|
||||||
),
|
),
|
||||||
|
@ -72,6 +74,8 @@ describe('calling notification helpers', () => {
|
||||||
groupCallEnded: false,
|
groupCallEnded: false,
|
||||||
deviceCount: 1,
|
deviceCount: 1,
|
||||||
maxDevices: 23,
|
maxDevices: 23,
|
||||||
|
isSelectMode: false,
|
||||||
|
isTargeted: false,
|
||||||
},
|
},
|
||||||
i18n
|
i18n
|
||||||
),
|
),
|
||||||
|
@ -102,6 +106,8 @@ describe('calling notification helpers', () => {
|
||||||
groupCallEnded: false,
|
groupCallEnded: false,
|
||||||
deviceCount: 1,
|
deviceCount: 1,
|
||||||
maxDevices: 23,
|
maxDevices: 23,
|
||||||
|
isSelectMode: false,
|
||||||
|
isTargeted: false,
|
||||||
},
|
},
|
||||||
i18n
|
i18n
|
||||||
),
|
),
|
||||||
|
@ -131,6 +137,8 @@ describe('calling notification helpers', () => {
|
||||||
groupCallEnded: false,
|
groupCallEnded: false,
|
||||||
deviceCount: 1,
|
deviceCount: 1,
|
||||||
maxDevices: 23,
|
maxDevices: 23,
|
||||||
|
isSelectMode: false,
|
||||||
|
isTargeted: false,
|
||||||
},
|
},
|
||||||
i18n
|
i18n
|
||||||
),
|
),
|
||||||
|
@ -157,6 +165,8 @@ describe('calling notification helpers', () => {
|
||||||
groupCallEnded: false,
|
groupCallEnded: false,
|
||||||
deviceCount: 1,
|
deviceCount: 1,
|
||||||
maxDevices: 23,
|
maxDevices: 23,
|
||||||
|
isSelectMode: false,
|
||||||
|
isTargeted: false,
|
||||||
},
|
},
|
||||||
i18n
|
i18n
|
||||||
),
|
),
|
||||||
|
|
|
@ -22,6 +22,8 @@ export type CallingNotificationType = Readonly<{
|
||||||
groupCallEnded: boolean | null;
|
groupCallEnded: boolean | null;
|
||||||
deviceCount: number;
|
deviceCount: number;
|
||||||
maxDevices: number;
|
maxDevices: number;
|
||||||
|
isSelectMode: boolean;
|
||||||
|
isTargeted: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function getDirectCallNotificationText(
|
function getDirectCallNotificationText(
|
||||||
|
|
|
@ -16,7 +16,11 @@ import { dropNull } from './dropNull';
|
||||||
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
||||||
import { getCallSelector, getActiveCall } from '../state/selectors/calling';
|
import { getCallSelector, getActiveCall } from '../state/selectors/calling';
|
||||||
import { getCallingNotificationText } from './callingNotification';
|
import { getCallingNotificationText } from './callingNotification';
|
||||||
import { getConversationSelector } from '../state/selectors/conversations';
|
import {
|
||||||
|
getConversationSelector,
|
||||||
|
getSelectedMessageIds,
|
||||||
|
getTargetedMessage,
|
||||||
|
} from '../state/selectors/conversations';
|
||||||
import { getStringForConversationMerge } from './getStringForConversationMerge';
|
import { getStringForConversationMerge } from './getStringForConversationMerge';
|
||||||
import { getStringForProfileChange } from './getStringForProfileChange';
|
import { getStringForProfileChange } from './getStringForProfileChange';
|
||||||
import { getTitleNoDefault, getNumber } from './getTitle';
|
import { getTitleNoDefault, getNumber } from './getTitle';
|
||||||
|
@ -376,6 +380,8 @@ export function getNotificationDataForMessage(
|
||||||
activeCall: getActiveCall(state),
|
activeCall: getActiveCall(state),
|
||||||
callHistorySelector: getCallHistorySelector(state),
|
callHistorySelector: getCallHistorySelector(state),
|
||||||
conversationSelector: getConversationSelector(state),
|
conversationSelector: getConversationSelector(state),
|
||||||
|
selectedMessageIds: getSelectedMessageIds(state),
|
||||||
|
targetedMessageId: getTargetedMessage(state)?.id,
|
||||||
});
|
});
|
||||||
if (callingNotification) {
|
if (callingNotification) {
|
||||||
const text = getCallingNotificationText(callingNotification, window.i18n);
|
const text = getCallingNotificationText(callingNotification, window.i18n);
|
||||||
|
|
|
@ -3677,10 +3677,17 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/conversation/TimelineMessage.tsx",
|
"path": "ts/components/conversation/CallingNotification.tsx",
|
||||||
"line": " const menuTriggerRef = useRef<Trigger | null>(null);",
|
"line": " const menuTriggerRef = React.useRef<ContextMenuTriggerType | null>(null);",
|
||||||
"reasonCategory": "usageTrusted",
|
"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<ContextMenuTriggerType | null>(null);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2023-12-08T20:28:57.595Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
|
|
Loading…
Reference in a new issue