Migrate react-contextmenu menus to axo menus

Co-authored-by: Fedor Indutny <indutny@signal.org>
This commit is contained in:
Jamie
2025-11-12 09:31:52 -08:00
committed by GitHub
parent 7d52f761e3
commit 714e161671
37 changed files with 1366 additions and 1693 deletions

View File

@@ -157,7 +157,6 @@ const DOM_PACKAGES = new Set([
'react-aria',
'react-aria-components',
'react-blurhash',
'react-contextmenu',
'react-popper',
'react-virtualized',
// Note that: react-dom/server is categorized separately

View File

@@ -5485,30 +5485,6 @@ Signal Desktop makes use of the following open source projects.
License: MIT
## react-contextmenu
The MIT License (MIT)
Copyright (c) 2015 Vivek Kumar Bansal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## react-dom
MIT License

View File

@@ -9058,6 +9058,10 @@
"messageformat": "Edit history",
"description": "Modal title for the edit history messages modal"
},
"icu:EditHistoryMessagesModal__Message__ContextMenu__CopyTimestamp": {
"messageformat": "Copy timestamp",
"description": "Context menu item to help debugging"
},
"icu:ResendMessageEdit__body": {
"messageformat": "This edit could not be sent. Check your connection and try again",
"description": "Modal body for the confirmation dialog shown to user when attempting to resend message edit"

View File

@@ -201,7 +201,6 @@
"react-aria": "3.35.1",
"react-aria-components": "1.4.1",
"react-blurhash": "0.3.0",
"react-contextmenu": "2.14.0",
"react-dom": "18.3.1",
"react-intl": "7.1.11",
"react-popper": "2.3.0",
@@ -377,9 +376,7 @@
"canvas": "-",
"jsdom": "-",
"thenify-all>thenify": "3.3.1",
"@electron/rebuild@3.7.2>@electron/node-gyp": "10.2.0-electron.2",
"react-contextmenu>react": "18.3.1",
"react-contextmenu>react-dom": "18.3.1"
"@electron/rebuild@3.7.2>@electron/node-gyp": "10.2.0-electron.2"
},
"patchedDependencies": {
"casual@1.6.2": "patches/casual+1.6.2.patch",
@@ -390,7 +387,6 @@
"qrcode-generator@1.4.4": "patches/qrcode-generator+1.4.4.patch",
"@types/node-fetch@2.6.12": "patches/@types+node-fetch+2.6.12.patch",
"fabric@4.6.0": "patches/fabric+4.6.0.patch",
"react-contextmenu@2.14.0": "patches/react-contextmenu+2.14.0.patch",
"@vitest/expect@2.0.5": "patches/@vitest+expect+2.0.5.patch",
"got@11.8.5": "patches/got+11.8.5.patch",
"growing-file@0.1.3": "patches/growing-file+0.1.3.patch",

View File

@@ -1,186 +0,0 @@
diff --git a/modules/ContextMenu.js b/modules/ContextMenu.js
index 2f8821393f15a1ce85132385688f08cd6e390bf4..41e47ea740636f81bdd484a7093d0ea3169eb9d5 100644
--- a/modules/ContextMenu.js
+++ b/modules/ContextMenu.js
@@ -81,6 +81,11 @@ var ContextMenu = function (_AbstractMenu) {
x = _e$detail$position.x,
y = _e$detail$position.y;
+ if (x === undefined) {
+ var rect = e.detail.target.getBoundingClientRect();
+ x = rect.x;
+ y = rect.y;
+ }
_this.setState({ isVisible: true, x: x, y: y });
_this.registerHandlers();
@@ -226,6 +231,9 @@ var ContextMenu = function (_AbstractMenu) {
var wrapper = window.requestAnimationFrame || setTimeout;
if (this.state.isVisible) {
+ if (!this.previousFocus) {
+ this.previousFocus = document.activeElement;
+ }
wrapper(function () {
var _state = _this2.state,
x = _state.x,
@@ -240,7 +248,10 @@ var ContextMenu = function (_AbstractMenu) {
_this2.menu.style.top = top + 'px';
_this2.menu.style.left = left + 'px';
_this2.menu.style.opacity = 1;
+ _this2.menu.style.visibility = 'visible';
_this2.menu.style.pointerEvents = 'auto';
+
+ _this2.menu.focus();
});
});
} else {
@@ -248,6 +259,16 @@ var ContextMenu = function (_AbstractMenu) {
if (!_this2.menu) return;
_this2.menu.style.opacity = 0;
_this2.menu.style.pointerEvents = 'none';
+
+ // Return to the previous focus state when dismissing the menu, unless the
+ // menu option focused another element. This is important for keyboard mode.
+ if (_this2.props.avoidFocusRestoreOnBlur) return;
+
+ var isFocusWithinMenu = _this2.menu.contains(document.activeElement);
+ if (isFocusWithinMenu && _this2.previousFocus && _this2.previousFocus.focus) {
+ _this2.previousFocus.focus();
+ _this2.previousFocus = null;
+ }
});
}
}
diff --git a/modules/SubMenu.js b/modules/SubMenu.js
index ad1dc7043c13cbc30f1659d5b82696ed974c996a..c919be8d12329dd5bcf77d3660b85edb83714bb8 100644
--- a/modules/SubMenu.js
+++ b/modules/SubMenu.js
@@ -129,6 +129,7 @@ var SubMenu = function (_AbstractMenu) {
if (_this.props.disabled || _this.state.visible) return;
+ if (_this.opentimer) clearTimeout(_this.opentimer);
_this.opentimer = setTimeout(function () {
return _this.setState({
visible: true,
@@ -142,6 +143,7 @@ var SubMenu = function (_AbstractMenu) {
if (!_this.state.visible) return;
+ if (_this.closetimer) clearTimeout(_this.closetimer);
_this.closetimer = setTimeout(function () {
return _this.setState({
visible: false,
@@ -170,6 +172,15 @@ var SubMenu = function (_AbstractMenu) {
}
};
+ _this.cleanup = function () {
+ _this.subMenu.removeEventListener('transitionend', _this.cleanup);
+ _this.subMenu.style.removeProperty('bottom');
+ _this.subMenu.style.removeProperty('right');
+ _this.subMenu.style.top = 0;
+ _this.subMenu.style.left = '100%';
+ _this.unregisterHandlers();
+ };
+
_this.state = (0, _objectAssign2.default)({}, _this.state, {
visible: false
});
@@ -202,32 +213,28 @@ var SubMenu = function (_AbstractMenu) {
if (this.props.forceOpen || this.state.visible) {
var wrapper = window.requestAnimationFrame || setTimeout;
wrapper(function () {
- var styles = _this2.props.rtl ? _this2.getRTLMenuPosition() : _this2.getMenuPosition();
-
- _this2.subMenu.style.removeProperty('top');
- _this2.subMenu.style.removeProperty('bottom');
- _this2.subMenu.style.removeProperty('left');
- _this2.subMenu.style.removeProperty('right');
-
- if ((0, _helpers.hasOwnProp)(styles, 'top')) _this2.subMenu.style.top = styles.top;
- if ((0, _helpers.hasOwnProp)(styles, 'left')) _this2.subMenu.style.left = styles.left;
- if ((0, _helpers.hasOwnProp)(styles, 'bottom')) _this2.subMenu.style.bottom = styles.bottom;
- if ((0, _helpers.hasOwnProp)(styles, 'right')) _this2.subMenu.style.right = styles.right;
- _this2.subMenu.classList.add(_helpers.cssClasses.menuVisible);
-
- _this2.registerHandlers();
- _this2.setState({ selectedItem: null });
+ if (_this2.props.forceOpen || _this2.state.visible) {
+ _this2.subMenu.removeEventListener('transitionend', _this2.cleanup);
+ var styles = _this2.props.rtl ? _this2.getRTLMenuPosition() : _this2.getMenuPosition();
+
+ _this2.subMenu.style.removeProperty('top');
+ _this2.subMenu.style.removeProperty('bottom');
+ _this2.subMenu.style.removeProperty('left');
+ _this2.subMenu.style.removeProperty('right');
+
+ if ((0, _helpers.hasOwnProp)(styles, 'top')) _this2.subMenu.style.top = styles.top;
+ if ((0, _helpers.hasOwnProp)(styles, 'left')) _this2.subMenu.style.left = styles.left;
+ if ((0, _helpers.hasOwnProp)(styles, 'bottom')) _this2.subMenu.style.bottom = styles.bottom;
+ if ((0, _helpers.hasOwnProp)(styles, 'right')) _this2.subMenu.style.right = styles.right;
+ _this2.subMenu.classList.add(_helpers.cssClasses.menuVisible);
+
+ _this2.registerHandlers();
+ _this2.setState({ selectedItem: null });
+ }
});
} else {
- var cleanup = function cleanup() {
- _this2.subMenu.removeEventListener('transitionend', cleanup);
- _this2.subMenu.style.removeProperty('bottom');
- _this2.subMenu.style.removeProperty('right');
- _this2.subMenu.style.top = 0;
- _this2.subMenu.style.left = '100%';
- _this2.unregisterHandlers();
- };
- this.subMenu.addEventListener('transitionend', cleanup);
+ this.subMenu.removeEventListener('transitionend', this.cleanup);
+ this.subMenu.addEventListener('transitionend', this.cleanup);
this.subMenu.classList.remove(_helpers.cssClasses.menuVisible);
}
}
diff --git a/src/index.d.ts b/src/index.d.ts
index 753ce9081490fd90b4354e6e73937dc85957689c..e8a8b0815a7ca61ef9bc488299cda08e181267bc 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -14,6 +14,8 @@ declare module "react-contextmenu" {
preventHideOnResize?: boolean,
preventHideOnScroll?: boolean,
style?: React.CSSProperties,
+ avoidFocusRestoreOnBlur?: boolean;
+ children?: React.ReactNode;
}
export interface ContextMenuTriggerProps {
@@ -25,6 +27,7 @@ declare module "react-contextmenu" {
renderTag?: React.ElementType,
mouseButton?: number,
disableIfShiftIsPressed?: boolean,
+ children?: React.ReactNode,
}
export interface MenuItemProps {
@@ -35,6 +38,7 @@ declare module "react-contextmenu" {
divider?: boolean,
preventClose?: boolean,
onClick?: {(event: React.TouchEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>, data: Object, target: HTMLElement): void} | Function,
+ children?: React.ReactNode,
}
export interface SubMenuProps {
@@ -45,11 +49,13 @@ declare module "react-contextmenu" {
rtl?: boolean,
preventCloseOnClick?: boolean,
onClick?: {(event: React.TouchEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>, data: Object, target: HTMLElement): void} | Function,
+ children?: React.ReactNode,
}
export interface ConnectMenuProps {
id: string;
trigger: any;
+ children?: React.ReactNode;
}
export const ContextMenu: React.ComponentClass<ContextMenuProps>;

23
pnpm-lock.yaml generated
View File

@@ -11,8 +11,6 @@ overrides:
jsdom: '-'
thenify-all>thenify: 3.3.1
'@electron/rebuild@3.7.2>@electron/node-gyp': 10.2.0-electron.2
react-contextmenu>react: 18.3.1
react-contextmenu>react-dom: 18.3.1
patchedDependencies:
'@types/express@4.17.21':
@@ -63,9 +61,6 @@ patchedDependencies:
qrcode-generator@1.4.4:
hash: 1f10c592d849ed4cfc9f81301196d39857b79240997ef5772138218cb3717e80
path: patches/qrcode-generator+1.4.4.patch
react-contextmenu@2.14.0:
hash: 0a61a588d4e16ca308a33d4765e00ccade23abac650b981439a128bd5be785d7
path: patches/react-contextmenu+2.14.0.patch
websocket@1.0.34:
hash: b8d361a6a73e44000bb51102dea0d841c22d2bb455dd6c54de566d0e0bd86355
path: patches/websocket+1.0.34.patch
@@ -335,9 +330,6 @@ importers:
react-blurhash:
specifier: 0.3.0
version: 0.3.0(blurhash@2.0.5)(react@18.3.1)
react-contextmenu:
specifier: 2.14.0
version: 2.14.0(patch_hash=0a61a588d4e16ca308a33d4765e00ccade23abac650b981439a128bd5be785d7)(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
@@ -9201,13 +9193,6 @@ packages:
blurhash: ^2.0.3
react: '>=15'
react-contextmenu@2.14.0:
resolution: {integrity: sha512-ktqMOuad6sCFNJs/ltEwppN8F0YeXmqoZfwycgtZR/MxOXMYx1xgYC44SzWH259HdGyshk1/7sXGuIRwj9hzbw==}
peerDependencies:
prop-types: ^15.0.0
react: 18.3.1
react-dom: 18.3.1
react-devtools-core@6.0.1:
resolution: {integrity: sha512-II3iSJhnR5nAscYDa9FCgPLq8mO5aEx/EKKtdXYTDnvdFEa3K7gs3jn1SKRXwQf9maOmIilmjnnx7Qy+3annPA==}
@@ -21233,14 +21218,6 @@ snapshots:
blurhash: 2.0.5
react: 18.3.1
react-contextmenu@2.14.0(patch_hash=0a61a588d4e16ca308a33d4765e00ccade23abac650b981439a128bd5be785d7)(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
classnames: 2.5.1
object-assign: 4.1.1
prop-types: 15.8.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-devtools-core@6.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10):
dependencies:
shell-quote: 1.8.2

View File

@@ -160,7 +160,8 @@
opacity: 0;
}
.module-message:hover .module-message__buttons {
.module-message:hover .module-message__buttons,
.module-message__buttons:has([data-state='open']) {
opacity: 1;
}
@@ -6537,169 +6538,6 @@ button.module-calling-participants-list__contact {
margin-bottom: 3px;
}
/* Third-party module: react-contextmenu*/
.react-contextmenu {
@include mixins.popper-shadow();
& {
outline: none;
border-radius: 4px;
min-width: 220px;
padding-block: 6px;
padding-inline: 0;
opacity: 0;
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 mixins.light-theme {
background-color: variables.$color-white;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-75;
}
}
}
.react-contextmenu--visible {
opacity: 1;
visibility: visible;
z-index: variables.$z-index-context-menu;
-webkit-app-region: no-drag;
}
.react-contextmenu-item {
outline: none;
cursor: pointer;
white-space: nowrap;
display: flex;
width: 100%;
align-items: center;
@include mixins.font-body-2;
padding-block: 7px;
padding-inline: 12px;
@include mixins.light-theme {
color: variables.$color-gray-90;
}
@include mixins.dark-theme {
color: variables.$color-gray-02;
}
&--divider {
height: 1px;
margin-block: 6px;
margin-inline: 0;
padding: 0;
@include mixins.light-theme {
background-color: variables.$color-gray-15;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-60;
}
}
}
.react-contextmenu-item--checked:before {
content: '';
display: inline-block;
position: absolute;
inset-inline-end: 7px;
@include mixins.light-theme {
color: variables.$color-gray-90;
}
@include mixins.dark-theme {
color: variables.$color-gray-02;
}
}
.react-contextmenu-item.react-contextmenu-submenu {
padding: 0;
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item {
padding-inline-end: 36px;
}
.react-contextmenu-item.react-contextmenu-submenu
> .react-contextmenu-item:after {
content: ' ';
display: inline-block;
height: 18px;
position: absolute;
inset-inline-end: 7px;
width: 12px;
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
variables.$color-gray-75
);
& {
color: variables.$color-gray-90;
}
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
variables.$color-gray-15
);
& {
color: variables.$color-gray-02;
}
}
}
.react-contextmenu-item.react-contextmenu-item--active,
.react-contextmenu-item.react-contextmenu-item--selected {
color: variables.$color-black;
@include mixins.light-theme {
background-color: variables.$color-gray-15;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-60;
color: variables.$color-white;
}
}
.react-contextmenu-item--disabled.react-contextmenu-item--selected {
background-color: inherit;
cursor: inherit;
}
.react-contextmenu-item.react-contextmenu-item--active.react-contextmenu-item--checked:before,
.react-contextmenu-item.react-contextmenu-item--selected.react-contextmenu-item--checked:before {
color: variables.$color-black;
@include mixins.dark-theme {
color: variables.$color-white;
}
}
.react-contextmenu-item.react-contextmenu-submenu
> .react-contextmenu-item.react-contextmenu-item--active:after,
.react-contextmenu-item.react-contextmenu-submenu
> .react-contextmenu-item.react-contextmenu-item--selected:after {
color: variables.$color-black;
@include mixins.dark-theme {
color: variables.$color-white;
}
}
// To limit messages with things forcing them wider, like long attachment names
.module-message__container {
max-width: 100%;
@@ -6731,158 +6569,6 @@ button.module-calling-participants-list__contact {
vertical-align: middle;
}
&__download::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/save/save-compact.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/save/save-compact.svg',
variables.$color-gray-15
);
}
}
&__reply::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/reply/reply-compact.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/reply/reply-compact.svg',
variables.$color-gray-15
);
}
}
&__react::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/heart/heart-plus-compact.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/heart/heart-plus-compact.svg',
variables.$color-gray-15
);
}
}
&__end-poll::before {
@include mixins.color-svg(
'../images/icons/v3/stop/stop-circle.svg',
light-dark(variables.$color-black, variables.$color-gray-15)
);
}
&__more-info::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/info/info-compact.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/info/info-compact.svg',
variables.$color-gray-15
);
}
}
&__select::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/check/check-circle-compact.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/check/check-circle-compact.svg',
variables.$color-gray-15
);
}
}
&__retry-send::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/send/send.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/send/send.svg',
variables.$color-gray-15
);
}
}
&__forward-message::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/forward/forward-compact.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/forward/forward-compact.svg',
variables.$color-gray-15
);
}
}
&__edit-message::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/edit/edit-compact.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/edit/edit-compact.svg',
variables.$color-gray-15
);
}
}
&__delete-message::before,
&__delete-message-for-everyone::before {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/trash/trash-compact.svg',
variables.$color-black
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/trash/trash-compact.svg',
variables.$color-gray-15
);
}
}
&__copy-timestamp::before {
@include mixins.light-theme {
@include mixins.color-svg(

View File

@@ -329,13 +329,4 @@
}
}
}
&__disappearing-timer__item {
padding-inline-start: 25px;
&--active {
padding-inline-start: 0px;
@include icon-element('../images/icons/v3/check/check-compact.svg', 12px);
}
}
}

View File

@@ -1,11 +1,17 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ContextMenu } from 'radix-ui';
import type { FC } from 'react';
import type {
FC,
KeyboardEvent,
KeyboardEventHandler,
MouseEvent as ReactMouseEvent,
} from 'react';
import { AxoSymbol } from './AxoSymbol.dom.js';
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js';
import { tw } from './tw.dom.js';
import { assert } from './_internal/assert.dom.js';
const Namespace = 'AxoContextMenu';
@@ -57,7 +63,11 @@ export namespace AxoContextMenu {
export type RootProps = AxoBaseMenu.MenuRootProps;
export const Root: FC<RootProps> = memo(props => {
return <ContextMenu.Root>{props.children}</ContextMenu.Root>;
return (
<ContextMenu.Root onOpenChange={props.onOpenChange}>
{props.children}
</ContextMenu.Root>
);
});
Root.displayName = `${Namespace}.Root`;
@@ -67,14 +77,111 @@ export namespace AxoContextMenu {
* -----------------------------------
*/
type TriggerElementGetter = (event: KeyboardEvent) => Element;
// eslint-disable-next-line no-inner-declarations
function useContextMenuTriggerKeyboardEventHandler(
getTriggerElement: TriggerElementGetter
) {
const getTriggerElementRef =
useRef<TriggerElementGetter>(getTriggerElement);
useEffect(() => {
getTriggerElementRef.current = getTriggerElement;
}, [getTriggerElement]);
return useCallback(
(event: KeyboardEvent) => {
const isMacOS = window.platform === 'darwin';
if (
(isMacOS ? event.metaKey : !event.metaKey) &&
(isMacOS ? !event.ctrlKey : event.ctrlKey) &&
(isMacOS ? !event.shiftKey : event.shiftKey) &&
!event.altKey &&
(isMacOS ? event.key === 'F12' : event.key === 'F10')
) {
event.preventDefault();
event.stopPropagation();
const trigger = getTriggerElement(event);
const clientRect = trigger.getBoundingClientRect();
trigger.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: clientRect.left,
clientY: clientRect.bottom,
})
);
}
},
[getTriggerElement]
);
}
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
export const Trigger: FC<TriggerProps> = memo(props => {
return <ContextMenu.Trigger asChild>{props.children}</ContextMenu.Trigger>;
const [disableCurrentEvent, setDisableCurrentEvent] = useState(false);
const handleContextMenuCapture = useCallback(
(event: ReactMouseEvent<HTMLElement>) => {
const { target, currentTarget } = event;
if (
target instanceof HTMLElement &&
target.closest('a[href], [role=link]') != null
) {
setDisableCurrentEvent(true);
}
const selection = window.getSelection();
if (
selection != null &&
!selection.isCollapsed &&
selection.containsNode(currentTarget, true)
) {
setDisableCurrentEvent(true);
}
},
[]
);
const handleContextMenu = useCallback(() => {
setDisableCurrentEvent(false);
}, []);
const handleKeyDown = useContextMenuTriggerKeyboardEventHandler(event => {
return event.currentTarget;
});
return (
<ContextMenu.Trigger
asChild
onContextMenuCapture={handleContextMenuCapture}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
disabled={disableCurrentEvent || props.disabled}
data-axo-context-menu-trigger
>
{props.children}
</ContextMenu.Trigger>
);
});
Trigger.displayName = `${Namespace}.Trigger`;
export function useAxoContextMenuOutsideKeyboardTrigger(): KeyboardEventHandler {
return useContextMenuTriggerKeyboardEventHandler(event => {
return assert(
event.currentTarget.querySelector('[data-axo-context-menu-trigger]'),
`Couldn't find <${Namespace}.Trigger> element, did you forget to pass all html props through?`
);
});
}
/**
* Component: <AxoContextMenu.Content>
* -----------------------------------

View File

@@ -3,7 +3,7 @@
import React, { memo, useEffect, useId, useRef } from 'react';
import { DropdownMenu } from 'radix-ui';
import type { FC, ReactNode } from 'react';
import { getRole, computeAccessibleName } from 'dom-accessibility-api';
import { computeAccessibleName } from 'dom-accessibility-api';
import { AxoSymbol } from './AxoSymbol.dom.js';
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js';
import { tw } from './tw.dom.js';
@@ -13,6 +13,10 @@ import {
useCreateAriaLabellingContext,
} from './_internal/AriaLabellingContext.dom.js';
import { assert } from './_internal/assert.dom.js';
import {
getElementAriaRole,
isAriaWidgetRole,
} from './_internal/ariaRoles.dom.js';
const Namespace = 'AxoDropdownMenu';
@@ -107,8 +111,8 @@ export namespace AxoDropdownMenu {
`${triggerDisplayName} child must forward ref`
);
assert(
getRole(ref.current) === 'button',
`${triggerDisplayName} child must be a <button> or role=button`
isAriaWidgetRole(getElementAriaRole(ref.current)),
`${triggerDisplayName} child must have a widget role like 'button'`
);
assert(
computeAccessibleName(ref.current) !== '',
@@ -118,7 +122,7 @@ export namespace AxoDropdownMenu {
});
return (
<DropdownMenu.Trigger ref={ref} asChild>
<DropdownMenu.Trigger ref={ref} asChild disabled={props.disabled}>
{props.children}
</DropdownMenu.Trigger>
);
@@ -412,6 +416,8 @@ export namespace AxoDropdownMenu {
<DropdownMenu.RadioItem
value={props.value}
className={AxoBaseMenu.menuRadioItemStyles}
disabled={props.disabled}
textValue={props.textValue}
onSelect={props.onSelect}
>
<AxoBaseMenu.ItemLeadingSlot>
@@ -501,7 +507,11 @@ export namespace AxoDropdownMenu {
*/
export const SubTrigger: FC<SubTriggerProps> = memo(props => {
return (
<DropdownMenu.SubTrigger className={AxoBaseMenu.menuSubTriggerStyles}>
<DropdownMenu.SubTrigger
disabled={props.disabled}
textValue={props.textValue}
className={AxoBaseMenu.menuSubTriggerStyles}
>
{props.symbol && (
<AxoBaseMenu.ItemLeadingSlot>
<AxoBaseMenu.ItemSymbol symbol={props.symbol} />

View File

@@ -90,7 +90,7 @@ export namespace AxoSymbol {
}>;
const iconStyles = tw(
'inline-flex size-[1em] shrink-0 items-center justify-center align-top'
'inline-flex size-[1em] shrink-0 items-center justify-center align-middle'
);
export const Icon: FC<IconProps> = memo(props => {

View File

@@ -4,14 +4,21 @@ import React from 'react';
import type { ReactNode } from 'react';
import { tw } from '../tw.dom.js';
import { AxoSymbol } from '../AxoSymbol.dom.js';
import { isTestOrMockEnvironment } from '../../environment.std.js';
// Pulled from $z-index-context-menu. In the future we should be relying more
// on insert order of dialogs/popovers/menus into portals
const LEGACY_CONTEXT_MENU_Z_INDEX = tw('z-[125]');
export namespace AxoBaseMenu {
// <Content/SubContent>
const baseContentStyles = tw(
LEGACY_CONTEXT_MENU_Z_INDEX,
'max-w-[300px] min-w-[200px]',
'select-none',
'rounded-xl bg-elevated-background-tertiary shadow-elevation-3',
'animate-opacity-0 data-[state=closed]:animate-exit',
isTestOrMockEnvironment() ||
'animate-opacity-0 data-[state=closed]:animate-exit',
'forced-colors:border',
'forced-colors:bg-[Canvas]',
'forced-colors:text-[CanvasText]'
@@ -168,6 +175,9 @@ export namespace AxoBaseMenu {
*/
export type MenuRootProps = Readonly<{
// Note: Radix context menus don't have an `open` prop
// so we have to push it down to the dropdown menu props
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}>;
@@ -178,7 +188,7 @@ export namespace AxoBaseMenu {
export type MenuTriggerProps = Readonly<{
/**
* When true, the context menu won't open when right-clicking.
* When true, the menu won't open when right-clicking.
* Note that this will also restore the native context menu.
*/
disabled?: boolean;

View File

@@ -0,0 +1,185 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AriaRole as ReactAriaRole } from 'react';
import { getRole } from 'dom-accessibility-api';
import { assert } from './assert.dom.js';
const AbstractRoles = {
/** Abstract Roles: https://www.w3.org/TR/wai-aria-1.2/#abstract_roles */
command: true,
composite: true,
input: true,
landmark: true,
range: true,
roletype: true,
section: true,
sectionhead: true,
select: true,
structure: true,
widget: true,
window: true,
} as const satisfies Record<string, true>;
export type AbstractAriaRole = keyof typeof AbstractRoles;
export type AriaRole =
| Exclude<ReactAriaRole, object>
// Missing from React's types
| 'blockquote'
| 'caption'
| 'code'
| 'deletion'
| 'emphasis'
| 'insertion'
| 'meter'
| 'paragraph'
| 'strong'
| 'subscript'
| 'superscript'
| 'time';
type ProhibitedAriaRole = 'generic';
type AnyAriaRole = AbstractAriaRole | AriaRole | ProhibitedAriaRole;
const ParentRoles = {
/** Abstract Roles: https://www.w3.org/TR/wai-aria-1.2/#abstract_roles */
command: ['widget'],
composite: ['widget'],
input: ['widget'],
landmark: ['section'],
range: ['structure'],
roletype: [],
section: ['structure'],
sectionhead: ['structure'],
select: ['composite', 'group'],
structure: ['roletype'],
widget: ['roletype'],
window: ['roletype'],
/** Widget Roles: https://www.w3.org/TR/wai-aria-1.2/#widget_roles */
button: ['command'],
checkbox: ['input'],
gridcell: ['cell', 'widget'],
link: ['command'],
menuitem: ['command'],
menuitemcheckbox: ['menuitem'],
menuitemradio: ['menuitem'],
option: ['input'],
progressbar: ['range', 'widget'],
radio: ['input'],
scrollbar: ['range', 'widget'],
searchbox: ['textbox'],
separator: ['structure' /* no-focus */, 'widget' /* focus */],
slider: ['input', 'range'],
spinbutton: ['composite', 'input', 'range'],
switch: ['checkbox'],
tab: ['sectionhead', 'widget'],
tabpanel: ['section'],
textbox: ['input'],
treeitem: ['listitem', 'option'],
/** Composite Widget Roles */
combobox: ['input'],
grid: ['composite', 'table'],
listbox: ['select'],
menu: ['select'],
menubar: ['menu'],
radiogroup: ['select'],
tablist: ['composite'],
tree: ['select'],
treegrid: ['grid', 'tree'],
/** Document Structure Roles: https://www.w3.org/TR/wai-aria-1.2/#document_structure_roles */
application: ['structure'],
article: ['document'],
blockquote: ['section'],
caption: ['section'],
cell: ['section'],
code: ['section'],
columnheader: ['cell', 'gridcell', 'sectionhead'],
definition: ['section'],
deletion: ['section'],
directory: ['list'],
document: ['structure'],
emphasis: ['section'],
feed: ['list'],
figure: ['section'],
generic: ['structure'],
group: ['section'],
heading: ['sectionhead'],
img: ['section'],
insertion: ['section'],
list: ['section'],
listitem: ['section'],
math: ['section'],
meter: ['range'],
none: ['structure'],
note: ['section'],
paragraph: ['section'],
presentation: ['structure'],
row: ['group', 'widget'],
rowgroup: ['structure'],
rowheader: ['cell', 'gridcell', 'sectionhead'],
// skip separator
strong: ['section'],
subscript: ['section'],
superscript: ['section'],
table: ['section'],
term: ['section'],
time: ['section'],
toolbar: ['group'],
tooltip: ['section'],
/** Landmark Roles: https://www.w3.org/TR/wai-aria-1.2/#landmark_roles */
banner: ['landmark'],
complementary: ['landmark'],
contentinfo: ['landmark'],
form: ['landmark'],
main: ['landmark'],
navigation: ['landmark'],
region: ['landmark'],
search: ['landmark'],
/** Live Region Roles: https://www.w3.org/TR/wai-aria-1.2/#live_region_roles */
alert: ['section'],
log: ['section'],
marquee: ['section'],
status: ['section'],
timer: ['status'],
/** Window Roles: https://www.w3.org/TR/wai-aria-1.2/#window_roles */
alertdialog: ['alert', 'dialog'],
dialog: ['window'],
} as const satisfies Record<AnyAriaRole, ReadonlyArray<AnyAriaRole>>;
function inherits(role: AnyAriaRole, superRole: AnyAriaRole): boolean {
if (role === superRole) {
return true;
}
const parentRoles = assert(ParentRoles[role], `Unknown Aria role: ${role}`);
for (const parentRole of parentRoles) {
if (inherits(parentRole, superRole)) {
return true;
}
}
return false;
}
export function isValidAriaRole(role: string | null): role is AriaRole {
return role != null && Object.hasOwn(ParentRoles, role);
}
export function getElementAriaRole(element: Element): AriaRole | null {
const role = getRole(element);
if (isValidAriaRole(role)) {
return role;
}
return null;
}
export function isAriaWidgetRole(role: AriaRole | null): boolean {
return role != null && inherits(role, 'widget');
}

View File

@@ -1,10 +1,9 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEvent, MouseEvent } from 'react';
import React, { useRef, useState } from 'react';
import type { MouseEvent, ReactNode } from 'react';
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
import { CustomColorEditor } from './CustomColorEditor.dom.js';
import { Modal } from './Modal.dom.js';
@@ -20,6 +19,8 @@ import { PanelRow } from './conversation/conversation-details/PanelRow.dom.js';
import { getCustomColorStyle } from '../util/getCustomColorStyle.dom.js';
import { useDelayedRestoreFocus } from '../hooks/useRestoreFocus.dom.js';
import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.dom.js';
import { tw } from '../axo/tw.dom.js';
type CustomColorDataType = {
id?: string;
@@ -177,73 +178,66 @@ export function ChatColorPicker({
/>
<hr />
<div className="ChatColorPicker__bubbles">
{ConversationColors.map((color, i) => (
<div
aria-label={color}
aria-selected={color === selectedColor}
className={classNames(
`ChatColorPicker__bubble ChatColorPicker__bubble--${color}`,
{
'ChatColorPicker__bubble--selected': color === selectedColor,
}
)}
key={color}
onClick={() => onSelectColor(color)}
onKeyDown={(ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
onSelectColor(color);
}
}}
role="option"
tabIndex={0}
ref={i === 0 ? focusRef : undefined}
/>
))}
{Object.keys(customColors).map(colorId => {
const colorValues = customColors[colorId];
return (
<CustomColorBubble
color={colorValues}
colorId={colorId}
getConversationsWithCustomColor={getConversationsWithCustomColor}
key={colorId}
i18n={i18n}
isSelected={colorId === selectedCustomColor.id}
onChoose={() => {
onSelectColor('custom', {
id: colorId,
value: colorValues,
});
}}
onDelete={() => {
removeCustomColor(colorId);
removeCustomColorOnConversations(colorId);
}}
onDupe={() => {
addCustomColor(colorValues, conversationId);
}}
onEdit={() => {
setCustomColorToEdit({ id: colorId, value: colorValues });
}}
<div role="listbox" className={tw('contents')}>
{ConversationColors.map((color, i) => (
<button
type="button"
role="option"
aria-label={color}
aria-selected={color === selectedColor}
className={classNames(
`ChatColorPicker__bubble ChatColorPicker__bubble--${color}`,
{
'ChatColorPicker__bubble--selected': color === selectedColor,
}
)}
key={color}
onClick={() => onSelectColor(color)}
ref={i === 0 ? focusRef : undefined}
/>
);
})}
<div
))}
{Object.keys(customColors).map(colorId => {
const colorValues = customColors[colorId];
return (
<CustomColorBubble
color={colorValues}
colorId={colorId}
getConversationsWithCustomColor={
getConversationsWithCustomColor
}
key={colorId}
i18n={i18n}
isSelected={colorId === selectedCustomColor.id}
onChoose={() => {
onSelectColor('custom', {
id: colorId,
value: colorValues,
});
}}
onDelete={() => {
removeCustomColor(colorId);
removeCustomColorOnConversations(colorId);
}}
onDupe={() => {
addCustomColor(colorValues, conversationId);
}}
onEdit={() => {
setCustomColorToEdit({ id: colorId, value: colorValues });
}}
/>
);
})}
</div>
<button
type="button"
aria-label={i18n('icu:ChatColorPicker__custom-color--label')}
className="ChatColorPicker__bubble ChatColorPicker__bubble--custom"
onClick={() =>
setCustomColorToEdit({ id: undefined, value: undefined })
}
onKeyDown={(ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
setCustomColorToEdit({ id: undefined, value: undefined });
}
onClick={() => {
setCustomColorToEdit({ id: undefined, value: undefined });
}}
role="button"
tabIndex={0}
>
<i className="ChatColorPicker__add-icon" />
</div>
</button>
</div>
<hr />
{conversationId ? (
@@ -291,49 +285,32 @@ function CustomColorBubble({
onEdit,
onChoose,
}: CustomColorBubblePropsType): JSX.Element {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const menuRef = useRef<any | null>(null);
const [confirmDeleteCount, setConfirmDeleteCount] = useState<
number | undefined
>(undefined);
const handleClick = (ev: KeyboardEvent | MouseEvent) => {
if (!isSelected) {
onChoose();
return;
}
if (menuRef && menuRef.current) {
menuRef.current.handleContextClick(ev);
}
};
const bubble = (
<div
aria-label={colorId}
aria-selected={isSelected}
className={classNames({
ChatColorPicker__bubble: true,
'ChatColorPicker__bubble--custom-selected': isSelected,
'ChatColorPicker__bubble--selected': isSelected,
})}
onClick={handleClick}
onKeyDown={(ev: KeyboardEvent) => {
if (ev.key === 'Enter') {
handleClick(ev);
}
}}
role="option"
tabIndex={0}
style={{
...getCustomColorStyle(color),
}}
/>
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
if (!isSelected) {
onChoose();
event.currentTarget.focus();
}
},
[isSelected, onChoose]
);
const handleDelete = useCallback(() => {
const conversations = getConversationsWithCustomColor(colorId);
if (!conversations.length) {
onDelete();
} else {
setConfirmDeleteCount(conversations.length);
}
}, [getConversationsWithCustomColor, colorId, onDelete]);
return (
<>
{confirmDeleteCount ? (
{confirmDeleteCount != null && (
<ConfirmationDialog
dialogName="ChatColorPicker.confirmDelete"
actions={[
@@ -353,64 +330,70 @@ function CustomColorBubble({
num: confirmDeleteCount,
})}
</ConfirmationDialog>
) : null}
{isSelected ? (
<ContextMenuTrigger id={colorId} ref={menuRef}>
{bubble}
</ContextMenuTrigger>
) : (
bubble
)}
<ContextMenu id={colorId}>
<MenuItem
attributes={{
className: 'ChatColorPicker__context--edit',
}}
onClick={(event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onEdit();
}}
>
{i18n('icu:ChatColorPicker__context--edit')}
</MenuItem>
<MenuItem
attributes={{
className: 'ChatColorPicker__context--duplicate',
}}
onClick={(event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onDupe();
}}
>
{i18n('icu:ChatColorPicker__context--duplicate')}
</MenuItem>
<MenuItem
attributes={{
className: 'ChatColorPicker__context--delete',
}}
onClick={(event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
const conversations = getConversationsWithCustomColor(colorId);
if (!conversations.length) {
onDelete();
} else {
setConfirmDeleteCount(conversations.length);
}
}}
>
{i18n('icu:ChatColorPicker__context--delete')}
</MenuItem>
</ContextMenu>
<CustomColorBubbleDropdownMenu
i18n={i18n}
onEdit={onEdit}
onDupe={onDupe}
onDelete={handleDelete}
disabled={!isSelected}
>
<button
type="button"
role="option"
aria-label={colorId}
aria-selected={isSelected}
className={classNames({
ChatColorPicker__bubble: true,
'ChatColorPicker__bubble--custom-selected': isSelected,
'ChatColorPicker__bubble--selected': isSelected,
})}
onClick={handleClick}
style={getCustomColorStyle(color)}
/>
</CustomColorBubbleDropdownMenu>
</>
);
}
function CustomColorBubbleDropdownMenu(props: {
i18n: LocalizerType;
disabled: boolean;
onEdit: () => void;
onDupe: () => void;
onDelete: () => void;
children: ReactNode;
}): JSX.Element {
const { i18n, disabled } = props;
const [open, setOpen] = useState(false);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!disabled) {
setOpen(nextOpen);
}
},
[disabled]
);
return (
<AxoDropdownMenu.Root open={open} onOpenChange={handleOpenChange}>
<AxoDropdownMenu.Trigger>{props.children}</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item onSelect={props.onEdit}>
{i18n('icu:ChatColorPicker__context--edit')}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item onSelect={props.onDupe}>
{i18n('icu:ChatColorPicker__context--duplicate')}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item onSelect={props.onDelete}>
{i18n('icu:ChatColorPicker__context--delete')}
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
);
}
type CustomColorEditorWrapperPropsType = {
customColorToEdit?: CustomColorDataType;
i18n: LocalizerType;

View File

@@ -1,6 +1,7 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useCallback, useState, useRef } from 'react';
import lodash from 'lodash';
@@ -15,7 +16,9 @@ import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled.std.js';
import { useTheme } from '../hooks/useTheme.dom.js';
import { isSameDay } from '../util/timestamp.std.js';
import { TimelineDateHeader } from './conversation/TimelineDateHeader.dom.js';
import { AxoContextMenu } from '../axo/AxoContextMenu.dom.js';
import { drop } from '../util/drop.std.js';
import type { AxoMenuBuilder } from '../axo/AxoMenuBuilder.dom.js';
const { noop } = lodash;
@@ -145,13 +148,6 @@ export function EditHistoryMessagesModal({
};
setDisplayLimitById(update);
}}
onContextMenu={() => {
drop(
window.navigator.clipboard.writeText(
String(currentMessage.timestamp)
)
);
}}
platform={platform}
showLightbox={closeAndShowLightbox}
showSpoiler={(messageId, data) => {
@@ -162,6 +158,19 @@ export function EditHistoryMessagesModal({
setRevealedSpoilersById(update);
}}
theme={theme}
renderMessageContextMenu={(
_renderer: AxoMenuBuilder.Renderer,
children
) => {
return (
<EditHistoryMessageContextMenu
i18n={i18n}
timestamp={currentMessage.timestamp}
>
{children}
</EditHistoryMessageContextMenu>
);
}}
/>
<hr className="EditHistoryMessagesModal__divider" />
@@ -213,13 +222,6 @@ export function EditHistoryMessagesModal({
};
setDisplayLimitById(update);
}}
onContextMenu={() => {
drop(
window.navigator.clipboard.writeText(
String(messageAttributes.timestamp)
)
);
}}
platform={platform}
showLightbox={closeAndShowLightbox}
showSpoiler={(messageId, data) => {
@@ -230,6 +232,19 @@ export function EditHistoryMessagesModal({
setRevealedSpoilersById(update);
}}
theme={theme}
renderMessageContextMenu={(
_renderer: AxoMenuBuilder.Renderer,
children
) => {
return (
<EditHistoryMessageContextMenu
i18n={i18n}
timestamp={messageAttributes.timestamp}
>
{children}
</EditHistoryMessageContextMenu>
);
}}
/>
</React.Fragment>
);
@@ -238,3 +253,28 @@ export function EditHistoryMessagesModal({
</Modal>
);
}
function EditHistoryMessageContextMenu(props: {
i18n: LocalizerType;
timestamp: number;
children: ReactNode;
}) {
const { i18n, timestamp } = props;
const onCopyTimestamp = useCallback(() => {
drop(window.navigator.clipboard.writeText(`${timestamp}`));
}, [timestamp]);
return (
<AxoContextMenu.Root>
<AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
<AxoContextMenu.Content>
<AxoContextMenu.Item symbol="copy" onSelect={onCopyTimestamp}>
{i18n(
'icu:EditHistoryMessagesModal__Message__ContextMenu__CopyTimestamp'
)}
</AxoContextMenu.Item>
</AxoContextMenu.Content>
</AxoContextMenu.Root>
);
}

View File

@@ -72,7 +72,7 @@ export const ModalHost = React.memo(function ModalHostInner({
node => {
// In strange event propagation situations we can get the actual document.body
// node here. We don't want to handle those events.
if (node === document.body) {
if (node === document.body || node === document.documentElement) {
return false;
}
@@ -81,7 +81,13 @@ export const ModalHost = React.memo(function ModalHostInner({
if (
modalContainer === document.body &&
node instanceof Element &&
node.closest('.module-calling__modal-container, [data-fun-overlay]')
node.closest(
[
'.module-calling__modal-container',
'[data-fun-overlay]',
'[data-radix-popper-content-wrapper]',
].join(', ')
) != null
) {
return false;
}
@@ -114,7 +120,7 @@ export const ModalHost = React.memo(function ModalHostInner({
);
return createPortal(
<FocusScope contain autoFocus restoreFocus>
<FocusScope contain={false} autoFocus restoreFocus>
{modalContent}
</FocusScope>,
document.body

View File

@@ -563,7 +563,7 @@ export function StoryViewer({
};
return (
<FocusScope contain autoFocus>
<FocusScope contain={currentViewTarget == null} autoFocus>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="StoryViewer"

View File

@@ -1,6 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, {
useCallback,
useLayoutEffect,
@@ -31,13 +32,15 @@ import { ThemeType } from '../types/Util.std.js';
import { WidthBreakpoint } from './_util.std.js';
import { getAvatarColor } from '../types/Colors.std.js';
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled.std.js';
import { ContextMenu } from './ContextMenu.dom.js';
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
import type { EmojiSkinTone } from './fun/data/emojis.std.js';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.js';
import { FunEmojiPickerButton } from './fun/FunButton.dom.js';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.js';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.js';
import { AxoContextMenu } from '../axo/AxoContextMenu.dom.js';
import type { AxoMenuBuilder } from '../axo/AxoMenuBuilder.dom.js';
import { drop } from '../util/drop.std.js';
const { noop, orderBy } = lodash;
@@ -558,124 +561,134 @@ function ReplyOrReactionMessage({
showContactModal,
showSpoiler,
}: ReplyOrReactionMessageProps) {
const renderContent = (onContextMenu?: (ev: React.MouseEvent) => void) => {
if (reply.reactionEmoji && !reply.deletedForEveryone) {
const handleDeleteReply = useCallback(() => {
deleteGroupStoryReply(reply.id);
}, [deleteGroupStoryReply, reply.id]);
const handleDeleteReplyForEveryone = useCallback(() => {
deleteGroupStoryReplyForEveryone(reply.id);
}, [deleteGroupStoryReplyForEveryone, reply.id]);
const handleCopyReplyTimestamp = useCallback(() => {
drop(window.navigator.clipboard.writeText(String(reply.timestamp)));
}, [reply.timestamp]);
const renderMessageContextMenu = useCallback(
(_renderer: AxoMenuBuilder.Renderer, children: ReactNode) => {
return (
<div
className="StoryViewsNRepliesModal__reaction"
onContextMenu={onContextMenu}
data-id={id}
>
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
avatarUrl={reply.author.avatarUrl}
badge={getPreferredBadge(reply.author.badges)}
color={getAvatarColor(reply.author.color)}
conversationType="direct"
i18n={i18n}
profileName={reply.author.profileName}
sharedGroupNames={reply.author.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
theme={ThemeType.dark}
title={reply.author.title}
/>
<div className="StoryViewsNRepliesModal__reaction--body">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={
reply.author.isMe ? i18n('icu:you') : reply.author.title
}
/>
</div>
{reply.author.isMe
? i18n('icu:StoryViewsNRepliesModal__reacted--you')
: i18n('icu:StoryViewsNRepliesModal__reacted--someone-else')}
<MessageTimestamp
i18n={i18n}
isRelativeTime
module="StoryViewsNRepliesModal__reply--timestamp"
timestamp={reply.timestamp}
<AxoContextMenu.Root>
<AxoContextMenu.Trigger>{children}</AxoContextMenu.Trigger>
<AxoContextMenu.Content>
<AxoContextMenu.Item symbol="trash" onSelect={handleDeleteReply}>
{i18n('icu:StoryViewsNRepliesModal__delete-reply')}
</AxoContextMenu.Item>
{!reply.deletedForEveryone && (
<AxoContextMenu.Item
symbol="trash"
onSelect={handleDeleteReplyForEveryone}
>
{i18n('icu:StoryViewsNRepliesModal__delete-reply-for-everyone')}
</AxoContextMenu.Item>
)}
{isInternalUser && (
<AxoContextMenu.Item
symbol="copy"
onSelect={handleCopyReplyTimestamp}
>
{i18n('icu:StoryViewsNRepliesModal__copy-reply-timestamp')}
</AxoContextMenu.Item>
)}
</AxoContextMenu.Content>
</AxoContextMenu.Root>
);
},
[
i18n,
reply,
handleDeleteReply,
handleDeleteReplyForEveryone,
isInternalUser,
handleCopyReplyTimestamp,
]
);
if (reply.reactionEmoji && !reply.deletedForEveryone) {
return renderMessageContextMenu(
'AxoContextMenu',
<div className="StoryViewsNRepliesModal__reaction" data-id={id}>
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
avatarUrl={reply.author.avatarUrl}
badge={getPreferredBadge(reply.author.badges)}
color={getAvatarColor(reply.author.color)}
conversationType="direct"
i18n={i18n}
profileName={reply.author.profileName}
sharedGroupNames={reply.author.sharedGroupNames || []}
size={AvatarSize.TWENTY_EIGHT}
theme={ThemeType.dark}
title={reply.author.title}
/>
<div className="StoryViewsNRepliesModal__reaction--body">
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
title={reply.author.isMe ? i18n('icu:you') : reply.author.title}
/>
</div>
{reply.author.isMe
? i18n('icu:StoryViewsNRepliesModal__reacted--you')
: i18n('icu:StoryViewsNRepliesModal__reacted--someone-else')}
<MessageTimestamp
i18n={i18n}
isRelativeTime
module="StoryViewsNRepliesModal__reply--timestamp"
timestamp={reply.timestamp}
/>
</div>
<Emojify text={reply.reactionEmoji} />
</div>
);
}
return (
<div className="StoryViewsNRepliesModal__reply" data-id={id}>
<Message
{...MESSAGE_DEFAULT_PROPS}
author={reply.author}
bodyRanges={reply.bodyRanges}
contactNameColor={reply.contactNameColor}
containerElementRef={containerElementRef}
conversationColor="ultramarine"
conversationId={reply.conversationId}
conversationTitle={reply.author.title}
conversationType="group"
deletedForEveryone={reply.deletedForEveryone}
direction="incoming"
displayLimit={displayLimit}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
platform={platform}
id={reply.id}
interactionMode="mouse"
isSpoilerExpanded={isSpoilerExpanded}
messageExpanded={messageExpanded}
onContextMenu={onContextMenu}
readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal"
renderMenu={undefined}
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={false}
showContactModal={showContactModal}
showSpoiler={showSpoiler}
text={reply.body}
textDirection={TextDirection.Default}
timestamp={reply.timestamp}
/>
<Emojify text={reply.reactionEmoji} />
</div>
);
};
const menuOptions = [
{
icon: 'module-message__context--icon module-message__context__delete-message',
label: i18n('icu:StoryViewsNRepliesModal__delete-reply'),
onClick: () => deleteGroupStoryReply(reply.id),
},
{
icon: 'module-message__context--icon module-message__context__delete-message-for-everyone',
label: i18n('icu:StoryViewsNRepliesModal__delete-reply-for-everyone'),
onClick: () => deleteGroupStoryReplyForEveryone(reply.id),
},
];
if (isInternalUser) {
menuOptions.push({
icon: 'module-message__context--icon module-message__context__copy-timestamp',
label: i18n('icu:StoryViewsNRepliesModal__copy-reply-timestamp'),
onClick: () => {
void window.navigator.clipboard.writeText(String(reply.timestamp));
},
});
}
return reply.author.isMe && !reply.deletedForEveryone ? (
<ContextMenu i18n={i18n} key={reply.id} menuOptions={menuOptions}>
{({ onClick, menuNode }) => (
<>
{renderContent(onClick)}
{menuNode}
</>
)}
</ContextMenu>
) : (
renderContent()
return (
<div className="StoryViewsNRepliesModal__reply" data-id={id}>
<Message
{...MESSAGE_DEFAULT_PROPS}
author={reply.author}
bodyRanges={reply.bodyRanges}
contactNameColor={reply.contactNameColor}
containerElementRef={containerElementRef}
conversationColor="ultramarine"
conversationId={reply.conversationId}
conversationTitle={reply.author.title}
conversationType="group"
deletedForEveryone={reply.deletedForEveryone}
direction="incoming"
displayLimit={displayLimit}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
platform={platform}
id={reply.id}
interactionMode="mouse"
isSpoilerExpanded={isSpoilerExpanded}
messageExpanded={messageExpanded}
readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal"
renderMenu={undefined}
renderMessageContextMenu={renderMessageContextMenu}
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={false}
showContactModal={showContactModal}
showSpoiler={showSpoiler}
text={reply.body}
textDirection={TextDirection.Default}
timestamp={reply.timestamp}
/>
</div>
);
}

View File

@@ -63,7 +63,6 @@ const getCommonProps = (options: {
id: 'message-id',
conversationId: conversation.id,
i18n,
interactionMode: 'mouse',
isNextItemCallingNotification: false,
onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation'

View File

@@ -4,7 +4,6 @@
import type { ReactNode } from 'react';
import React from 'react';
import lodash from 'lodash';
import { ContextMenuTrigger } from 'react-contextmenu';
import { SystemMessage, SystemMessageKind } from './SystemMessage.dom.js';
import { Button, ButtonSize, ButtonVariant } from '../Button.dom.js';
@@ -25,20 +24,11 @@ import {
import { missingCaseError } from '../../util/missingCaseError.std.js';
import { Tooltip, TooltipPlacement } from '../Tooltip.dom.js';
import { createLogger } from '../../logging/log.std.js';
import {
type ContextMenuTriggerType,
MessageContextMenu,
useHandleMessageContextMenu,
} from './MessageContextMenu.dom.js';
import { MessageContextMenu } from './MessageContextMenu.dom.js';
import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals.preload.js';
import {
useKeyboardShortcutsConditionally,
useOpenContextMenu,
} from '../../hooks/useKeyboardShortcuts.dom.js';
import { MINUTE } from '../../util/durations/index.std.js';
import { isMoreRecentThan } from '../../util/timestamp.std.js';
import { InAnotherCallTooltip } from './InAnotherCallTooltip.dom.js';
import type { InteractionModeType } from '../../state/ducks/conversations.preload.js';
const { noop } = lodash;
@@ -55,7 +45,6 @@ type PropsHousekeeping = {
i18n: LocalizerType;
id: string;
conversationId: string;
interactionMode: InteractionModeType;
isNextItemCallingNotification: boolean;
};
@@ -65,13 +54,6 @@ 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;
@@ -80,73 +62,61 @@ export const CallingNotification: React.FC<PropsType> = React.memo(
const { type, direction, status, timestamp } = props.callHistory;
const icon = getCallingIcon(type, direction, status);
return (
<>
<ContextMenuTrigger
id={props.id}
// react-contextmenu's typings are incorrect here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={menuTriggerRef as any}
disable={props.isSelectMode}
// Immediately hide the context menu on outside click.
// This is a bug in react-contextmenu trying to handle touch events.
holdToDisplay={-1}
<MessageContextMenu
renderer="AxoContextMenu"
disabled={props.isSelectMode}
i18n={i18n}
onDeleteMessage={() => {
props.toggleDeleteMessagesModal({
conversationId: props.conversationId,
messageIds: [props.id],
});
}}
shouldShowAdditional={false}
onDownload={undefined}
onEdit={undefined}
onReplyToMessage={undefined}
onReact={undefined}
onEndPoll={undefined}
onRetryMessageSend={undefined}
onRetryDeleteForEveryone={undefined}
onCopy={undefined}
onSelect={undefined}
onForward={undefined}
onMoreInfo={undefined}
>
<div
// @ts-expect-error -- React/TS doesn't know about inert
// eslint-disable-next-line react/no-unknown-property
inert={props.isSelectMode ? '' : undefined}
>
<div
// @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 ||
status === DirectCallStatus.Declined ||
status === GroupCallStatus.Declined
? SystemMessageKind.Danger
: SystemMessageKind.Normal
}
/>
</div>
</ContextMenuTrigger>
<MessageContextMenu
i18n={i18n}
triggerId={props.id}
interactionMode={props.interactionMode}
onDeleteMessage={() => {
props.toggleDeleteMessagesModal({
conversationId: props.conversationId,
messageIds: [props.id],
});
}}
shouldShowAdditional={false}
onDownload={undefined}
onEdit={undefined}
onReplyToMessage={undefined}
onReact={undefined}
onEndPoll={undefined}
onRetryMessageSend={undefined}
onRetryDeleteForEveryone={undefined}
onCopy={undefined}
onSelect={undefined}
onForward={undefined}
onMoreInfo={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 ||
status === DirectCallStatus.Declined ||
status === GroupCallStatus.Declined
? SystemMessageKind.Danger
: SystemMessageKind.Normal
}
/>
</div>
</MessageContextMenu>
);
}
);

View File

@@ -400,6 +400,60 @@ export function Unaccepted(): JSX.Element {
);
}
export function Blocked(): JSX.Element {
const items: ItemsType = [
{
title: 'Unaccepted & Blocked',
props: {
...commonProps,
outgoingCallButtonStyle: OutgoingCallButtonStyle.None,
conversation: getDefaultConversation({
color: getRandomColor(),
title: '(202) 555-0007',
phoneNumber: '(202) 555-0007',
id: '16',
type: 'direct',
isMe: false,
acceptedMessageRequest: false,
isBlocked: true,
}),
},
},
{
title: 'Accepted & Blocked',
props: {
...commonProps,
outgoingCallButtonStyle: OutgoingCallButtonStyle.None,
conversation: getDefaultConversation({
color: getRandomColor(),
title: '(202) 555-0007',
phoneNumber: '(202) 555-0007',
id: '16',
type: 'direct',
isMe: false,
acceptedMessageRequest: true,
isBlocked: true,
}),
},
},
];
const theme = useContext(StorybookThemeContext);
return (
<>
{items.map(({ title: subtitle, props }, i) => {
return (
<div key={i}>
{subtitle ? <h3>{subtitle}</h3> : null}
<ConversationHeader {...props} theme={theme} />
</div>
);
})}
</>
);
}
export function NeedsDeleteConfirmation(): JSX.Element {
const [localDeleteWarningShown, setLocalDeleteWarningShown] =
React.useState(false);

View File

@@ -2,15 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import type { ReactNode, RefObject } from 'react';
import React, { memo, useRef, useState } from 'react';
import {
ContextMenu,
ContextMenuTrigger,
MenuItem,
SubMenu,
} from 'react-contextmenu';
import { createPortal } from 'react-dom';
import type { RefObject } from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import type { BadgeType } from '../../badges/types.std.js';
import {
useKeyboardShortcuts,
@@ -20,7 +13,7 @@ import { SizeObserver } from '../../hooks/useSizeObserver.dom.js';
import type { ConversationTypeType } from '../../state/ducks/conversations.preload.js';
import type { HasStories } from '../../types/Stories.std.js';
import type { LocalizerType, ThemeType } from '../../types/Util.std.js';
import { DurationInSeconds } from '../../util/durations/index.std.js';
import type { DurationInSeconds } from '../../util/durations/index.std.js';
import * as expirationTimer from '../../util/expirationTimer.std.js';
import { getMuteOptions } from '../../util/getMuteOptions.std.js';
import { isConversationMuted } from '../../util/isConversationMuted.std.js';
@@ -40,6 +33,8 @@ import {
import type { MinimalConversation } from '../../hooks/useMinimalConversation.std.js';
import { InAnotherCallTooltip } from './InAnotherCallTooltip.dom.js';
import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.dom.js';
import { AxoDropdownMenu } from '../../axo/AxoDropdownMenu.dom.js';
import { strictAssert } from '../../util/assert.std.js';
function HeaderInfoTitle({
name,
@@ -153,8 +148,6 @@ export type PropsType = PropsDataType &
PropsActionsType &
PropsHousekeepingType;
const TIMER_ITEM_CLASS = 'module-ConversationHeader__disappearing-timer__item';
export const ConversationHeader = memo(function ConversationHeader({
addedByName,
badge,
@@ -198,8 +191,6 @@ export const ConversationHeader = memo(function ConversationHeader({
theme,
}: PropsType): JSX.Element | null {
// Comes from a third-party dependency
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const menuTriggerRef = useRef<any>(null);
const headerRef = useRef<HTMLDivElement>(null);
const [
@@ -219,8 +210,6 @@ export const ConversationHeader = memo(function ConversationHeader({
MessageRequestState.default
);
const triggerId = `conversation-${conversation.id}`;
if (hasPanelShowing) {
return null;
}
@@ -324,75 +313,70 @@ export const ConversationHeader = memo(function ConversationHeader({
)}
aria-label={i18n('icu:search')}
/>
<ContextMenuTrigger
id={triggerId}
ref={menuTriggerRef}
disable={isSelectMode}
>
<button
type="button"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
menuTriggerRef.current?.handleContextClick(event);
}}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--more'
)}
aria-label={i18n('icu:moreInfo')}
disabled={isSelectMode}
/>
</ContextMenuTrigger>
<HeaderMenu
i18n={i18n}
conversation={conversation}
isMissingMandatoryProfileSharing={
isMissingMandatoryProfileSharing ?? false
}
isSelectMode={isSelectMode}
isSignalConversation={isSignalConversation ?? false}
onChangeDisappearingMessages={
onConversationDisappearingMessagesChange
}
onChangeMuteExpiration={onConversationMuteExpirationChange}
onConversationAccept={onConversationAccept}
onConversationArchive={onConversationArchive}
onConversationBlock={() => {
setMessageRequestState(MessageRequestState.blocking);
}}
onConversationDelete={() => {
setMessageRequestState(MessageRequestState.deleting);
}}
onConversationDeleteMessages={() => {
setHasDeleteMessagesConfirmation(true);
}}
onConversationLeaveGroup={() => {
if (cannotLeaveBecauseYouAreLastAdmin) {
setHasCannotLeaveGroupBecauseYouAreLastAdminAlert(true);
} else {
setHasLeaveGroupConfirmation(true);
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger disabled={isSelectMode}>
<button
type="button"
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--more'
)}
aria-label={i18n('icu:moreInfo')}
/>
</AxoDropdownMenu.Trigger>
<HeaderDropdownMenuContent
i18n={i18n}
conversation={conversation}
isMissingMandatoryProfileSharing={
isMissingMandatoryProfileSharing ?? false
}
}}
onConversationMarkUnread={onConversationMarkUnread}
onConversationPin={onConversationPin}
onConversationReportAndMaybeBlock={() => {
setMessageRequestState(
MessageRequestState.reportingAndMaybeBlocking
);
}}
onConversationUnarchive={onConversationUnarchive}
onConversationUnblock={() => {
setMessageRequestState(MessageRequestState.unblocking);
}}
onConversationUnpin={onConversationUnpin}
onSelectModeEnter={onSelectModeEnter}
onSetupCustomDisappearingTimeout={() => {
setHasCustomDisappearingTimeoutModal(true);
}}
onShowMembers={onShowMembers}
onViewAllMedia={onViewAllMedia}
onViewConversationDetails={onViewConversationDetails}
triggerId={triggerId}
/>
isSelectMode={isSelectMode}
isSignalConversation={isSignalConversation ?? false}
onChangeDisappearingMessages={
onConversationDisappearingMessagesChange
}
onChangeMuteExpiration={onConversationMuteExpirationChange}
onConversationAccept={onConversationAccept}
onConversationArchive={onConversationArchive}
onConversationBlock={() => {
setMessageRequestState(MessageRequestState.blocking);
}}
onConversationDelete={() => {
setMessageRequestState(MessageRequestState.deleting);
}}
onConversationDeleteMessages={() => {
setHasDeleteMessagesConfirmation(true);
}}
onConversationLeaveGroup={() => {
if (cannotLeaveBecauseYouAreLastAdmin) {
setHasCannotLeaveGroupBecauseYouAreLastAdminAlert(true);
} else {
setHasLeaveGroupConfirmation(true);
}
}}
onConversationMarkUnread={onConversationMarkUnread}
onConversationPin={onConversationPin}
onConversationReportAndMaybeBlock={() => {
setMessageRequestState(
MessageRequestState.reportingAndMaybeBlocking
);
}}
onConversationUnarchive={onConversationUnarchive}
onConversationUnblock={() => {
setMessageRequestState(MessageRequestState.unblocking);
}}
onConversationUnpin={onConversationUnpin}
onSelectModeEnter={onSelectModeEnter}
onSetupCustomDisappearingTimeout={() => {
setHasCustomDisappearingTimeoutModal(true);
}}
onShowMembers={onShowMembers}
onViewAllMedia={onViewAllMedia}
onViewConversationDetails={onViewConversationDetails}
/>
</AxoDropdownMenu.Root>
<MessageRequestActionsConfirmation
i18n={i18n}
conversationId={conversation.id}
@@ -536,7 +520,7 @@ function HeaderContent({
);
}
function HeaderMenu({
function HeaderDropdownMenuContent({
conversation,
i18n,
isMissingMandatoryProfileSharing,
@@ -561,7 +545,6 @@ function HeaderMenu({
onShowMembers,
onViewAllMedia,
onViewConversationDetails,
triggerId,
}: {
conversation: MinimalConversation;
i18n: LocalizerType;
@@ -587,9 +570,7 @@ function HeaderMenu({
onShowMembers: () => void;
onViewAllMedia: () => void;
onViewConversationDetails: () => void;
triggerId: string;
}) {
const isRTL = i18n.getLocaleDirection() === 'rtl';
const muteOptions = getMuteOptions(conversation.muteExpiresAt, i18n);
const isGroup = conversation.type === 'group';
const disableTimerChanges = Boolean(
@@ -600,19 +581,31 @@ function HeaderMenu({
);
const hasGV2AdminEnabled = isGroup && conversation.groupVersion === 2;
const isActiveExpireTimer = (value: number): boolean => {
if (!conversation.expireTimer) {
return value === 0;
const disappearingMessagesValue = useMemo(() => {
const { expireTimer } = conversation;
if (expireTimer == null) {
return '0';
}
// Custom time...
if (value === -1) {
return !expirationTimer.DEFAULT_DURATIONS_SET.has(
conversation.expireTimer
);
if (expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.includes(expireTimer)) {
return `${expireTimer}`;
}
return value === conversation.expireTimer;
};
return 'custom';
}, [conversation]);
const onDisappearingMessagesValueChange = useCallback(
(value: string) => {
if (value === 'custom') {
return;
}
const seconds = Number(value);
strictAssert(Number.isFinite(seconds), 'Invalid value in radio item');
onChangeDisappearingMessages(seconds as DurationInSeconds);
},
[onChangeDisappearingMessages]
);
if (isSelectMode) {
return null;
@@ -626,213 +619,266 @@ function HeaderMenu({
conversation.muteExpiresAt && isConversationMuted(conversation);
return (
<ContextMenu id={triggerId} rtl={isRTL}>
<SubMenu hoverDelay={1} title={muteTitle} rtl={!isRTL}>
{isMuted ? (
<MenuItem
onClick={() => {
onChangeMuteExpiration(0);
}}
>
{i18n('icu:unmute')}
</MenuItem>
) : (
<MenuItem
onClick={() => {
onChangeMuteExpiration(Number.MAX_SAFE_INTEGER);
}}
>
{i18n('icu:muteAlways')}
</MenuItem>
)}
</SubMenu>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Sub>
<AxoDropdownMenu.SubTrigger symbol="bell-slash">
{muteTitle}
</AxoDropdownMenu.SubTrigger>
<AxoDropdownMenu.SubContent>
{isMuted ? (
<AxoDropdownMenu.Item
onSelect={() => {
onChangeMuteExpiration(0);
}}
>
{i18n('icu:unmute')}
</AxoDropdownMenu.Item>
) : (
<AxoDropdownMenu.Item
onSelect={() => {
onChangeMuteExpiration(Number.MAX_SAFE_INTEGER);
}}
>
{i18n('icu:muteAlways')}
</AxoDropdownMenu.Item>
)}
</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
{conversation.isArchived ? (
<MenuItem onClick={onConversationUnarchive}>
<AxoDropdownMenu.Item
symbol="archive-up"
onSelect={onConversationUnarchive}
>
{i18n('icu:moveConversationToInbox')}
</MenuItem>
</AxoDropdownMenu.Item>
) : (
<MenuItem onClick={onConversationArchive}>
<AxoDropdownMenu.Item
symbol="archive"
onSelect={onConversationArchive}
>
{i18n('icu:archiveConversation')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
<MenuItem onClick={onConversationDeleteMessages}>
<AxoDropdownMenu.Item
symbol="trash"
onSelect={onConversationDeleteMessages}
>
{i18n('icu:deleteConversation')}
</MenuItem>
</ContextMenu>
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
);
}
if (isGroup && conversation.groupVersion !== 2) {
return (
<ContextMenu id={triggerId}>
<MenuItem onClick={onShowMembers}>{i18n('icu:showMembers')}</MenuItem>
<MenuItem onClick={onViewAllMedia}>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item symbol="group" onSelect={onShowMembers}>
{i18n('icu:showMembers')}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item symbol="album" onSelect={onViewAllMedia}>
{i18n('icu:allMediaMenuItem')}
</MenuItem>
<MenuItem divider />
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Separator />
{conversation.isArchived ? (
<MenuItem onClick={onConversationUnarchive}>
<AxoDropdownMenu.Item
symbol="archive-up"
onSelect={onConversationUnarchive}
>
{i18n('icu:moveConversationToInbox')}
</MenuItem>
</AxoDropdownMenu.Item>
) : (
<MenuItem onClick={onConversationArchive}>
<AxoDropdownMenu.Item
symbol="archive"
onSelect={onConversationArchive}
>
{i18n('icu:archiveConversation')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
<MenuItem onClick={onConversationDeleteMessages}>
<AxoDropdownMenu.Item
symbol="trash"
onSelect={onConversationDeleteMessages}
>
{i18n('icu:deleteConversation')}
</MenuItem>
</ContextMenu>
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
);
}
const expireDurations: ReadonlyArray<ReactNode> = [
...expirationTimer.DEFAULT_DURATIONS_IN_SECONDS,
DurationInSeconds.fromSeconds(-1),
].map(seconds => {
let text: string;
if (seconds === -1) {
text = i18n('icu:customDisappearingTimeOption');
} else {
text = expirationTimer.format(i18n, seconds, {
capitalizeOff: true,
});
}
const onDurationClick = () => {
if (seconds === -1) {
onSetupCustomDisappearingTimeout();
} else {
onChangeDisappearingMessages(seconds);
}
};
return (
<MenuItem key={seconds} onClick={onDurationClick}>
<div
className={classNames(
TIMER_ITEM_CLASS,
isActiveExpireTimer(seconds) && `${TIMER_ITEM_CLASS}--active`
)}
>
{text}
</div>
</MenuItem>
);
});
return createPortal(
<ContextMenu id={triggerId} rtl={isRTL}>
return (
<AxoDropdownMenu.Content>
{!conversation.acceptedMessageRequest && (
<>
{!conversation.isBlocked && (
<MenuItem onClick={onConversationBlock}>
<AxoDropdownMenu.Item symbol="block" onSelect={onConversationBlock}>
{i18n('icu:ConversationHeader__MenuItem--Block')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
{conversation.isBlocked && (
<MenuItem onClick={onConversationUnblock}>
<AxoDropdownMenu.Item
symbol="message-thread"
onSelect={onConversationUnblock}
>
{i18n('icu:ConversationHeader__MenuItem--Unblock')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
{!conversation.isBlocked && (
<MenuItem onClick={onConversationAccept}>
<AxoDropdownMenu.Item
symbol="message-thread"
onSelect={onConversationAccept}
>
{i18n('icu:ConversationHeader__MenuItem--Accept')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
<MenuItem onClick={onConversationReportAndMaybeBlock}>
<AxoDropdownMenu.Item
symbol="error-octagon"
onSelect={onConversationReportAndMaybeBlock}
>
{i18n('icu:ConversationHeader__MenuItem--ReportSpam')}
</MenuItem>
<MenuItem onClick={onConversationDelete}>
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item symbol="trash" onSelect={onConversationDelete}>
{i18n('icu:ConversationHeader__MenuItem--DeleteChat')}
</MenuItem>
</AxoDropdownMenu.Item>
</>
)}
{conversation.acceptedMessageRequest && (
<>
{disableTimerChanges ? null : (
<SubMenu hoverDelay={1} title={disappearingTitle} rtl={!isRTL}>
{expireDurations}
</SubMenu>
<AxoDropdownMenu.Sub>
<AxoDropdownMenu.SubTrigger symbol="timer">
<span data-testid="ConversationHeader__ContextMenu__DisappearingTimer">
{disappearingTitle}
</span>
</AxoDropdownMenu.SubTrigger>
<AxoDropdownMenu.SubContent>
<AxoDropdownMenu.RadioGroup
value={disappearingMessagesValue}
onValueChange={onDisappearingMessagesValueChange}
>
{expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map(seconds => {
return (
<AxoDropdownMenu.RadioItem value={`${seconds}`}>
{expirationTimer.format(i18n, seconds, {
capitalizeOff: true,
})}
</AxoDropdownMenu.RadioItem>
);
})}
<AxoDropdownMenu.RadioItem
value="custom"
onSelect={onSetupCustomDisappearingTimeout}
>
{i18n('icu:customDisappearingTimeOption')}
</AxoDropdownMenu.RadioItem>
</AxoDropdownMenu.RadioGroup>
</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
)}
<SubMenu hoverDelay={1} title={muteTitle} rtl={!isRTL}>
{muteOptions.map(item => (
<MenuItem
key={item.name}
disabled={item.disabled}
onClick={() => {
onChangeMuteExpiration(item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
<AxoDropdownMenu.Sub>
<AxoDropdownMenu.SubTrigger symbol="bell-slash">
{muteTitle}
</AxoDropdownMenu.SubTrigger>
<AxoDropdownMenu.SubContent>
{muteOptions.map(item => (
<AxoDropdownMenu.Item
key={item.name}
disabled={item.disabled}
onSelect={() => {
onChangeMuteExpiration(item.value);
}}
>
{item.name}
</AxoDropdownMenu.Item>
))}
</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
{!isGroup || hasGV2AdminEnabled ? (
<MenuItem onClick={onViewConversationDetails}>
<AxoDropdownMenu.Item
symbol="settings"
onSelect={onViewConversationDetails}
>
{isGroup
? i18n('icu:showConversationDetails')
: i18n('icu:showConversationDetails--direct')}
</MenuItem>
</AxoDropdownMenu.Item>
) : null}
<MenuItem onClick={onViewAllMedia}>
<AxoDropdownMenu.Item symbol="album" onSelect={onViewAllMedia}>
{i18n('icu:allMediaMenuItem')}
</MenuItem>
<MenuItem divider />
<MenuItem onClick={onSelectModeEnter}>
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Separator />
<AxoDropdownMenu.Item
symbol="check-circle"
onSelect={onSelectModeEnter}
>
{i18n('icu:ConversationHeader__menu__selectMessages')}
</MenuItem>
<MenuItem divider />
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Separator />
{!conversation.markedUnread ? (
<MenuItem onClick={onConversationMarkUnread}>
<AxoDropdownMenu.Item
symbol="message-badge"
onSelect={onConversationMarkUnread}
>
{i18n('icu:markUnread')}
</MenuItem>
</AxoDropdownMenu.Item>
) : null}
{conversation.isPinned ? (
<MenuItem onClick={onConversationUnpin}>
<AxoDropdownMenu.Item
symbol="pin-slash"
onSelect={onConversationUnpin}
>
{i18n('icu:unpinConversation')}
</MenuItem>
</AxoDropdownMenu.Item>
) : (
<MenuItem onClick={onConversationPin}>
<AxoDropdownMenu.Item symbol="pin" onSelect={onConversationPin}>
{i18n('icu:pinConversation')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
{conversation.isArchived ? (
<MenuItem onClick={onConversationUnarchive}>
<AxoDropdownMenu.Item
symbol="archive-up"
onSelect={onConversationUnarchive}
>
{i18n('icu:moveConversationToInbox')}
</MenuItem>
</AxoDropdownMenu.Item>
) : (
<MenuItem onClick={onConversationArchive}>
<AxoDropdownMenu.Item
symbol="archive"
onSelect={onConversationArchive}
>
{i18n('icu:archiveConversation')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
{!conversation.isBlocked && (
<MenuItem onClick={onConversationBlock}>
<AxoDropdownMenu.Item symbol="block" onSelect={onConversationBlock}>
{i18n('icu:ConversationHeader__MenuItem--Block')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
{conversation.isBlocked && (
<MenuItem onClick={onConversationUnblock}>
<AxoDropdownMenu.Item
symbol="message-thread"
onSelect={onConversationUnblock}
>
{i18n('icu:ConversationHeader__MenuItem--Unblock')}
</MenuItem>
</AxoDropdownMenu.Item>
)}
<MenuItem onClick={onConversationDeleteMessages}>
<AxoDropdownMenu.Item
symbol="trash"
onSelect={onConversationDeleteMessages}
>
{i18n('icu:deleteConversation')}
</MenuItem>
</AxoDropdownMenu.Item>
{isGroup && (
<MenuItem onClick={onConversationLeaveGroup}>
<AxoDropdownMenu.Item
symbol="leave"
onSelect={onConversationLeaveGroup}
>
{i18n(
'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title'
)}
</MenuItem>
</AxoDropdownMenu.Item>
)}
</>
)}
</ContextMenu>,
document.body
</AxoDropdownMenu.Content>
);
}

View File

@@ -124,6 +124,7 @@ import {
isEmojiVariantValue,
} from '../fun/data/emojis.std.js';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.dom.js';
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
const { drop, take, unescape } = lodash;
@@ -327,6 +328,10 @@ export type PropsData = {
bodyRanges?: HydratedBodyRangesType;
renderMenu?: () => JSX.Element | undefined;
renderMessageContextMenu?: (
renderer: AxoMenuBuilder.Renderer,
children: ReactNode
) => JSX.Element;
item?: never;
// test-only, to force GIF's reduced motion experience
@@ -345,7 +350,7 @@ export type PropsHousekeeping = {
shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean;
shouldHideMetadata: boolean;
onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void;
onWrapperKeyDown?: (event: React.KeyboardEvent) => void;
theme: ThemeType;
};
@@ -3176,7 +3181,7 @@ export class Message extends React.PureComponent<Props, State> {
id,
isSticker,
isTapToView,
onContextMenu,
renderMessageContextMenu,
text,
textDirection,
} = this.props;
@@ -3234,28 +3239,36 @@ export class Message extends React.PureComponent<Props, State> {
Object.assign(containerStyles, getCustomColorStyle(customColor));
}
function maybeWrapWithContextMenu(children: ReactNode): ReactNode {
if (renderMessageContextMenu) {
return renderMessageContextMenu('AxoContextMenu', children);
}
return children;
}
return (
<div className="module-message__container-outer">
{/* the keyboard handler is a level higher in hierarchy due to selection */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className={containerClassnames}
id={`message-accessibility-contents:${id}`}
style={containerStyles}
onContextMenu={onContextMenu}
role="row"
onClick={this.handleClick}
onDoubleClick={ev => {
// Prevent double click from triggering the replyToMessage action
ev.stopPropagation();
}}
tabIndex={-1}
>
{this.#renderAuthor()}
<div dir={TextDirectionToDirAttribute[textDirection]}>
{this.renderContents()}
{maybeWrapWithContextMenu(
// the keyboard handler is a level higher in hierarchy due to selection
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
className={containerClassnames}
id={`message-accessibility-contents:${id}`}
style={containerStyles}
role="row"
onClick={this.handleClick}
onDoubleClick={ev => {
// Prevent double click from triggering the replyToMessage action
ev.stopPropagation();
}}
tabIndex={-1}
>
{this.#renderAuthor()}
<div dir={TextDirectionToDirAttribute[textDirection]}>
{this.renderContents()}
</div>
</div>
</div>
)}
{this.renderReactions(direction === 'outgoing')}
</div>
);
@@ -3296,6 +3309,7 @@ export class Message extends React.PureComponent<Props, State> {
timestamp,
onToggleSelect,
onReplyToMessage,
onWrapperKeyDown,
} = this.props;
const isMacOS = platform === 'darwin';
const { expired, expiring, isTargeted, imageBroken } = this.state;
@@ -3363,7 +3377,10 @@ export class Message extends React.PureComponent<Props, State> {
onReplyToMessage();
}
},
onKeyDown: event => this.handleKeyDown(event),
onKeyDown: event => {
this.handleKeyDown(event);
onWrapperKeyDown?.(event);
},
};
}

View File

@@ -1,11 +1,9 @@
// 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 React, { type ReactNode } from 'react';
import type { LocalizerType } from '../../types/I18N.std.js';
import type { InteractionModeType } from '../../state/ducks/conversations.preload.js';
import { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
export type ContextMenuTriggerType = {
handleContextClick: (
@@ -13,11 +11,12 @@ export type ContextMenuTriggerType = {
) => void;
};
type MessageContextProps = {
type MessageContextMenuProps = Readonly<{
i18n: LocalizerType;
triggerId: string;
renderer: AxoMenuBuilder.Renderer;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
shouldShowAdditional: boolean;
interactionMode: InteractionModeType;
onDownload: (() => void) | undefined;
onEdit: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
@@ -30,12 +29,15 @@ type MessageContextProps = {
onDeleteMessage: () => void;
onMoreInfo: (() => void) | undefined;
onSelect: (() => void) | undefined;
};
export const MessageContextMenu = ({
children: ReactNode;
}>;
export function MessageContextMenu({
i18n,
triggerId,
renderer,
onOpenChange,
disabled,
shouldShowAdditional,
interactionMode,
onDownload,
onEdit,
onReplyToMessage,
@@ -48,224 +50,80 @@ export const MessageContextMenu = ({
onRetryDeleteForEveryone,
onForward,
onDeleteMessage,
}: MessageContextProps): JSX.Element => {
const menu = (
// We avoid restoring focus on this context menu because it is not intended for
// keyboard use and restoring focus to the message could cause an unwanted scroll
<ContextMenu
id={triggerId}
// In keyboard mode, we do want to restore focus to the message; the message is very
// likely already scrolled into view in this case.
avoidFocusRestoreOnBlur={interactionMode !== 'keyboard'}
>
{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>
)}
</>
)}
{onEndPoll && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__end-poll',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onEndPoll();
}}
>
{i18n('icu:Poll__end-poll')}
</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]
children,
}: MessageContextMenuProps): JSX.Element {
return (
<AxoMenuBuilder.Root renderer={renderer} onOpenChange={onOpenChange}>
<AxoMenuBuilder.Trigger disabled={disabled}>
{children}
</AxoMenuBuilder.Trigger>
<AxoMenuBuilder.Content>
{shouldShowAdditional && (
<>
{onDownload && (
<AxoMenuBuilder.Item symbol="download" onSelect={onDownload}>
{i18n('icu:MessageContextMenu__download')}
</AxoMenuBuilder.Item>
)}
{onReplyToMessage && (
<AxoMenuBuilder.Item symbol="reply" onSelect={onReplyToMessage}>
{i18n('icu:MessageContextMenu__reply')}
</AxoMenuBuilder.Item>
)}
{onReact && (
<AxoMenuBuilder.Item symbol="heart-plus" onSelect={onReact}>
{i18n('icu:MessageContextMenu__react')}
</AxoMenuBuilder.Item>
)}
</>
)}
{onEndPoll && (
<AxoMenuBuilder.Item symbol="stop-circle" onSelect={onEndPoll}>
{i18n('icu:Poll__end-poll')}
</AxoMenuBuilder.Item>
)}
{onForward && (
<AxoMenuBuilder.Item symbol="forward" onSelect={onForward}>
{i18n('icu:MessageContextMenu__forward')}
</AxoMenuBuilder.Item>
)}
{onEdit && (
<AxoMenuBuilder.Item symbol="pencil" onSelect={onEdit}>
{i18n('icu:edit')}
</AxoMenuBuilder.Item>
)}
{onSelect && (
<AxoMenuBuilder.Item symbol="check-circle" onSelect={onSelect}>
{i18n('icu:MessageContextMenu__select')}
</AxoMenuBuilder.Item>
)}
{onCopy && (
<AxoMenuBuilder.Item symbol="copy" onSelect={onCopy}>
{i18n('icu:copy')}
</AxoMenuBuilder.Item>
)}
{onMoreInfo && (
<AxoMenuBuilder.Item symbol="info" onSelect={onMoreInfo}>
{i18n('icu:MessageContextMenu__info')}
</AxoMenuBuilder.Item>
)}
<AxoMenuBuilder.Item symbol="trash" onSelect={onDeleteMessage}>
{i18n('icu:MessageContextMenu__deleteMessage')}
</AxoMenuBuilder.Item>
{onRetryMessageSend && (
<AxoMenuBuilder.Item symbol="send" onSelect={onRetryMessageSend}>
{i18n('icu:retrySend')}
</AxoMenuBuilder.Item>
)}
{onRetryDeleteForEveryone && (
<AxoMenuBuilder.Item
symbol="trash"
onSelect={onRetryDeleteForEveryone}
>
{i18n('icu:retryDeleteForEveryone')}
</AxoMenuBuilder.Item>
)}
</AxoMenuBuilder.Content>
</AxoMenuBuilder.Root>
);
}

View File

@@ -297,7 +297,6 @@ export const TimelineItem = memo(function TimelineItem({
<CallingNotification
id={id}
conversationId={conversationId}
interactionMode={reducedProps.interactionMode}
i18n={i18n}
isNextItemCallingNotification={isNextItemCallingNotification}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}

View File

@@ -10,8 +10,7 @@ import React, {
useRef,
useState,
} from 'react';
import type { Ref } from 'react';
import { ContextMenuTrigger } from 'react-contextmenu';
import type { ReactNode, Ref } from 'react';
import { createPortal } from 'react-dom';
import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow.js';
@@ -19,7 +18,6 @@ import { isDownloaded } from '../../util/Attachment.std.js';
import type { LocalizerType } from '../../types/I18N.std.js';
import { handleOutsideClick } from '../../util/handleOutsideClick.dom.js';
import { offsetDistanceModifier } from '../../util/popperUtil.std.js';
import { StopPropagation } from '../StopPropagation.dom.js';
import { WidthBreakpoint } from '../_util.std.js';
import { Message } from './Message.dom.js';
import type { SmartReactionPicker } from '../../state/smart/ReactionPicker.dom.js';
@@ -33,7 +31,6 @@ import type { PushPanelForConversationActionType } from '../../state/ducks/conve
import { doesMessageBodyOverflow } from './MessageBodyReadMore.dom.js';
import {
useKeyboardShortcutsConditionally,
useOpenContextMenu,
useToggleReactionPicker,
} from '../../hooks/useKeyboardShortcuts.dom.js';
import { PanelType } from '../../types/Panels.std.js';
@@ -45,11 +42,14 @@ import { useScrollerLock } from '../../hooks/useScrollLock.dom.js';
import {
type ContextMenuTriggerType,
MessageContextMenu,
useHandleMessageContextMenu,
} from './MessageContextMenu.dom.js';
import { ForwardMessagesModalType } from '../ForwardMessagesModal.dom.js';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.dom.js';
import { isNotNil } from '../../util/isNotNil.std.js';
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js';
const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu;
const { noop } = lodash;
@@ -267,8 +267,6 @@ export function TimelineMessage(props: Props): JSX.Element {
]
);
const handleContextMenu = useHandleMessageContextMenu(menuTriggerRef);
const shouldShowAdditional =
doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow;
@@ -291,11 +289,8 @@ export function TimelineMessage(props: Props): JSX.Element {
handleReact || noop
);
const openContextMenuKeyboard = useOpenContextMenu(handleContextMenu);
useKeyboardShortcutsConditionally(
Boolean(isTargeted),
openContextMenuKeyboard,
toggleReactionPickerKeyboard
);
@@ -312,6 +307,82 @@ export function TimelineMessage(props: Props): JSX.Element {
.filter(isNotNil);
}, [groupedReactions]);
const renderMessageContextMenu = useCallback(
(renderer: AxoMenuBuilder.Renderer, children: ReactNode): JSX.Element => {
return (
<MessageContextMenu
i18n={i18n}
renderer={renderer}
shouldShowAdditional={shouldShowAdditional}
onDownload={handleDownload}
onEdit={
canEditMessage
? () => setMessageToEdit(conversationId, id)
: undefined
}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
onEndPoll={canEndPoll ? () => endPoll(id) : undefined}
onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined}
onRetryDeleteForEveryone={
canRetryDeleteForEveryone
? () => retryDeleteForEveryone(id)
: undefined
}
onCopy={canCopy ? () => copyMessageText(id) : undefined}
onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
onForward={
canForward
? () =>
toggleForwardMessagesModal({
type: ForwardMessagesModalType.Forward,
messageIds: [id],
})
: undefined
}
onDeleteMessage={() => {
toggleDeleteMessagesModal({
conversationId,
messageIds: [id],
});
}}
onMoreInfo={() =>
pushPanelForConversation({
type: PanelType.MessageDetails,
args: { messageId: id },
})
}
>
{children}
</MessageContextMenu>
);
},
[
canCopy,
canEditMessage,
canForward,
canRetry,
canEndPoll,
canRetryDeleteForEveryone,
conversationId,
copyMessageText,
handleDownload,
handleReact,
endPoll,
handleReplyToMessage,
i18n,
id,
pushPanelForConversation,
retryDeleteForEveryone,
retryMessageSend,
setMessageToEdit,
shouldShowAdditional,
toggleDeleteMessagesModal,
toggleForwardMessagesModal,
toggleSelectMessage,
]
);
const renderMenu = useCallback(() => {
return (
<Manager>
@@ -321,10 +392,10 @@ export function TimelineMessage(props: Props): JSX.Element {
isWindowWidthNotNarrow={isWindowWidthNotNarrow}
direction={direction}
menuTriggerRef={menuTriggerRef}
showMenu={handleContextMenu}
onDownload={handleDownload}
onReplyToMessage={canReply ? handleReplyToMessage : undefined}
onReact={canReact ? handleReact : undefined}
renderMessageContextMenu={renderMessageContextMenu}
/>
{reactionPickerRoot &&
createPortal(
@@ -364,7 +435,6 @@ export function TimelineMessage(props: Props): JSX.Element {
menuTriggerRef,
canReply,
canReact,
handleContextMenu,
handleDownload,
handleReplyToMessage,
handleReact,
@@ -376,66 +446,23 @@ export function TimelineMessage(props: Props): JSX.Element {
toggleReactionPicker,
id,
messageEmojis,
renderMessageContextMenu,
]);
return (
<>
<Message
{...props}
renderingContext="conversation/TimelineItem"
onContextMenu={handleContextMenu}
renderMenu={renderMenu}
onToggleSelect={(selected, shift) => {
toggleSelectMessage(conversationId, id, shift, selected);
}}
onReplyToMessage={handleReplyToMessage}
/>
const handleWrapperKeyDown = useAxoContextMenuOutsideKeyboardTrigger();
<MessageContextMenu
i18n={i18n}
triggerId={triggerId}
shouldShowAdditional={shouldShowAdditional}
interactionMode={props.interactionMode}
onDownload={handleDownload}
onEdit={
canEditMessage
? () => setMessageToEdit(conversationId, id)
: undefined
}
onReplyToMessage={handleReplyToMessage}
onReact={handleReact}
onEndPoll={canEndPoll ? () => endPoll(id) : undefined}
onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined}
onRetryDeleteForEveryone={
canRetryDeleteForEveryone
? () => retryDeleteForEveryone(id)
: undefined
}
onCopy={canCopy ? () => copyMessageText(id) : undefined}
onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
onForward={
canForward
? () =>
toggleForwardMessagesModal({
type: ForwardMessagesModalType.Forward,
messageIds: [id],
})
: undefined
}
onDeleteMessage={() => {
toggleDeleteMessagesModal({
conversationId,
messageIds: [id],
});
}}
onMoreInfo={() =>
pushPanelForConversation({
type: PanelType.MessageDetails,
args: { messageId: id },
})
}
/>
</>
return (
<Message
{...props}
renderingContext="conversation/TimelineItem"
renderMenu={renderMenu}
renderMessageContextMenu={renderMessageContextMenu}
onToggleSelect={(selected, shift) => {
toggleSelectMessage(conversationId, id, shift, selected);
}}
onReplyToMessage={handleReplyToMessage}
onWrapperKeyDown={handleWrapperKeyDown}
/>
);
}
@@ -444,62 +471,25 @@ type MessageMenuProps = {
triggerId: string;
isWindowWidthNotNarrow: boolean;
menuTriggerRef: Ref<ContextMenuTriggerType>;
showMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
onDownload: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined;
renderMessageContextMenu: (
renderer: AxoMenuBuilder.Renderer,
children: ReactNode
) => ReactNode;
} & Pick<MessageProps, 'i18n' | 'direction'>;
function MessageMenu({
i18n,
triggerId,
direction,
isWindowWidthNotNarrow,
menuTriggerRef,
showMenu,
onDownload,
onReplyToMessage,
onReact,
renderMessageContextMenu,
}: MessageMenuProps) {
// 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('icu:messageContextMenuButton')}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
onDoubleClick={ev => {
// Prevent double click from triggering the replyToMessage action
ev.stopPropagation();
}}
/>
</ContextMenuTrigger>
</StopPropagation>
);
}}
</Reference>
);
/* eslint-enable jsx-a11y/interactive-supports-focus */
/* eslint-enable jsx-a11y/click-events-have-key-events */
return (
<div
@@ -589,7 +579,21 @@ function MessageMenu({
)}
</>
)}
{menuButton}
{renderMessageContextMenu(
'AxoDropdownMenu',
<button
type="button"
aria-label={i18n('icu:messageContextMenuButton')}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
)}
onDoubleClick={ev => {
// Prevent double click from triggering the replyToMessage action
ev.stopPropagation();
}}
/>
)}
</div>
);
}

View File

@@ -8,7 +8,6 @@ import * as KeyboardLayout from '../services/keyboardLayout.dom.js';
import { getHasPanelOpen } from '../state/selectors/conversations.dom.js';
import { isInFullScreenCall } from '../state/selectors/calling.std.js';
import { isShowingAnyModal } from '../state/selectors/globalModals.std.js';
import type { ContextMenuTriggerType } from '../components/conversation/MessageContextMenu.dom.js';
const { get } = lodash;
@@ -295,39 +294,6 @@ export function useToggleReactionPicker(
);
}
export function useOpenContextMenu(
openContextMenu: ContextMenuTriggerType['handleContextClick'] | undefined
): KeyboardShortcutHandlerType {
const hasOverlay = useHasAnyOverlay();
return useCallback(
ev => {
if (hasOverlay) {
return false;
}
const { shiftKey } = ev;
const key = KeyboardLayout.lookup(ev);
const isMacOS = get(window, 'platform') === 'darwin';
if (
(!isMacOS && shiftKey && key === 'F10') ||
(isMacOS && isCmdOrCtrl(ev) && key === 'F12')
) {
ev.preventDefault();
ev.stopPropagation();
openContextMenu?.(new MouseEvent('click'));
return true;
}
return false;
},
[hasOverlay, openContextMenu]
);
}
export function useEditLastMessageSent(
maybeEditMessage: () => boolean
): KeyboardShortcutHandlerType {

View File

@@ -173,12 +173,10 @@ describe('backups', function (this: Mocha.Suite) {
debug('setting bubble color');
const conversationStack = window.locator('.Inbox__conversation-stack');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.getByRole('button', { name: 'More Info' })
.click();
await window
.locator('.react-contextmenu-item >> "Chat settings"')
.click();
await window.getByRole('menuitem', { name: 'Chat settings' }).click();
await conversationStack
.locator('.ConversationDetails__chat-color')

View File

@@ -110,8 +110,9 @@ describe('editing', function (this: Mocha.Suite) {
) {
await page
.getByTestId(`${timestamp}`)
.locator('.module-message__buttons__menu')
.getByRole('button', { name: 'More actions' })
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
const input = await waitForEnabledComposer(page);
await typeIntoInput(input, additionalText, previousText);

View File

@@ -279,19 +279,13 @@ describe('messaging/expireTimerVersion', function (this: Mocha.Suite) {
const conversationStack = window.locator('.Inbox__conversation-stack');
debug('setting timer to 1 week');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await conversationStack.getByRole('button', { name: 'More Info' }).click();
await window
.locator('.react-contextmenu-item >> "Disappearing messages"')
.getByRole('menuitem', { name: 'Disappearing messages' })
.click();
await window
.locator(
'.module-ConversationHeader__disappearing-timer__item >> "1 week"'
)
.click();
await window.getByRole('menuitemradio', { name: '1 week' }).click();
debug('Getting first expiration update');
{
@@ -305,19 +299,13 @@ describe('messaging/expireTimerVersion', function (this: Mocha.Suite) {
}
debug('setting timer to 4 weeks');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await conversationStack.getByRole('button', { name: 'More Info' }).click();
await window
.locator('.react-contextmenu-item >> "Disappearing messages"')
.getByRole('menuitem', { name: 'Disappearing messages' })
.click();
await window
.locator(
'.module-ConversationHeader__disappearing-timer__item >> "4 weeks"'
)
.click();
await window.getByRole('menuitemradio', { name: '4 weeks' }).click();
debug('Getting second expiration update');
{

View File

@@ -143,11 +143,8 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
.locator('.module-message-request-actions button >> "Accept"')
.waitFor({ state: 'hidden' });
await window
.locator('button.module-ConversationHeader__button--more')
.click();
await window.locator('.react-contextmenu-item >> "Group settings"').click();
await window.getByRole('button', { name: 'More Info' }).click();
await window.getByRole('menuitem', { name: 'Group settings' }).click();
debug(
'Checking that we see all members of group, including (previously) unknown contact'

View File

@@ -159,11 +159,9 @@ describe('pnp/send gv2 invite', function (this: Mocha.Suite) {
debug('opening group settings');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await conversationStack.getByRole('button', { name: 'More Info' }).click();
await window.locator('.react-contextmenu-item >> "Group settings"').click();
await window.getByRole('menuitem', { name: 'Group settings' }).click();
debug('editing group title');
{

View File

@@ -92,14 +92,14 @@ describe('storage service', function (this: Mocha.Suite) {
.locator(`[data-testid="${firstContact.device.aci}"]`)
.click();
const moreButton = conversationStack.locator(
'button.module-ConversationHeader__button--more'
);
const moreButton = conversationStack.getByRole('button', {
name: 'More Info',
});
await moreButton.click();
const archiveButton = window.locator(
'.react-contextmenu-item >> "Archive"'
);
const archiveButton = window.getByRole('menuitem', {
name: 'Archive',
});
await archiveButton.click();
const newState = await phone.waitForStorageState({

View File

@@ -61,10 +61,10 @@ describe('storage service', function (this: Mocha.Suite) {
await leftPane.locator(`[data-testid="${testid}"]`).click();
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.getByRole('button', { name: 'More Info' })
.click();
await window.locator('.react-contextmenu-item >> "Archive"').click();
await window.getByRole('menuitem', { name: 'Archive' }).click();
const newState = await phone.waitForStorageState({
after: state,
@@ -102,10 +102,10 @@ describe('storage service', function (this: Mocha.Suite) {
await leftPane.locator(`[data-testid="${testid}"]`).click();
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.getByRole('button', { name: 'More Info' })
.click();
await window.locator('.react-contextmenu-item >> "Unarchive"').click();
await window.getByRole('menuitem', { name: 'Unarchive' }).click();
await app.waitForManifestVersion(archivedVersion);
@@ -114,10 +114,10 @@ describe('storage service', function (this: Mocha.Suite) {
// Conversation should be still open
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.getByRole('button', { name: 'More Info' })
.click();
await window.locator('.react-contextmenu-item >> "Unarchive"').waitFor();
await window.getByRole('menuitem', { name: 'Unarchive' }).waitFor();
debug('Verifying the final manifest version');
const finalState = await phone.expectStorageState('final state');
@@ -148,10 +148,10 @@ describe('storage service', function (this: Mocha.Suite) {
await leftPane.locator(`[data-testid="${second.device.aci}"]`).click();
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.getByRole('button', { name: 'More Info' })
.click();
await window.locator('.react-contextmenu-item >> "Pin chat"').click();
await window.getByRole('menuitem', { name: 'Pin chat' }).click();
const newState = await phone.waitForStorageState({
after: state,
@@ -172,20 +172,16 @@ describe('storage service', function (this: Mocha.Suite) {
}
debug('unpinning second contact');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await conversationStack.getByRole('button', { name: 'More Info' }).click();
await window.locator('.react-contextmenu-item >> "Unpin chat"').click();
await window.getByRole('menuitem', { name: 'Unpin chat' }).click();
await app.waitForManifestVersion(archivedVersion);
debug('verifying that second contact is still unpinned');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await conversationStack.getByRole('button', { name: 'More Info' }).click();
await window.locator('.react-contextmenu-item >> "Unpin chat"').waitFor();
await window.getByRole('menuitem', { name: 'Unpin chat' }).waitFor();
debug('Verifying the final manifest version');
const finalState = await phone.expectStorageState('final state');

View File

@@ -65,14 +65,14 @@ describe('storage service', function (this: Mocha.Suite) {
const convo = leftPane.getByTestId(group.id);
await convo.click();
const moreButton = conversationStack.locator(
'button.module-ConversationHeader__button--more'
);
const moreButton = conversationStack.getByRole('button', {
name: 'More Info',
});
await moreButton.click();
const pinButton = window.locator(
'.react-contextmenu-item >> "Pin chat"'
);
const pinButton = window.getByRole('menuitem', {
name: 'Pin chat',
});
await pinButton.click();
const newState = await phone.waitForStorageState({
@@ -113,14 +113,14 @@ describe('storage service', function (this: Mocha.Suite) {
);
await convo.click();
const moreButton = conversationStack.locator(
'button.module-ConversationHeader__button--more'
);
const moreButton = conversationStack.getByRole('button', {
name: 'More Info',
});
await moreButton.click();
const pinButton = window.locator(
'.react-contextmenu-item >> "Pin chat"'
);
const pinButton = window.getByRole('menuitem', {
name: 'Pin chat',
});
await pinButton.click();
if (isLast) {

View File

@@ -825,6 +825,13 @@
"reasonCategory": "usageTrusted",
"updated": "2025-08-28T23:36:44.974Z"
},
{
"rule": "React-useRef",
"path": "ts/axo/AxoContextMenu.dom.tsx",
"line": " useRef<TriggerElementGetter>(getTriggerElement);",
"reasonCategory": "usageTrusted",
"updated": "2025-11-11T22:17:13.219Z"
},
{
"rule": "React-useRef",
"path": "ts/axo/AxoDropdownMenu.dom.tsx",
@@ -1148,13 +1155,6 @@
"reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "React-useRef",
"path": "ts/components/ChatColorPicker.dom.tsx",
"line": " const menuRef = useRef<any | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.dom.tsx",
@@ -1169,6 +1169,14 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-23T00:07:11.885Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.dom.tsx",
"line": " const photoVideoInputRef = useRef<null | HTMLInputElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-10-27T16:28:11.852Z",
"reasonDetail": "Ref for photo/video file input element"
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.dom.tsx",
@@ -1492,6 +1500,22 @@
"updated": "2022-09-14T16:20:15.384Z",
"reasonDetail": "Holds a reference to a container element to prevent outside clicks"
},
{
"rule": "React-useRef",
"path": "ts/components/PollCreateModal.dom.tsx",
"line": " const optionRefsMap = useRef<Map<string, HTMLTextAreaElement | null>>(",
"reasonCategory": "usageTrusted",
"updated": "2025-11-02T17:27:24.705Z",
"reasonDetail": "Map of refs for poll option inputs to manage focus"
},
{
"rule": "React-useRef",
"path": "ts/components/PollCreateModal.dom.tsx",
"line": " const questionInputRef = useRef<HTMLTextAreaElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-11-02T17:27:24.705Z",
"reasonDetail": "Ref for question input to manage focus on validation errors"
},
{
"rule": "React-useRef",
"path": "ts/components/Preferences.dom.tsx",
@@ -1735,20 +1759,6 @@
"updated": "2025-05-28T00:57:39.376Z",
"reasonDetail": "Holding on to a close function"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/CallingNotification.dom.tsx",
"line": " const menuTriggerRef = React.useRef<ContextMenuTriggerType | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2023-12-08T20:28:57.595Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/ConversationHeader.dom.tsx",
"line": " const menuTriggerRef = useRef<any>(null);",
"reasonCategory": "usageTrusted",
"updated": "2024-03-15T18:29:48.327Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/ConversationHeader.dom.tsx",
@@ -1931,6 +1941,14 @@
"updated": "2024-09-03T00:45:23.978Z",
"reasonDetail": "Because we need the current tab value outside the callback"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/poll-message/PollMessageContents.dom.tsx",
"line": " const pendingCheckTimer = useRef<NodeJS.Timeout | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-11-06T20:28:00.760Z",
"reasonDetail": "Ref for timer"
},
{
"rule": "React-useRef",
"path": "ts/components/fun/FunGif.dom.tsx",
@@ -2334,37 +2352,5 @@
"line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');",
"reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.dom.tsx",
"line": " const photoVideoInputRef = useRef<null | HTMLInputElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-10-27T16:28:11.852Z",
"reasonDetail": "Ref for photo/video file input element"
},
{
"rule": "React-useRef",
"path": "ts/components/PollCreateModal.dom.tsx",
"line": " const questionInputRef = useRef<HTMLTextAreaElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-11-02T17:27:24.705Z",
"reasonDetail": "Ref for question input to manage focus on validation errors"
},
{
"rule": "React-useRef",
"path": "ts/components/PollCreateModal.dom.tsx",
"line": " const optionRefsMap = useRef<Map<string, HTMLTextAreaElement | null>>(",
"reasonCategory": "usageTrusted",
"updated": "2025-11-02T17:27:24.705Z",
"reasonDetail": "Map of refs for poll option inputs to manage focus"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/poll-message/PollMessageContents.dom.tsx",
"line": " const pendingCheckTimer = useRef<NodeJS.Timeout | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-11-06T20:28:00.760Z",
"reasonDetail": "Ref for timer"
}
]

View File

@@ -94,7 +94,6 @@ const excludedFilesRegexp = RegExp(
'^node_modules/lodash/.+',
'^node_modules/react/.+',
'^node_modules/react-aria-components/.+',
'^node_modules/react-contextmenu/.+',
'^node_modules/react-dom/.+',
'^node_modules/react-icon-base/.+',
'^node_modules/react-input-autosize/.+',