diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 2fc20fe14..0911c1f3b 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3972,6 +3972,21 @@ button.module-image__border-overlay:focus { transform: translate(0, 0); transition: transform 200ms linear, width 200ms linear, height 200ms linear; + &:after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + border: 0 solid transparent; + border-radius: 5px; + transition: border-width 200ms, border-color 200ms; + transition-timing-function: ease-in-out; + } + &--speaking:after { + border-width: 3px; + border-color: $color-white; + } + &__remote-video { // The background-color is seen while the video loads. background-color: $color-gray-75; diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index ae23e64f3..c6b3f5e4a 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -40,11 +40,15 @@ import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPerm import { missingCaseError } from '../util/missingCaseError'; import * as KeyboardLayout from '../services/keyboardLayout'; import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; -import { CallingAudioIndicator } from './CallingAudioIndicator'; +import { + CallingAudioIndicator, + SPEAKING_LINGER_MS, +} from './CallingAudioIndicator'; import { useActiveCallShortcuts, useKeyboardShortcuts, } from '../hooks/useKeyboardShortcuts'; +import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate'; export type PropsType = { activeCall: ActiveCallType; @@ -155,6 +159,11 @@ export function CallScreen({ showParticipantsList, } = activeCall; + const isSpeaking = useValueAtFixedRate( + localAudioLevel > 0, + SPEAKING_LINGER_MS + ); + useActivateSpeakerViewOnPresenting({ remoteParticipants, switchToPresentationView, @@ -536,6 +545,7 @@ export function CallScreen({ diff --git a/ts/components/CallingAudioIndicator.stories.tsx b/ts/components/CallingAudioIndicator.stories.tsx index 04d38ed58..ae0f02ba7 100644 --- a/ts/components/CallingAudioIndicator.stories.tsx +++ b/ts/components/CallingAudioIndicator.stories.tsx @@ -4,8 +4,12 @@ import React, { useState, useEffect } from 'react'; import { boolean } from '@storybook/addon-knobs'; -import { CallingAudioIndicator } from './CallingAudioIndicator'; +import { + CallingAudioIndicator, + SPEAKING_LINGER_MS, +} from './CallingAudioIndicator'; import { AUDIO_LEVEL_INTERVAL_MS } from '../calling/constants'; +import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate'; export default { title: 'Components/CallingAudioIndicator', @@ -24,10 +28,13 @@ export function Extreme(): JSX.Element { }; }, [audioLevel, setAudioLevel]); + const isSpeaking = useValueAtFixedRate(audioLevel > 0, SPEAKING_LINGER_MS); + return ( ); } @@ -45,10 +52,13 @@ export function Random(): JSX.Element { }; }, [audioLevel, setAudioLevel]); + const isSpeaking = useValueAtFixedRate(audioLevel > 0, SPEAKING_LINGER_MS); + return ( ); } diff --git a/ts/components/CallingAudioIndicator.tsx b/ts/components/CallingAudioIndicator.tsx index 5d8873f2e..8fdcbc0fe 100644 --- a/ts/components/CallingAudioIndicator.tsx +++ b/ts/components/CallingAudioIndicator.tsx @@ -2,16 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import classNames from 'classnames'; -import { noop } from 'lodash'; import type { ReactElement } from 'react'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useSpring, animated } from '@react-spring/web'; import { AUDIO_LEVEL_INTERVAL_MS } from '../calling/constants'; import { missingCaseError } from '../util/missingCaseError'; -const SPEAKING_LINGER_MS = 500; - +export const SPEAKING_LINGER_MS = 500; const BASE_CLASS_NAME = 'CallingAudioIndicator'; const CONTENT_CLASS_NAME = `${BASE_CLASS_NAME}__content`; @@ -104,23 +102,12 @@ function Bars({ audioLevel }: { audioLevel: number }): ReactElement { export function CallingAudioIndicator({ hasAudio, audioLevel, -}: Readonly<{ hasAudio: boolean; audioLevel: number }>): ReactElement { - const [shouldShowSpeaking, setShouldShowSpeaking] = useState(audioLevel > 0); - - useEffect(() => { - if (audioLevel > 0) { - setShouldShowSpeaking(true); - } else if (shouldShowSpeaking) { - const timeout = setTimeout(() => { - setShouldShowSpeaking(false); - }, SPEAKING_LINGER_MS); - return () => { - clearTimeout(timeout); - }; - } - return noop; - }, [audioLevel, shouldShowSpeaking]); - + shouldShowSpeaking, +}: Readonly<{ + hasAudio: boolean; + audioLevel: number; + shouldShowSpeaking: boolean; +}>): ReactElement { if (!hasAudio) { return (
= React.memo( videoAspectRatio, } = props.remoteParticipant; + const isSpeaking = useValueAtFixedRate( + !props.isInPip ? props.audioLevel > 0 : false, + SPEAKING_LINGER_MS + ); + const [hasReceivedVideoRecently, setHasReceivedVideoRecently] = useState(false); const [isWide, setIsWide] = useState( @@ -266,7 +275,11 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( )}
@@ -283,6 +296,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo(
)} diff --git a/ts/hooks/useValueAtFixedRate.ts b/ts/hooks/useValueAtFixedRate.ts new file mode 100644 index 000000000..d8057f72c --- /dev/null +++ b/ts/hooks/useValueAtFixedRate.ts @@ -0,0 +1,19 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useEffect, useState } from 'react'; + +export function useValueAtFixedRate(value: T, rate: number): T { + const [currentValue, setCurrentValue] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => { + setCurrentValue(value); + }, rate); + return () => { + clearTimeout(timeout); + }; + }, [value, rate]); + + return currentValue; +}