Add contextMenu for deleting call events on right-click

This commit is contained in:
trevor-signal 2023-12-12 11:11:39 -05:00 committed by GitHub
parent 7fb01f102d
commit 88fd42a46b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 394 additions and 269 deletions

View file

@ -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;
}

View file

@ -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,
};
};

View file

@ -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<PropsType> = React.memo(
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;
if (props.callHistory == null) {
return null;
}
const { type, direction, status, timestamp } = props.callHistory;
const icon = getCallingIcon(type, direction, status);
return (
<SystemMessage
button={renderCallingNotificationButton(props)}
contents={
<>
{getCallingNotificationText(props, i18n)} &middot;{' '}
<MessageTimestamp
direction="outgoing"
i18n={i18n}
timestamp={timestamp}
withImageNoCaption={false}
withSticker={false}
withTapToViewExpired={false}
/>
</>
}
icon={icon}
kind={
status === DirectCallStatus.Missed ||
status === GroupCallStatus.Missed
? SystemMessageKind.Danger
: SystemMessageKind.Normal
}
/>
<>
<div
onContextMenu={handleContextMenu}
// @ts-expect-error -- React/TS doesn't know about inert
// eslint-disable-next-line react/no-unknown-property
inert={props.isSelectMode ? '' : undefined}
>
<SystemMessage
button={renderCallingNotificationButton(props)}
contents={
<>
{getCallingNotificationText(props, i18n)} &middot;{' '}
<MessageTimestamp
direction="outgoing"
i18n={i18n}
timestamp={timestamp}
withImageNoCaption={false}
withSticker={false}
withTapToViewExpired={false}
/>
</>
}
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}
/>
</>
);
}
);

View 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]
);
}

View file

@ -253,11 +253,13 @@ export const TimelineItem = memo(function TimelineItem({
} else if (item.type === 'callHistory') {
notification = (
<CallingNotification
id={id}
conversationId={conversationId}
i18n={i18n}
isNextItemCallingNotification={isNextItemCallingNotification}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
toggleDeleteMessagesModal={reducedProps.toggleDeleteMessagesModal}
returnToActiveCall={returnToActiveCall}
{...item.data}
/>

View file

@ -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<HTMLDivElement> | 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<Trigger | null>(null);
const menuTriggerRef = useRef<ContextMenuTriggerType | null>(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<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 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<Trigger>;
menuTriggerRef: Ref<ContextMenuTriggerType>;
showMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
onDownload: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
@ -569,208 +545,3 @@ function MessageMenu({
</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);
};

View file

@ -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;
}

View file

@ -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,
};
}

View file

@ -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
),

View file

@ -22,6 +22,8 @@ export type CallingNotificationType = Readonly<{
groupCallEnded: boolean | null;
deviceCount: number;
maxDevices: number;
isSelectMode: boolean;
isTargeted: boolean;
}>;
function getDirectCallNotificationText(

View file

@ -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);

View file

@ -3677,10 +3677,17 @@
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/TimelineMessage.tsx",
"line": " const menuTriggerRef = useRef<Trigger | null>(null);",
"path": "ts/components/conversation/CallingNotification.tsx",
"line": " const menuTriggerRef = React.useRef<ContextMenuTriggerType | null>(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<ContextMenuTriggerType | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2023-12-08T20:28:57.595Z"
},
{
"rule": "React-useRef",