Speaking indicator for group calls

Co-authored-by: Peter Thatcher <peter@signal.org>
Co-authored-by: Jim Gustafson <jim@signal.org>
Co-authored-by: Josh Perez <60019601+josh-signal@users.noreply.github.com>
This commit is contained in:
Evan Hahn 2022-02-08 12:30:33 -06:00 committed by GitHub
parent cb5131420f
commit 5ce26eb91a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 482 additions and 42 deletions

View File

@ -27,8 +27,9 @@ js/curve/**
js/Mp3LameEncoder.min.js
js/WebAudioRecorderMp3.js
# Test fixtures
test/fixtures.js
# Assets
/images/
/fixtures/
# Github workflows
.github/**

View File

@ -5,6 +5,55 @@
Signal Desktop makes use of the following open source projects.
## @evanhahn/lottie-web-light
The MIT License (MIT)
Copyright (c) 2022 Evan Hahn
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.
************
Original lottie-web license:
The MIT License (MIT)
Copyright (c) 2015 Bodymovin
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.
## @popperjs/core
License: MIT

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -79,6 +79,7 @@
"fs-xattr": "0.3.0"
},
"dependencies": {
"@evanhahn/lottie-web-light": "5.8.1",
"@popperjs/core": "2.9.2",
"@react-spring/web": "9.4.1",
"@signalapp/signal-client": "0.11.1",
@ -163,7 +164,7 @@
"redux-ts-utils": "3.2.2",
"reselect": "4.1.2",
"rimraf": "2.6.2",
"ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#f22009252bd3742f5b8a2761fe8f9c76a3bbc11d",
"ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#561484a82f75f64391da5ce9f48217db30e9ba4b",
"rotating-file-stream": "2.1.5",
"sanitize.css": "11.0.0",
"semver": "5.4.1",

View File

@ -4208,20 +4208,6 @@ button.module-image__border-overlay:focus {
visibility: hidden;
white-space: nowrap;
}
&--audio-muted::after {
$size: 14px;
@include color-svg(
'../images/icons/v2/mic-off-solid-28.svg',
$color-white
);
content: '';
height: $size;
min-width: $size;
right: 6px;
width: $size;
z-index: $z-index-base;
}
}
&:hover {

View File

@ -0,0 +1,14 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CallingAudioIndicator {
$size: 14px;
height: $size;
min-width: $size;
width: $size;
z-index: $z-index-base;
&--muted {
@include color-svg('../images/icons/v2/mic-off-solid-28.svg', $color-white);
}
}

View File

@ -37,6 +37,7 @@
@import './components/BadgeSustainerInstructionsDialog.scss';
@import './components/BetterAvatarBubble.scss';
@import './components/Button.scss';
@import './components/CallingAudioIndicator.scss';
@import './components/CallingButton.scss';
@import './components/CallingLobby.scss';
@import './components/CallingLobbyJoinButton.scss';

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -144,6 +144,7 @@ story.add('Ongoing Group Call', () => (
groupMembers: [],
peekedParticipants: [],
remoteParticipants: [],
speakingDemuxIds: new Set<number>(),
},
})}
/>
@ -218,6 +219,7 @@ story.add('Group call - Safety Number Changed', () => (
groupMembers: [],
peekedParticipants: [],
remoteParticipants: [],
speakingDemuxIds: new Set<number>(),
},
})}
/>

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect } from 'react';

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -103,6 +103,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
peekedParticipants:
overrideProps.peekedParticipants || overrideProps.remoteParticipants || [],
remoteParticipants: overrideProps.remoteParticipants || [],
speakingDemuxIds: new Set<number>(),
});
const createActiveCallProp = (

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
@ -299,6 +299,7 @@ export const CallScreen: React.FC<PropsType> = ({
isInSpeakerView={isInSpeakerView}
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
speakingDemuxIds={activeCall.speakingDemuxIds}
/>
);
break;

View File

@ -0,0 +1,30 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
import animationData from '../../images/lottie-animations/CallingSpeakingIndicator.json';
import { Lottie } from './Lottie';
export function CallingAudioIndicator({
hasRemoteAudio,
isSpeaking,
}: Readonly<{
hasRemoteAudio: boolean;
isSpeaking: boolean;
}>): ReactElement {
if (!hasRemoteAudio) {
return (
<div className="CallingAudioIndicator CallingAudioIndicator--muted" />
);
}
if (isSpeaking) {
return (
<Lottie animationData={animationData} className="CallingAudioIndicator" />
);
}
// Render an empty spacer so that names don't move around.
return <div className="CallingAudioIndicator" />;
}

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -119,6 +119,7 @@ story.add('Group Call', () => {
deviceCount: 0,
peekedParticipants: [],
remoteParticipants: [],
speakingDemuxIds: new Set<number>(),
},
});
return <CallingPip {...props} />;

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useMemo, useEffect } from 'react';

View File

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC } from 'react';
@ -39,6 +39,7 @@ const defaultProps = {
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
i18n,
onParticipantVisibilityChanged: action('onParticipantVisibilityChanged'),
speakingDemuxIds: new Set<number>(),
};
// This component is usually rendered on a call screen.

View File

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FC, ReactElement } from 'react';
@ -24,6 +24,7 @@ type PropsType = {
isVisible: boolean
) => unknown;
overflowedParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
speakingDemuxIds: Set<number>;
};
export const GroupCallOverflowArea: FC<PropsType> = ({
@ -32,6 +33,7 @@ export const GroupCallOverflowArea: FC<PropsType> = ({
i18n,
onParticipantVisibilityChanged,
overflowedParticipants,
speakingDemuxIds,
}) => {
const overflowRef = useRef<HTMLDivElement | null>(null);
const [overflowScrollTop, setOverflowScrollTop] = useState(0);
@ -114,6 +116,7 @@ export const GroupCallOverflowArea: FC<PropsType> = ({
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
isSpeaking={speakingDemuxIds.has(remoteParticipant.demuxId)}
onVisibilityChanged={onParticipantVisibilityChanged}
width={OVERFLOW_PARTICIPANT_WIDTH}
height={Math.floor(

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -36,6 +36,7 @@ const createProps = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGroupCallVideoFrameSource: noop as any,
i18n,
isSpeaking: false,
remoteParticipant: {
demuxId: 123,
hasRemoteAudio: false,
@ -51,6 +52,7 @@ const createProps = (
}),
},
...overrideProps,
...(overrideProps.isInPip ? {} : { isSpeaking: false }),
});
const story = storiesOf('Components/GroupCallRemoteParticipant', module);

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties } from 'react';
@ -16,6 +16,7 @@ import type { GroupCallRemoteParticipantType } from '../types/Calling';
import type { LocalizerType } from '../types/Util';
import { AvatarColors } from '../types/Colors';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { CallingAudioIndicator } from './CallingAudioIndicator';
import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
import { Intl } from './Intl';
@ -41,6 +42,7 @@ type InPipPropsType = {
type InOverflowAreaPropsType = {
height: number;
isInPip?: false;
isSpeaking: boolean;
width: number;
};
@ -282,6 +284,10 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
module="module-ongoing-call__group-call-remote-participant__info__contact-name"
title={title}
/>
<CallingAudioIndicator
hasRemoteAudio={hasRemoteAudio}
isSpeaking={props.isSpeaking}
/>
</div>
)}
{wantsToShowVideo && (

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState, useMemo, useEffect } from 'react';
@ -48,6 +48,7 @@ type PropsType = {
isInSpeakerView: boolean;
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
setGroupCallVideoRequest: (_: Array<GroupCallVideoRequest>) => void;
speakingDemuxIds: Set<number>;
};
enum VideoRequestMode {
@ -85,6 +86,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
isInSpeakerView,
remoteParticipants,
setGroupCallVideoRequest,
speakingDemuxIds,
}) => {
const [containerDimensions, setContainerDimensions] = useState<Dimensions>({
width: 0,
@ -266,8 +268,12 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
let rowWidthSoFar = 0;
return remoteParticipantsInRow.map(remoteParticipant => {
const { demuxId, videoAspectRatio } = remoteParticipant;
const isSpeaking = speakingDemuxIds.has(demuxId);
const renderedWidth = Math.floor(
remoteParticipant.videoAspectRatio * gridParticipantHeight
videoAspectRatio * gridParticipantHeight
);
const left = rowWidthSoFar + leftOffset;
@ -275,11 +281,12 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
return (
<GroupCallRemoteParticipant
key={remoteParticipant.demuxId}
key={demuxId}
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
height={gridParticipantHeight}
i18n={i18n}
isSpeaking={isSpeaking}
left={left}
remoteParticipant={remoteParticipant}
top={top}
@ -411,6 +418,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
i18n={i18n}
onParticipantVisibilityChanged={onParticipantVisibilityChanged}
overflowedParticipants={overflowedParticipants}
speakingDemuxIds={speakingDemuxIds}
/>
</div>
)}

View File

@ -0,0 +1,21 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { Lottie } from './Lottie';
import testAnimationData from '../../fixtures/lottie-loader-by-lucas-bariani.json';
const STORYBOOK_CONTAINER_CLASS_NAME = 'lottie-test-storybook-container';
const story = storiesOf('Components/Lottie', module);
story.add('Default', () => (
<Lottie
animationData={testAnimationData}
className={STORYBOOK_CONTAINER_CLASS_NAME}
style={{ width: 300, height: 300 }}
/>
));

42
ts/components/Lottie.tsx Normal file
View File

@ -0,0 +1,42 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties, ReactElement } from 'react';
import React, { useEffect, useRef } from 'react';
import lottie from '@evanhahn/lottie-web-light';
import { lottieNoopAudioFactory } from '../util/lottieNoopAudioFactory';
export function Lottie({
animationData,
className,
style,
}: Readonly<{
animationData: unknown;
className?: string;
style?: CSSProperties;
}>): ReactElement {
const containerRef = useRef<null | HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const animationItem = lottie.loadAnimation({
container,
renderer: 'svg',
loop: true,
autoplay: true,
animationData,
audioFactory: lottieNoopAudioFactory,
});
return () => {
animationItem.destroy();
};
}, [animationData]);
return <div className={className} ref={containerRef} style={style} />;
}

View File

@ -627,6 +627,7 @@ export class CallingClass {
groupIdBuffer,
this.sfuUrl,
Buffer.alloc(0),
500,
{
onLocalDeviceStateChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
@ -674,8 +675,16 @@ export class CallingClass {
onRemoteDeviceStatesChanged: groupCall => {
this.syncGroupCallToRedux(conversationId, groupCall);
},
onAudioLevels: _groupCall => {
// TODO: Implement audio level handling for group calls.
onAudioLevels: groupCall => {
const remoteDeviceStates = groupCall.getRemoteDeviceStates();
if (!remoteDeviceStates) {
return;
}
this.uxActions?.groupCallAudioLevelsChange({
conversationId,
remoteDeviceStates,
});
},
onPeekChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
@ -1955,6 +1964,8 @@ export class CallingClass {
},
hideIp: shouldRelayCalls || isContactUnknown,
bandwidthMode: BandwidthMode.Normal,
// TODO: DESKTOP-3101
// audioLevelsIntervalMillis: 500,
};
}

View File

@ -39,6 +39,7 @@ import type { UUIDStringType } from '../../types/UUID';
import type { ConversationChangedActionType } from './conversations';
import * as log from '../../logging/log';
import { strictAssert } from '../../util/assert';
import * as setUtil from '../../util/setUtil';
// State
@ -89,6 +90,7 @@ export type GroupCallStateType = {
joinState: GroupCallJoinState;
peekInfo: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
speakingDemuxIds?: Set<number>;
} & GroupCallRingStateType;
export type ActiveCallStateType = {
@ -305,6 +307,7 @@ const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED';
const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN';
const DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL';
const GROUP_CALL_AUDIO_LEVELS_CHANGE = 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE';
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
const HANG_UP = 'calling/HANG_UP';
const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL';
@ -370,6 +373,16 @@ type DeclineCallActionType = {
payload: DeclineCallType;
};
type GroupCallAudioLevelsChangeActionPayloadType = Readonly<{
conversationId: string;
remoteDeviceStates: ReadonlyArray<{ audioLevel: number; demuxId: number }>;
}>;
type GroupCallAudioLevelsChangeActionType = {
type: 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE';
payload: GroupCallAudioLevelsChangeActionPayloadType;
};
export type GroupCallStateChangeActionType = {
type: 'calling/GROUP_CALL_STATE_CHANGE';
payload: GroupCallStateChangeActionPayloadType;
@ -500,6 +513,7 @@ export type CallingActionType =
| CloseNeedPermissionScreenActionType
| ConversationChangedActionType
| DeclineCallActionType
| GroupCallAudioLevelsChangeActionType
| GroupCallStateChangeActionType
| HangUpActionType
| IncomingDirectCallActionType
@ -706,6 +720,12 @@ function getPresentingSources(): ThunkAction<
};
}
function groupCallAudioLevelsChange(
payload: GroupCallAudioLevelsChangeActionPayloadType
): GroupCallAudioLevelsChangeActionType {
return { type: GROUP_CALL_AUDIO_LEVELS_CHANGE, payload };
}
function groupCallStateChange(
payload: GroupCallStateChangeArgumentType
): ThunkAction<void, RootStateType, unknown, GroupCallStateChangeActionType> {
@ -1242,6 +1262,7 @@ export const actions = {
closeNeedPermissionScreen,
declineCall,
getPresentingSources,
groupCallAudioLevelsChange,
groupCallStateChange,
hangUp,
hangUpActiveCall,
@ -1631,6 +1652,40 @@ export function reducer(
};
}
if (action.type === GROUP_CALL_AUDIO_LEVELS_CHANGE) {
const { conversationId, remoteDeviceStates } = action.payload;
const existingCall = getGroupCall(conversationId, state);
if (!existingCall) {
return state;
}
const speakingDemuxIds = new Set<number>();
remoteDeviceStates.forEach(({ audioLevel, demuxId }) => {
// We expect `audioLevel` to be a number but have this check just in case.
if (typeof audioLevel === 'number' && audioLevel > 0.25) {
speakingDemuxIds.add(demuxId);
}
});
// This action is dispatched frequently. This equality check helps avoid re-renders.
const oldSpeakingDemuxIds = existingCall.speakingDemuxIds;
if (
oldSpeakingDemuxIds &&
setUtil.isEqual(oldSpeakingDemuxIds, speakingDemuxIds)
) {
return state;
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: { ...existingCall, speakingDemuxIds },
},
};
}
if (action.type === GROUP_CALL_STATE_CHANGE) {
const {
connectionState,

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -251,6 +251,7 @@ const mapStateToActiveCallProp = (
maxDevices: call.peekInfo.maxDevices,
peekedParticipants,
remoteParticipants,
speakingDemuxIds: call.speakingDemuxIds || new Set<number>(),
};
}
default:

View File

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
@ -6,6 +6,7 @@ import * as sinon from 'sinon';
import {
concat,
every,
filter,
find,
groupBy,
@ -170,6 +171,30 @@ describe('iterable utilities', () => {
});
});
describe('every', () => {
const isOdd = (n: number): boolean => Boolean(n % 2);
it('returns true for empty iterables and never checks the predicate', () => {
const fn = sinon.fake();
assert.isTrue(every([], fn));
assert.isTrue(every(new Set(), fn));
assert.isTrue(every(new Map(), fn));
sinon.assert.notCalled(fn);
});
it('returns false if any values make the predicate return false', () => {
assert.isFalse(every([2], isOdd));
assert.isFalse(every([1, 2, 3], isOdd));
});
it('returns true if all values make the predicate return true', () => {
assert.isTrue(every([1], isOdd));
assert.isTrue(every([1, 3, 5], isOdd));
});
});
describe('filter', () => {
it('returns an empty iterable when passed an empty iterable', () => {
const fn = sinon.fake();

View File

@ -1,13 +1,42 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { remove, toggle } from '../../util/setUtil';
import { isEqual, remove, toggle } from '../../util/setUtil';
describe('set utilities', () => {
const original = new Set([1, 2, 3]);
describe('isEqual', () => {
it('returns false if the sets are different', () => {
const sets = [
new Set([1, 2, 3]),
new Set([1, 2, 3, 4]),
new Set([1, 2]),
new Set([4, 5, 6]),
];
for (const a of sets) {
for (const b of sets) {
if (a !== b) {
assert.isFalse(isEqual(a, b));
}
}
}
});
it('returns true if both arguments are the same set', () => {
const set = new Set([1, 2, 3]);
assert.isTrue(isEqual(set, set));
});
it('returns true if the sets have the same values', () => {
assert.isTrue(isEqual(new Set(), new Set()));
assert.isTrue(isEqual(new Set([1, 2]), new Set([2, 1])));
});
});
describe('remove', () => {
it('accepts zero arguments, returning a new set', () => {
const result = remove(original);

View File

@ -776,6 +776,67 @@ describe('calling duck', () => {
});
});
describe('groupCallAudioLevelsChange', () => {
const { groupCallAudioLevelsChange } = actions;
const remoteDeviceStates = [
{ audioLevel: 0.3, demuxId: 1 },
{ audioLevel: 0.4, demuxId: 2 },
{ audioLevel: 0.5, demuxId: 3 },
{ audioLevel: 0.2, demuxId: 7 },
{ audioLevel: 0.1, demuxId: 8 },
{ audioLevel: 0, demuxId: 9 },
];
it("does nothing if there's no relevant call", () => {
const action = groupCallAudioLevelsChange({
conversationId: 'garbage',
remoteDeviceStates,
});
const result = reducer(stateWithActiveGroupCall, action);
assert.strictEqual(result, stateWithActiveGroupCall);
});
it('does nothing if the state change would be a no-op', () => {
const state = {
...stateWithActiveGroupCall,
callsByConversation: {
'fake-group-call-conversation-id': {
...stateWithActiveGroupCall.callsByConversation[
'fake-group-call-conversation-id'
],
speakingDemuxIds: new Set([3, 2, 1]),
},
},
};
const action = groupCallAudioLevelsChange({
conversationId: 'fake-group-call-conversation-id',
remoteDeviceStates,
});
const result = reducer(state, action);
assert.strictEqual(result, state);
});
it('updates the set of speaking participants', () => {
const action = groupCallAudioLevelsChange({
conversationId: 'fake-group-call-conversation-id',
remoteDeviceStates,
});
const result = reducer(stateWithActiveGroupCall, action);
const call =
result.callsByConversation['fake-group-call-conversation-id'];
if (call?.callMode !== CallMode.Group) {
throw new Error('Expected a group call to be found');
}
assert.deepStrictEqual(call.speakingDemuxIds, new Set([1, 2, 3]));
});
});
describe('groupCallStateChange', () => {
const { groupCallStateChange } = actions;

View File

@ -65,6 +65,7 @@ type ActiveGroupCallType = ActiveCallBaseType & {
groupMembers: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
peekedParticipants: Array<ConversationType>;
remoteParticipants: Array<GroupCallRemoteParticipantType>;
speakingDemuxIds: Set<number>;
};
export type ActiveCallType = ActiveDirectCallType | ActiveGroupCallType;

View File

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
@ -46,6 +46,18 @@ class ConcatIterable<T> implements Iterable<T> {
}
}
export function every<T>(
iterable: Iterable<T>,
predicate: (value: T) => boolean
): boolean {
for (const value of iterable) {
if (!predicate(value)) {
return false;
}
}
return true;
}
export function filter<T, S extends T>(
iterable: Iterable<T>,
predicate: (value: T) => value is S

View File

@ -132,6 +132,41 @@
"reasonCategory": "falseMatch",
"updated": "2021-04-05T20:48:36.065Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/@evanhahn/lottie-web-light/index.js",
"line": " this._elementHelper.append(img);",
"reasonCategory": "falseMatch",
"updated": "2022-01-27T20:06:59.988Z"
},
{
"rule": "jQuery-insertBefore(",
"path": "node_modules/@evanhahn/lottie-web-light/index.js",
"line": " this.layerElement.insertBefore(newElement, nextElement);",
"reasonCategory": "falseMatch",
"updated": "2022-01-27T20:06:59.988Z"
},
{
"rule": "jQuery-insertBefore(",
"path": "node_modules/@evanhahn/lottie-web-light/index.js",
"line": " parentNode.insertBefore(useElem, nextChild);",
"reasonCategory": "falseMatch",
"updated": "2022-01-27T20:06:59.988Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/@evanhahn/lottie-web-light/index.js",
"line": " _workerSelf.assetLoader.load(",
"reasonCategory": "falseMatch",
"updated": "2022-01-27T20:06:59.988Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/@evanhahn/lottie-web-light/index.js",
"line": " _workerSelf.assetLoader.load(",
"reasonCategory": "falseMatch",
"updated": "2022-01-27T20:06:59.988Z"
},
{
"rule": "eval",
"path": "node_modules/@protobufjs/inquire/index.js",
@ -7494,6 +7529,14 @@
"reasonCategory": "usageTrusted",
"updated": "2021-10-11T21:21:08.188Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Lottie.tsx",
"line": " const containerRef = useRef<null | HTMLDivElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2022-01-27T20:06:59.988Z",
"reasonDetail": "Doesn't manipulate the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/Modal.tsx",

View File

@ -0,0 +1,18 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { noop } from 'lodash';
import type { AnimationConfig } from '@evanhahn/lottie-web-light';
type LottieAudioFactory = NonNullable<AnimationConfig['audioFactory']>;
type LottieAudio = ReturnType<LottieAudioFactory>;
const lottieNoopAudio: LottieAudio = {
play: noop,
seek: noop,
playing: noop,
rate: noop,
setVolume: noop,
};
export const lottieNoopAudioFactory: LottieAudioFactory = () => lottieNoopAudio;

View File

@ -1,6 +1,8 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { every } from './iterables';
const add = <T>(set: Readonly<Set<T>>, item: T): Set<T> =>
new Set(set).add(item);
@ -20,3 +22,8 @@ export const toggle = <T>(
item: Readonly<T>,
shouldInclude: boolean
): Set<T> => (shouldInclude ? add : remove)(set, item);
export const isEqual = (
a: Readonly<Set<unknown>>,
b: Readonly<Set<unknown>>
): boolean => a === b || (a.size === b.size && every(a, item => b.has(item)));

View File

@ -1176,6 +1176,11 @@
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.3.tgz#dfa0c92efe44a1d1a7974fb49ffeb40ef2da5a27"
integrity sha512-zVgvPwGK7c1aVdUVc9Qv7SqepOGRDrqCw7KZPSZziWGxSlbII3gmvGLPzLX4d0n0BMbamBacUrN22zOMyFFEkQ==
"@evanhahn/lottie-web-light@5.8.1":
version "5.8.1"
resolved "https://registry.yarnpkg.com/@evanhahn/lottie-web-light/-/lottie-web-light-5.8.1.tgz#9154f9301479ec16745da925d44bd721efa04cbb"
integrity sha512-U0G1tt3/UEYnyCNNslWPi1dB7X1xQ9aoSip+B3GTKO/Bns8yz/p39vBkRSN9d25nkbHuCsbjky2coQftj5YVKw==
"@icons/material@^0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
@ -13000,9 +13005,9 @@ rimraf@^3.0.0, rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#f22009252bd3742f5b8a2761fe8f9c76a3bbc11d":
version "2.17.2"
resolved "https://github.com/signalapp/signal-ringrtc-node.git#f22009252bd3742f5b8a2761fe8f9c76a3bbc11d"
"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#561484a82f75f64391da5ce9f48217db30e9ba4b":
version "2.18.0"
resolved "https://github.com/signalapp/signal-ringrtc-node.git#561484a82f75f64391da5ce9f48217db30e9ba4b"
ripemd160@^2.0.0, ripemd160@^2.0.1:
version "2.0.1"