Use native js-sdk group call support (#9625)
* Use native js-sdk group call support Now that the js-sdk supports group calls natively, our group call implementation can be simplified a bit. Switching to the js-sdk implementation also brings the react-sdk up to date with recent MSC3401 changes, and adds support for joining calls from multiple devices. (So, the previous logic which sent to-device messages to prevent multi-device sessions is no longer necessary.) * Fix strings * Fix strict type errors
This commit is contained in:
parent
3c7781a561
commit
2c612d5aa1
20 changed files with 383 additions and 567 deletions
|
@ -15,34 +15,34 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import dispatcher, { defaultDispatcher } from "../../../dispatcher/dispatcher";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { Call, ConnectionState, ElementCall } from "../../../models/Call";
|
||||
import { ConnectionState, ElementCall } from "../../../models/Call";
|
||||
import { useCall } from "../../../hooks/useCall";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import {
|
||||
OwnBeaconStore,
|
||||
OwnBeaconStoreEvent,
|
||||
} from "../../../stores/OwnBeaconStore";
|
||||
import { CallDurationFromEvent } from "../voip/CallDuration";
|
||||
import { GroupCallDuration } from "../voip/CallDuration";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
|
||||
interface RoomCallBannerProps {
|
||||
roomId: Room["roomId"];
|
||||
call: Call;
|
||||
call: ElementCall;
|
||||
}
|
||||
|
||||
const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({
|
||||
roomId,
|
||||
call,
|
||||
}) => {
|
||||
const callEvent: MatrixEvent | null = (call as ElementCall)?.groupCall;
|
||||
|
||||
const connect = useCallback(
|
||||
(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
|
@ -57,15 +57,23 @@ const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({
|
|||
);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
const event = call.groupCall.room.currentState.getStateEvents(
|
||||
EventType.GroupCallPrefix, call.groupCall.groupCallId,
|
||||
);
|
||||
if (event === null) {
|
||||
logger.error("Couldn't find a group call event to jump to");
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: roomId,
|
||||
metricsTrigger: undefined,
|
||||
event_id: callEvent.getId(),
|
||||
event_id: event.getId(),
|
||||
scroll_into_view: true,
|
||||
highlighted: true,
|
||||
});
|
||||
}, [callEvent, roomId]);
|
||||
}, [call, roomId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -74,7 +82,7 @@ const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({
|
|||
>
|
||||
<div className="mx_RoomCallBanner_text">
|
||||
<span className="mx_RoomCallBanner_label">{ _t("Video call") }</span>
|
||||
<CallDurationFromEvent mxEvent={callEvent} />
|
||||
<GroupCallDuration groupCall={call.groupCall} />
|
||||
</div>
|
||||
|
||||
<AccessibleButton
|
||||
|
@ -119,12 +127,11 @@ const RoomCallBanner: React.FC<Props> = ({ roomId }) => {
|
|||
}
|
||||
|
||||
// Split into outer/inner to avoid watching various parts if there is no call
|
||||
if (call) {
|
||||
// No banner if the call is connected (or connecting/disconnecting)
|
||||
if (call.connectionState !== ConnectionState.Disconnected) return null;
|
||||
|
||||
return <RoomCallBannerInner call={call} roomId={roomId} />;
|
||||
// No banner if the call is connected (or connecting/disconnecting)
|
||||
if (call !== null && call.connectionState === ConnectionState.Disconnected) {
|
||||
return <RoomCallBannerInner call={call as ElementCall} roomId={roomId} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
|
@ -18,14 +18,13 @@ import React, { forwardRef, useCallback, useContext, useMemo } from "react";
|
|||
|
||||
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { Call, ConnectionState } from "../../../models/Call";
|
||||
import { ConnectionState, ElementCall } from "../../../models/Call";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import {
|
||||
useCall,
|
||||
useConnectionState,
|
||||
useJoinCallButtonDisabled,
|
||||
useJoinCallButtonTooltip,
|
||||
useParticipants,
|
||||
useJoinCallButtonDisabledTooltip,
|
||||
useParticipatingMembers,
|
||||
} from "../../../hooks/useCall";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
|
@ -35,18 +34,18 @@ import MemberAvatar from "../avatars/MemberAvatar";
|
|||
import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary";
|
||||
import FacePile from "../elements/FacePile";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration";
|
||||
import { CallDuration, GroupCallDuration } from "../voip/CallDuration";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
||||
const MAX_FACES = 8;
|
||||
|
||||
interface ActiveCallEventProps {
|
||||
mxEvent: MatrixEvent;
|
||||
participants: Set<RoomMember>;
|
||||
call: ElementCall | null;
|
||||
participatingMembers: RoomMember[];
|
||||
buttonText: string;
|
||||
buttonKind: string;
|
||||
buttonTooltip?: string;
|
||||
buttonDisabled?: boolean;
|
||||
buttonDisabledTooltip?: string;
|
||||
onButtonClick: ((ev: ButtonEvent) => void) | null;
|
||||
}
|
||||
|
||||
|
@ -54,19 +53,19 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
|
|||
(
|
||||
{
|
||||
mxEvent,
|
||||
participants,
|
||||
call,
|
||||
participatingMembers,
|
||||
buttonText,
|
||||
buttonKind,
|
||||
buttonDisabled,
|
||||
buttonTooltip,
|
||||
buttonDisabledTooltip,
|
||||
onButtonClick,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const senderName = useMemo(() => mxEvent.sender?.name ?? mxEvent.getSender(), [mxEvent]);
|
||||
|
||||
const facePileMembers = useMemo(() => [...participants].slice(0, MAX_FACES), [participants]);
|
||||
const facePileOverflow = participants.size > facePileMembers.length;
|
||||
const facePileMembers = useMemo(() => participatingMembers.slice(0, MAX_FACES), [participatingMembers]);
|
||||
const facePileOverflow = participatingMembers.length > facePileMembers.length;
|
||||
|
||||
return <div className="mx_CallEvent_wrapper" ref={ref}>
|
||||
<div className="mx_CallEvent mx_CallEvent_active">
|
||||
|
@ -85,17 +84,17 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
|
|||
type={LiveContentType.Video}
|
||||
text={_t("Video call")}
|
||||
active={false}
|
||||
participantCount={participants.size}
|
||||
participantCount={participatingMembers.length}
|
||||
/>
|
||||
<FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} />
|
||||
</div>
|
||||
<CallDurationFromEvent mxEvent={mxEvent} />
|
||||
{ call && <GroupCallDuration groupCall={call.groupCall} /> }
|
||||
<AccessibleTooltipButton
|
||||
className="mx_CallEvent_button"
|
||||
kind={buttonKind}
|
||||
disabled={onButtonClick === null || buttonDisabled}
|
||||
disabled={onButtonClick === null || buttonDisabledTooltip !== undefined}
|
||||
onClick={onButtonClick}
|
||||
tooltip={buttonTooltip}
|
||||
tooltip={buttonDisabledTooltip}
|
||||
>
|
||||
{ buttonText }
|
||||
</AccessibleTooltipButton>
|
||||
|
@ -106,14 +105,13 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
|
|||
|
||||
interface ActiveLoadedCallEventProps {
|
||||
mxEvent: MatrixEvent;
|
||||
call: Call;
|
||||
call: ElementCall;
|
||||
}
|
||||
|
||||
const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
|
||||
const connectionState = useConnectionState(call);
|
||||
const participants = useParticipants(call);
|
||||
const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
|
||||
const joinCallButtonDisabled = useJoinCallButtonDisabled(call);
|
||||
const participatingMembers = useParticipatingMembers(call);
|
||||
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
|
||||
|
||||
const connect = useCallback((ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
|
@ -142,11 +140,11 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
|
|||
return <ActiveCallEvent
|
||||
ref={ref}
|
||||
mxEvent={mxEvent}
|
||||
participants={participants}
|
||||
call={call}
|
||||
participatingMembers={participatingMembers}
|
||||
buttonText={buttonText}
|
||||
buttonKind={buttonKind}
|
||||
buttonDisabled={joinCallButtonDisabled}
|
||||
buttonTooltip={joinCallButtonTooltip}
|
||||
buttonDisabledTooltip={joinCallButtonDisabledTooltip ?? undefined}
|
||||
onButtonClick={onButtonClick}
|
||||
/>;
|
||||
});
|
||||
|
@ -159,7 +157,6 @@ interface CallEventProps {
|
|||
* An event tile representing an active or historical Element call.
|
||||
*/
|
||||
export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => {
|
||||
const noParticipants = useMemo(() => new Set<RoomMember>(), []);
|
||||
const client = useContext(MatrixClientContext);
|
||||
const call = useCall(mxEvent.getRoomId()!);
|
||||
const latestEvent = client.getRoom(mxEvent.getRoomId())!.currentState
|
||||
|
@ -180,12 +177,13 @@ export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => {
|
|||
return <ActiveCallEvent
|
||||
ref={ref}
|
||||
mxEvent={mxEvent}
|
||||
participants={noParticipants}
|
||||
call={null}
|
||||
participatingMembers={[]}
|
||||
buttonText={_t("Join")}
|
||||
buttonKind="primary"
|
||||
onButtonClick={null}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <ActiveLoadedCallEvent mxEvent={mxEvent} call={call} ref={ref} />;
|
||||
return <ActiveLoadedCallEvent mxEvent={mxEvent} call={call as ElementCall} ref={ref} />;
|
||||
});
|
||||
|
|
|
@ -19,7 +19,7 @@ import classNames from "classnames";
|
|||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Call } from "../../../models/Call";
|
||||
import { useParticipants } from "../../../hooks/useCall";
|
||||
import { useParticipantCount } from "../../../hooks/useCall";
|
||||
|
||||
export enum LiveContentType {
|
||||
Video,
|
||||
|
@ -62,13 +62,10 @@ interface LiveContentSummaryWithCallProps {
|
|||
call: Call;
|
||||
}
|
||||
|
||||
export function LiveContentSummaryWithCall({ call }: LiveContentSummaryWithCallProps) {
|
||||
const participants = useParticipants(call);
|
||||
|
||||
return <LiveContentSummary
|
||||
export const LiveContentSummaryWithCall: FC<LiveContentSummaryWithCallProps> = ({ call }) =>
|
||||
<LiveContentSummary
|
||||
type={LiveContentType.Video}
|
||||
text={_t("Video")}
|
||||
active={false}
|
||||
participantCount={participants.size}
|
||||
participantCount={useParticipantCount(call)}
|
||||
/>;
|
||||
}
|
||||
|
|
|
@ -66,7 +66,7 @@ import IconizedContextMenu, {
|
|||
IconizedContextMenuRadio,
|
||||
} from "../context_menus/IconizedContextMenu";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { CallDurationFromEvent } from "../voip/CallDuration";
|
||||
import { GroupCallDuration } from "../voip/CallDuration";
|
||||
import { Alignment } from "../elements/Tooltip";
|
||||
import RoomCallBanner from '../beacon/RoomCallBanner';
|
||||
|
||||
|
@ -512,7 +512,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) {
|
||||
startButtons.push(<CallLayoutSelector call={this.props.activeCall} />);
|
||||
startButtons.push(<CallLayoutSelector key="layout" call={this.props.activeCall} />);
|
||||
}
|
||||
|
||||
if (!this.props.viewingCall && this.props.onForgetClick) {
|
||||
|
@ -685,7 +685,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
|
|||
{ _t("Video call") }
|
||||
</div>
|
||||
{ this.props.activeCall instanceof ElementCall && (
|
||||
<CallDurationFromEvent mxEvent={this.props.activeCall.groupCall} />
|
||||
<GroupCallDuration groupCall={this.props.activeCall.groupCall} />
|
||||
) }
|
||||
{ /* Empty topic element to fill out space */ }
|
||||
<div className="mx_RoomHeader_topic" />
|
||||
|
|
|
@ -18,7 +18,7 @@ import React, { FC } from "react";
|
|||
|
||||
import type { Call } from "../../../models/Call";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useConnectionState, useParticipants } from "../../../hooks/useCall";
|
||||
import { useConnectionState, useParticipantCount } from "../../../hooks/useCall";
|
||||
import { ConnectionState } from "../../../models/Call";
|
||||
import { LiveContentSummary, LiveContentType } from "./LiveContentSummary";
|
||||
|
||||
|
@ -27,13 +27,10 @@ interface Props {
|
|||
}
|
||||
|
||||
export const RoomTileCallSummary: FC<Props> = ({ call }) => {
|
||||
const connectionState = useConnectionState(call);
|
||||
const participants = useParticipants(call);
|
||||
|
||||
let text: string;
|
||||
let active: boolean;
|
||||
|
||||
switch (connectionState) {
|
||||
switch (useConnectionState(call)) {
|
||||
case ConnectionState.Disconnected:
|
||||
text = _t("Video");
|
||||
active = false;
|
||||
|
@ -53,6 +50,6 @@ export const RoomTileCallSummary: FC<Props> = ({ call }) => {
|
|||
type={LiveContentType.Video}
|
||||
text={text}
|
||||
active={active}
|
||||
participantCount={participants.size}
|
||||
participantCount={useParticipantCount(call)}
|
||||
/>;
|
||||
};
|
||||
|
|
|
@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useState, useEffect } from "react";
|
||||
import React, { FC, useState, useEffect, memo } from "react";
|
||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
|
||||
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { formatCallTime } from "../../../DateUtils";
|
||||
|
||||
interface CallDurationProps {
|
||||
|
@ -26,26 +26,28 @@ interface CallDurationProps {
|
|||
/**
|
||||
* A call duration counter.
|
||||
*/
|
||||
export const CallDuration: FC<CallDurationProps> = ({ delta }) => {
|
||||
export const CallDuration: FC<CallDurationProps> = memo(({ delta }) => {
|
||||
// Clock desync could lead to a negative duration, so just hide it if that happens
|
||||
if (delta <= 0) return null;
|
||||
return <div className="mx_CallDuration">{ formatCallTime(new Date(delta)) }</div>;
|
||||
};
|
||||
});
|
||||
|
||||
interface CallDurationFromEventProps {
|
||||
mxEvent: MatrixEvent;
|
||||
interface GroupCallDurationProps {
|
||||
groupCall: GroupCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* A call duration counter that automatically counts up, given the event that
|
||||
* started the call.
|
||||
* A call duration counter that automatically counts up, given a live GroupCall
|
||||
* object.
|
||||
*/
|
||||
export const CallDurationFromEvent: FC<CallDurationFromEventProps> = ({ mxEvent }) => {
|
||||
export const GroupCallDuration: FC<GroupCallDurationProps> = ({ groupCall }) => {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return <CallDuration delta={now - mxEvent.getTs()} />;
|
||||
return groupCall.creationTs === null
|
||||
? null
|
||||
: <CallDuration delta={now - groupCall.creationTs} />;
|
||||
};
|
||||
|
|
|
@ -25,9 +25,8 @@ import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call"
|
|||
import {
|
||||
useCall,
|
||||
useConnectionState,
|
||||
useJoinCallButtonDisabled,
|
||||
useJoinCallButtonTooltip,
|
||||
useParticipants,
|
||||
useJoinCallButtonDisabledTooltip,
|
||||
useParticipatingMembers,
|
||||
} from "../../../hooks/useCall";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import AppTile from "../elements/AppTile";
|
||||
|
@ -116,12 +115,11 @@ const MAX_FACES = 8;
|
|||
interface LobbyProps {
|
||||
room: Room;
|
||||
connect: () => Promise<void>;
|
||||
joinCallButtonTooltip?: string;
|
||||
joinCallButtonDisabled?: boolean;
|
||||
joinCallButtonDisabledTooltip?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabled, joinCallButtonTooltip, connect, children }) => {
|
||||
export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, connect, children }) => {
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
@ -246,10 +244,10 @@ export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabled, joinCallBu
|
|||
<AccessibleTooltipButton
|
||||
className="mx_CallView_connectButton"
|
||||
kind="primary"
|
||||
disabled={connecting || joinCallButtonDisabled}
|
||||
disabled={connecting || joinCallButtonDisabledTooltip !== undefined}
|
||||
onClick={onConnectClick}
|
||||
label={_t("Join")}
|
||||
tooltip={connecting ? _t("Connecting") : joinCallButtonTooltip}
|
||||
tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip}
|
||||
alignment={Alignment.Bottom}
|
||||
/>
|
||||
</div>;
|
||||
|
@ -331,9 +329,8 @@ interface JoinCallViewProps {
|
|||
const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const connected = isConnected(useConnectionState(call));
|
||||
const participants = useParticipants(call);
|
||||
const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
|
||||
const joinCallButtonDisabled = useJoinCallButtonDisabled(call);
|
||||
const members = useParticipatingMembers(call);
|
||||
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
// Disconnect from any other active calls first, since we don't yet support holding
|
||||
|
@ -347,12 +344,12 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
|
|||
let lobby: JSX.Element | null = null;
|
||||
if (!connected) {
|
||||
let facePile: JSX.Element | null = null;
|
||||
if (participants.size) {
|
||||
const shownMembers = [...participants].slice(0, MAX_FACES);
|
||||
const overflow = participants.size > shownMembers.length;
|
||||
if (members.length) {
|
||||
const shownMembers = members.slice(0, MAX_FACES);
|
||||
const overflow = members.length > shownMembers.length;
|
||||
|
||||
facePile = <div className="mx_CallView_participants">
|
||||
{ _t("%(count)s people joined", { count: participants.size }) }
|
||||
{ _t("%(count)s people joined", { count: members.length }) }
|
||||
<FacePile members={shownMembers} faceSize={24} overflow={overflow} />
|
||||
</div>;
|
||||
}
|
||||
|
@ -360,8 +357,7 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
|
|||
lobby = <Lobby
|
||||
room={room}
|
||||
connect={connect}
|
||||
joinCallButtonTooltip={joinCallButtonTooltip ?? undefined}
|
||||
joinCallButtonDisabled={joinCallButtonDisabled}
|
||||
joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip ?? undefined}
|
||||
>
|
||||
{ facePile }
|
||||
</Lobby>;
|
||||
|
|
|
@ -24,7 +24,6 @@ import { CallStore, CallStoreEvent } from "../stores/CallStore";
|
|||
import { useEventEmitter } from "./useEventEmitter";
|
||||
import SdkConfig, { DEFAULTS } from "../SdkConfig";
|
||||
import { _t } from "../languageHandler";
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
|
||||
export const useCall = (roomId: string): Call | null => {
|
||||
const [call, setCall] = useState(() => CallStore.instance.getCall(roomId));
|
||||
|
@ -41,49 +40,51 @@ export const useConnectionState = (call: Call): ConnectionState =>
|
|||
useCallback(state => state ?? call.connectionState, [call]),
|
||||
);
|
||||
|
||||
export const useParticipants = (call: Call): Set<RoomMember> =>
|
||||
export const useParticipants = (call: Call): Map<RoomMember, Set<string>> =>
|
||||
useTypedEventEmitterState(
|
||||
call,
|
||||
CallEvent.Participants,
|
||||
useCallback(state => state ?? call.participants, [call]),
|
||||
);
|
||||
|
||||
export const useFull = (call: Call): boolean => {
|
||||
const participants = useParticipants(call);
|
||||
|
||||
return (
|
||||
participants.size
|
||||
>= (SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit)
|
||||
);
|
||||
};
|
||||
|
||||
export const useIsAlreadyParticipant = (call: Call): boolean => {
|
||||
const client = MatrixClientPeg.get();
|
||||
export const useParticipantCount = (call: Call): number => {
|
||||
const participants = useParticipants(call);
|
||||
|
||||
return useMemo(() => {
|
||||
return participants.has(client.getRoom(call.roomId).getMember(client.getUserId()));
|
||||
}, [participants, client, call]);
|
||||
let count = 0;
|
||||
for (const devices of participants.values()) count += devices.size;
|
||||
return count;
|
||||
}, [participants]);
|
||||
};
|
||||
|
||||
export const useJoinCallButtonTooltip = (call: Call): string | null => {
|
||||
export const useParticipatingMembers = (call: Call): RoomMember[] => {
|
||||
const participants = useParticipants(call);
|
||||
|
||||
return useMemo(() => {
|
||||
const members: RoomMember[] = [];
|
||||
for (const [member, devices] of participants) {
|
||||
// Repeat the member for as many devices as they're using
|
||||
for (let i = 0; i < devices.size; i++) members.push(member);
|
||||
}
|
||||
return members;
|
||||
}, [participants]);
|
||||
};
|
||||
|
||||
export const useFull = (call: Call): boolean => {
|
||||
return useParticipantCount(call) >= (
|
||||
SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit!
|
||||
);
|
||||
};
|
||||
|
||||
export const useJoinCallButtonDisabledTooltip = (call: Call): string | null => {
|
||||
const isFull = useFull(call);
|
||||
const state = useConnectionState(call);
|
||||
const isAlreadyParticipant = useIsAlreadyParticipant(call);
|
||||
|
||||
if (state === ConnectionState.Connecting) return _t("Connecting");
|
||||
if (isFull) return _t("Sorry — this call is currently full");
|
||||
if (isAlreadyParticipant) return _t("You have already joined this call from another device");
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useJoinCallButtonDisabled = (call: Call): boolean => {
|
||||
const isFull = useFull(call);
|
||||
const state = useConnectionState(call);
|
||||
|
||||
return isFull || state === ConnectionState.Connecting;
|
||||
};
|
||||
|
||||
export const useLayout = (call: ElementCall): Layout =>
|
||||
useTypedEventEmitterState(
|
||||
call,
|
||||
|
|
|
@ -1042,7 +1042,6 @@
|
|||
"This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
|
||||
"Connecting": "Connecting",
|
||||
"Sorry — this call is currently full": "Sorry — this call is currently full",
|
||||
"You have already joined this call from another device": "You have already joined this call from another device",
|
||||
"Create account": "Create account",
|
||||
"You made it!": "You made it!",
|
||||
"Find and invite your friends": "Find and invite your friends",
|
||||
|
|
|
@ -17,13 +17,20 @@ limitations under the License.
|
|||
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomEvent } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
|
||||
import { IWidgetApiRequest, MatrixWidgetType } from "matrix-widget-api";
|
||||
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
|
||||
import {
|
||||
GroupCall,
|
||||
GroupCallEvent,
|
||||
GroupCallIntent,
|
||||
GroupCallState,
|
||||
GroupCallType,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import type EventEmitter from "events";
|
||||
import type { IMyDevice } from "matrix-js-sdk/src/client";
|
||||
|
@ -89,7 +96,10 @@ export enum CallEvent {
|
|||
|
||||
interface CallEventHandlerMap {
|
||||
[CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void;
|
||||
[CallEvent.Participants]: (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => void;
|
||||
[CallEvent.Participants]: (
|
||||
participants: Map<RoomMember, Set<string>>,
|
||||
prevParticipants: Map<RoomMember, Set<string>>,
|
||||
) => void;
|
||||
[CallEvent.Layout]: (layout: Layout) => void;
|
||||
[CallEvent.Destroy]: () => void;
|
||||
}
|
||||
|
@ -135,11 +145,14 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
|||
return isConnected(this.connectionState);
|
||||
}
|
||||
|
||||
private _participants = new Set<RoomMember>();
|
||||
public get participants(): Set<RoomMember> {
|
||||
private _participants = new Map<RoomMember, Set<string>>();
|
||||
/**
|
||||
* The participants in the call, as a map from members to device IDs.
|
||||
*/
|
||||
public get participants(): Map<RoomMember, Set<string>> {
|
||||
return this._participants;
|
||||
}
|
||||
protected set participants(value: Set<RoomMember>) {
|
||||
protected set participants(value: Map<RoomMember, Set<string>>) {
|
||||
const prevValue = this._participants;
|
||||
this._participants = value;
|
||||
this.emit(CallEvent.Participants, value, prevValue);
|
||||
|
@ -164,68 +177,11 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
|||
return ElementCall.get(room) ?? JitsiCall.get(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the connected devices associated with the given user in room state.
|
||||
* @param userId The user's ID.
|
||||
* @returns The IDs of the user's connected devices.
|
||||
*/
|
||||
protected abstract getDevices(userId: string): string[];
|
||||
|
||||
/**
|
||||
* Sets the connected devices associated with ourselves in room state.
|
||||
* @param devices The devices with which we're connected.
|
||||
*/
|
||||
protected abstract setDevices(devices: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates our member state with the devices returned by the given function.
|
||||
* @param fn A function from the current devices to the new devices. If it
|
||||
* returns null, the update is skipped.
|
||||
*/
|
||||
protected async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
|
||||
if (this.room.getMyMembership() !== "join") return;
|
||||
|
||||
const devices = fn(this.getDevices(this.client.getUserId()!));
|
||||
if (devices) {
|
||||
await this.setDevices(devices);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a routine check of the call's associated room state, cleaning up
|
||||
* any data left over from an unclean disconnection.
|
||||
*/
|
||||
public async clean(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const { devices: myDevices } = await this.client.getDevices();
|
||||
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
|
||||
|
||||
// Clean up our member state by filtering out logged out devices,
|
||||
// inactive devices, and our own device (if we're disconnected)
|
||||
await this.updateDevices(devices => {
|
||||
const newDevices = devices.filter(d => {
|
||||
const device = deviceMap.get(d);
|
||||
return device?.last_seen_ts !== undefined
|
||||
&& !(d === this.client.getDeviceId() && !this.connected)
|
||||
&& (now - device.last_seen_ts) < this.STUCK_DEVICE_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
// Skip the update if the devices are unchanged
|
||||
return newDevices.length === devices.length ? null : newDevices;
|
||||
});
|
||||
}
|
||||
|
||||
protected async addOurDevice(): Promise<void> {
|
||||
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
|
||||
}
|
||||
|
||||
protected async removeOurDevice(): Promise<void> {
|
||||
await this.updateDevices(devices => {
|
||||
const devicesSet = new Set(devices);
|
||||
devicesSet.delete(this.client.getDeviceId());
|
||||
return Array.from(devicesSet);
|
||||
});
|
||||
}
|
||||
public abstract clean(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Contacts the widget to connect to the call.
|
||||
|
@ -384,7 +340,7 @@ export class JitsiCall extends Call {
|
|||
this.participantsExpirationTimer = null;
|
||||
}
|
||||
|
||||
const members = new Set<RoomMember>();
|
||||
const participants = new Map<RoomMember, Set<string>>();
|
||||
const now = Date.now();
|
||||
let allExpireAt = Infinity;
|
||||
|
||||
|
@ -392,44 +348,95 @@ export class JitsiCall extends Call {
|
|||
const member = this.room.getMember(e.getStateKey()!);
|
||||
const content = e.getContent<JitsiCallMemberContent>();
|
||||
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
|
||||
let devices = expiresAt > now && Array.isArray(content.devices) ? content.devices : [];
|
||||
let devices = expiresAt > now && Array.isArray(content.devices)
|
||||
? content.devices.filter(d => typeof d === "string")
|
||||
: [];
|
||||
|
||||
// Apply local echo for the disconnected case
|
||||
if (!this.connected && member?.userId === this.client.getUserId()) {
|
||||
devices = devices.filter(d => d !== this.client.getDeviceId());
|
||||
}
|
||||
// Must have a connected device and still be joined to the room
|
||||
if (devices.length && member?.membership === "join") {
|
||||
members.add(member);
|
||||
if (devices.length > 0 && member?.membership === "join") {
|
||||
participants.set(member, new Set(devices));
|
||||
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply local echo for the connected case
|
||||
if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
|
||||
if (this.connected) {
|
||||
const localMember = this.room.getMember(this.client.getUserId()!)!;
|
||||
let devices = participants.get(localMember);
|
||||
if (devices === undefined) {
|
||||
devices = new Set();
|
||||
participants.set(localMember, devices);
|
||||
}
|
||||
|
||||
this.participants = members;
|
||||
devices.add(this.client.getDeviceId()!);
|
||||
}
|
||||
|
||||
this.participants = participants;
|
||||
if (allExpireAt < Infinity) {
|
||||
this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
|
||||
}
|
||||
}
|
||||
|
||||
protected getDevices(userId: string): string[] {
|
||||
const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, userId);
|
||||
/**
|
||||
* Updates our member state with the devices returned by the given function.
|
||||
* @param fn A function from the current devices to the new devices. If it
|
||||
* returns null, the update is skipped.
|
||||
*/
|
||||
private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
|
||||
if (this.room.getMyMembership() !== "join") return;
|
||||
|
||||
const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!);
|
||||
const content = event?.getContent<JitsiCallMemberContent>();
|
||||
const expiresAt = typeof content?.expires_ts === "number" ? content.expires_ts : -Infinity;
|
||||
return expiresAt > Date.now() && Array.isArray(content?.devices) ? content.devices : [];
|
||||
const devices = expiresAt > Date.now() && Array.isArray(content?.devices) ? content!.devices : [];
|
||||
const newDevices = fn(devices);
|
||||
|
||||
if (newDevices !== null) {
|
||||
const newContent: JitsiCallMemberContent = {
|
||||
devices: newDevices,
|
||||
expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId, JitsiCall.MEMBER_EVENT_TYPE, newContent, this.client.getUserId()!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async setDevices(devices: string[]): Promise<void> {
|
||||
const content: JitsiCallMemberContent = {
|
||||
devices,
|
||||
expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
|
||||
};
|
||||
public async clean(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const { devices: myDevices } = await this.client.getDevices();
|
||||
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
|
||||
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
|
||||
);
|
||||
// Clean up our member state by filtering out logged out devices,
|
||||
// inactive devices, and our own device (if we're disconnected)
|
||||
await this.updateDevices(devices => {
|
||||
const newDevices = devices.filter(d => {
|
||||
const device = deviceMap.get(d);
|
||||
return device?.last_seen_ts !== undefined
|
||||
&& !(d === this.client.getDeviceId() && !this.connected)
|
||||
&& (now - device.last_seen_ts) < this.STUCK_DEVICE_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
// Skip the update if the devices are unchanged
|
||||
return newDevices.length === devices.length ? null : newDevices;
|
||||
});
|
||||
}
|
||||
|
||||
private async addOurDevice(): Promise<void> {
|
||||
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()!)));
|
||||
}
|
||||
|
||||
private async removeOurDevice(): Promise<void> {
|
||||
await this.updateDevices(devices => {
|
||||
const devicesSet = new Set(devices);
|
||||
devicesSet.delete(this.client.getDeviceId()!);
|
||||
return Array.from(devicesSet);
|
||||
});
|
||||
}
|
||||
|
||||
protected async performConnection(
|
||||
|
@ -591,31 +598,15 @@ export class JitsiCall extends Call {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ElementCallMemberContent {
|
||||
"m.expires_ts": number;
|
||||
"m.calls": {
|
||||
"m.call_id": string;
|
||||
"m.devices": {
|
||||
device_id: string;
|
||||
session_id: string;
|
||||
feeds: unknown[]; // We don't care about what these are
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A group call using MSC3401 and Element Call as a backend.
|
||||
* (somewhat cheekily named)
|
||||
*/
|
||||
export class ElementCall extends Call {
|
||||
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call");
|
||||
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member");
|
||||
public static readonly DUPLICATE_CALL_DEVICE_EVENT_TYPE = "io.element.duplicate_call_device";
|
||||
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallPrefix);
|
||||
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallMemberPrefix);
|
||||
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
private kickedOutByAnotherDevice = false;
|
||||
private connectionTime: number | null = null;
|
||||
private participantsExpirationTimer: number | null = null;
|
||||
private terminationTimer: number | null = null;
|
||||
|
||||
private _layout = Layout.Tile;
|
||||
|
@ -627,7 +618,7 @@ export class ElementCall extends Call {
|
|||
this.emit(CallEvent.Layout, value);
|
||||
}
|
||||
|
||||
private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
|
||||
private constructor(public readonly groupCall: GroupCall, client: MatrixClient) {
|
||||
// Splice together the Element Call URL for this call
|
||||
const url = new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url!);
|
||||
url.pathname = "/room";
|
||||
|
@ -636,8 +627,8 @@ export class ElementCall extends Call {
|
|||
preload: "",
|
||||
hideHeader: "",
|
||||
userId: client.getUserId()!,
|
||||
deviceId: client.getDeviceId(),
|
||||
roomId: groupCall.getRoomId()!,
|
||||
deviceId: client.getDeviceId()!,
|
||||
roomId: groupCall.room.roomId,
|
||||
baseUrl: client.baseUrl,
|
||||
lang: getCurrentLanguage().replace("_", "-"),
|
||||
});
|
||||
|
@ -652,14 +643,15 @@ export class ElementCall extends Call {
|
|||
name: "Element Call",
|
||||
type: MatrixWidgetType.Custom,
|
||||
url: url.toString(),
|
||||
}, groupCall.getRoomId()!),
|
||||
}, groupCall.room.roomId),
|
||||
client,
|
||||
);
|
||||
|
||||
this.groupCall.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
this.room.on(RoomStateEvent.Update, this.onRoomState);
|
||||
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
||||
this.on(CallEvent.Participants, this.onParticipants);
|
||||
groupCall.on(GroupCallEvent.ParticipantsChanged, this.onGroupCallParticipants);
|
||||
groupCall.on(GroupCallEvent.GroupCallStateChanged, this.onGroupCallState);
|
||||
|
||||
this.updateParticipants();
|
||||
}
|
||||
|
||||
|
@ -672,22 +664,8 @@ export class ElementCall extends Call {
|
|||
&& room.isCallRoom()
|
||||
)
|
||||
) {
|
||||
const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType =>
|
||||
room.currentState.getStateEvents(eventType),
|
||||
);
|
||||
|
||||
// Find the newest unterminated call
|
||||
let groupCall: MatrixEvent | null = null;
|
||||
for (const event of groupCalls) {
|
||||
if (
|
||||
!("m.terminated" in event.getContent())
|
||||
&& (groupCall === null || event.getTs() > groupCall.getTs())
|
||||
) {
|
||||
groupCall = event;
|
||||
}
|
||||
}
|
||||
|
||||
if (groupCall !== null) return new ElementCall(groupCall, room.client);
|
||||
const groupCall = room.client.groupCallEventHandler!.groupCalls.get(room.roomId);
|
||||
if (groupCall !== undefined) return new ElementCall(groupCall, room.client);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -698,113 +676,25 @@ export class ElementCall extends Call {
|
|||
&& SettingsStore.getValue("feature_element_call_video_rooms")
|
||||
&& room.isCallRoom();
|
||||
|
||||
await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, {
|
||||
"m.intent": isVideoRoom ? "m.room" : "m.prompt",
|
||||
"m.type": "m.video",
|
||||
}, randomString(24));
|
||||
}
|
||||
|
||||
private updateParticipants() {
|
||||
if (this.participantsExpirationTimer !== null) {
|
||||
clearTimeout(this.participantsExpirationTimer);
|
||||
this.participantsExpirationTimer = null;
|
||||
}
|
||||
|
||||
const members = new Set<RoomMember>();
|
||||
const now = Date.now();
|
||||
let allExpireAt = Infinity;
|
||||
|
||||
const memberEvents = ElementCall.MEMBER_EVENT_TYPE.names.flatMap(eventType =>
|
||||
this.room.currentState.getStateEvents(eventType),
|
||||
const groupCall = new GroupCall(
|
||||
room.client,
|
||||
room,
|
||||
GroupCallType.Video,
|
||||
false,
|
||||
isVideoRoom ? GroupCallIntent.Room : GroupCallIntent.Prompt,
|
||||
);
|
||||
|
||||
for (const e of memberEvents) {
|
||||
const member = this.room.getMember(e.getStateKey()!);
|
||||
const content = e.getContent<ElementCallMemberContent>();
|
||||
const expiresAt = typeof content["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
|
||||
const calls = expiresAt > now && Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
|
||||
const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
|
||||
let devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||
|
||||
// Apply local echo for the disconnected case
|
||||
if (!this.connected && member?.userId === this.client.getUserId()) {
|
||||
devices = devices.filter(d => d.device_id !== this.client.getDeviceId());
|
||||
}
|
||||
// Must have a connected device and still be joined to the room
|
||||
if (devices.length && member?.membership === "join") {
|
||||
members.add(member);
|
||||
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply local echo for the connected case
|
||||
if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
|
||||
|
||||
this.participants = members;
|
||||
if (allExpireAt < Infinity) {
|
||||
this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
|
||||
}
|
||||
await groupCall.create();
|
||||
}
|
||||
|
||||
private getCallsState(userId: string): ElementCallMemberContent["m.calls"] {
|
||||
const event = (() => {
|
||||
for (const eventType of ElementCall.MEMBER_EVENT_TYPE.names) {
|
||||
const e = this.room.currentState.getStateEvents(eventType, userId);
|
||||
if (e) return e;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const content = event?.getContent<ElementCallMemberContent>();
|
||||
const expiresAt = typeof content?.["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
|
||||
return expiresAt > Date.now() && Array.isArray(content?.["m.calls"]) ? content!["m.calls"] : [];
|
||||
}
|
||||
|
||||
protected getDevices(userId: string): string[] {
|
||||
const calls = this.getCallsState(userId);
|
||||
const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
|
||||
const devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||
return devices.map(d => d.device_id);
|
||||
}
|
||||
|
||||
protected async setDevices(devices: string[]): Promise<void> {
|
||||
const calls = this.getCallsState(this.client.getUserId()!);
|
||||
const call = calls.find(c => c["m.call_id"] === this.groupCall.getStateKey())!;
|
||||
const prevDevices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||
const prevDevicesMap = new Map(prevDevices.map(d => [d.device_id, d]));
|
||||
|
||||
const newContent: ElementCallMemberContent = {
|
||||
"m.expires_ts": Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
|
||||
"m.calls": [
|
||||
{
|
||||
"m.call_id": this.groupCall.getStateKey()!,
|
||||
// This method will only ever be used to remove devices, so
|
||||
// it's safe to assume that all requested devices are
|
||||
// present in the map
|
||||
"m.devices": devices.map(d => prevDevicesMap.get(d)!),
|
||||
},
|
||||
...calls.filter(c => c !== call),
|
||||
],
|
||||
};
|
||||
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId, ElementCall.MEMBER_EVENT_TYPE.name, newContent, this.client.getUserId()!,
|
||||
);
|
||||
public clean(): Promise<void> {
|
||||
return this.groupCall.cleanMemberState();
|
||||
}
|
||||
|
||||
protected async performConnection(
|
||||
audioInput: MediaDeviceInfo | null,
|
||||
videoInput: MediaDeviceInfo | null,
|
||||
): Promise<void> {
|
||||
this.kickedOutByAnotherDevice = false;
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
|
||||
this.connectionTime = Date.now();
|
||||
await this.client.sendToDevice(ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE, {
|
||||
[this.client.getUserId()]: {
|
||||
"*": { device_id: this.client.getDeviceId(), timestamp: this.connectionTime },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
|
||||
audioInput: audioInput?.label ?? null,
|
||||
|
@ -829,7 +719,6 @@ export class ElementCall extends Call {
|
|||
}
|
||||
|
||||
public setDisconnected() {
|
||||
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
||||
this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
|
||||
this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
|
||||
|
@ -838,16 +727,12 @@ export class ElementCall extends Call {
|
|||
}
|
||||
|
||||
public destroy() {
|
||||
this.groupCall.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
|
||||
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
|
||||
this.room.off(RoomStateEvent.Update, this.onRoomState);
|
||||
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.room.roomId);
|
||||
this.off(CallEvent.ConnectionState, this.onConnectionState);
|
||||
this.off(CallEvent.Participants, this.onParticipants);
|
||||
this.groupCall.off(GroupCallEvent.ParticipantsChanged, this.onGroupCallParticipants);
|
||||
this.groupCall.off(GroupCallEvent.GroupCallStateChanged, this.onGroupCallState);
|
||||
|
||||
if (this.participantsExpirationTimer !== null) {
|
||||
clearTimeout(this.participantsExpirationTimer);
|
||||
this.participantsExpirationTimer = null;
|
||||
}
|
||||
if (this.terminationTimer !== null) {
|
||||
clearTimeout(this.terminationTimer);
|
||||
this.terminationTimer = null;
|
||||
|
@ -868,50 +753,35 @@ export class ElementCall extends Call {
|
|||
await this.messaging!.transport.send(action, {});
|
||||
}
|
||||
|
||||
private updateParticipants() {
|
||||
const participants = new Map<RoomMember, Set<string>>();
|
||||
|
||||
for (const [member, deviceMap] of this.groupCall.participants) {
|
||||
participants.set(member, new Set(deviceMap.keys()));
|
||||
}
|
||||
|
||||
// We never enter group calls natively, so the GroupCall will think it's
|
||||
// disconnected regardless of what our call member state says. Thus we
|
||||
// have to insert our own device manually when connected via the widget.
|
||||
if (this.connected) {
|
||||
const localMember = this.room.getMember(this.client.getUserId()!)!;
|
||||
let devices = participants.get(localMember);
|
||||
if (devices === undefined) {
|
||||
devices = new Set();
|
||||
participants.set(localMember, devices);
|
||||
}
|
||||
|
||||
devices.add(this.client.getDeviceId()!);
|
||||
}
|
||||
|
||||
this.participants = participants;
|
||||
}
|
||||
|
||||
private get mayTerminate(): boolean {
|
||||
if (this.kickedOutByAnotherDevice) return false;
|
||||
if (this.groupCall.getContent()["m.intent"] === "m.room") return false;
|
||||
if (
|
||||
!this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client)
|
||||
) return false;
|
||||
|
||||
return true;
|
||||
return this.groupCall.intent !== GroupCallIntent.Room
|
||||
&& this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client);
|
||||
}
|
||||
|
||||
private async terminate(): Promise<void> {
|
||||
await this.client.sendStateEvent(
|
||||
this.roomId,
|
||||
ElementCall.CALL_EVENT_TYPE.name,
|
||||
{ ...this.groupCall.getContent(), "m.terminated": "Call ended" },
|
||||
this.groupCall.getStateKey(),
|
||||
);
|
||||
}
|
||||
|
||||
private onBeforeRedaction = (): void => {
|
||||
this.disconnect();
|
||||
};
|
||||
|
||||
private onRoomState = () => {
|
||||
this.updateParticipants();
|
||||
|
||||
// Destroy the call if it's been terminated
|
||||
const newGroupCall = this.room.currentState.getStateEvents(
|
||||
this.groupCall.getType(), this.groupCall.getStateKey()!,
|
||||
);
|
||||
if ("m.terminated" in newGroupCall.getContent()) this.destroy();
|
||||
};
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
const content = event.getContent();
|
||||
if (event.getType() !== ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE) return;
|
||||
if (event.getSender() !== this.client.getUserId()) return;
|
||||
if (content.device_id === this.client.getDeviceId()) return;
|
||||
if (content.timestamp <= this.connectionTime) return;
|
||||
|
||||
this.kickedOutByAnotherDevice = true;
|
||||
this.disconnect();
|
||||
};
|
||||
|
||||
private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => {
|
||||
if (
|
||||
(state === ConnectionState.Connected && !isConnected(prevState))
|
||||
|
@ -921,12 +791,21 @@ export class ElementCall extends Call {
|
|||
}
|
||||
};
|
||||
|
||||
private onParticipants = async (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => {
|
||||
private onParticipants = async (
|
||||
participants: Map<RoomMember, Set<string>>,
|
||||
prevParticipants: Map<RoomMember, Set<string>>,
|
||||
) => {
|
||||
let participantCount = 0;
|
||||
for (const devices of participants.values()) participantCount += devices.size;
|
||||
|
||||
let prevParticipantCount = 0;
|
||||
for (const devices of prevParticipants.values()) prevParticipantCount += devices.size;
|
||||
|
||||
// If the last participant disconnected, terminate the call
|
||||
if (participants.size === 0 && prevParticipants.size > 0 && this.mayTerminate) {
|
||||
if (prevParticipants.has(this.room.getMember(this.client.getUserId()!)!)) {
|
||||
if (participantCount === 0 && prevParticipantCount > 0 && this.mayTerminate) {
|
||||
if (prevParticipants.get(this.room.getMember(this.client.getUserId()!)!)?.has(this.client.getDeviceId()!)) {
|
||||
// If we were that last participant, do the termination ourselves
|
||||
await this.terminate();
|
||||
await this.groupCall.terminate();
|
||||
} else {
|
||||
// We don't appear to have been the last participant, but because of
|
||||
// the potential for races, users lacking permission, and a myriad of
|
||||
|
@ -935,11 +814,20 @@ export class ElementCall extends Call {
|
|||
// randomly between 2 and 8 seconds before terminating the call, to
|
||||
// probabilistically reduce event spam. If someone else beats us to it,
|
||||
// this timer will be automatically cleared upon the call's destruction.
|
||||
this.terminationTimer = setTimeout(() => this.terminate(), Math.random() * 6000 + 2000);
|
||||
this.terminationTimer = setTimeout(
|
||||
() => this.groupCall.terminate(),
|
||||
Math.random() * 6000 + 2000,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onGroupCallParticipants = () => this.updateParticipants();
|
||||
|
||||
private onGroupCallState = (state: GroupCallState) => {
|
||||
if (state === GroupCallState.Ended) this.destroy();
|
||||
};
|
||||
|
||||
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||
ev.preventDefault();
|
||||
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
||||
|
|
|
@ -15,12 +15,10 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/client";
|
||||
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
||||
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
|
||||
|
||||
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
import type { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||
import { UPDATE_EVENT } from "./AsyncStore";
|
||||
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
||||
|
@ -56,13 +54,13 @@ export class CallStore extends AsyncStoreWithClient<{}> {
|
|||
|
||||
protected async onReady(): Promise<any> {
|
||||
// We assume that the calls present in a room are a function of room
|
||||
// state and room widgets, so we initialize the room map here and then
|
||||
// widgets and group calls, so we initialize the room map here and then
|
||||
// update it whenever those change
|
||||
for (const room of this.matrixClient.getRooms()) {
|
||||
this.updateRoom(room);
|
||||
}
|
||||
this.matrixClient.on(ClientEvent.Room, this.onRoom);
|
||||
this.matrixClient.on(RoomStateEvent.Events, this.onRoomState);
|
||||
this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall);
|
||||
this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall);
|
||||
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets);
|
||||
|
||||
// If the room ID of a previously connected call is still in settings at
|
||||
|
@ -92,8 +90,9 @@ export class CallStore extends AsyncStoreWithClient<{}> {
|
|||
this.calls.clear();
|
||||
this._activeCalls.clear();
|
||||
|
||||
this.matrixClient.off(ClientEvent.Room, this.onRoom);
|
||||
this.matrixClient.off(RoomStateEvent.Events, this.onRoomState);
|
||||
this.matrixClient.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall);
|
||||
this.matrixClient.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall);
|
||||
this.matrixClient.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall);
|
||||
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets);
|
||||
}
|
||||
|
||||
|
@ -166,18 +165,6 @@ export class CallStore extends AsyncStoreWithClient<{}> {
|
|||
return call !== null && this.activeCalls.has(call) ? call : null;
|
||||
}
|
||||
|
||||
private onRoom = (room: Room) => this.updateRoom(room);
|
||||
|
||||
private onRoomState = (event: MatrixEvent, state: RoomState) => {
|
||||
// If there's already a call stored for this room, it's understood to
|
||||
// still be valid until destroyed
|
||||
if (!this.calls.has(state.roomId)) {
|
||||
const room = this.matrixClient.getRoom(state.roomId);
|
||||
// State events can arrive before the room does, when creating a room
|
||||
if (room !== null) this.updateRoom(room);
|
||||
}
|
||||
};
|
||||
|
||||
private onWidgets = (roomId: string | null) => {
|
||||
if (roomId === null) {
|
||||
// This store happened to start before the widget store was done
|
||||
|
@ -191,4 +178,6 @@ export class CallStore extends AsyncStoreWithClient<{}> {
|
|||
if (room !== null) this.updateRoom(room);
|
||||
}
|
||||
};
|
||||
|
||||
private onGroupCall = (groupCall: GroupCall) => this.updateRoom(groupCall.room);
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ import {
|
|||
LiveContentSummaryWithCall,
|
||||
LiveContentType,
|
||||
} from "../components/views/rooms/LiveContentSummary";
|
||||
import { useCall, useJoinCallButtonDisabled, useJoinCallButtonTooltip } from "../hooks/useCall";
|
||||
import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall";
|
||||
import { useRoomState } from "../hooks/useRoomState";
|
||||
import { ButtonEvent } from "../components/views/elements/AccessibleButton";
|
||||
import { useDispatcher } from "../hooks/useDispatcher";
|
||||
|
@ -45,14 +45,13 @@ interface JoinCallButtonWithCallProps {
|
|||
}
|
||||
|
||||
function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) {
|
||||
const tooltip = useJoinCallButtonTooltip(call);
|
||||
const disabled = useJoinCallButtonDisabled(call);
|
||||
const disabledTooltip = useJoinCallButtonDisabledTooltip(call);
|
||||
|
||||
return <AccessibleTooltipButton
|
||||
className="mx_IncomingCallToast_joinButton"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
tooltip={tooltip}
|
||||
disabled={disabledTooltip !== null}
|
||||
tooltip={disabledTooltip}
|
||||
kind="primary"
|
||||
>
|
||||
{ _t("Join") }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue