Skip to content

Commit

Permalink
Use focus trap for CallingLobby
Browse files Browse the repository at this point in the history
  • Loading branch information
indutny-signal committed Oct 25, 2021
1 parent 191bfee commit b38b22f
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 132 deletions.
141 changes: 72 additions & 69 deletions ts/components/CallingLobby.tsx
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only

import React from 'react';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import {
SetLocalAudioType,
Expand Down Expand Up @@ -203,83 +204,85 @@ export const CallingLobby = ({
}

return (
<div className="module-calling__container">
{shouldShowLocalVideo ? (
<video
className="module-CallingLobby__local-preview module-CallingLobby__local-preview--camera-is-on"
ref={localVideoRef}
autoPlay
/>
) : (
<CallBackgroundBlur
className="module-CallingLobby__local-preview module-CallingLobby__local-preview--camera-is-off"
avatarPath={me.avatarPath}
color={me.color}
/>
)}

<CallingHeader
i18n={i18n}
isGroupCall={isGroupCall}
participantCount={peekedParticipants.length}
showParticipantsList={showParticipantsList}
toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings}
onCancel={onCallCanceled}
/>

<CallingPreCallInfo
conversation={conversation}
groupMembers={groupMembers}
i18n={i18n}
isCallFull={isCallFull}
me={me}
peekedParticipants={peekedParticipants}
ringMode={preCallInfoRingMode}
/>

<div
className={classNames(
'module-CallingLobby__camera-is-off',
`module-CallingLobby__camera-is-off--${
shouldShowLocalVideo ? 'invisible' : 'visible'
}`
<FocusTrap>
<div className="module-calling__container">
{shouldShowLocalVideo ? (
<video
className="module-CallingLobby__local-preview module-CallingLobby__local-preview--camera-is-on"
ref={localVideoRef}
autoPlay
/>
) : (
<CallBackgroundBlur
className="module-CallingLobby__local-preview module-CallingLobby__local-preview--camera-is-off"
avatarPath={me.avatarPath}
color={me.color}
/>
)}
>
{i18n('calling__your-video-is-off')}
</div>

<div className="module-calling__buttons module-calling__buttons--inline">
<CallingButton
buttonType={videoButtonType}
<CallingHeader
i18n={i18n}
onClick={toggleVideo}
tooltipDirection={TooltipPlacement.Top}
isGroupCall={isGroupCall}
participantCount={peekedParticipants.length}
showParticipantsList={showParticipantsList}
toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings}
onCancel={onCallCanceled}
/>
<CallingButton
buttonType={audioButtonType}

<CallingPreCallInfo
conversation={conversation}
groupMembers={groupMembers}
i18n={i18n}
onClick={toggleAudio}
tooltipDirection={TooltipPlacement.Top}
isCallFull={isCallFull}
me={me}
peekedParticipants={peekedParticipants}
ringMode={preCallInfoRingMode}
/>
<CallingButton
buttonType={ringButtonType}

<div
className={classNames(
'module-CallingLobby__camera-is-off',
`module-CallingLobby__camera-is-off--${
shouldShowLocalVideo ? 'invisible' : 'visible'
}`
)}
>
{i18n('calling__your-video-is-off')}
</div>

<div className="module-calling__buttons module-calling__buttons--inline">
<CallingButton
buttonType={videoButtonType}
i18n={i18n}
onClick={toggleVideo}
tooltipDirection={TooltipPlacement.Top}
/>
<CallingButton
buttonType={audioButtonType}
i18n={i18n}
onClick={toggleAudio}
tooltipDirection={TooltipPlacement.Top}
/>
<CallingButton
buttonType={ringButtonType}
i18n={i18n}
isVisible={isRingButtonVisible}
onClick={toggleOutgoingRing}
tooltipDirection={TooltipPlacement.Top}
/>
</div>

<CallingLobbyJoinButton
disabled={!canJoin}
i18n={i18n}
isVisible={isRingButtonVisible}
onClick={toggleOutgoingRing}
tooltipDirection={TooltipPlacement.Top}
onClick={() => {
setIsCallConnecting(true);
onJoinCall();
}}
variant={callingLobbyJoinButtonVariant}
/>
</div>

<CallingLobbyJoinButton
disabled={!canJoin}
i18n={i18n}
onClick={() => {
setIsCallConnecting(true);
onJoinCall();
}}
variant={callingLobbyJoinButtonVariant}
/>
</div>
</FocusTrap>
);
};
59 changes: 6 additions & 53 deletions ts/components/Tooltip.tsx
Expand Up @@ -3,62 +3,12 @@

import React from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
import { Manager, Reference, Popper } from 'react-popper';
import type { StrictModifiers } from '@popperjs/core';
import { Theme, themeClassName } from '../util/theme';
import { refMerger } from '../util/refMerger';
import { offsetDistanceModifier } from '../util/popperUtil';

type EventWrapperPropsType = {
children: React.ReactNode;
onHoverChanged: (_: boolean) => void;
};

// React doesn't reliably fire `onMouseLeave` or `onMouseOut` events if wrapping a
// disabled button. This uses native browser events to avoid that.
//
// See <https://lecstor.com/react-disabled-button-onmouseleave/>.
const TooltipEventWrapper = React.forwardRef<
HTMLSpanElement,
EventWrapperPropsType
>(({ onHoverChanged, children }, ref) => {
const wrapperRef = React.useRef<HTMLSpanElement | null>(null);

const on = React.useCallback(() => {
onHoverChanged(true);
}, [onHoverChanged]);

const off = React.useCallback(() => {
onHoverChanged(false);
}, [onHoverChanged]);

React.useEffect(() => {
const wrapperEl = wrapperRef.current;

if (!wrapperEl) {
return noop;
}

wrapperEl.addEventListener('mouseenter', on);
wrapperEl.addEventListener('mouseleave', off);

return () => {
wrapperEl.removeEventListener('mouseenter', on);
wrapperEl.removeEventListener('mouseleave', off);
};
}, [on, off]);

return (
<span
onFocus={on}
onBlur={off}
ref={refMerger<HTMLSpanElement>(ref, wrapperRef)}
>
{children}
</span>
);
});
import { SmartTooltipEventWrapper } from '../state/smart/TooltipEventWrapper';

export enum TooltipPlacement {
Top = 'top',
Expand Down Expand Up @@ -97,9 +47,12 @@ export const Tooltip: React.FC<PropsType> = ({
<Manager>
<Reference>
{({ ref }) => (
<TooltipEventWrapper ref={ref} onHoverChanged={setIsHovering}>
<SmartTooltipEventWrapper
innerRef={ref}
onHoverChanged={setIsHovering}
>
{children}
</TooltipEventWrapper>
</SmartTooltipEventWrapper>
)}
</Reference>
<Popper
Expand Down
69 changes: 69 additions & 0 deletions ts/components/TooltipEventWrapper.tsx
@@ -0,0 +1,69 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import React, { Ref, useCallback, useEffect, useRef } from 'react';
import { noop } from 'lodash';

import { refMerger } from '../util/refMerger';
import type { InteractionModeType } from '../state/ducks/conversations';

type PropsType = {
children: React.ReactNode;
interactionMode: InteractionModeType;
// Matches Popper's RefHandler type
innerRef: Ref<HTMLElement>;
onHoverChanged: (_: boolean) => void;
};

// React doesn't reliably fire `onMouseLeave` or `onMouseOut` events if wrapping a
// disabled button. This uses native browser events to avoid that.
//
// See <https://lecstor.com/react-disabled-button-onmouseleave/>.
export const TooltipEventWrapper: React.FC<PropsType> = ({
onHoverChanged,
children,
interactionMode,
innerRef,
}) => {
const wrapperRef = useRef<HTMLSpanElement | null>(null);

const on = useCallback(() => {
onHoverChanged(true);
}, [onHoverChanged]);

const off = useCallback(() => {
onHoverChanged(false);
}, [onHoverChanged]);

const onFocus = useCallback(() => {
if (interactionMode === 'keyboard') {
on();
}
}, [on, interactionMode]);

useEffect(() => {
const wrapperEl = wrapperRef.current;

if (!wrapperEl) {
return noop;
}

wrapperEl.addEventListener('mouseenter', on);
wrapperEl.addEventListener('mouseleave', off);

return () => {
wrapperEl.removeEventListener('mouseenter', on);
wrapperEl.removeEventListener('mouseleave', off);
};
}, [on, off]);

return (
<span
onFocus={onFocus}
onBlur={off}
ref={refMerger<HTMLSpanElement>(innerRef, wrapperRef)}
>
{children}
</span>
);
};
29 changes: 29 additions & 0 deletions ts/state/smart/TooltipEventWrapper.tsx
@@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import React, { Ref } from 'react';
import { connect } from 'react-redux';

import { mapDispatchToProps } from '../actions';
import { StateType } from '../reducer';

import { TooltipEventWrapper } from '../../components/TooltipEventWrapper';
import { getInteractionMode } from '../selectors/user';

type ExternalProps = {
// Matches Popper's RefHandler type
innerRef: Ref<HTMLElement>;
children: React.ReactNode;
onHoverChanged: (_: boolean) => void;
};

const mapStateToProps = (state: StateType, props: ExternalProps) => {
return {
...props,
interactionMode: getInteractionMode(state),
};
};

const smart = connect(mapStateToProps, mapDispatchToProps);

export const SmartTooltipEventWrapper = smart(TooltipEventWrapper);
13 changes: 3 additions & 10 deletions ts/util/lint/exceptions.json
Expand Up @@ -12763,19 +12763,12 @@
},
{
"rule": "React-useRef",
"path": "ts/components/Tooltip.js",
"line": " const wrapperRef = react_1.default.useRef(null);",
"path": "ts/components/TooltipEventWrapper.tsx",
"line": " const wrapperRef = useRef<HTMLSpanElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2020-12-04T00:11:08.128Z",
"updated": "2021-10-21T16:10:14.143Z",
"reasonDetail": "Used to add (and remove) event listeners."
},
{
"rule": "React-useRef",
"path": "ts/components/Tooltip.tsx",
"line": " const wrapperRef = React.useRef<HTMLSpanElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.js",
Expand Down

0 comments on commit b38b22f

Please sign in to comment.