mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-19 17:58:48 +00:00
Migrate react-contextmenu menus to axo menus
Co-authored-by: Fedor Indutny <indutny@signal.org>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
23
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
* -----------------------------------
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
185
ts/axo/_internal/ariaRoles.dom.tsx
Normal file
185
ts/axo/_internal/ariaRoles.dom.tsx
Normal 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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ const getCommonProps = (options: {
|
||||
id: 'message-id',
|
||||
conversationId: conversation.id,
|
||||
i18n,
|
||||
interactionMode: 'mouse',
|
||||
isNextItemCallingNotification: false,
|
||||
onOutgoingAudioCallInConversation: action(
|
||||
'onOutgoingAudioCallInConversation'
|
||||
|
||||
@@ -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)} ·{' '}
|
||||
<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)} ·{' '}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,7 +297,6 @@ export const TimelineItem = memo(function TimelineItem({
|
||||
<CallingNotification
|
||||
id={id}
|
||||
conversationId={conversationId}
|
||||
interactionMode={reducedProps.interactionMode}
|
||||
i18n={i18n}
|
||||
isNextItemCallingNotification={isNextItemCallingNotification}
|
||||
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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/.+',
|
||||
|
||||
Reference in New Issue
Block a user