diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 71427d66c..a83916953 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7490,18 +7490,24 @@ button.module-image__border-overlay:focus { .module-modal-host__overlay-container { display: flex; - flex-direction: column; + flex-direction: row; width: var(--window-width); height: var(--window-height); left: var(--window-border); top: var(--titlebar-height); justify-content: center; + align-items: center; overflow: hidden; padding: 20px; position: fixed; z-index: $z-index-modal-host; } +.module-modal-host__width-container { + max-width: 360px; + width: 95%; +} + .module-modal-host--on-top-of-everything { $loading-screen-modal-overlay: $z-index-on-top-of-everything + 1; diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss index 76d133b99..0b22bc49d 100644 --- a/stylesheets/components/Modal.scss +++ b/stylesheets/components/Modal.scss @@ -4,9 +4,6 @@ .module-Modal { @include popper-shadow(); border-radius: 8px; - margin: 0 auto; - max-width: 360px; - width: 95%; // We need this to be a number not divisible by 5 so that if we have sticky // buttons the bottom doesn't bleed through by 1px. max-height: 89vh; diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index 272943550..e34ffe14d 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -13,6 +13,7 @@ import type { Theme } from '../util/theme'; import type { LocalizerType } from '../types/Util'; import { getClassNamesFor } from '../util/getClassNamesFor'; import { themeClassName } from '../util/theme'; +import { handleOutsideClick } from '../util/handleOutsideClick'; export type ContextMenuOptionType = { readonly description?: string; @@ -104,20 +105,15 @@ export function ContextMenu({ return noop; } - const handleOutsideClick = (event: MouseEvent) => { - if (!referenceElement?.contains(event.target as Node)) { + return handleOutsideClick( + () => { setIsMenuShowing(false); closeCurrentOpenContextMenu = undefined; - event.stopPropagation(); - event.preventDefault(); - } - }; - document.addEventListener('click', handleOutsideClick); - - return () => { - document.removeEventListener('click', handleOutsideClick); - }; - }, [isMenuShowing, referenceElement]); + return true; + }, + { containerElements: [referenceElement, popperElement] } + ); + }, [isMenuShowing, referenceElement, popperElement]); const handleKeyDown = (ev: KeyboardEvent) => { if (!isMenuShowing) { diff --git a/ts/components/CustomizingPreferredReactionsModal.tsx b/ts/components/CustomizingPreferredReactionsModal.tsx index d0f80eb85..b1bc191dd 100644 --- a/ts/components/CustomizingPreferredReactionsModal.tsx +++ b/ts/components/CustomizingPreferredReactionsModal.tsx @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect, useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { usePopper } from 'react-popper'; import { isEqual, noop } from 'lodash'; @@ -17,6 +17,7 @@ import { EmojiPicker } from './emoji/EmojiPicker'; import { DEFAULT_PREFERRED_REACTION_EMOJI_SHORT_NAMES } from '../reactions/constants'; import { convertShortName } from './emoji/lib'; import { offsetDistanceModifier } from '../util/popperUtil'; +import { handleOutsideClick } from '../util/handleOutsideClick'; type PropsType = { draftPreferredReactions: Array; @@ -77,22 +78,13 @@ export function CustomizingPreferredReactionsModal({ return noop; } - const onBodyClick = (event: MouseEvent) => { - const { target } = event; - if (!(target instanceof HTMLElement) || !popperElement) { - return; - } - - const isClickOutsidePicker = !popperElement.contains(target); - if (isClickOutsidePicker) { + return handleOutsideClick( + () => { deselectDraftEmoji(); - } - }; - - document.body.addEventListener('click', onBodyClick); - return () => { - document.body.removeEventListener('click', onBodyClick); - }; + return true; + }, + { containerElements: [popperElement] } + ); }, [isSomethingSelected, popperElement, deselectDraftEmoji]); const hasChanged = !isEqual( diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index bf1563b21..d80dc5b47 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -11,6 +11,7 @@ import { AvatarPopup } from './AvatarPopup'; import type { LocalizerType, ThemeType } from '../types/Util'; import type { AvatarColorType } from '../types/Colors'; import type { BadgeType } from '../badges/types'; +import { handleOutsideClick } from '../util/handleOutsideClick'; export type PropsType = { areStoriesEnabled: boolean; @@ -38,6 +39,7 @@ export type PropsType = { type StateType = { showingAvatarPopup: boolean; popperRoot: HTMLDivElement | null; + outsideClickDestructor?: () => void; }; export class MainHeader extends React.Component { @@ -52,54 +54,62 @@ export class MainHeader extends React.Component { }; } - public handleOutsideClick = ({ target }: MouseEvent): void => { - const { popperRoot, showingAvatarPopup } = this.state; - - if ( - showingAvatarPopup && - popperRoot && - !popperRoot.contains(target as Node) && - !this.containerRef.current?.contains(target as Node) - ) { - this.hideAvatarPopup(); - } - }; - public showAvatarPopup = (): void => { const popperRoot = document.createElement('div'); document.body.appendChild(popperRoot); + const outsideClickDestructor = handleOutsideClick( + () => { + const { showingAvatarPopup } = this.state; + if (!showingAvatarPopup) { + return false; + } + + this.hideAvatarPopup(); + + return true; + }, + { containerElements: [popperRoot, this.containerRef] } + ); + this.setState({ showingAvatarPopup: true, popperRoot, + outsideClickDestructor, }); - document.addEventListener('click', this.handleOutsideClick); }; public hideAvatarPopup = (): void => { - const { popperRoot } = this.state; - - document.removeEventListener('click', this.handleOutsideClick); + const { popperRoot, outsideClickDestructor } = this.state; this.setState({ showingAvatarPopup: false, popperRoot: null, + outsideClickDestructor: undefined, }); + outsideClickDestructor?.(); + if (popperRoot && document.body.contains(popperRoot)) { document.body.removeChild(popperRoot); } }; public override componentDidMount(): void { - document.addEventListener('keydown', this.handleGlobalKeyDown); + const useCapture = true; + document.addEventListener('keydown', this.handleGlobalKeyDown, useCapture); } public override componentWillUnmount(): void { - const { popperRoot } = this.state; + const { popperRoot, outsideClickDestructor } = this.state; - document.removeEventListener('click', this.handleOutsideClick); - document.removeEventListener('keydown', this.handleGlobalKeyDown); + const useCapture = true; + outsideClickDestructor?.(); + document.removeEventListener( + 'keydown', + this.handleGlobalKeyDown, + useCapture + ); if (popperRoot && document.body.contains(popperRoot)) { document.body.removeChild(popperRoot); diff --git a/ts/components/MediaQualitySelector.tsx b/ts/components/MediaQualitySelector.tsx index 6d6dc25b6..7e7af2bd7 100644 --- a/ts/components/MediaQualitySelector.tsx +++ b/ts/components/MediaQualitySelector.tsx @@ -9,6 +9,7 @@ import classNames from 'classnames'; import { Manager, Popper, Reference } from 'react-popper'; import type { LocalizerType } from '../types/Util'; import { useRefMerger } from '../hooks/useRefMerger'; +import { handleOutsideClick } from '../util/handleOutsideClick'; export type PropsType = { i18n: LocalizerType; @@ -66,21 +67,9 @@ export const MediaQualitySelector = ({ const root = document.createElement('div'); setPopperRoot(root); document.body.appendChild(root); - const handleOutsideClick = (event: MouseEvent) => { - if ( - !root.contains(event.target as Node) && - event.target !== buttonRef.current - ) { - handleClose(); - event.stopPropagation(); - event.preventDefault(); - } - }; - document.addEventListener('click', handleOutsideClick); return () => { document.body.removeChild(root); - document.removeEventListener('click', handleOutsideClick); setPopperRoot(null); }; } @@ -88,6 +77,20 @@ export const MediaQualitySelector = ({ return noop; }, [menuShowing, setPopperRoot, handleClose]); + useEffect(() => { + if (!menuShowing) { + return noop; + } + + return handleOutsideClick( + () => { + handleClose(); + return true; + }, + { containerElements: [popperRoot, buttonRef] } + ); + }, [menuShowing, popperRoot, handleClose]); + return ( diff --git a/ts/components/ModalHost.tsx b/ts/components/ModalHost.tsx index 2f850b6bd..78ff31490 100644 --- a/ts/components/ModalHost.tsx +++ b/ts/components/ModalHost.tsx @@ -7,12 +7,14 @@ import FocusTrap from 'focus-trap-react'; import type { SpringValues } from '@react-spring/web'; import { animated } from '@react-spring/web'; import classNames from 'classnames'; +import { noop } from 'lodash'; import type { ModalConfigType } from '../hooks/useAnimated'; import type { Theme } from '../util/theme'; import { getClassNamesFor } from '../util/getClassNamesFor'; import { themeClassName } from '../util/theme'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; +import { handleOutsideClick } from '../util/handleOutsideClick'; export type PropsType = Readonly<{ children: React.ReactElement; @@ -39,7 +41,7 @@ export const ModalHost = React.memo( useFocusTrap = true, }: PropsType) => { const [root, setRoot] = React.useState(null); - const [isMouseDown, setIsMouseDown] = React.useState(false); + const containerRef = React.useRef(null); useEffect(() => { const div = document.createElement('div'); @@ -53,27 +55,18 @@ export const ModalHost = React.memo( }, []); useEscapeHandling(onEscape || onClose); - - // This makes it easier to write dialogs to be hosted here; they won't have to worry - // as much about preventing propagation of mouse events. - const handleMouseDown = React.useCallback( - (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - setIsMouseDown(true); - } - }, - [setIsMouseDown] - ); - const handleMouseUp = React.useCallback( - (e: React.MouseEvent) => { - setIsMouseDown(false); - - if (e.target === e.currentTarget && isMouseDown) { + useEffect(() => { + if (noMouseClose) { + return noop; + } + return handleOutsideClick( + () => { onClose(); - } - }, - [onClose, isMouseDown, setIsMouseDown] - ); + return true; + }, + { containerElements: [containerRef] } + ); + }, [noMouseClose, onClose]); const className = classNames([ theme ? themeClassName(theme) : undefined, @@ -88,12 +81,10 @@ export const ModalHost = React.memo( className={getClassName('__overlay')} style={overlayStyles} /> -
- {children} +
+
+ {children} +
); diff --git a/ts/components/TextStoryCreator.tsx b/ts/components/TextStoryCreator.tsx index 048b4bd08..ec09ec45c 100644 --- a/ts/components/TextStoryCreator.tsx +++ b/ts/components/TextStoryCreator.tsx @@ -4,7 +4,7 @@ import FocusTrap from 'focus-trap-react'; import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; -import { get, has } from 'lodash'; +import { get, has, noop } from 'lodash'; import { usePopper } from 'react-popper'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; @@ -26,6 +26,7 @@ import { getBackgroundColor, } from '../util/getStoryBackground'; import { objectMap } from '../util/objectMap'; +import { handleOutsideClick } from '../util/handleOutsideClick'; export type PropsType = { debouncedMaybeGrabLinkPreview: ( @@ -232,13 +233,6 @@ export const TextStoryCreator = ({ ); useEffect(() => { - const handleOutsideClick = (event: MouseEvent) => { - if (!colorPickerPopperButtonRef?.contains(event.target as Node)) { - setIsColorPickerShowing(false); - event.stopPropagation(); - event.preventDefault(); - } - }; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { if ( @@ -257,12 +251,11 @@ export const TextStoryCreator = ({ } }; - document.addEventListener('click', handleOutsideClick); - document.addEventListener('keydown', handleEscape); + const useCapture = true; + document.addEventListener('keydown', handleEscape, useCapture); return () => { - document.removeEventListener('click', handleOutsideClick); - document.removeEventListener('keydown', handleEscape); + document.removeEventListener('keydown', handleEscape, useCapture); }; }, [ isColorPickerShowing, @@ -272,6 +265,19 @@ export const TextStoryCreator = ({ onClose, ]); + useEffect(() => { + if (!isColorPickerShowing) { + return noop; + } + return handleOutsideClick( + () => { + setIsColorPickerShowing(false); + return true; + }, + { containerElements: [colorPickerPopperButtonRef] } + ); + }, [isColorPickerShowing, colorPickerPopperButtonRef]); + const sliderColorNumber = getRGBANumber(sliderValue); let textForegroundColor = sliderColorNumber; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 89dc6417b..fd7a49548 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -92,6 +92,7 @@ import type { UUIDStringType } from '../../types/UUID'; import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations'; import { BadgeImageTheme } from '../../badges/BadgeImageTheme'; import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath'; +import { handleOutsideClick } from '../../util/handleOutsideClick'; type Trigger = { handleContextClick: (event: React.MouseEvent) => void; @@ -381,7 +382,9 @@ type State = { prevSelectedCounter?: number; reactionViewerRoot: HTMLDivElement | null; + reactionViewerOutsideClickDestructor?: () => void; reactionPickerRoot: HTMLDivElement | null; + reactionPickerOutsideClickDestructor?: () => void; giftBadgeCounter: number | null; showOutgoingGiftBadgeModal: boolean; @@ -2394,29 +2397,34 @@ export class Message extends React.PureComponent { } public toggleReactionViewer = (onlyRemove = false): void => { - this.setState(({ reactionViewerRoot }) => { + this.setState(oldState => { + const { reactionViewerRoot } = oldState; if (reactionViewerRoot) { document.body.removeChild(reactionViewerRoot); - document.body.removeEventListener( - 'click', - this.handleClickOutsideReactionViewer, - true - ); - return { reactionViewerRoot: null }; + oldState.reactionViewerOutsideClickDestructor?.(); + + return { + reactionViewerRoot: null, + reactionViewerOutsideClickDestructor: undefined, + }; } if (!onlyRemove) { const root = document.createElement('div'); document.body.appendChild(root); - document.body.addEventListener( - 'click', - this.handleClickOutsideReactionViewer, - true + + const reactionViewerOutsideClickDestructor = handleOutsideClick( + () => { + this.toggleReactionViewer(true); + return true; + }, + { containerElements: [root, this.reactionsContainerRef] } ); return { reactionViewerRoot: root, + reactionViewerOutsideClickDestructor, }; } @@ -2425,29 +2433,34 @@ export class Message extends React.PureComponent { }; public toggleReactionPicker = (onlyRemove = false): void => { - this.setState(({ reactionPickerRoot }) => { + this.setState(oldState => { + const { reactionPickerRoot } = oldState; if (reactionPickerRoot) { document.body.removeChild(reactionPickerRoot); - document.body.removeEventListener( - 'click', - this.handleClickOutsideReactionPicker, - true - ); - return { reactionPickerRoot: null }; + oldState.reactionPickerOutsideClickDestructor?.(); + + return { + reactionPickerRoot: null, + reactionPickerOutsideClickDestructor: undefined, + }; } if (!onlyRemove) { const root = document.createElement('div'); document.body.appendChild(root); - document.body.addEventListener( - 'click', - this.handleClickOutsideReactionPicker, - true + + const reactionPickerOutsideClickDestructor = handleOutsideClick( + () => { + this.toggleReactionPicker(true); + return true; + }, + { containerElements: [root] } ); return { reactionPickerRoot: root, + reactionPickerOutsideClickDestructor, }; } @@ -2455,28 +2468,6 @@ export class Message extends React.PureComponent { }); }; - public handleClickOutsideReactionViewer = (e: MouseEvent): void => { - const { reactionViewerRoot } = this.state; - const { current: reactionsContainer } = this.reactionsContainerRef; - if (reactionViewerRoot && reactionsContainer) { - if ( - !reactionViewerRoot.contains(e.target as HTMLElement) && - !reactionsContainer.contains(e.target as HTMLElement) - ) { - this.toggleReactionViewer(true); - } - } - }; - - public handleClickOutsideReactionPicker = (e: MouseEvent): void => { - const { reactionPickerRoot } = this.state; - if (reactionPickerRoot) { - if (!reactionPickerRoot.contains(e.target as HTMLElement)) { - this.toggleReactionPicker(true); - } - } - }; - public renderReactions(outgoing: boolean): JSX.Element | null { const { getPreferredBadge, reactions = [], i18n, theme } = this.props; diff --git a/ts/components/emoji/EmojiButton.tsx b/ts/components/emoji/EmojiButton.tsx index ef35c8950..b4da00568 100644 --- a/ts/components/emoji/EmojiButton.tsx +++ b/ts/components/emoji/EmojiButton.tsx @@ -12,6 +12,7 @@ import type { Props as EmojiPickerProps } from './EmojiPicker'; import { EmojiPicker } from './EmojiPicker'; import type { LocalizerType } from '../../types/Util'; import { useRefMerger } from '../../hooks/useRefMerger'; +import { handleOutsideClick } from '../../util/handleOutsideClick'; import * as KeyboardLayout from '../../services/keyboardLayout'; export type OwnProps = Readonly<{ @@ -88,21 +89,9 @@ export const EmojiButton = React.memo( const root = document.createElement('div'); setPopperRoot(root); document.body.appendChild(root); - const handleOutsideClick = (event: MouseEvent) => { - if ( - !root.contains(event.target as Node) && - event.target !== buttonRef.current - ) { - handleClose(); - event.stopPropagation(); - event.preventDefault(); - } - }; - document.addEventListener('click', handleOutsideClick); return () => { document.body.removeChild(root); - document.removeEventListener('click', handleOutsideClick); setPopperRoot(null); }; } @@ -110,6 +99,20 @@ export const EmojiButton = React.memo( return noop; }, [open, setOpen, setPopperRoot, handleClose]); + React.useEffect(() => { + if (!open) { + return noop; + } + + return handleOutsideClick( + () => { + handleClose(); + return true; + }, + { containerElements: [popperRoot, buttonRef] } + ); + }, [open, handleClose, popperRoot]); + // Install keyboard shortcut to open emoji picker React.useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { diff --git a/ts/components/stickers/StickerButton.tsx b/ts/components/stickers/StickerButton.tsx index 56ce05329..c0d1eca10 100644 --- a/ts/components/stickers/StickerButton.tsx +++ b/ts/components/stickers/StickerButton.tsx @@ -14,6 +14,7 @@ import { StickerPicker } from './StickerPicker'; import { countStickers } from './lib'; import { offsetDistanceModifier } from '../../util/popperUtil'; import { themeClassName } from '../../util/theme'; +import { handleOutsideClick } from '../../util/handleOutsideClick'; import * as KeyboardLayout from '../../services/keyboardLayout'; import { useRefMerger } from '../../hooks/useRefMerger'; @@ -136,7 +137,23 @@ export const StickerButton = React.memo( const root = document.createElement('div'); setPopperRoot(root); document.body.appendChild(root); - const handleOutsideClick = ({ target }: MouseEvent) => { + + return () => { + document.body.removeChild(root); + setPopperRoot(null); + }; + } + + return noop; + }, [open, setOpen, setPopperRoot]); + + React.useEffect(() => { + if (!open) { + return noop; + } + + return handleOutsideClick( + target => { const targetElement = target as HTMLElement; const targetClassName = targetElement ? targetElement.className || '' @@ -149,25 +166,16 @@ export const StickerButton = React.memo( targetClassName.indexOf('module-sticker-picker__header__button') < 0; - if ( - !root.contains(targetElement) && - isMissingButtonClass && - targetElement !== buttonRef.current - ) { - setOpen(false); + if (!isMissingButtonClass) { + return false; } - }; - document.addEventListener('click', handleOutsideClick); - return () => { - document.body.removeChild(root); - document.removeEventListener('click', handleOutsideClick); - setPopperRoot(null); - }; - } - - return noop; - }, [open, setOpen, setPopperRoot]); + setOpen(false); + return true; + }, + { containerElements: [popperRoot, buttonRef] } + ); + }, [open, popperRoot, setOpen]); // Install keyboard shortcut to open sticker picker React.useEffect(() => { diff --git a/ts/util/handleOutsideClick.ts b/ts/util/handleOutsideClick.ts new file mode 100644 index 000000000..69da33093 --- /dev/null +++ b/ts/util/handleOutsideClick.ts @@ -0,0 +1,68 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { RefObject } from 'react'; + +export type ClickHandlerType = (target: Node) => boolean; +export type ContainerElementType = Node | RefObject | null | undefined; + +// TODO(indutny): DESKTOP-4177 +// A stack of handlers. Handlers are executed from the top to the bottom +const fakeClickHandlers = new Array<(event: MouseEvent) => boolean>(); + +function runFakeClickHandlers(event: MouseEvent): void { + for (const handler of fakeClickHandlers.slice().reverse()) { + if (handler(event)) { + break; + } + } +} + +export type HandleOutsideClickOptionsType = Readonly<{ + containerElements: ReadonlyArray; +}>; + +export const handleOutsideClick = ( + handler: ClickHandlerType, + { containerElements }: HandleOutsideClickOptionsType +): (() => void) => { + const handleEvent = (event: MouseEvent) => { + const target = event.target as Node; + + const isInside = containerElements.some(elem => { + if (!elem) { + return false; + } + if (elem instanceof Node) { + return elem.contains(target); + } + return elem.current?.contains(target); + }); + if (isInside) { + return false; + } + + const isHandled = handler(target); + if (!isHandled) { + return false; + } + + return true; + }; + + fakeClickHandlers.push(handleEvent); + if (fakeClickHandlers.length === 1) { + const useCapture = true; + document.addEventListener('click', runFakeClickHandlers, useCapture); + } + + return () => { + const index = fakeClickHandlers.indexOf(handleEvent); + fakeClickHandlers.splice(index, 1); + + if (fakeClickHandlers.length === 0) { + const useCapture = true; + document.removeEventListener('click', handleEvent, useCapture); + } + }; +}; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1de2ed5e8..22780bdf6 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -9181,6 +9181,14 @@ "reasonCategory": "usageTrusted", "updated": "2021-09-21T01:40:08.534Z" }, + { + "rule": "React-useRef", + "path": "ts/components/ModalHost.tsx", + "line": " const containerRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "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/ProfileEditor.tsx",