Merge pull request #761 from robintown/update-js-sdk

Enable users to join calls from multiple devices
This commit is contained in:
Robin 2022-11-28 16:37:15 -05:00 committed by GitHub
commit aa828fe9f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 292 additions and 230 deletions

View file

@ -45,7 +45,7 @@
"i18next": "^21.10.0",
"i18next-browser-languagedetector": "^6.1.8",
"i18next-http-backend": "^1.4.4",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#3f1c3392d45b0fc054c3788cc6c043cd5b4fb730",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#9d3ac66cf847fe8cb0359d64e1f1d67902b982a1",
"matrix-widget-api": "^1.0.0",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
@ -86,7 +86,7 @@
"i18next-parser": "^6.6.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.2.2",
"jest-environment-jsdom": "^29.2.2",
"jest-environment-jsdom": "^29.3.1",
"prettier": "^2.6.2",
"sass": "^1.42.1",
"storybook-builder-vite": "^0.1.12",

View file

@ -43,8 +43,6 @@
"Display name": "Display name",
"Download debug logs": "Download debug logs",
"Element Call Home": "Element Call Home",
"Single-key keyboard shortcuts": "Single-key keyboard shortcuts",
"Entering room…": "Entering room…",
"Exit full screen": "Exit full screen",
"Fetching group call timed out.": "Fetching group call timed out.",
"Freedom": "Freedom",
@ -105,6 +103,7 @@
"Show call inspector": "Show call inspector",
"Sign in": "Sign in",
"Sign out": "Sign out",
"Single-key keyboard shortcuts": "Single-key keyboard shortcuts",
"Spatial audio": "Spatial audio",
"Speaker": "Speaker",
"Speaker {{n}}": "Speaker {{n}}",

View file

@ -25,8 +25,7 @@ import React, {
useRef,
} from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
@ -40,6 +39,7 @@ import {
import { widget } from "./widget";
import { PosthogAnalytics, RegistrationType } from "./PosthogAnalytics";
import { translatedError } from "./TranslatedError";
import { useEventTarget } from "./useEvents";
declare global {
interface Window {
@ -55,6 +55,9 @@ export interface Session {
tempPassword?: string;
}
const loadChannel =
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
const loadSession = (): Session => {
const data = localStorage.getItem("matrix-auth-store");
if (data) return JSON.parse(data);
@ -292,47 +295,29 @@ export const ClientProvider: FC<Props> = ({ children }) => {
const { t } = useTranslation();
// To protect against multiple sessions writing to the same storage
// simultaneously, we send a broadcast message that shuts down all other
// running instances of the app. This isn't necessary if the app is running in
// a widget though, since then it'll be mostly stateless.
useEffect(() => {
// To protect against multiple sessions writing to the same storage
// simultaneously, we send a to-device message that shuts down all other
// running instances of the app. This isn't necessary if the app is running
// in a widget though, since then it'll be mostly stateless.
if (!widget && client) {
const loadTime = Date.now();
if (!widget) loadChannel?.postMessage({});
}, []);
const onToDeviceEvent = (event: MatrixEvent) => {
if (event.getType() !== "org.matrix.call_duplicate_session") return;
useEventTarget(
loadChannel,
"message",
useCallback(() => {
client?.stopClient();
const content = event.getContent();
if (content.session_id === client.getSessionId()) return;
if (content.timestamp > loadTime) {
client?.stopClient();
setState((prev) => ({
...prev,
error: translatedError(
"This application has been opened in another tab.",
t
),
}));
}
};
client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent);
client.sendToDevice("org.matrix.call_duplicate_session", {
[client.getUserId()]: {
"*": { session_id: client.getSessionId(), timestamp: loadTime },
},
});
return () => {
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
};
}
}, [client, t]);
setState((prev) => ({
...prev,
error: translatedError(
"This application has been opened in another tab.",
t
),
}));
}, [client, setState, t])
);
const context = useMemo<ClientState>(
() => ({

View file

@ -32,7 +32,7 @@ const overlapMap: Partial<Record<Size, number>> = {
interface Props extends HTMLAttributes<HTMLDivElement> {
className: string;
client: MatrixClient;
participants: RoomMember[];
members: RoomMember[];
max?: number;
size?: Size;
}
@ -40,7 +40,7 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
export function Facepile({
className,
client,
participants,
members,
max = 3,
size = Size.XS,
...rest
@ -51,14 +51,14 @@ export function Facepile({
const _overlap = overlapMap[size];
const title = useMemo(() => {
return participants.reduce<string | null>(
return members.reduce<string | null>(
(prev, curr) =>
prev === null
? curr.name
: t("{{names}}, {{name}}", { names: prev, name: curr.name }),
null
) as string;
}, [participants, t]);
}, [members, t]);
return (
<div
@ -66,12 +66,11 @@ export function Facepile({
title={title}
style={{
width:
Math.min(participants.length, max + 1) * (_size - _overlap) +
_overlap,
Math.min(members.length, max + 1) * (_size - _overlap) + _overlap,
}}
{...rest}
>
{participants.slice(0, max).map((member, i) => {
{members.slice(0, max).map((member, i) => {
const avatarUrl = member.getMxcAvatarUrl();
return (
<Avatar
@ -84,11 +83,11 @@ export function Facepile({
/>
);
})}
{participants.length > max && (
{members.length > max && (
<Avatar
key="additional"
size={size}
fallback={`+${participants.length - max}`}
fallback={`+${members.length - max}`}
className={styles.avatar}
style={{ left: max * (_size - _overlap) }}
/>

View file

@ -92,7 +92,7 @@ function CallTile({
<Facepile
className={styles.facePile}
client={client}
participants={participants}
members={participants}
/>
)}
</div>

View file

@ -79,7 +79,6 @@ export function GroupCallView({
isScreensharing,
screenshareFeeds,
participants,
calls,
unencryptedEventsFromUsers,
} = useGroupCall(groupCall);
@ -173,9 +172,14 @@ export function GroupCallView({
const onLeave = useCallback(() => {
setLeft(true);
let participantCount = 0;
for (const deviceMap of groupCall.participants.values()) {
participantCount += deviceMap.size;
}
PosthogAnalytics.instance.eventCallEnded.track(
groupCall.room.name,
groupCall.participants.length
participantCount
);
leave();
@ -187,14 +191,7 @@ export function GroupCallView({
if (!isPasswordlessUser && !isEmbedded) {
history.push("/");
}
}, [
groupCall.room.name,
groupCall.participants.length,
leave,
isPasswordlessUser,
isEmbedded,
history,
]);
}, [groupCall, leave, isPasswordlessUser, isEmbedded, history]);
useEffect(() => {
if (widget && state === GroupCallState.Entered) {
@ -236,7 +233,6 @@ export function GroupCallView({
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
participants={participants}
calls={calls}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
@ -253,12 +249,6 @@ export function GroupCallView({
/>
);
}
} else if (state === GroupCallState.Entering) {
return (
<FullScreenView>
<h1>{t("Entering room…")}</h1>
</FullScreenView>
);
} else if (left) {
if (isPasswordlessUser) {
return <CallEndedView client={client} />;

View file

@ -14,13 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
useEffect,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import React, { useEffect, useCallback, useMemo, useRef } from "react";
import { usePreventScroll } from "@react-aria/overlays";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
@ -31,11 +25,6 @@ import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import {
CallEvent,
CallState,
MatrixCall,
} from "matrix-js-sdk/src/webrtc/call";
import type { IWidgetApiRequest } from "matrix-widget-api";
import styles from "./InCallView.module.css";
@ -73,6 +62,7 @@ import { widget, ElementWidgetActions } from "../widget";
import { useJoinRule } from "./useJoinRule";
import { useUrlParams } from "../UrlParams";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { ConnectionState, ParticipantInfo } from "./useGroupCall";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@ -83,8 +73,7 @@ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
interface Props {
client: MatrixClient;
groupCall: GroupCall;
participants: RoomMember[];
calls: MatrixCall[];
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
roomName: string;
avatarUrl: string;
microphoneMuted: boolean;
@ -93,7 +82,7 @@ interface Props {
toggleMicrophoneMuted: () => void;
toggleScreensharing: () => void;
userMediaFeeds: CallFeed[];
activeSpeaker: string;
activeSpeaker: CallFeed | null;
onLeave: () => void;
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
@ -102,12 +91,6 @@ interface Props {
hideHeader: boolean;
}
export enum ConnectionState {
EstablishingCall = "establishing call", // call hasn't been established yet
WaitMedia = "wait_media", // call is set up, waiting for ICE to connect
Connected = "connected", // media is flowing
}
// Represents something that should get a tile on the layout,
// ie. a user's video feed or a screen share feed.
export interface TileDescriptor {
@ -124,7 +107,6 @@ export function InCallView({
client,
groupCall,
participants,
calls,
roomName,
avatarUrl,
microphoneMuted,
@ -174,50 +156,6 @@ export function InCallView({
const { hideScreensharing } = useUrlParams();
const makeConnectionStatesMap = useCallback(() => {
const newConnStates = new Map<string, ConnectionState>();
for (const participant of participants) {
const userCall = groupCall.getCallByUserId(participant.userId);
const feed = userMediaFeeds.find((f) => f.userId === participant.userId);
let connectionState = ConnectionState.EstablishingCall;
if (feed && feed.isLocal()) {
connectionState = ConnectionState.Connected;
} else if (userCall) {
if (userCall.state === CallState.Connected) {
connectionState = ConnectionState.Connected;
} else if (userCall.state === CallState.Connecting) {
connectionState = ConnectionState.WaitMedia;
}
}
newConnStates.set(participant.userId, connectionState);
}
return newConnStates;
}, [groupCall, participants, userMediaFeeds]);
const [connStates, setConnStates] = useState(
new Map<string, ConnectionState>()
);
const updateConnectionStates = useCallback(() => {
setConnStates(makeConnectionStatesMap());
}, [setConnStates, makeConnectionStatesMap]);
useEffect(() => {
for (const call of calls) {
call.on(CallEvent.State, updateConnectionStates);
}
return () => {
for (const call of calls) {
call.off(CallEvent.State, updateConnectionStates);
}
};
}, [calls, updateConnectionStates]);
useEffect(() => {
updateConnectionStates();
}, [participants, updateConnectionStates]);
useEffect(() => {
widget?.api.transport.send(
layout === "freedom"
@ -256,59 +194,57 @@ export function InCallView({
const items = useMemo(() => {
const tileDescriptors: TileDescriptor[] = [];
const localUserId = client.getUserId()!;
const localDeviceId = client.getDeviceId()!;
// one tile for each participants, to start with (we want a tile for everyone we
// think should be in the call, even if we don't have a media feed for them yet)
for (const p of participants) {
const userMediaFeed = userMediaFeeds.find((f) => f.userId === p.userId);
// One tile for each participant, to start with (we want a tile for everyone we
// think should be in the call, even if we don't have a call feed for them yet)
for (const [member, participantMap] of participants) {
for (const [deviceId, { connectionState, presenter }] of participantMap) {
const callFeed = userMediaFeeds.find(
(f) => f.userId === member.userId && f.deviceId === deviceId
);
// NB. this assumes that the same user can't join more than once from multiple
// devices, but the participants are just RoomMembers, so this assumption is baked
// into GroupCall itself.
tileDescriptors.push({
id: p.userId,
member: p,
callFeed: userMediaFeed,
focused: screenshareFeeds.length === 0 && p.userId === activeSpeaker,
isLocal: p.userId === client.getUserId(),
presenter: false,
connectionState: connStates.get(p.userId),
});
tileDescriptors.push({
id: `${member.userId} ${deviceId}`,
member,
callFeed,
focused: screenshareFeeds.length === 0 && callFeed === activeSpeaker,
isLocal: member.userId === localUserId && deviceId === localDeviceId,
presenter,
connectionState,
});
}
}
PosthogAnalytics.instance.eventCallEnded.cacheParticipantCountChanged(
participants.length
tileDescriptors.length
);
// add the screenshares too
// Add the screenshares too
for (const screenshareFeed of screenshareFeeds) {
const userMediaItem = tileDescriptors.find(
(item) => item.member.userId === screenshareFeed.userId
);
const member = screenshareFeed.getMember()!;
const connectionState = participants
.get(member)
?.get(screenshareFeed.deviceId!)?.connectionState;
if (userMediaItem) {
userMediaItem.presenter = true;
// If the participant has left, their screenshare feed is stale and we
// shouldn't bother showing it
if (connectionState !== undefined) {
tileDescriptors.push({
id: screenshareFeed.stream.id,
member,
callFeed: screenshareFeed,
focused: true,
isLocal: screenshareFeed.isLocal(),
presenter: false,
connectionState,
});
}
tileDescriptors.push({
id: screenshareFeed.stream.id,
member: screenshareFeed.getMember()!,
callFeed: screenshareFeed,
focused: true,
isLocal: screenshareFeed.isLocal(),
presenter: false,
connectionState: connStates.get(screenshareFeed.userId),
});
}
return tileDescriptors;
}, [
client,
participants,
userMediaFeeds,
activeSpeaker,
screenshareFeeds,
connStates,
]);
}, [client, participants, userMediaFeeds, activeSpeaker, screenshareFeeds]);
// The maximised participant: either the participant that the user has
// manually put in fullscreen, or the focused (active) participant if the

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect } from "react";
import React, { useEffect, useMemo } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import i18n from "i18next";
@ -43,6 +43,7 @@ import { PTTClips } from "../sound/PTTClips";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
import { Size } from "../Avatar";
import { ParticipantInfo } from "./useGroupCall";
function getPromptText(
networkWaiting: boolean,
@ -100,7 +101,7 @@ interface Props {
roomName: string;
avatarUrl: string;
groupCall: GroupCall;
participants: RoomMember[];
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
userMediaFeeds: CallFeed[];
onLeave: () => void;
isEmbedded: boolean;
@ -152,6 +153,15 @@ export const PTTCallView: React.FC<Props> = ({
connected,
} = usePTT(client, groupCall, userMediaFeeds, playClip);
const participatingMembers = useMemo(() => {
const members: RoomMember[] = [];
for (const [member, deviceMap] of participants) {
// Repeat the member for as many devices as they're using
for (let i = 0; i < deviceMap.size; i++) members.push(member);
}
return members;
}, [participants]);
const [talkingExpected, enqueueTalkingExpected, setTalkingExpected] =
useDelayedState(false);
const showTalkOverError = pttButtonHeld && transmitBlocked;
@ -205,7 +215,7 @@ export const PTTCallView: React.FC<Props> = ({
<div className={styles.participants}>
<p>
{t("{{count}} people connected", {
count: participants.length,
count: participatingMembers.length,
})}
</p>
<Facepile
@ -213,7 +223,7 @@ export const PTTCallView: React.FC<Props> = ({
max={8}
className={styles.facepile}
client={client}
participants={participants}
members={participatingMembers}
/>
</div>
<div className={styles.footer}>

View file

@ -23,7 +23,11 @@ import {
GroupCallUnknownDeviceError,
GroupCallError,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import {
CallState,
MatrixCall,
CallEvent,
} from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useTranslation } from "react-i18next";
@ -36,11 +40,21 @@ import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget";
import { getSetting } from "../settings/useSetting";
import { useEventTarget } from "../useEvents";
export enum ConnectionState {
EstablishingCall = "establishing call", // call hasn't been established yet
WaitMedia = "wait_media", // call is set up, waiting for ICE to connect
Connected = "connected", // media is flowing
}
export interface ParticipantInfo {
connectionState: ConnectionState;
presenter: boolean;
}
export interface UseGroupCallReturnType {
state: GroupCallState;
calls: MatrixCall[];
localCallFeed: CallFeed;
activeSpeaker: string;
activeSpeaker: CallFeed | null;
userMediaFeeds: CallFeed[];
microphoneMuted: boolean;
localVideoMuted: boolean;
@ -55,16 +69,15 @@ export interface UseGroupCallReturnType {
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
localDesktopCapturerSourceId: string; // XXX: This looks unused?
participants: RoomMember[];
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
hasLocalParticipant: boolean;
unencryptedEventsFromUsers: Set<string>;
}
interface State {
state: GroupCallState;
calls: MatrixCall[];
localCallFeed: CallFeed;
activeSpeaker: string;
activeSpeaker: CallFeed | null;
userMediaFeeds: CallFeed[];
error: TranslatedError | null;
microphoneMuted: boolean;
@ -73,15 +86,51 @@ interface State {
localDesktopCapturerSourceId: string;
isScreensharing: boolean;
requestingScreenshare: boolean;
participants: RoomMember[];
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
hasLocalParticipant: boolean;
}
function getParticipants(
groupCall: GroupCall
): Map<RoomMember, Map<string, ParticipantInfo>> {
const participants = new Map<RoomMember, Map<string, ParticipantInfo>>();
for (const [member, participantsStateMap] of groupCall.participants) {
const callMap = groupCall.calls.get(member);
const participantInfoMap = new Map<string, ParticipantInfo>();
participants.set(member, participantInfoMap);
for (const [deviceId, participant] of participantsStateMap) {
const call = callMap?.get(deviceId);
const feed = groupCall.userMediaFeeds.find(
(f) => f.userId === member.userId && f.deviceId === deviceId
);
let connectionState = ConnectionState.EstablishingCall;
if (feed?.isLocal()) {
connectionState = ConnectionState.Connected;
} else if (call !== undefined) {
if (call.state === CallState.Connected) {
connectionState = ConnectionState.Connected;
} else if (call.state === CallState.Connecting) {
connectionState = ConnectionState.WaitMedia;
}
}
participantInfoMap.set(deviceId, {
connectionState,
presenter: participant.screensharing,
});
}
}
return participants;
}
export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
const [
{
state,
calls,
localCallFeed,
activeSpeaker,
userMediaFeeds,
@ -98,7 +147,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
setState,
] = useState<State>({
state: GroupCallState.LocalCallFeedUninitialized,
calls: [],
localCallFeed: null,
activeSpeaker: null,
userMediaFeeds: [],
@ -109,7 +157,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
screenshareFeeds: [],
localDesktopCapturerSourceId: null,
requestingScreenshare: false,
participants: [],
participants: new Map(),
hasLocalParticipant: false,
});
@ -120,29 +168,30 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
new Set<string>()
);
const updateState = (state: Partial<State>) =>
setState((prevState) => ({ ...prevState, ...state }));
const updateState = useCallback(
(state: Partial<State>) => setState((prev) => ({ ...prev, ...state })),
[setState]
);
useEffect(() => {
function onGroupCallStateChanged() {
updateState({
state: groupCall.state,
calls: [...groupCall.calls],
localCallFeed: groupCall.localCallFeed,
activeSpeaker: groupCall.activeSpeaker,
activeSpeaker: groupCall.activeSpeaker ?? null,
userMediaFeeds: [...groupCall.userMediaFeeds],
microphoneMuted: groupCall.isMicrophoneMuted(),
localVideoMuted: groupCall.isLocalVideoMuted(),
isScreensharing: groupCall.isScreensharing(),
localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId,
screenshareFeeds: [...groupCall.screenshareFeeds],
participants: [...groupCall.participants],
});
}
function onUserMediaFeedsChanged(userMediaFeeds: CallFeed[]): void {
updateState({
userMediaFeeds: [...userMediaFeeds],
participants: getParticipants(groupCall),
});
}
@ -152,9 +201,9 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
});
}
function onActiveSpeakerChanged(activeSpeaker: string): void {
function onActiveSpeakerChanged(activeSpeaker: CallFeed | undefined): void {
updateState({
activeSpeaker: activeSpeaker,
activeSpeaker: activeSpeaker ?? null,
});
}
@ -179,15 +228,31 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
});
}
function onCallsChanged(calls: MatrixCall[]): void {
updateState({
calls: [...calls],
});
const prevCalls = new Set<MatrixCall>();
function onCallState(): void {
updateState({ participants: getParticipants(groupCall) });
}
function onParticipantsChanged(participants: RoomMember[]): void {
function onCallsChanged(
calls: Map<RoomMember, Map<string, MatrixCall>>
): void {
for (const call of prevCalls) call.off(CallEvent.State, onCallState);
prevCalls.clear();
for (const deviceMap of calls.values()) {
for (const call of deviceMap.values()) {
call.on(CallEvent.State, onCallState);
prevCalls.add(call);
}
}
updateState({ participants: getParticipants(groupCall) });
}
function onParticipantsChanged(): void {
updateState({
participants: [...participants],
participants: getParticipants(groupCall),
hasLocalParticipant: groupCall.hasLocalParticipant(),
});
}
@ -218,16 +283,15 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
updateState({
error: null,
state: groupCall.state,
calls: [...groupCall.calls],
localCallFeed: groupCall.localCallFeed,
activeSpeaker: groupCall.activeSpeaker,
activeSpeaker: groupCall.activeSpeaker ?? null,
userMediaFeeds: [...groupCall.userMediaFeeds],
microphoneMuted: groupCall.isMicrophoneMuted(),
localVideoMuted: groupCall.isLocalVideoMuted(),
isScreensharing: groupCall.isScreensharing(),
localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId,
screenshareFeeds: [...groupCall.screenshareFeeds],
participants: [...groupCall.participants],
participants: getParticipants(groupCall),
hasLocalParticipant: groupCall.hasLocalParticipant(),
});
@ -264,7 +328,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
groupCall.removeListener(GroupCallEvent.Error, onError);
groupCall.leave();
};
}, [groupCall]);
}, [groupCall, updateState]);
usePageUnload(() => {
groupCall.leave();
@ -290,7 +354,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
console.error(error);
updateState({ error });
});
}, [groupCall]);
}, [groupCall, updateState]);
const leave = useCallback(() => groupCall.leave(), [groupCall]);
@ -341,7 +405,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
// toggling off
groupCall.setScreensharingEnabled(false);
}
}, [groupCall]);
}, [groupCall, updateState]);
const onScreenshareStart = useCallback(
async (ev: CustomEvent<IWidgetApiRequest>) => {
@ -355,7 +419,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
});
await widget.api.transport.reply(ev.detail, {});
},
[groupCall]
[groupCall, updateState]
);
const onScreenshareStop = useCallback(
@ -364,7 +428,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
await groupCall.setScreensharingEnabled(false);
await widget.api.transport.reply(ev.detail, {});
},
[groupCall]
[groupCall, updateState]
);
useEffect(() => {
@ -402,7 +466,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
console.error(error);
updateState({ error });
}
}, [t]);
}, [t, updateState]);
const [spacebarHeld, setSpacebarHeld] = useState(false);
@ -468,7 +532,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
return {
state,
calls,
localCallFeed,
activeSpeaker,
userMediaFeeds,

View file

@ -24,7 +24,7 @@ import type {
// Shortcut for registering a listener on an EventTarget
export const useEventTarget = <T extends Event>(
target: EventTarget,
target: EventTarget | null | undefined,
eventType: string,
listener: (event: T) => void,
options?: AddEventListenerOptions

View file

@ -21,7 +21,8 @@ import { RoomMember } from "matrix-js-sdk";
import { VideoGrid, useVideoGridLayout } from "./VideoGrid";
import { VideoTile } from "./VideoTile";
import { Button } from "../button";
import { ConnectionState, TileDescriptor } from "../room/InCallView";
import { TileDescriptor } from "../room/InCallView";
import { ConnectionState } from "../room/useGroupCall";
export default {
title: "VideoGrid",

View file

@ -23,7 +23,7 @@ import styles from "./VideoTile.module.css";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
import { AudioButton, FullscreenButton } from "../button/Button";
import { ConnectionState } from "../room/InCallView";
import { ConnectionState } from "../room/useGroupCall";
interface Props {
name: string;

103
yarn.lock
View file

@ -1581,6 +1581,16 @@
"@types/node" "*"
jest-mock "^29.2.2"
"@jest/environment@^29.3.1":
version "29.3.1"
resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.3.1.tgz#eb039f726d5fcd14698acd072ac6576d41cfcaa6"
integrity sha512-pMmvfOPmoa1c1QpfFW0nXYtNLpofqo4BrCIk6f2kW4JFeNlHV2t3vd+3iDLf31e2ot2Mec0uqZfmI+U0K2CFag==
dependencies:
"@jest/fake-timers" "^29.3.1"
"@jest/types" "^29.3.1"
"@types/node" "*"
jest-mock "^29.3.1"
"@jest/expect-utils@^29.2.2":
version "29.2.2"
resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.2.2.tgz#460a5b5a3caf84d4feb2668677393dd66ff98665"
@ -1608,6 +1618,18 @@
jest-mock "^29.2.2"
jest-util "^29.2.1"
"@jest/fake-timers@^29.3.1":
version "29.3.1"
resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.3.1.tgz#b140625095b60a44de820876d4c14da1aa963f67"
integrity sha512-iHTL/XpnDlFki9Tq0Q1GGuVeQ8BHZGIYsvCO5eN/O/oJaRzofG9Xndd9HuSDBI/0ZS79pg0iwn07OMTQ7ngF2A==
dependencies:
"@jest/types" "^29.3.1"
"@sinonjs/fake-timers" "^9.1.2"
"@types/node" "*"
jest-message-util "^29.3.1"
jest-mock "^29.3.1"
jest-util "^29.3.1"
"@jest/globals@^29.2.2":
version "29.2.2"
resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.2.2.tgz#205ff1e795aa774301c2c0ba0be182558471b845"
@ -1717,6 +1739,18 @@
"@types/yargs" "^17.0.8"
chalk "^4.0.0"
"@jest/types@^29.3.1":
version "29.3.1"
resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.3.1.tgz#7c5a80777cb13e703aeec6788d044150341147e3"
integrity sha512-d0S0jmmTpjnhCmNpApgX3jrUZgZ22ivKJRvL2lli5hpCRoNnp1f85r2/wpKfXuYu8E7Jjh1hGfhPyup1NM5AmA==
dependencies:
"@jest/schemas" "^29.0.0"
"@types/istanbul-lib-coverage" "^2.0.0"
"@types/istanbul-reports" "^3.0.0"
"@types/node" "*"
"@types/yargs" "^17.0.8"
chalk "^4.0.0"
"@joshwooding/vite-plugin-react-docgen-typescript@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.0.2.tgz#e0ae8c94f468da3a273a7b0acf23ba3565f86cbc"
@ -9457,18 +9491,18 @@ jest-each@^29.2.1:
jest-util "^29.2.1"
pretty-format "^29.2.1"
jest-environment-jsdom@^29.2.2:
version "29.2.2"
resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-29.2.2.tgz#1e2d9f1f017fbaa7362a83e670b569158b4b8527"
integrity sha512-5mNtTcky1+RYv9kxkwMwt7fkzyX4EJUarV7iI+NQLigpV4Hz4sgfOdP4kOpCHXbkRWErV7tgXoXLm2CKtucr+A==
jest-environment-jsdom@^29.3.1:
version "29.3.1"
resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-29.3.1.tgz#14ca63c3e0ef5c63c5bcb46033e50bc649e3b639"
integrity sha512-G46nKgiez2Gy4zvYNhayfMEAFlVHhWfncqvqS6yCd0i+a4NsSUD2WtrKSaYQrYiLQaupHXxCRi8xxVL2M9PbhA==
dependencies:
"@jest/environment" "^29.2.2"
"@jest/fake-timers" "^29.2.2"
"@jest/types" "^29.2.1"
"@jest/environment" "^29.3.1"
"@jest/fake-timers" "^29.3.1"
"@jest/types" "^29.3.1"
"@types/jsdom" "^20.0.0"
"@types/node" "*"
jest-mock "^29.2.2"
jest-util "^29.2.1"
jest-mock "^29.3.1"
jest-util "^29.3.1"
jsdom "^20.0.0"
jest-environment-node@^29.2.2:
@ -9540,6 +9574,21 @@ jest-message-util@^29.2.1:
slash "^3.0.0"
stack-utils "^2.0.3"
jest-message-util@^29.3.1:
version "29.3.1"
resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.3.1.tgz#37bc5c468dfe5120712053dd03faf0f053bd6adb"
integrity sha512-lMJTbgNcDm5z+6KDxWtqOFWlGQxD6XaYwBqHR8kmpkP+WWWG90I35kdtQHY67Ay5CSuydkTBbJG+tH9JShFCyA==
dependencies:
"@babel/code-frame" "^7.12.13"
"@jest/types" "^29.3.1"
"@types/stack-utils" "^2.0.0"
chalk "^4.0.0"
graceful-fs "^4.2.9"
micromatch "^4.0.4"
pretty-format "^29.3.1"
slash "^3.0.0"
stack-utils "^2.0.3"
jest-mock@^29.2.2:
version "29.2.2"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.2.2.tgz#9045618b3f9d27074bbcf2d55bdca6a5e2e8bca7"
@ -9549,6 +9598,15 @@ jest-mock@^29.2.2:
"@types/node" "*"
jest-util "^29.2.1"
jest-mock@^29.3.1:
version "29.3.1"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.3.1.tgz#60287d92e5010979d01f218c6b215b688e0f313e"
integrity sha512-H8/qFDtDVMFvFP4X8NuOT3XRDzOUTz+FeACjufHzsOIBAxivLqkB1PoLCaJx9iPPQ8dZThHPp/G3WRWyMgA3JA==
dependencies:
"@jest/types" "^29.3.1"
"@types/node" "*"
jest-util "^29.3.1"
jest-pnp-resolver@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c"
@ -9679,6 +9737,18 @@ jest-util@^29.2.1:
graceful-fs "^4.2.9"
picomatch "^2.2.3"
jest-util@^29.3.1:
version "29.3.1"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.3.1.tgz#1dda51e378bbcb7e3bc9d8ab651445591ed373e1"
integrity sha512-7YOVZaiX7RJLv76ZfHt4nbNEzzTRiMW/IiOG7ZOKmTXmoGBxUDefgMAxQubu6WPVqP5zSzAdZG0FfLcC7HOIFQ==
dependencies:
"@jest/types" "^29.3.1"
"@types/node" "*"
chalk "^4.0.0"
ci-info "^3.2.0"
graceful-fs "^4.2.9"
picomatch "^2.2.3"
jest-validate@^29.2.2:
version "29.2.2"
resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.2.2.tgz#e43ce1931292dfc052562a11bc681af3805eadce"
@ -10196,9 +10266,9 @@ matrix-events-sdk@0.0.1:
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#3f1c3392d45b0fc054c3788cc6c043cd5b4fb730":
version "21.1.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f1c3392d45b0fc054c3788cc6c043cd5b4fb730"
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#9d3ac66cf847fe8cb0359d64e1f1d67902b982a1":
version "21.2.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/9d3ac66cf847fe8cb0359d64e1f1d67902b982a1"
dependencies:
"@babel/runtime" "^7.12.5"
"@types/sdp-transform" "^2.4.5"
@ -11745,6 +11815,15 @@ pretty-format@^29.0.0, pretty-format@^29.2.1:
ansi-styles "^5.0.0"
react-is "^18.0.0"
pretty-format@^29.3.1:
version "29.3.1"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.3.1.tgz#1841cac822b02b4da8971dacb03e8a871b4722da"
integrity sha512-FyLnmb1cYJV8biEIiRyzRFvs2lry7PPIvOqKVe1GCUEYg4YGmlx1qG9EJNMxArYm7piII4qb8UV1Pncq5dxmcg==
dependencies:
"@jest/schemas" "^29.0.0"
ansi-styles "^5.0.0"
react-is "^18.0.0"
pretty-hrtime@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"