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;
+}