Element Call video rooms (#9267)

* Add an element_call_url config option

* Add a labs flag for Element Call video rooms

* Add Element Call as another video rooms backend

* Consolidate event power level defaults

* Remember to clean up participantsExpirationTimer

* Fix a code smell

* Test the clean method

* Fix some strict mode errors

* Test that clean still works when there are no state events

* Test auto-approval of Element Call widget capabilities

* Deduplicate some code to placate SonarCloud

* Fix more strict mode errors

* Test that calls disconnect when leaving the room

* Test the get methods of JitsiCall and ElementCall more

* Test Call.ts even more

* Test creation of Element video rooms

* Test that createRoom works for non-video-rooms

* Test Call's get method rather than the methods of derived classes

* Ensure that the clean method is able to preserve devices

* Remove duplicate clean method

* Fix lints

* Fix some strict mode errors in RoomPreviewCard

* Test RoomPreviewCard changes

* Quick and dirty hotfix for the community testing session

* Revert "Quick and dirty hotfix for the community testing session"

This reverts commit 37056514fbc040aaf1bff2539da770a1c8ba72a2.

* Fix the event schema for org.matrix.msc3401.call.member devices

* Remove org.matrix.call_duplicate_session from Element Call capabilities

It's no longer used by Element Call when running as a widget.

* Replace element_call_url with a map

* Make PiPs work for virtual widgets

* Auto-approve room timeline capability

Because Element Call uses this now

* Create a reusable isVideoRoom util
This commit is contained in:
Robin 2022-09-16 11:12:27 -04:00 committed by GitHub
parent db5716b776
commit cb735c9439
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1699 additions and 1384 deletions

View file

@ -64,12 +64,6 @@ limitations under the License.
color: $secondary-content; color: $secondary-content;
} }
} }
/* XXX Remove this when video rooms leave beta */
.mx_BetaCard_betaPill {
margin-inline-start: auto;
align-self: start;
}
} }
.mx_RoomPreviewCard_avatar { .mx_RoomPreviewCard_avatar {
@ -104,6 +98,13 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/call/video-call.svg'); mask-image: url('$(res)/img/element-icons/call/video-call.svg');
} }
} }
/* XXX Remove this when video rooms leave beta */
.mx_BetaCard_betaPill {
position: absolute;
inset-block-start: $spacing-32;
inset-inline-end: $spacing-24;
}
} }
h1.mx_RoomPreviewCard_name { h1.mx_RoomPreviewCard_name {

View file

@ -116,6 +116,9 @@ export interface IConfigOptions {
voip?: { voip?: {
obey_asserted_identity?: boolean; // MSC3086 obey_asserted_identity?: boolean; // MSC3086
}; };
element_call: {
url: string;
};
logout_redirect_url?: string; logout_redirect_url?: string;

View file

@ -30,6 +30,9 @@ export const DEFAULTS: IConfigOptions = {
jitsi: { jitsi: {
preferred_domain: "meet.element.io", preferred_domain: "meet.element.io",
}, },
element_call: {
url: "https://call.element.io",
},
// @ts-ignore - we deliberately use the camelCase version here so we trigger // @ts-ignore - we deliberately use the camelCase version here so we trigger
// the fallback behaviour. If we used the snake_case version then we'd break // the fallback behaviour. If we used the snake_case version then we'd break
@ -79,14 +82,8 @@ export default class SdkConfig {
return val === undefined ? undefined : null; return val === undefined ? undefined : null;
} }
public static put(cfg: IConfigOptions) { public static put(cfg: Partial<IConfigOptions>) {
const defaultKeys = Object.keys(DEFAULTS); SdkConfig.setInstance({ ...DEFAULTS, ...cfg });
for (let i = 0; i < defaultKeys.length; ++i) {
if (cfg[defaultKeys[i]] === undefined) {
cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]];
}
}
SdkConfig.setInstance(cfg);
} }
/** /**
@ -97,9 +94,7 @@ export default class SdkConfig {
} }
public static add(cfg: Partial<IConfigOptions>) { public static add(cfg: Partial<IConfigOptions>) {
const liveConfig = SdkConfig.get(); SdkConfig.put({ ...SdkConfig.get(), ...cfg });
const newConfig = Object.assign({}, liveConfig, cfg);
SdkConfig.put(newConfig);
} }
} }

View file

@ -119,6 +119,7 @@ import { isLocalRoom } from '../../utils/localRoom/isLocalRoom';
import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages';
import { LargeLoader } from './LargeLoader'; import { LargeLoader } from './LargeLoader';
import { isVideoRoom } from '../../utils/video-rooms';
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -514,7 +515,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}; };
private getMainSplitContentType = (room: Room) => { private getMainSplitContentType = (room: Room) => {
if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) { if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) {
return MainSplitContentType.Video; return MainSplitContentType.Video;
} }
if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
@ -2015,8 +2016,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const myMembership = this.state.room.getMyMembership(); const myMembership = this.state.room.getMyMembership();
if ( if (
this.state.room.isElementVideoRoom() && isVideoRoom(this.state.room)
!(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") && !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join")
) { ) {
return <ErrorBoundary> return <ErrorBoundary>
<div className="mx_MainSplit"> <div className="mx_MainSplit">

View file

@ -108,8 +108,9 @@ const SpaceLandingAddButton = ({ space }) => {
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms); const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
const canCreateSpace = shouldShowComponent(UIComponent.CreateSpaces); const canCreateSpace = shouldShowComponent(UIComponent.CreateSpaces);
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
let contextMenu; let contextMenu: JSX.Element | null = null;
if (menuDisplayed) { if (menuDisplayed) {
const rect = handle.current.getBoundingClientRect(); const rect = handle.current.getBoundingClientRect();
contextMenu = <IconizedContextMenu contextMenu = <IconizedContextMenu
@ -145,7 +146,12 @@ const SpaceLandingAddButton = ({ space }) => {
e.stopPropagation(); e.stopPropagation();
closeMenu(); closeMenu();
if (await showCreateNewRoom(space, RoomType.ElementVideo)) { if (
await showCreateNewRoom(
space,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
)
) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy); defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
} }
}} }}

View file

@ -105,10 +105,14 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => {
} }
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId); const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
const isVideoRoom = useFeatureEnabled("feature_video_rooms") && room.isElementVideoRoom(); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const isVideoRoom = videoRoomsEnabled && (
room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom())
);
let inviteOption: JSX.Element; let inviteOption: JSX.Element;
if (room.canInvite(cli.getUserId()) && !isDm) { if (room.canInvite(cli.getUserId()!) && !isDm) {
const onInviteClick = (ev: ButtonEvent) => { const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();

View file

@ -35,6 +35,7 @@ import { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import { BetaPill } from "../beta/BetaCard"; import { BetaPill } from "../beta/BetaCard";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature"; import { UIComponent } from "../../../settings/UIFeature";
@ -48,9 +49,9 @@ interface IProps extends IContextMenuProps {
const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => { const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const userId = cli.getUserId(); const userId = cli.getUserId()!;
let inviteOption; let inviteOption: JSX.Element | null = null;
if (space.getJoinRule() === "public" || space.canInvite(userId)) { if (space.getJoinRule() === "public" || space.canInvite(userId)) {
const onInviteClick = (ev: ButtonEvent) => { const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
@ -71,8 +72,8 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
); );
} }
let settingsOption; let settingsOption: JSX.Element | null = null;
let leaveOption; let leaveOption: JSX.Element | null = null;
if (shouldShowSpaceSettings(space)) { if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => { const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
@ -110,7 +111,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
); );
} }
let devtoolsOption; let devtoolsOption: JSX.Element | null = null;
if (SettingsStore.getValue("developerMode")) { if (SettingsStore.getValue("developerMode")) {
const onViewTimelineClick = (ev: ButtonEvent) => { const onViewTimelineClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
@ -134,12 +135,15 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
); );
} }
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms); const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms);
const canAddVideoRooms = canAddRooms && SettingsStore.getValue("feature_video_rooms"); const canAddVideoRooms = canAddRooms && videoRoomsEnabled;
const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces); const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces);
let newRoomSection: JSX.Element; let newRoomSection: JSX.Element | null = null;
if (canAddRooms || canAddSubSpaces) { if (canAddRooms || canAddSubSpaces) {
const onNewRoomClick = (ev: ButtonEvent) => { const onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
@ -154,7 +158,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
showCreateNewRoom(space, RoomType.ElementVideo); showCreateNewRoom(space, elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo);
onFinished(); onFinished();
}; };
@ -266,4 +270,3 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
}; };
export default SpaceContextMenu; export default SpaceContextMenu;

View file

@ -146,10 +146,9 @@ const WidgetContextMenu: React.FC<IProps> = ({
/>; />;
} }
let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId]; const isAllowedWidget =
if (isAllowedWidget === undefined) { (app.eventId !== undefined && (SettingsStore.getValue("allowedWidgets", roomId)[app.eventId] ?? false))
isAllowedWidget = app.creatorUserId === cli.getUserId(); || app.creatorUserId === cli.getUserId();
}
const isLocalWidget = WidgetType.JITSI.matches(app.type); const isLocalWidget = WidgetType.JITSI.matches(app.type);
let revokeButton; let revokeButton;
@ -157,7 +156,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
const onRevokeClick = () => { const onRevokeClick = () => {
logger.info("Revoking permission for widget to load: " + app.eventId); logger.info("Revoking permission for widget to load: " + app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId); const current = SettingsStore.getValue("allowedWidgets", roomId);
current[app.eventId] = false; if (app.eventId !== undefined) current[app.eventId] = false;
const level = SettingsStore.firstSupportedLevel("allowedWidgets"); const level = SettingsStore.firstSupportedLevel("allowedWidgets");
SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => { SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => {
logger.error(err); logger.error(err);

View file

@ -78,7 +78,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
} }
public componentDidMount() { public componentDidMount() {
const driver = new StopGapWidgetDriver([], this.widget, WidgetKind.Modal); const driver = new StopGapWidgetDriver([], this.widget, WidgetKind.Modal, false);
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver); const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
this.setState({ messaging }); this.setState({ messaging });
} }

View file

@ -165,10 +165,8 @@ export default class AppTile extends React.Component<IProps, IState> {
if (!props.room) return true; // user widgets always have permissions if (!props.room) return true; // user widgets always have permissions
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId); const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
if (currentlyAllowedWidgets[props.app.eventId] === undefined) { const allowed = props.app.eventId !== undefined && (currentlyAllowedWidgets[props.app.eventId] ?? false);
return props.userId === props.creatorUserId; return allowed || props.userId === props.creatorUserId;
}
return !!currentlyAllowedWidgets[props.app.eventId];
}; };
private onUserLeftRoom() { private onUserLeftRoom() {
@ -442,7 +440,7 @@ export default class AppTile extends React.Component<IProps, IState> {
const roomId = this.props.room?.roomId; const roomId = this.props.room?.roomId;
logger.info("Granting permission for widget to load: " + this.props.app.eventId); logger.info("Granting permission for widget to load: " + this.props.app.eventId);
const current = SettingsStore.getValue("allowedWidgets", roomId); const current = SettingsStore.getValue("allowedWidgets", roomId);
current[this.props.app.eventId] = true; if (this.props.app.eventId !== undefined) current[this.props.app.eventId] = true;
const level = SettingsStore.firstSupportedLevel("allowedWidgets"); const level = SettingsStore.firstSupportedLevel("allowedWidgets");
SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => { SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => {
this.setState({ hasPermissionToLoad: true }); this.setState({ hasPermissionToLoad: true });

View file

@ -20,7 +20,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import AppTile from "./AppTile"; import AppTile from "./AppTile";
import { IApp } from '../../../stores/WidgetStore'; import WidgetStore from '../../../stores/WidgetStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps { interface IProps {
@ -37,28 +37,13 @@ export default class PersistentApp extends React.Component<IProps> {
constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) { constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
super(props, context); super(props, context);
this.room = context.getRoom(this.props.persistentRoomId); this.room = context.getRoom(this.props.persistentRoomId)!;
} }
private get app(): IApp | null { public render(): JSX.Element | null {
// get the widget data const app = WidgetStore.instance.get(this.props.persistentWidgetId, this.props.persistentRoomId);
const appEvent = WidgetUtils.getRoomWidgets(this.room).find(ev => if (!app) return null;
ev.getStateKey() === this.props.persistentWidgetId,
);
if (appEvent) {
return WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
this.room.roomId, appEvent.getId(),
);
} else {
return null;
}
}
public render(): JSX.Element {
const app = this.app;
if (app) {
return <AppTile return <AppTile
key={app.id} key={app.id}
app={app} app={app}
@ -74,7 +59,5 @@ export default class PersistentApp extends React.Component<IProps> {
movePersistedElement={this.props.movePersistedElement} movePersistedElement={this.props.movePersistedElement}
/>; />;
} }
return null;
}
} }

View file

@ -262,7 +262,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
const isRoomEncrypted = useIsEncrypted(cli, room); const isRoomEncrypted = useIsEncrypted(cli, room);
const roomContext = useContext(RoomContext); const roomContext = useContext(RoomContext);
const e2eStatus = roomContext.e2eStatus; const e2eStatus = roomContext.e2eStatus;
const isVideoRoom = useFeatureEnabled("feature_video_rooms") && room.isElementVideoRoom(); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const isVideoRoom = videoRoomsEnabled && (
room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom())
);
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || ""; const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
const header = <React.Fragment> const header = <React.Fragment>

View file

@ -47,6 +47,7 @@ import RoomLiveShareWarning from '../beacon/RoomLiveShareWarning';
import { BetaPill } from "../beta/BetaCard"; import { BetaPill } from "../beta/BetaCard";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
export interface ISearchInfo { export interface ISearchInfo {
searchTerm: string; searchTerm: string;
@ -312,7 +313,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined; const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && this.props.room.isElementVideoRoom(); const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
const viewLabs = () => defaultDispatcher.dispatch({ const viewLabs = () => defaultDispatcher.dispatch({
action: Action.ViewUserSettings, action: Action.ViewUserSettings,
initialTabId: UserTab.Labs, initialTabId: UserTab.Labs,

View file

@ -23,6 +23,7 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { useRoomState } from "../../../hooks/useRoomState"; import { useRoomState } from "../../../hooks/useRoomState";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers"; import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
@ -44,9 +45,12 @@ const RoomInfoLine: FC<IProps> = ({ room }) => {
const membership = useMyRoomMembership(room); const membership = useMyRoomMembership(room);
const memberCount = useRoomMemberCount(room); const memberCount = useRoomMemberCount(room);
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const isVideoRoom = room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom());
let iconClass: string; let iconClass: string;
let roomType: string; let roomType: string;
if (room.isElementVideoRoom()) { if (isVideoRoom) {
iconClass = "mx_RoomInfoLine_video"; iconClass = "mx_RoomInfoLine_video";
roomType = _t("Video room"); roomType = _t("Video room");
} else if (joinRule === JoinRule.Public) { } else if (joinRule === JoinRule.Public) {

View file

@ -32,6 +32,7 @@ import { _t, _td } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import PosthogTrackers from "../../../PosthogTrackers"; import PosthogTrackers from "../../../PosthogTrackers";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { UIComponent } from "../../../settings/UIFeature"; import { UIComponent } from "../../../settings/UIFeature";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { ITagMap } from "../../../stores/room-list/algorithms/models";
@ -200,8 +201,10 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
}); });
const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms); const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
let contextMenuContent: JSX.Element; let contextMenuContent: JSX.Element | null = null;
if (menuDisplayed && activeSpace) { if (menuDisplayed && activeSpace) {
const canAddRooms = activeSpace.currentState.maySendStateEvent(EventType.SpaceChild, const canAddRooms = activeSpace.currentState.maySendStateEvent(EventType.SpaceChild,
MatrixClientPeg.get().getUserId()); MatrixClientPeg.get().getUserId());
@ -239,7 +242,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
tooltip={canAddRooms ? undefined tooltip={canAddRooms ? undefined
: _t("You do not have permissions to create new rooms in this space")} : _t("You do not have permissions to create new rooms in this space")}
/> />
{ SettingsStore.getValue("feature_video_rooms") && ( { videoRoomsEnabled && (
<IconizedContextMenuOption <IconizedContextMenuOption
label={_t("New video room")} label={_t("New video room")}
iconClassName="mx_RoomList_iconNewVideoRoom" iconClassName="mx_RoomList_iconNewVideoRoom"
@ -247,7 +250,10 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
closeMenu(); closeMenu();
showCreateNewRoom(activeSpace, RoomType.ElementVideo); showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
}} }}
disabled={!canAddRooms} disabled={!canAddRooms}
tooltip={canAddRooms ? undefined tooltip={canAddRooms ? undefined
@ -287,7 +293,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e); PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}} }}
/> />
{ SettingsStore.getValue("feature_video_rooms") && ( { videoRoomsEnabled && (
<IconizedContextMenuOption <IconizedContextMenuOption
label={_t("New video room")} label={_t("New video room")}
iconClassName="mx_RoomList_iconNewVideoRoom" iconClassName="mx_RoomList_iconNewVideoRoom"
@ -297,7 +303,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
closeMenu(); closeMenu();
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: "view_create_room", action: "view_create_room",
type: RoomType.ElementVideo, type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
}); });
}} }}
> >
@ -319,7 +325,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
</IconizedContextMenuOptionList>; </IconizedContextMenuOptionList>;
} }
let contextMenu: JSX.Element; let contextMenu: JSX.Element | null = null;
if (menuDisplayed) { if (menuDisplayed) {
contextMenu = <IconizedContextMenu {...auxButtonContextMenuPosition(handle)} onFinished={closeMenu} compact> contextMenu = <IconizedContextMenu {...auxButtonContextMenuPosition(handle)} onFinished={closeMenu} compact>
{ contextMenuContent } { contextMenuContent }

View file

@ -127,6 +127,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
return SpaceStore.instance.allRoomsInHome; return SpaceStore.instance.allRoomsInHome;
}); });
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const pendingActions = usePendingActions(); const pendingActions = usePendingActions();
const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home; const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home;
@ -211,7 +212,10 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
showCreateNewRoom(activeSpace, RoomType.ElementVideo); showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
closePlusMenu(); closePlusMenu();
}} }}
> >
@ -310,7 +314,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
e.stopPropagation(); e.stopPropagation();
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: "view_create_room", action: "view_create_room",
type: RoomType.ElementVideo, type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
}); });
closePlusMenu(); closePlusMenu();
}} }}

View file

@ -51,6 +51,8 @@ interface IProps {
const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButtonClicked }) => { const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButtonClicked }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const isVideoRoom = room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom());
const myMembership = useMyRoomMembership(room); const myMembership = useMyRoomMembership(room);
useDispatcher(defaultDispatcher, payload => { useDispatcher(defaultDispatcher, payload => {
if (payload.action === Action.JoinRoomError && payload.roomId === room.roomId) { if (payload.action === Action.JoinRoomError && payload.roomId === room.roomId) {
@ -69,7 +71,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
initialTabId: UserTab.Labs, initialTabId: UserTab.Labs,
}); });
let inviterSection: JSX.Element; let inviterSection: JSX.Element | null = null;
let joinButtons: JSX.Element; let joinButtons: JSX.Element;
if (myMembership === "join") { if (myMembership === "join") {
joinButtons = ( joinButtons = (
@ -86,10 +88,11 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
</AccessibleButton> </AccessibleButton>
); );
} else if (myMembership === "invite") { } else if (myMembership === "invite") {
const inviteSender = room.getMember(cli.getUserId())?.events.member?.getSender(); const inviteSender = room.getMember(cli.getUserId()!)?.events.member?.getSender();
const inviter = inviteSender && room.getMember(inviteSender);
if (inviteSender) { if (inviteSender) {
const inviter = room.getMember(inviteSender);
inviterSection = <div className="mx_RoomPreviewCard_inviter"> inviterSection = <div className="mx_RoomPreviewCard_inviter">
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} /> <MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
<div> <div>
@ -102,10 +105,6 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
{ inviteSender } { inviteSender }
</div> : null } </div> : null }
</div> </div>
{ room.isElementVideoRoom()
? <BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
: null
}
</div>; </div>;
} }
@ -152,10 +151,11 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
} }
let avatarRow: JSX.Element; let avatarRow: JSX.Element;
if (room.isElementVideoRoom()) { if (isVideoRoom) {
avatarRow = <> avatarRow = <>
<RoomAvatar room={room} height={50} width={50} viewAvatarOnClick /> <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />
<div className="mx_RoomPreviewCard_video" /> <div className="mx_RoomPreviewCard_video" />
<BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
</>; </>;
} else if (room.isSpaceRoom()) { } else if (room.isSpaceRoom()) {
avatarRow = <RoomAvatar room={room} height={80} width={80} viewAvatarOnClick />; avatarRow = <RoomAvatar room={room} height={80} width={80} viewAvatarOnClick />;
@ -163,12 +163,12 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
avatarRow = <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />; avatarRow = <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />;
} }
let notice: string; let notice: string | null = null;
if (cannotJoin) { if (cannotJoin) {
notice = _t("To view %(roomName)s, you need an invite", { notice = _t("To view %(roomName)s, you need an invite", {
roomName: room.name, roomName: room.name,
}); });
} else if (room.isElementVideoRoom() && !videoRoomsEnabled) { } else if (isVideoRoom && !videoRoomsEnabled) {
notice = myMembership === "join" notice = myMembership === "join"
? _t("To view, please enable video rooms in Labs first") ? _t("To view, please enable video rooms in Labs first")
: _t("To join, please enable video rooms in Labs first"); : _t("To join, please enable video rooms in Labs first");

View file

@ -37,7 +37,7 @@ import { getAddressType } from "./UserAddress";
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types"; import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
import SpaceStore from "./stores/spaces/SpaceStore"; import SpaceStore from "./stores/spaces/SpaceStore";
import { makeSpaceParentEvent } from "./utils/space"; import { makeSpaceParentEvent } from "./utils/space";
import { JitsiCall } from "./models/Call"; import { JitsiCall, ElementCall } from "./models/Call";
import { Action } from "./dispatcher/actions"; import { Action } from "./dispatcher/actions";
import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import Spinner from "./components/views/elements/Spinner"; import Spinner from "./components/views/elements/Spinner";
@ -67,6 +67,17 @@ export interface IOpts {
joinRule?: JoinRule; joinRule?: JoinRule;
} }
const DEFAULT_EVENT_POWER_LEVELS = {
[EventType.RoomName]: 50,
[EventType.RoomAvatar]: 50,
[EventType.RoomPowerLevels]: 100,
[EventType.RoomHistoryVisibility]: 100,
[EventType.RoomCanonicalAlias]: 50,
[EventType.RoomTombstone]: 100,
[EventType.RoomServerAcl]: 100,
[EventType.RoomEncryption]: 100,
};
/** /**
* Create a new room, and switch to it. * Create a new room, and switch to it.
* *
@ -131,23 +142,29 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
if (opts.roomType === RoomType.ElementVideo) { if (opts.roomType === RoomType.ElementVideo) {
createOpts.power_level_content_override = { createOpts.power_level_content_override = {
events: { events: {
...DEFAULT_EVENT_POWER_LEVELS,
// Allow all users to send call membership updates // Allow all users to send call membership updates
[JitsiCall.MEMBER_EVENT_TYPE]: 0, [JitsiCall.MEMBER_EVENT_TYPE]: 0,
// Make widgets immutable, even to admins // Make widgets immutable, even to admins
"im.vector.modular.widgets": 200, "im.vector.modular.widgets": 200,
// Annoyingly, we have to reiterate all the defaults here
[EventType.RoomName]: 50,
[EventType.RoomAvatar]: 50,
[EventType.RoomPowerLevels]: 100,
[EventType.RoomHistoryVisibility]: 100,
[EventType.RoomCanonicalAlias]: 50,
[EventType.RoomTombstone]: 100,
[EventType.RoomServerAcl]: 100,
[EventType.RoomEncryption]: 100,
}, },
users: { users: {
// Temporarily give ourselves the power to set up a widget // Temporarily give ourselves the power to set up a widget
[client.getUserId()]: 200, [client.getUserId()!]: 200,
},
};
} else if (opts.roomType === RoomType.UnstableCall) {
createOpts.power_level_content_override = {
events: {
...DEFAULT_EVENT_POWER_LEVELS,
// Allow all users to send call membership updates
"org.matrix.msc3401.call.member": 0,
// Make calls immutable, even to admins
"org.matrix.msc3401.call": 200,
},
users: {
// Temporarily give ourselves the power to set up a call
[client.getUserId()!]: 200,
}, },
}; };
} }
@ -281,11 +298,18 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
} }
}).then(async () => { }).then(async () => {
if (opts.roomType === RoomType.ElementVideo) { if (opts.roomType === RoomType.ElementVideo) {
// Set up video rooms with a Jitsi call // Set up this video room with a Jitsi call
await JitsiCall.create(await room); await JitsiCall.create(await room);
// Reset our power level back to admin so that the widget becomes immutable // Reset our power level back to admin so that the widget becomes immutable
const plEvent = (await room)?.currentState.getStateEvents(EventType.RoomPowerLevels, ""); const plEvent = (await room).currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
} else if (opts.roomType === RoomType.UnstableCall) {
// Set up this video room with an Element call
await ElementCall.create(await room);
// Reset our power level back to admin so that the call becomes immutable
const plEvent = (await room).currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent); await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
} }
}).then(function() { }).then(function() {

View file

@ -909,6 +909,7 @@
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)", "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
"Send read receipts": "Send read receipts", "Send read receipts": "Send read receipts",
"Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)", "Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)",
"Element Call video rooms": "Element Call video rooms",
"Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)",
"Favourite Messages (under active development)": "Favourite Messages (under active development)", "Favourite Messages (under active development)": "Favourite Messages (under active development)",
"Voice broadcast (under active development)": "Voice broadcast (under active development)", "Voice broadcast (under active development)": "Voice broadcast (under active development)",

View file

@ -16,18 +16,23 @@ limitations under the License.
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { 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 { RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { IWidgetApiRequest } from "matrix-widget-api"; import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
import { IWidgetApiRequest, MatrixWidgetType } from "matrix-widget-api";
import type EventEmitter from "events"; import type EventEmitter from "events";
import type { IMyDevice } from "matrix-js-sdk/src/client"; import type { IMyDevice } from "matrix-js-sdk/src/client";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api"; import type { ClientWidgetApi } from "matrix-widget-api";
import type { IApp } from "../stores/WidgetStore"; import type { IApp } from "../stores/WidgetStore";
import SdkConfig from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
import { timeout } from "../utils/promise"; import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils"; import WidgetUtils from "../utils/WidgetUtils";
@ -40,15 +45,19 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidge
const TIMEOUT_MS = 16000; const TIMEOUT_MS = 16000;
// Waits until an event is emitted satisfying the given predicate // Waits until an event is emitted satisfying the given predicate
const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => { const waitForEvent = async (
let listener: (...args) => void; emitter: EventEmitter,
event: string,
pred: (...args: any[]) => boolean = () => true,
): Promise<void> => {
let listener: (...args: any[]) => void;
const wait = new Promise<void>(resolve => { const wait = new Promise<void>(resolve => {
listener = (...args) => { if (pred(...args)) resolve(); }; listener = (...args) => { if (pred(...args)) resolve(); };
emitter.on(event, listener); emitter.on(event, listener);
}); });
const timedOut = await timeout(wait, false, TIMEOUT_MS) === false; const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
emitter.off(event, listener); emitter.off(event, listener!);
if (timedOut) throw new Error("Timed out"); if (timedOut) throw new Error("Timed out");
}; };
@ -74,18 +83,17 @@ interface CallEventHandlerMap {
[CallEvent.Destroy]: () => void; [CallEvent.Destroy]: () => void;
} }
interface JitsiCallMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
}
/** /**
* A group call accessed through a widget. * A group call accessed through a widget.
*/ */
export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandlerMap> { export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
protected readonly widgetUid = WidgetUtils.getWidgetUid(this.widget); protected readonly widgetUid = WidgetUtils.getWidgetUid(this.widget);
protected readonly room = this.client.getRoom(this.roomId)!;
/**
* The time after which device member state should be considered expired.
*/
public abstract readonly STUCK_DEVICE_TIMEOUT_MS: number;
private _messaging: ClientWidgetApi | null = null; private _messaging: ClientWidgetApi | null = null;
/** /**
@ -130,6 +138,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
* The widget used to access this call. * The widget used to access this call.
*/ */
public readonly widget: IApp, public readonly widget: IApp,
protected readonly client: MatrixClient,
) { ) {
super(); super();
} }
@ -140,21 +149,77 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
* @returns {Call | null} The call. * @returns {Call | null} The call.
*/ */
public static get(room: Room): Call | null { public static get(room: Room): Call | null {
// There's currently only one implementation return ElementCall.get(room) ?? JitsiCall.get(room);
return 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 * Performs a routine check of the call's associated room state, cleaning up
* any data left over from an unclean disconnection. * any data left over from an unclean disconnection.
*/ */
public abstract clean(): Promise<void>; 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);
});
}
/** /**
* Contacts the widget to connect to the call. * Contacts the widget to connect to the call.
* @param {MediaDeviceInfo | null} audioDevice The audio input to use, or * @param {MediaDeviceInfo | null} audioInput The audio input to use, or
* null to start muted. * null to start muted.
* @param {MediaDeviceInfo | null} audioDevice The video input to use, or * @param {MediaDeviceInfo | null} audioInput The video input to use, or
* null to start muted. * null to start muted.
*/ */
protected abstract performConnection( protected abstract performConnection(
@ -219,6 +284,8 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
throw e; throw e;
} }
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Connected; this.connectionState = ConnectionState.Connected;
} }
@ -237,6 +304,8 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
* Manually marks the call as disconnected and cleans up. * Manually marks the call as disconnected and cleans up.
*/ */
public setDisconnected() { public setDisconnected() {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.messaging = null; this.messaging = null;
this.connectionState = ConnectionState.Disconnected; this.connectionState = ConnectionState.Disconnected;
} }
@ -248,6 +317,19 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
if (this.connected) this.setDisconnected(); if (this.connected) this.setDisconnected();
this.emit(CallEvent.Destroy); this.emit(CallEvent.Destroy);
} }
private onMyMembership = async (_room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};
private beforeUnload = () => this.setDisconnected();
}
export interface JitsiCallMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
} }
/** /**
@ -255,14 +337,13 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
*/ */
export class JitsiCall extends Call { export class JitsiCall extends Call {
public static readonly MEMBER_EVENT_TYPE = "io.element.video.member"; public static readonly MEMBER_EVENT_TYPE = "io.element.video.member";
public static readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private room: Room = this.client.getRoom(this.roomId)!;
private resendDevicesTimer: number | null = null; private resendDevicesTimer: number | null = null;
private participantsExpirationTimer: number | null = null; private participantsExpirationTimer: number | null = null;
private constructor(widget: IApp, private readonly client: MatrixClient) { private constructor(widget: IApp, client: MatrixClient) {
super(widget); super(widget, client);
this.room.on(RoomStateEvent.Update, this.onRoomState); this.room.on(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState); this.on(CallEvent.ConnectionState, this.onConnectionState);
@ -270,10 +351,15 @@ export class JitsiCall extends Call {
} }
public static get(room: Room): JitsiCall | null { public static get(room: Room): JitsiCall | null {
// Only supported in video rooms
if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) {
const apps = WidgetStore.instance.getApps(room.roomId); const apps = WidgetStore.instance.getApps(room.roomId);
// The isVideoChannel field differentiates rich Jitsi calls from bare Jitsi widgets // The isVideoChannel field differentiates rich Jitsi calls from bare Jitsi widgets
const jitsiWidget = apps.find(app => WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel); const jitsiWidget = apps.find(app => WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel);
return jitsiWidget ? new JitsiCall(jitsiWidget, room.client) : null; if (jitsiWidget) return new JitsiCall(jitsiWidget, room.client);
}
return null;
} }
public static async create(room: Room): Promise<void> { public static async create(room: Room): Promise<void> {
@ -293,15 +379,15 @@ export class JitsiCall extends Call {
for (const e of this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE)) { for (const e of this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE)) {
const member = this.room.getMember(e.getStateKey()!); const member = this.room.getMember(e.getStateKey()!);
const content = e.getContent<JitsiCallMemberContent>(); const content = e.getContent<JitsiCallMemberContent>();
let devices = Array.isArray(content.devices) ? content.devices : [];
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity; const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
let devices = expiresAt > now && Array.isArray(content.devices) ? content.devices : [];
// Apply local echo for the disconnected case // Apply local echo for the disconnected case
if (!this.connected && member?.userId === this.client.getUserId()) { if (!this.connected && member?.userId === this.client.getUserId()) {
devices = devices.filter(d => d !== this.client.getDeviceId()); devices = devices.filter(d => d !== this.client.getDeviceId());
} }
// Must have a connected device, be unexpired, and still be joined to the room // Must have a connected device and still be joined to the room
if (devices.length && expiresAt > now && member?.membership === "join") { if (devices.length && member?.membership === "join") {
members.add(member); members.add(member);
if (expiresAt < allExpireAt) allExpireAt = expiresAt; if (expiresAt < allExpireAt) allExpireAt = expiresAt;
} }
@ -316,60 +402,23 @@ export class JitsiCall extends Call {
} }
} }
// Helper method that updates our member state with the devices returned by protected getDevices(userId: string): string[] {
// the given function. If it returns null, the update is skipped. const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, userId);
private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> { const content = event?.getContent<JitsiCallMemberContent>();
if (this.room.getMyMembership() !== "join") return; const expiresAt = typeof content?.expires_ts === "number" ? content.expires_ts : -Infinity;
return expiresAt > Date.now() && Array.isArray(content?.devices) ? content.devices : [];
}
const devicesState = this.room.currentState.getStateEvents( protected async setDevices(devices: string[]): Promise<void> {
JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!,
);
const devices = devicesState?.getContent<JitsiCallMemberContent>().devices ?? [];
const newDevices = fn(devices);
if (newDevices) {
const content: JitsiCallMemberContent = { const content: JitsiCallMemberContent = {
devices: newDevices, devices,
expires_ts: Date.now() + JitsiCall.STUCK_DEVICE_TIMEOUT_MS, expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
}; };
await this.client.sendStateEvent( await this.client.sendStateEvent(
this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!, this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
); );
} }
}
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);
});
}
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
&& !(d === this.client.getDeviceId() && !this.connected)
&& (now - device.last_seen_ts) < JitsiCall.STUCK_DEVICE_TIMEOUT_MS;
});
// Skip the update if the devices are unchanged
return newDevices.length === devices.length ? null : newDevices;
});
}
protected async performConnection( protected async performConnection(
audioInput: MediaDeviceInfo | null, audioInput: MediaDeviceInfo | null,
@ -433,8 +482,6 @@ export class JitsiCall extends Call {
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload);
} }
protected async performDisconnection(): Promise<void> { protected async performDisconnection(): Promise<void> {
@ -459,14 +506,12 @@ export class JitsiCall extends Call {
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
super.setDisconnected(); super.setDisconnected();
} }
public destroy() { public destroy() {
this.room.off(RoomStateEvent.Update, this.updateParticipants); this.room.off(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState); this.on(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) { if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer); clearTimeout(this.participantsExpirationTimer);
@ -483,8 +528,8 @@ export class JitsiCall extends Call {
private onRoomState = () => this.updateParticipants(); private onRoomState = () => this.updateParticipants();
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => { private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
if (state === ConnectionState.Connected && prevState === ConnectionState.Connecting) { if (state === ConnectionState.Connected && !isConnected(prevState)) {
this.updateParticipants(); this.updateParticipants(); // Local echo
// Tell others that we're connected, by adding our device to room state // Tell others that we're connected, by adding our device to room state
await this.addOurDevice(); await this.addOurDevice();
@ -492,12 +537,14 @@ export class JitsiCall extends Call {
this.resendDevicesTimer = setInterval(async () => { this.resendDevicesTimer = setInterval(async () => {
logger.log(`Resending video member event for ${this.roomId}`); logger.log(`Resending video member event for ${this.roomId}`);
await this.addOurDevice(); await this.addOurDevice();
}, (JitsiCall.STUCK_DEVICE_TIMEOUT_MS * 3) / 4); }, (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
} else if (state === ConnectionState.Disconnected && isConnected(prevState)) { } else if (state === ConnectionState.Disconnected && isConnected(prevState)) {
this.updateParticipants(); this.updateParticipants(); // Local echo
if (this.resendDevicesTimer !== null) {
clearInterval(this.resendDevicesTimer); clearInterval(this.resendDevicesTimer);
this.resendDevicesTimer = null; this.resendDevicesTimer = null;
}
// Tell others that we're disconnected, by removing our device from room state // Tell others that we're disconnected, by removing our device from room state
await this.removeOurDevice(); await this.removeOurDevice();
} }
@ -514,12 +561,6 @@ export class JitsiCall extends Call {
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {}); await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
}; };
private onMyMembership = async (room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};
private beforeUnload = () => this.setDisconnected();
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => { private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
// If we're already in the middle of a client-initiated disconnection, // If we're already in the middle of a client-initiated disconnection,
// ignore the event // ignore the event
@ -537,3 +578,239 @@ export class JitsiCall extends Call {
this.setDisconnected(); this.setDisconnected();
}; };
} }
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 readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private participantsExpirationTimer: number | null = null;
private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
// Splice together the Element Call URL for this call
const url = new URL(SdkConfig.get("element_call").url);
url.pathname = "/room";
const params = new URLSearchParams({
embed: "",
preload: "",
hideHeader: "",
userId: client.getUserId()!,
deviceId: client.getDeviceId(),
roomId: groupCall.getRoomId()!,
});
url.hash = `#?${params.toString()}`;
// To use Element Call without touching room state, we create a virtual
// widget (one that doesn't have a corresponding state event)
super(
WidgetStore.instance.addVirtualWidget({
id: randomString(24), // So that it's globally unique
creatorUserId: client.getUserId()!,
name: "Element Call",
type: MatrixWidgetType.Custom,
url: url.toString(),
}, groupCall.getRoomId()!),
client,
);
this.room.on(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
this.updateParticipants();
}
public static get(room: Room): ElementCall | null {
// Only supported in video rooms (for now)
if (
SettingsStore.getValue("feature_video_rooms")
&& SettingsStore.getValue("feature_element_call_video_rooms")
&& 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);
}
return null;
}
public static async create(room: Room): Promise<void> {
await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, {
"m.intent": "m.room",
"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),
);
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);
}
}
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()!,
);
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.deviceId ?? null,
videoInput: videoInput?.deviceId ?? null,
});
} catch (e) {
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
}
protected async performDisconnection(): Promise<void> {
try {
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
}
public setDisconnected() {
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
super.setDisconnected();
}
public destroy() {
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
this.room.off(RoomStateEvent.Update, this.onRoomState);
this.off(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
super.destroy();
}
private onRoomState = () => this.updateParticipants();
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
if (
(state === ConnectionState.Connected && !isConnected(prevState))
|| (state === ConnectionState.Disconnected && isConnected(prevState))
) {
this.updateParticipants(); // Local echo
}
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
await this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
};
}

View file

@ -423,6 +423,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: "", default: "",
}, },
"feature_element_call_video_rooms": {
isFeature: true,
supportedLevels: LEVELS_FEATURE,
labsGroup: LabGroup.Rooms,
displayName: _td("Element Call video rooms"),
controller: new ReloadOnChangeController(),
default: false,
},
"feature_location_share_live": { "feature_location_share_live": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,

View file

@ -22,7 +22,6 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomState } from "matrix-js-sdk/src/models/room-state"; import type { RoomState } from "matrix-js-sdk/src/models/room-state";
import defaultDispatcher from "../dispatcher/dispatcher"; import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { UPDATE_EVENT } from "./AsyncStore"; import { UPDATE_EVENT } from "./AsyncStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import WidgetStore from "./WidgetStore"; import WidgetStore from "./WidgetStore";
@ -51,7 +50,7 @@ export class CallStore extends AsyncStoreWithClient<{}> {
super(defaultDispatcher); super(defaultDispatcher);
} }
protected async onAction(payload: ActionPayload): Promise<void> { protected async onAction(): Promise<void> {
// nothing to do // nothing to do
} }

View file

@ -30,11 +30,11 @@ import WidgetUtils from "../utils/WidgetUtils";
import { WidgetType } from "../widgets/WidgetType"; import { WidgetType } from "../widgets/WidgetType";
import { UPDATE_EVENT } from "./AsyncStore"; import { UPDATE_EVENT } from "./AsyncStore";
interface IState {} interface IState { }
export interface IApp extends IWidget { export interface IApp extends IWidget {
roomId: string; roomId: string;
eventId: string; eventId?: string; // not present on virtual widgets
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765 avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
} }
@ -118,7 +118,12 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
// otherwise we are out of sync with the rest of the app with stale widget events during removal // otherwise we are out of sync with the rest of the app with stale widget events during removal
Array.from(this.widgetMap.values()).forEach(app => { Array.from(this.widgetMap.values()).forEach(app => {
if (app.roomId !== room.roomId) return; // skip - wrong room if (app.roomId !== room.roomId) return; // skip - wrong room
if (app.eventId === undefined) {
// virtual widget - keep it
roomInfo.widgets.push(app);
} else {
this.widgetMap.delete(WidgetUtils.getWidgetUid(app)); this.widgetMap.delete(WidgetUtils.getWidgetUid(app));
}
}); });
let edited = false; let edited = false;
@ -169,16 +174,38 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
this.emit(UPDATE_EVENT, roomId); this.emit(UPDATE_EVENT, roomId);
}; };
public getRoom = (roomId: string, initIfNeeded = false) => { public get(widgetId: string, roomId: string | undefined): IApp | undefined {
return this.widgetMap.get(WidgetUtils.calcWidgetUid(widgetId, roomId));
}
public getRoom(roomId: string, initIfNeeded = false): IRoomWidgets {
if (initIfNeeded) this.initRoom(roomId); // internally handles "if needed" if (initIfNeeded) this.initRoom(roomId); // internally handles "if needed"
return this.roomMap.get(roomId); return this.roomMap.get(roomId)!;
}; }
public getApps(roomId: string): IApp[] { public getApps(roomId: string): IApp[] {
const roomInfo = this.getRoom(roomId); const roomInfo = this.getRoom(roomId);
return roomInfo?.widgets || []; return roomInfo?.widgets || [];
} }
public addVirtualWidget(widget: IWidget, roomId: string): IApp {
this.initRoom(roomId);
const app = WidgetUtils.makeAppConfig(widget.id, widget, widget.creatorUserId, roomId, undefined);
this.widgetMap.set(WidgetUtils.getWidgetUid(app), app);
this.roomMap.get(roomId)!.widgets.push(app);
return app;
}
public removeVirtualWidget(widgetId: string, roomId: string): void {
this.widgetMap.delete(WidgetUtils.calcWidgetUid(widgetId, roomId));
const roomApps = this.roomMap.get(roomId);
if (roomApps) {
roomApps.widgets = roomApps.widgets.filter(app =>
!(app.id === widgetId && app.roomId === roomId),
);
}
}
public doesRoomHaveConference(room: Room): boolean { public doesRoomHaveConference(room: Room): boolean {
const roomInfo = this.getRoom(room.roomId); const roomInfo = this.getRoom(room.roomId);
if (!roomInfo) return false; if (!roomInfo) return false;

View file

@ -17,7 +17,7 @@
import { IWidgetApiRequest } from "matrix-widget-api"; import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions { export enum ElementWidgetActions {
// All of these actions are currently specific to Jitsi // All of these actions are currently specific to Jitsi and Element Call
JoinCall = "io.element.join", JoinCall = "io.element.join",
HangupCall = "im.vector.hangup", HangupCall = "im.vector.hangup",
CallParticipants = "io.element.participants", CallParticipants = "io.element.participants",

View file

@ -54,6 +54,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions"; import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
import { ModalWidgetStore } from "../ModalWidgetStore"; import { ModalWidgetStore } from "../ModalWidgetStore";
import { IApp } from "../WidgetStore";
import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
import { getCustomTheme } from "../../theme"; import { getCustomTheme } from "../../theme";
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
@ -69,7 +70,7 @@ import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
interface IAppTileProps { interface IAppTileProps {
// Note: these are only the props we care about // Note: these are only the props we care about
app: IWidget; app: IApp;
room?: Room; // without a room it is a user widget room?: Room; // without a room it is a user widget
userId: string; userId: string;
creatorUserId: string; creatorUserId: string;
@ -155,6 +156,7 @@ export class StopGapWidget extends EventEmitter {
private scalarToken: string; private scalarToken: string;
private roomId?: string; private roomId?: string;
private kind: WidgetKind; private kind: WidgetKind;
private readonly virtual: boolean;
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
constructor(private appTileProps: IAppTileProps) { constructor(private appTileProps: IAppTileProps) {
@ -171,6 +173,7 @@ export class StopGapWidget extends EventEmitter {
this.mockWidget = new ElementWidget(app); this.mockWidget = new ElementWidget(app);
this.roomId = appTileProps.room?.roomId; this.roomId = appTileProps.room?.roomId;
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
this.virtual = app.eventId === undefined;
} }
private get eventListenerRoomId(): string { private get eventListenerRoomId(): string {
@ -265,14 +268,18 @@ export class StopGapWidget extends EventEmitter {
if (this.started) return; if (this.started) return;
const allowedCapabilities = this.appTileProps.whitelistCapabilities || []; const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId); const driver = new StopGapWidgetDriver(
allowedCapabilities, this.mockWidget, this.kind, this.virtual, this.roomId,
);
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("ready", () => this.emit("ready")); this.messaging.on("ready", () => {
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging);
this.emit("ready");
});
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging);
// Always attach a handler for ViewRoom, but permission check it internally // Always attach a handler for ViewRoom, but permission check it internally
this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => { this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => {

View file

@ -40,6 +40,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { Direction } from "matrix-js-sdk/src/matrix"; import { Direction } from "matrix-js-sdk/src/matrix";
import SdkConfig from "../../SdkConfig";
import { iterableDiff, iterableIntersection } from "../../utils/iterables"; import { iterableDiff, iterableIntersection } from "../../utils/iterables";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import Modal from "../../Modal"; import Modal from "../../Modal";
@ -80,6 +81,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
allowedCapabilities: Capability[], allowedCapabilities: Capability[],
private forWidget: Widget, private forWidget: Widget,
private forWidgetKind: WidgetKind, private forWidgetKind: WidgetKind,
virtual: boolean,
private inRoomId?: string, private inRoomId?: string,
) { ) {
super(); super();
@ -102,6 +104,50 @@ export class StopGapWidgetDriver extends WidgetDriver {
// Auto-approve the legacy visibility capability. We send it regardless of capability. // Auto-approve the legacy visibility capability. We send it regardless of capability.
// Widgets don't technically need to request this capability, but Scalar still does. // Widgets don't technically need to request this capability, but Scalar still does.
this.allowedCapabilities.add("visibility"); this.allowedCapabilities.add("visibility");
} else if (virtual && new URL(SdkConfig.get("element_call").url).origin === this.forWidget.origin) {
// This is a trusted Element Call widget that we control
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Send, "org.matrix.msc3401.call").raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, "org.matrix.msc3401.call").raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(
EventDirection.Send, "org.matrix.msc3401.call.member", MatrixClientPeg.get().getUserId()!,
).raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, "org.matrix.msc3401.call.member").raw,
);
const sendRecvToDevice = [
EventType.CallInvite,
EventType.CallCandidates,
EventType.CallAnswer,
EventType.CallHangup,
EventType.CallReject,
EventType.CallSelectAnswer,
EventType.CallNegotiate,
EventType.CallSDPStreamMetadataChanged,
EventType.CallSDPStreamMetadataChangedPrefix,
EventType.CallReplaces,
];
for (const eventType of sendRecvToDevice) {
this.allowedCapabilities.add(
WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw,
);
this.allowedCapabilities.add(
WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw,
);
}
} }
} }

View file

@ -1,175 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventTimeline, MatrixClient, MatrixEvent, RoomState } from "matrix-js-sdk/src/matrix";
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
import { deepCopy } from "matrix-js-sdk/src/utils";
export const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
export const CALL_STATE_EVENT_TYPE = new UnstableValue("m.call", "org.matrix.msc3401.call");
export const CALL_MEMBER_STATE_EVENT_TYPE = new UnstableValue("m.call.member", "org.matrix.msc3401.call.member");
const CALL_STATE_EVENT_TERMINATED = "m.terminated";
interface MDevice {
["m.device_id"]: string;
}
interface MCall {
["m.call_id"]: string;
["m.devices"]: Array<MDevice>;
}
interface MCallMemberContent {
["m.expires_ts"]: number;
["m.calls"]: Array<MCall>;
}
const getRoomState = (client: MatrixClient, roomId: string): RoomState => {
return client.getRoom(roomId)
?.getLiveTimeline()
?.getState?.(EventTimeline.FORWARDS);
};
/**
* Returns all room state events for the stable and unstable type value.
*/
const getRoomStateEvents = (
client: MatrixClient,
roomId: string,
type: UnstableValue<string, string>,
): MatrixEvent[] => {
const roomState = getRoomState(client, roomId);
if (!roomState) return [];
return [
...roomState.getStateEvents(type.name),
...roomState.getStateEvents(type.altName),
];
};
/**
* Finds the latest, non-terminated call state event.
*/
export const getGroupCall = (client: MatrixClient, roomId: string): MatrixEvent => {
return getRoomStateEvents(client, roomId, CALL_STATE_EVENT_TYPE)
.sort((a: MatrixEvent, b: MatrixEvent) => b.getTs() - a.getTs())
.find((event: MatrixEvent) => {
return !(CALL_STATE_EVENT_TERMINATED in event.getContent());
});
};
/**
* Finds the "m.call.member" events for an "m.call" event.
*
* @returns {MatrixEvent[]} non-expired "m.call.member" events for the call
*/
export const useConnectedMembers = (client: MatrixClient, callEvent: MatrixEvent): MatrixEvent[] => {
if (!CALL_STATE_EVENT_TYPE.matches(callEvent.getType())) return [];
const callId = callEvent.getStateKey();
const now = Date.now();
return getRoomStateEvents(client, callEvent.getRoomId(), CALL_MEMBER_STATE_EVENT_TYPE)
.filter((callMemberEvent: MatrixEvent): boolean => {
const {
["m.expires_ts"]: expiresTs,
["m.calls"]: calls,
} = callMemberEvent.getContent<MCallMemberContent>();
// state event expired
if (expiresTs && expiresTs < now) return false;
return !!calls?.find((call: MCall) => call["m.call_id"] === callId);
}) || [];
};
/**
* Removes a list of devices from a call.
* Only works for the current user's devices.
*/
const removeDevices = async (client: MatrixClient, callEvent: MatrixEvent, deviceIds: string[]): Promise<void> => {
if (!CALL_STATE_EVENT_TYPE.matches(callEvent.getType())) return;
const roomId = callEvent.getRoomId();
const roomState = getRoomState(client, roomId);
if (!roomState) return;
const callMemberEvent = roomState.getStateEvents(CALL_MEMBER_STATE_EVENT_TYPE.name, client.getUserId())
?? roomState.getStateEvents(CALL_MEMBER_STATE_EVENT_TYPE.altName, client.getUserId());
const callMemberEventContent = callMemberEvent?.getContent<MCallMemberContent>();
if (
!Array.isArray(callMemberEventContent?.["m.calls"])
|| callMemberEventContent?.["m.calls"].length === 0
) {
return;
}
// copy the content to prevent mutations
const newContent = deepCopy(callMemberEventContent);
const callId = callEvent.getStateKey();
let changed = false;
newContent["m.calls"].forEach((call: MCall) => {
// skip other calls
if (call["m.call_id"] !== callId) return;
call["m.devices"] = call["m.devices"]?.filter((device: MDevice) => {
if (deviceIds.includes(device["m.device_id"])) {
changed = true;
return false;
}
return true;
});
});
if (changed) {
// only send a new state event if there has been a change
newContent["m.expires_ts"] = Date.now() + STUCK_DEVICE_TIMEOUT_MS;
await client.sendStateEvent(
roomId,
CALL_MEMBER_STATE_EVENT_TYPE.name,
newContent,
client.getUserId(),
);
}
};
/**
* Removes the current device from a call.
*/
export const removeOurDevice = async (client: MatrixClient, callEvent: MatrixEvent) => {
return removeDevices(client, callEvent, [client.getDeviceId()]);
};
/**
* Removes all devices of the current user that have not been seen within the STUCK_DEVICE_TIMEOUT_MS.
* Does per default not remove the current device unless includeCurrentDevice is true.
*
* @param {boolean} includeCurrentDevice - Whether to include the current device of this session here.
*/
export const fixStuckDevices = async (client: MatrixClient, callEvent: MatrixEvent, includeCurrentDevice: boolean) => {
const now = Date.now();
const { devices: myDevices } = await client.getDevices();
const currentDeviceId = client.getDeviceId();
const devicesToBeRemoved = myDevices.filter(({ last_seen_ts: lastSeenTs, device_id: deviceId }) => {
return lastSeenTs
&& (deviceId !== currentDeviceId || includeCurrentDevice)
&& (now - lastSeenTs) > STUCK_DEVICE_TIMEOUT_MS;
}).map(d => d.device_id);
return removeDevices(client, callEvent, devicesToBeRemoved);
};

View file

@ -482,8 +482,8 @@ export default class WidgetUtils {
appId: string, appId: string,
app: Partial<IApp>, app: Partial<IApp>,
senderUserId: string, senderUserId: string,
roomId: string | null, roomId: string | undefined,
eventId: string, eventId: string | undefined,
): IApp { ): IApp {
if (!senderUserId) { if (!senderUserId) {
throw new Error("Widgets must be created by someone - provide a senderUserId"); throw new Error("Widgets must be created by someone - provide a senderUserId");

21
src/utils/video-rooms.ts Normal file
View file

@ -0,0 +1,21 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { Room } from "matrix-js-sdk/src/models/room";
import SettingsStore from "../settings/SettingsStore";
export const isVideoRoom = (room: Room) => room.isElementVideoRoom()
|| (SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom());

View file

@ -0,0 +1,120 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { mocked, Mocked } from "jest-mock";
import { render, screen, act } from "@testing-library/react";
import { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { stubClient, wrapInMatrixClientContext, mkRoomMember } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import SettingsStore from "../../../../src/settings/SettingsStore";
import _RoomPreviewCard from "../../../../src/components/views/rooms/RoomPreviewCard";
const RoomPreviewCard = wrapInMatrixClientContext(_RoomPreviewCard);
describe("RoomPreviewCard", () => {
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let enabledFeatures: string[];
beforeEach(() => {
stubClient();
client = mocked(MatrixClientPeg.get());
client.getUserId.mockReturnValue("@alice:example.org");
DMRoomMap.makeShared();
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
jest.spyOn(room, "getMember").mockImplementation(userId => userId === alice.userId ? alice : null);
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
enabledFeatures = [];
jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName =>
enabledFeatures.includes(settingName) ? true : undefined,
);
});
afterEach(() => {
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.restoreAllMocks();
});
const renderPreview = async (): Promise<void> => {
render(
<RoomPreviewCard
room={room}
onJoinButtonClicked={() => { }}
onRejectButtonClicked={() => { }}
/>,
);
await act(() => Promise.resolve()); // Allow effects to settle
};
it("shows a beta pill on Jitsi video room invites", async () => {
jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
enabledFeatures = ["feature_video_rooms"];
await renderPreview();
screen.getByRole("button", { name: /beta/i });
});
it("shows a beta pill on Element video room invites", async () => {
jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
enabledFeatures = ["feature_video_rooms", "feature_element_call_video_rooms"];
await renderPreview();
screen.getByRole("button", { name: /beta/i });
});
it("doesn't show a beta pill on normal invites", async () => {
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
await renderPreview();
expect(screen.queryByRole("button", { name: /beta/i })).toBeNull();
});
it("shows instructions on Jitsi video rooms invites if video rooms are disabled", async () => {
jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
await renderPreview();
screen.getByText(/enable video rooms in labs/i);
});
it("shows instructions on Element video rooms invites if video rooms are disabled", async () => {
jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
enabledFeatures = ["feature_element_call_video_rooms"];
await renderPreview();
screen.getByText(/enable video rooms in labs/i);
});
});

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { mocked } from "jest-mock"; import { mocked, Mocked } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo"; import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo";
import { RoomType } from "matrix-js-sdk/src/@types/event"; import { RoomType } from "matrix-js-sdk/src/@types/event";
@ -23,25 +23,26 @@ import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg } from "./test-u
import { MatrixClientPeg } from "../src/MatrixClientPeg"; import { MatrixClientPeg } from "../src/MatrixClientPeg";
import WidgetStore from "../src/stores/WidgetStore"; import WidgetStore from "../src/stores/WidgetStore";
import WidgetUtils from "../src/utils/WidgetUtils"; import WidgetUtils from "../src/utils/WidgetUtils";
import { JitsiCall } from "../src/models/Call"; import { JitsiCall, ElementCall } from "../src/models/Call";
import createRoom, { canEncryptToAllUsers } from '../src/createRoom'; import createRoom, { canEncryptToAllUsers } from '../src/createRoom';
describe("createRoom", () => { describe("createRoom", () => {
mockPlatformPeg(); mockPlatformPeg();
let client: MatrixClient; let client: Mocked<MatrixClient>;
beforeEach(() => { beforeEach(() => {
stubClient(); stubClient();
client = MatrixClientPeg.get(); client = mocked(MatrixClientPeg.get());
}); });
afterEach(() => jest.clearAllMocks()); afterEach(() => jest.clearAllMocks());
it("sets up video rooms correctly", async () => { it("sets up Jitsi video rooms correctly", async () => {
setupAsyncStoreWithClient(WidgetStore.instance, client); setupAsyncStoreWithClient(WidgetStore.instance, client);
jest.spyOn(WidgetUtils, "waitForRoomWidget").mockResolvedValue(); jest.spyOn(WidgetUtils, "waitForRoomWidget").mockResolvedValue();
const createCallSpy = jest.spyOn(JitsiCall, "create");
const userId = client.getUserId(); const userId = client.getUserId()!;
const roomId = await createRoom({ roomType: RoomType.ElementVideo }); const roomId = await createRoom({ roomType: RoomType.ElementVideo });
const [[{ const [[{
@ -51,25 +52,63 @@ describe("createRoom", () => {
}, },
events: { events: {
"im.vector.modular.widgets": widgetPower, "im.vector.modular.widgets": widgetPower,
[JitsiCall.MEMBER_EVENT_TYPE]: jitsiMemberPower, [JitsiCall.MEMBER_EVENT_TYPE]: callMemberPower,
}, },
}, },
}]] = mocked(client.createRoom).mock.calls as any; // no good type }]] = client.createRoom.mock.calls as any; // no good type
const [[widgetRoomId, widgetStateKey]] = mocked(client.sendStateEvent).mock.calls;
// We should have had enough power to be able to set up the Jitsi widget // We should have had enough power to be able to set up the widget
expect(userPower).toBeGreaterThanOrEqual(widgetPower); expect(userPower).toBeGreaterThanOrEqual(widgetPower);
// and should have actually set it up // and should have actually set it up
expect(widgetRoomId).toEqual(roomId); expect(createCallSpy).toHaveBeenCalled();
expect(widgetStateKey).toEqual("im.vector.modular.widgets");
// All members should be able to update their connected devices // All members should be able to update their connected devices
expect(jitsiMemberPower).toEqual(0); expect(callMemberPower).toEqual(0);
// Jitsi widget should be immutable for admins // widget should be immutable for admins
expect(widgetPower).toBeGreaterThan(100); expect(widgetPower).toBeGreaterThan(100);
// and we should have been reset back to admin // and we should have been reset back to admin
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined); expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
}); });
it("sets up Element video rooms correctly", async () => {
const userId = client.getUserId()!;
const createCallSpy = jest.spyOn(ElementCall, "create");
const roomId = await createRoom({ roomType: RoomType.UnstableCall });
const [[{
power_level_content_override: {
users: {
[userId]: userPower,
},
events: {
[ElementCall.CALL_EVENT_TYPE.name]: callPower,
[ElementCall.MEMBER_EVENT_TYPE.name]: callMemberPower,
},
},
}]] = client.createRoom.mock.calls as any; // no good type
// We should have had enough power to be able to set up the call
expect(userPower).toBeGreaterThanOrEqual(callPower);
// and should have actually set it up
expect(createCallSpy).toHaveBeenCalled();
// All members should be able to update their connected devices
expect(callMemberPower).toEqual(0);
// call should be immutable for admins
expect(callPower).toBeGreaterThan(100);
// and we should have been reset back to admin
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
});
it("doesn't create calls in non-video-rooms", async () => {
const createJitsiCallSpy = jest.spyOn(JitsiCall, "create");
const createElementCallSpy = jest.spyOn(ElementCall, "create");
await createRoom({});
expect(createJitsiCallSpy).not.toHaveBeenCalled();
expect(createElementCallSpy).not.toHaveBeenCalled();
});
}); });
describe("canEncryptToAllUsers", () => { describe("canEncryptToAllUsers", () => {
@ -83,20 +122,20 @@ describe("canEncryptToAllUsers", () => {
"@badUser:localhost": {}, "@badUser:localhost": {},
}; };
let client: MatrixClient; let client: Mocked<MatrixClient>;
beforeEach(() => { beforeEach(() => {
stubClient(); stubClient();
client = MatrixClientPeg.get(); client = mocked(MatrixClientPeg.get());
}); });
it("returns true if all devices have crypto", async () => { it("returns true if all devices have crypto", async () => {
mocked(client.downloadKeys).mockResolvedValue(trueUser); client.downloadKeys.mockResolvedValue(trueUser);
const response = await canEncryptToAllUsers(client, ["@goodUser:localhost"]); const response = await canEncryptToAllUsers(client, ["@goodUser:localhost"]);
expect(response).toBe(true); expect(response).toBe(true);
}); });
it("returns false if not all users have crypto", async () => { it("returns false if not all users have crypto", async () => {
mocked(client.downloadKeys).mockResolvedValue({ ...trueUser, ...falseUser }); client.downloadKeys.mockResolvedValue({ ...trueUser, ...falseUser });
const response = await canEncryptToAllUsers(client, ["@goodUser:localhost", "@badUser:localhost"]); const response = await canEncryptToAllUsers(client, ["@goodUser:localhost", "@badUser:localhost"]);
expect(response).toBe(false); expect(response).toBe(false);
}); });

View file

@ -18,63 +18,61 @@ import EventEmitter from "events";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { waitFor } from "@testing-library/react"; import { waitFor } from "@testing-library/react";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import { PendingEventOrdering } from "matrix-js-sdk/src/client"; import { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api"; import { Widget } from "matrix-widget-api";
import type { Mocked } from "jest-mock"; import type { Mocked } from "jest-mock";
import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api"; import type { ClientWidgetApi } from "matrix-widget-api";
import type { Call } from "../../src/models/Call"; import type { JitsiCallMemberContent, ElementCallMemberContent } from "../../src/models/Call";
import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../test-utils"; import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../test-utils";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../src/MediaDeviceHandler"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../src/MediaDeviceHandler";
import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import { CallEvent, ConnectionState, JitsiCall } from "../../src/models/Call"; import { Call, CallEvent, ConnectionState, JitsiCall, ElementCall } from "../../src/models/Call";
import WidgetStore from "../../src/stores/WidgetStore"; import WidgetStore from "../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore"; import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore"; import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore";
import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions"; import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions";
import SettingsStore from "../../src/settings/SettingsStore";
describe("JitsiCall", () => { jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
[MediaDeviceKindEnum.AudioInput]: [ [MediaDeviceKindEnum.AudioInput]: [
{ deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => {} }, { deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => { } },
], ],
[MediaDeviceKindEnum.VideoInput]: [ [MediaDeviceKindEnum.VideoInput]: [
{ deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => {} }, { deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => { } },
], ],
[MediaDeviceKindEnum.AudioOutput]: [], [MediaDeviceKindEnum.AudioOutput]: [],
}); });
jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1"); jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1");
jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2"); jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
let client: Mocked<MatrixClient>; jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName =>
let room: Room; settingName === "feature_video_rooms" || settingName === "feature_element_call_video_rooms" ? true : undefined,
let alice: RoomMember; );
let bob: RoomMember;
let carol: RoomMember;
let call: Call;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
const setUpClientRoomAndStores = (roomType: RoomType): {
client: Mocked<MatrixClient>;
room: Room;
alice: RoomMember;
bob: RoomMember;
carol: RoomMember;
} => {
stubClient(); stubClient();
client = mocked(MatrixClientPeg.get()); const client = mocked<MatrixClient>(MatrixClientPeg.get());
room = new Room("!1:example.org", client, "@alice:example.org", { const room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached, pendingEventOrdering: PendingEventOrdering.Detached,
}); });
alice = mkRoomMember(room.roomId, "@alice:example.org"); jest.spyOn(room, "getType").mockReturnValue(roomType);
bob = mkRoomMember(room.roomId, "@bob:example.org");
carol = mkRoomMember(room.roomId, "@carol:example.org"); const alice = mkRoomMember(room.roomId, "@alice:example.org");
const bob = mkRoomMember(room.roomId, "@bob:example.org");
const carol = mkRoomMember(room.roomId, "@carol:example.org");
jest.spyOn(room, "getMember").mockImplementation(userId => { jest.spyOn(room, "getMember").mockImplementation(userId => {
switch (userId) { switch (userId) {
case alice.userId: return alice; case alice.userId: return alice;
@ -106,21 +104,112 @@ describe("JitsiCall", () => {
setupAsyncStoreWithClient(WidgetStore.instance, client); setupAsyncStoreWithClient(WidgetStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
await JitsiCall.create(room); return { client, room, alice, bob, carol };
call = JitsiCall.get(room); };
if (call === null) throw new Error("Failed to create call");
widget = new Widget(call.widget); const cleanUpClientRoomAndStores = (
client: MatrixClient,
room: Room,
) => {
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
};
const setUpWidget = (call: Call): {
widget: Widget;
messaging: Mocked<ClientWidgetApi>;
audioMutedSpy: jest.SpyInstance<boolean, []>;
videoMutedSpy: jest.SpyInstance<boolean, []>;
} => {
const widget = new Widget(call.widget);
const eventEmitter = new EventEmitter(); const eventEmitter = new EventEmitter();
messaging = { const messaging = {
on: eventEmitter.on.bind(eventEmitter), on: eventEmitter.on.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter), off: eventEmitter.off.bind(eventEmitter),
once: eventEmitter.once.bind(eventEmitter), once: eventEmitter.once.bind(eventEmitter),
emit: eventEmitter.emit.bind(eventEmitter), emit: eventEmitter.emit.bind(eventEmitter),
stop: jest.fn(), stop: jest.fn(),
transport: { transport: {
send: jest.fn(async action => { send: jest.fn(),
reply: jest.fn(),
},
} as unknown as Mocked<ClientWidgetApi>;
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
const audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get");
const videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get");
return { widget, messaging, audioMutedSpy, videoMutedSpy };
};
const cleanUpCallAndWidget = (
call: Call,
widget: Widget,
audioMutedSpy: jest.SpyInstance<boolean, []>,
videoMutedSpy: jest.SpyInstance<boolean, []>,
) => {
call.destroy();
jest.clearAllMocks();
WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
audioMutedSpy.mockRestore();
videoMutedSpy.mockRestore();
};
describe("JitsiCall", () => {
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let carol: RoomMember;
beforeEach(() => {
({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.ElementVideo));
});
afterEach(() => cleanUpClientRoomAndStores(client, room));
describe("get", () => {
it("finds no calls", () => {
expect(Call.get(room)).toBeNull();
});
it("finds calls", async () => {
await JitsiCall.create(room);
expect(Call.get(room)).toBeInstanceOf(JitsiCall);
});
it("ignores terminated calls", async () => {
await JitsiCall.create(room);
// Terminate the call
const [event] = room.currentState.getStateEvents("im.vector.modular.widgets");
await client.sendStateEvent(room.roomId, "im.vector.modular.widgets", {}, event.getStateKey()!);
expect(Call.get(room)).toBeNull();
});
});
describe("instance", () => {
let call: JitsiCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
await JitsiCall.create(room);
const maybeCall = JitsiCall.get(room);
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
mocked(messaging.transport).send.mockImplementation(async (action: string) => {
if (action === ElementWidgetActions.JoinCall) { if (action === ElementWidgetActions.JoinCall) {
messaging.emit( messaging.emit(
`action:${ElementWidgetActions.JoinCall}`, `action:${ElementWidgetActions.JoinCall}`,
@ -133,24 +222,10 @@ describe("JitsiCall", () => {
); );
} }
return {}; return {};
}), });
reply: jest.fn(),
},
} as unknown as Mocked<ClientWidgetApi>;
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get");
videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get");
}); });
afterEach(() => { afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
jest.clearAllMocks();
audioMutedSpy.mockRestore();
videoMutedSpy.mockRestore();
});
it("connects muted", async () => { it("connects muted", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
@ -192,6 +267,17 @@ describe("JitsiCall", () => {
expect(call.connectionState).toBe(ConnectionState.Connected); expect(call.connectionState).toBe(ConnectionState.Connected);
}); });
it("fails to connect if the widget returns an error", async () => {
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.connect()).rejects.toBeDefined();
});
it("fails to disconnect if the widget returns an error", async () => {
await call.connect();
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.disconnect()).rejects.toBeDefined();
});
it("handles remote disconnection", async () => { it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
@ -236,6 +322,20 @@ describe("JitsiCall", () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected); expect(call.connectionState).toBe(ConnectionState.Disconnected);
}); });
it("disconnects when we leave the room", async () => {
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, "leave");
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, "join");
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("tracks participants in room state", async () => { it("tracks participants in room state", async () => {
expect([...call.participants]).toEqual([]); expect([...call.participants]).toEqual([]);
@ -270,7 +370,7 @@ describe("JitsiCall", () => {
room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(), room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
).toEqual({ ).toEqual({
devices: [client.getDeviceId()], devices: [client.getDeviceId()],
expires_ts: now1 + JitsiCall.STUCK_DEVICE_TIMEOUT_MS, expires_ts: now1 + call.STUCK_DEVICE_TIMEOUT_MS,
}), { interval: 5 }); }), { interval: 5 });
const now2 = Date.now(); const now2 = Date.now();
@ -279,7 +379,7 @@ describe("JitsiCall", () => {
room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(), room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
).toEqual({ ).toEqual({
devices: [], devices: [],
expires_ts: now2 + JitsiCall.STUCK_DEVICE_TIMEOUT_MS, expires_ts: now2 + call.STUCK_DEVICE_TIMEOUT_MS,
}), { interval: 5 }); }), { interval: 5 });
}); });
@ -293,7 +393,7 @@ describe("JitsiCall", () => {
), { interval: 5 }); ), { interval: 5 });
client.sendStateEvent.mockClear(); client.sendStateEvent.mockClear();
jest.advanceTimersByTime(JitsiCall.STUCK_DEVICE_TIMEOUT_MS); jest.advanceTimersByTime(call.STUCK_DEVICE_TIMEOUT_MS);
await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith( await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId, room.roomId,
JitsiCall.MEMBER_EVENT_TYPE, JitsiCall.MEMBER_EVENT_TYPE,
@ -336,4 +436,416 @@ describe("JitsiCall", () => {
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
}); });
describe("clean", () => {
const aliceWeb: IMyDevice = {
device_id: "aliceweb",
last_seen_ts: 0,
};
const aliceDesktop: IMyDevice = {
device_id: "alicedesktop",
last_seen_ts: 0,
};
const aliceDesktopOffline: IMyDevice = {
device_id: "alicedesktopoffline",
last_seen_ts: 1000 * 60 * 60 * -2, // 2 hours ago
};
const aliceDesktopNeverOnline: IMyDevice = {
device_id: "alicedesktopneveronline",
};
const mkContent = (devices: IMyDevice[]): JitsiCallMemberContent => ({
expires_ts: 1000 * 60 * 10,
devices: devices.map(d => d.device_id),
});
const expectDevices = (devices: IMyDevice[]) => expect(
room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
).toEqual({
expires_ts: expect.any(Number),
devices: devices.map(d => d.device_id),
});
beforeEach(() => {
client.getDeviceId.mockReturnValue(aliceWeb.device_id);
client.getDevices.mockResolvedValue({
devices: [
aliceWeb,
aliceDesktop,
aliceDesktopOffline,
aliceDesktopNeverOnline,
],
});
});
it("doesn't clean up valid devices", async () => {
await call.connect();
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
mkContent([aliceWeb, aliceDesktop]),
alice.userId,
);
await call.clean();
expectDevices([aliceWeb, aliceDesktop]);
});
it("cleans up our own device if we're disconnected", async () => {
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
mkContent([aliceWeb, aliceDesktop]),
alice.userId,
);
await call.clean();
expectDevices([aliceDesktop]);
});
it("cleans up devices that have been offline for too long", async () => {
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
mkContent([aliceDesktop, aliceDesktopOffline]),
alice.userId,
);
await call.clean();
expectDevices([aliceDesktop]);
});
it("cleans up devices that have never been online", async () => {
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
mkContent([aliceDesktop, aliceDesktopNeverOnline]),
alice.userId,
);
await call.clean();
expectDevices([aliceDesktop]);
});
it("no-ops if there are no state events", async () => {
await call.clean();
expect(room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId)).toBe(null);
});
});
});
});
describe("ElementCall", () => {
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let carol: RoomMember;
beforeEach(() => {
({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.UnstableCall));
});
afterEach(() => cleanUpClientRoomAndStores(client, room));
describe("get", () => {
it("finds no calls", () => {
expect(Call.get(room)).toBeNull();
});
it("finds calls", async () => {
await ElementCall.create(room);
expect(Call.get(room)).toBeInstanceOf(ElementCall);
});
it("ignores terminated calls", async () => {
await ElementCall.create(room);
// Terminate the call
const [event] = room.currentState.getStateEvents(ElementCall.CALL_EVENT_TYPE.name);
const content = { ...event.getContent(), "m.terminated": "Call ended" };
await client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, content, event.getStateKey()!);
expect(Call.get(room)).toBeNull();
});
});
describe("instance", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
await ElementCall.create(room);
const maybeCall = ElementCall.get(room);
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
it("connects muted", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(true);
videoMutedSpy.mockReturnValue(true);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: null,
videoInput: null,
});
});
it("connects unmuted", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(false);
videoMutedSpy.mockReturnValue(false);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: "1",
videoInput: "2",
});
});
it("waits for messaging when connecting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.connect();
expect(call.connectionState).toBe(ConnectionState.Connecting);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("fails to connect if the widget returns an error", async () => {
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.connect()).rejects.toBeDefined();
});
it("fails to disconnect if the widget returns an error", async () => {
await call.connect();
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.disconnect()).rejects.toBeDefined();
});
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
});
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, "leave");
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, "join");
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("tracks participants in room state", async () => {
expect([...call.participants]).toEqual([]);
// A participant with multiple devices (should only show up once)
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
{
"m.expires_ts": 1000 * 60 * 10,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": [
{ device_id: "bobweb", session_id: "1", feeds: [] },
{ device_id: "bobdesktop", session_id: "1", feeds: [] },
],
}],
},
bob.userId,
);
// A participant with an expired device (should not show up)
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
{
"m.expires_ts": -1000 * 60,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": [
{ device_id: "carolandroid", session_id: "1", feeds: [] },
],
}],
},
carol.userId,
);
// Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset();
await call.connect();
expect([...call.participants]).toEqual([bob, alice]);
await call.disconnect();
expect([...call.participants]).toEqual([bob]);
});
it("emits events when connection state changes", async () => {
const events: ConnectionState[] = [];
const onConnectionState = (state: ConnectionState) => events.push(state);
call.on(CallEvent.ConnectionState, onConnectionState);
await call.connect();
await call.disconnect();
expect(events).toEqual([
ConnectionState.Connecting,
ConnectionState.Connected,
ConnectionState.Disconnecting,
ConnectionState.Disconnected,
]);
});
it("emits events when participants change", async () => {
const events: Set<RoomMember>[] = [];
const onParticipants = (participants: Set<RoomMember>) => {
if (!isEqual(participants, events[events.length - 1])) events.push(participants);
};
call.on(CallEvent.Participants, onParticipants);
await call.connect();
await call.disconnect();
expect(events).toEqual([new Set([alice]), new Set()]);
});
describe("clean", () => {
const aliceWeb: IMyDevice = {
device_id: "aliceweb",
last_seen_ts: 0,
};
const aliceDesktop: IMyDevice = {
device_id: "alicedesktop",
last_seen_ts: 0,
};
const aliceDesktopOffline: IMyDevice = {
device_id: "alicedesktopoffline",
last_seen_ts: 1000 * 60 * 60 * -2, // 2 hours ago
};
const aliceDesktopNeverOnline: IMyDevice = {
device_id: "alicedesktopneveronline",
};
const mkContent = (devices: IMyDevice[]): ElementCallMemberContent => ({
"m.expires_ts": 1000 * 60 * 10,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": devices.map(d => ({ device_id: d.device_id, session_id: "1", feeds: [] })),
}],
});
const expectDevices = (devices: IMyDevice[]) => expect(
room.currentState.getStateEvents(ElementCall.MEMBER_EVENT_TYPE.name, alice.userId).getContent(),
).toEqual({
"m.expires_ts": expect.any(Number),
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": devices.map(d => ({ device_id: d.device_id, session_id: "1", feeds: [] })),
}],
});
beforeEach(() => {
client.getDeviceId.mockReturnValue(aliceWeb.device_id);
client.getDevices.mockResolvedValue({
devices: [
aliceWeb,
aliceDesktop,
aliceDesktopOffline,
aliceDesktopNeverOnline,
],
});
});
it("doesn't clean up valid devices", async () => {
await call.connect();
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
mkContent([aliceWeb, aliceDesktop]),
alice.userId,
);
await call.clean();
expectDevices([aliceWeb, aliceDesktop]);
});
it("cleans up our own device if we're disconnected", async () => {
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
mkContent([aliceWeb, aliceDesktop]),
alice.userId,
);
await call.clean();
expectDevices([aliceDesktop]);
});
it("cleans up devices that have been offline for too long", async () => {
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
mkContent([aliceDesktop, aliceDesktopOffline]),
alice.userId,
);
await call.clean();
expectDevices([aliceDesktop]);
});
it("cleans up devices that have never been online", async () => {
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
mkContent([aliceDesktop, aliceDesktopNeverOnline]),
alice.userId,
);
await call.clean();
expectDevices([aliceDesktop]);
});
it("no-ops if there are no state events", async () => {
await call.clean();
expect(room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId)).toBe(null);
});
});
});
}); });

View file

@ -39,6 +39,7 @@ describe("StopGapWidget", () => {
creatorUserId: "@alice:example.org", creatorUserId: "@alice:example.org",
type: "example", type: "example",
url: "https://example.org", url: "https://example.org",
roomId: "!1:example.org",
}, },
room: mkRoom(client, "!1:example.org"), room: mkRoom(client, "!1:example.org"),
userId: "@alice:example.org", userId: "@alice:example.org",

View file

@ -15,10 +15,10 @@ limitations under the License.
*/ */
import { mocked, MockedObject } from "jest-mock"; import { mocked, MockedObject } from "jest-mock";
import { ClientEvent, ITurnServer as IClientTurnServer, MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client";
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { ITurnServer, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api"; import { Widget, MatrixWidgetType, WidgetKind, WidgetDriver, ITurnServer } from "matrix-widget-api";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { RoomViewStore } from "../../../src/stores/RoomViewStore"; import { RoomViewStore } from "../../../src/stores/RoomViewStore";
@ -27,13 +27,8 @@ import { stubClient } from "../../test-utils";
describe("StopGapWidgetDriver", () => { describe("StopGapWidgetDriver", () => {
let client: MockedObject<MatrixClient>; let client: MockedObject<MatrixClient>;
let driver: WidgetDriver;
beforeEach(() => { const mkDefaultDriver = (): WidgetDriver => new StopGapWidgetDriver(
stubClient();
client = mocked(MatrixClientPeg.get());
driver = new StopGapWidgetDriver(
[], [],
new Widget({ new Widget({
id: "test", id: "test",
@ -42,7 +37,65 @@ describe("StopGapWidgetDriver", () => {
url: "https://example.org", url: "https://example.org",
}), }),
WidgetKind.Room, WidgetKind.Room,
false,
"!1:example.org",
); );
beforeEach(() => {
stubClient();
client = mocked(MatrixClientPeg.get());
client.getUserId.mockReturnValue("@alice:example.org");
});
it("auto-approves capabilities of virtual Element Call widgets", async () => {
const driver = new StopGapWidgetDriver(
[],
new Widget({
id: "group_call",
creatorUserId: "@alice:example.org",
type: MatrixWidgetType.Custom,
url: "https://call.element.io",
}),
WidgetKind.Room,
true,
"!1:example.org",
);
// These are intentionally raw identifiers rather than constants, so it's obvious what's being requested
const requestedCapabilities = new Set([
"m.always_on_screen",
"town.robin.msc3846.turn_servers",
"org.matrix.msc2762.timeline:!1:example.org",
"org.matrix.msc2762.receive.state_event:m.room.member",
"org.matrix.msc2762.send.state_event:org.matrix.msc3401.call",
"org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call",
"org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#@alice:example.org",
"org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call.member",
"org.matrix.msc3819.send.to_device:m.call.invite",
"org.matrix.msc3819.receive.to_device:m.call.invite",
"org.matrix.msc3819.send.to_device:m.call.candidates",
"org.matrix.msc3819.receive.to_device:m.call.candidates",
"org.matrix.msc3819.send.to_device:m.call.answer",
"org.matrix.msc3819.receive.to_device:m.call.answer",
"org.matrix.msc3819.send.to_device:m.call.hangup",
"org.matrix.msc3819.receive.to_device:m.call.hangup",
"org.matrix.msc3819.send.to_device:m.call.reject",
"org.matrix.msc3819.receive.to_device:m.call.reject",
"org.matrix.msc3819.send.to_device:m.call.select_answer",
"org.matrix.msc3819.receive.to_device:m.call.select_answer",
"org.matrix.msc3819.send.to_device:m.call.negotiate",
"org.matrix.msc3819.receive.to_device:m.call.negotiate",
"org.matrix.msc3819.send.to_device:m.call.sdp_stream_metadata_changed",
"org.matrix.msc3819.receive.to_device:m.call.sdp_stream_metadata_changed",
"org.matrix.msc3819.send.to_device:org.matrix.call.sdp_stream_metadata_changed",
"org.matrix.msc3819.receive.to_device:org.matrix.call.sdp_stream_metadata_changed",
"org.matrix.msc3819.send.to_device:m.call.replaces",
"org.matrix.msc3819.receive.to_device:m.call.replaces",
]);
// As long as this resolves, we'll know that it didn't try to pop up a modal
const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities);
expect(approvedCapabilities).toEqual(requestedCapabilities);
}); });
describe("sendToDevice", () => { describe("sendToDevice", () => {
@ -59,6 +112,10 @@ describe("StopGapWidgetDriver", () => {
}, },
}; };
let driver: WidgetDriver;
beforeEach(() => { driver = mkDefaultDriver(); });
it("sends unencrypted messages", async () => { it("sends unencrypted messages", async () => {
await driver.sendToDevice("org.example.foo", false, contentMap); await driver.sendToDevice("org.example.foo", false, contentMap);
expect(client.queueToDevice.mock.calls).toMatchSnapshot(); expect(client.queueToDevice.mock.calls).toMatchSnapshot();
@ -80,6 +137,10 @@ describe("StopGapWidgetDriver", () => {
}); });
describe("getTurnServers", () => { describe("getTurnServers", () => {
let driver: WidgetDriver;
beforeEach(() => { driver = mkDefaultDriver(); });
it("stops if VoIP isn't supported", async () => { it("stops if VoIP isn't supported", async () => {
jest.spyOn(client, "pollingTurnServers", "get").mockReturnValue(false); jest.spyOn(client, "pollingTurnServers", "get").mockReturnValue(false);
const servers = driver.getTurnServers(); const servers = driver.getTurnServers();
@ -135,6 +196,10 @@ describe("StopGapWidgetDriver", () => {
}); });
describe("readEventRelations", () => { describe("readEventRelations", () => {
let driver: WidgetDriver;
beforeEach(() => { driver = mkDefaultDriver(); });
it('reads related events from the current room', async () => { it('reads related events from the current room', async () => {
jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id'); jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id');

View file

@ -23,9 +23,11 @@ import { Call } from "../../src/models/Call";
export class MockedCall extends Call { export class MockedCall extends Call {
private static EVENT_TYPE = "org.example.mocked_call"; private static EVENT_TYPE = "org.example.mocked_call";
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private constructor(private readonly room: Room, private readonly id: string) { private constructor(room: Room, id: string) {
super({ super(
{
id, id,
eventId: "$1:example.org", eventId: "$1:example.org",
roomId: room.roomId, roomId: room.roomId,
@ -33,7 +35,9 @@ export class MockedCall extends Call {
url: "https://example.org", url: "https://example.org",
name: "Group call", name: "Group call",
creatorUserId: "@alice:example.org", creatorUserId: "@alice:example.org",
}); },
room.client,
);
} }
public static get(room: Room): MockedCall | null { public static get(room: Room): MockedCall | null {
@ -61,12 +65,10 @@ export class MockedCall extends Call {
} }
// No action needed for any of the following methods since this is just a mock // No action needed for any of the following methods since this is just a mock
public async clean(): Promise<void> {} protected getDevices(): string[] { return []; }
protected async setDevices(): Promise<void> { }
// Public to allow spying // Public to allow spying
public async performConnection( public async performConnection(): Promise<void> {}
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {}
public async performDisconnection(): Promise<void> {} public async performDisconnection(): Promise<void> {}
public destroy() { public destroy() {
@ -77,7 +79,7 @@ export class MockedCall extends Call {
room: this.room.roomId, room: this.room.roomId,
user: "@alice:example.org", user: "@alice:example.org",
content: { terminated: true }, content: { terminated: true },
skey: this.id, skey: this.widget.id,
})]); })]);
super.destroy(); super.destroy();

View file

@ -99,7 +99,7 @@ export function createTestClient(): MatrixClient {
}, },
getPushActionsForEvent: jest.fn(), getPushActionsForEvent: jest.fn(),
getRoom: jest.fn().mockImplementation(mkStubRoom), getRoom: jest.fn().mockImplementation(roomId => mkStubRoom(roomId, "My room", client)),
getRooms: jest.fn().mockReturnValue([]), getRooms: jest.fn().mockReturnValue([]),
getVisibleRooms: jest.fn().mockReturnValue([]), getVisibleRooms: jest.fn().mockReturnValue([]),
loginFlows: jest.fn(), loginFlows: jest.fn(),
@ -335,8 +335,10 @@ export function mkRoomMember(roomId: string, userId: string, membership = "join"
name: userId, name: userId,
rawDisplayName: userId, rawDisplayName: userId,
roomId, roomId,
events: {},
getAvatarUrl: () => {}, getAvatarUrl: () => {},
getMxcAvatarUrl: () => {}, getMxcAvatarUrl: () => {},
getDMInviter: () => {},
} as unknown as RoomMember; } as unknown as RoomMember;
} }

View file

@ -1,673 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { IMyDevice, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import {
CALL_MEMBER_STATE_EVENT_TYPE,
CALL_STATE_EVENT_TYPE,
fixStuckDevices,
getGroupCall,
removeOurDevice,
STUCK_DEVICE_TIMEOUT_MS,
useConnectedMembers,
} from "../../src/utils/GroupCallUtils";
import { createTestClient, mkEvent } from "../test-utils";
[
{
callStateEventType: CALL_STATE_EVENT_TYPE.name,
callMemberStateEventType: CALL_MEMBER_STATE_EVENT_TYPE.name,
},
{
callStateEventType: CALL_STATE_EVENT_TYPE.altName,
callMemberStateEventType: CALL_MEMBER_STATE_EVENT_TYPE.altName,
},
].forEach(({ callStateEventType, callMemberStateEventType }) => {
describe(`GroupCallUtils (${callStateEventType}, ${callMemberStateEventType})`, () => {
const roomId = "!room:example.com";
let client: MatrixClient;
let callEvent: MatrixEvent;
const callId = "test call";
const callId2 = "test call 2";
const userId1 = "@user1:example.com";
const now = 1654616071686;
const setUpNonCallStateEvent = () => {
callEvent = mkEvent({
room: roomId,
user: userId1,
event: true,
type: "test",
skey: userId1,
content: {},
});
};
const setUpEmptyStateKeyCallEvent = () => {
callEvent = mkEvent({
room: roomId,
user: userId1,
event: true,
type: callStateEventType,
skey: "",
content: {},
});
};
const setUpValidCallEvent = () => {
callEvent = mkEvent({
room: roomId,
user: userId1,
event: true,
type: callStateEventType,
skey: callId,
content: {},
});
};
beforeEach(() => {
client = createTestClient();
});
describe("getGroupCall", () => {
describe("for a non-existing room", () => {
beforeEach(() => {
mocked(client.getRoom).mockReturnValue(null);
});
it("should return null", () => {
expect(getGroupCall(client, roomId)).toBeUndefined();
});
});
describe("for an existing room", () => {
let room: Room;
beforeEach(() => {
room = new Room(roomId, client, client.getUserId());
mocked(client.getRoom).mockImplementation((rid: string) => {
return rid === roomId
? room
: null;
});
});
it("should return null if no 'call' state event exist", () => {
expect(getGroupCall(client, roomId)).toBeUndefined();
});
describe("with call state events", () => {
let callEvent1: MatrixEvent;
let callEvent2: MatrixEvent;
let callEvent3: MatrixEvent;
beforeEach(() => {
callEvent1 = mkEvent({
room: roomId,
user: client.getUserId(),
event: true,
type: callStateEventType,
content: {},
ts: 150,
skey: "call1",
});
room.getLiveTimeline().addEvent(callEvent1, {
toStartOfTimeline: false,
});
callEvent2 = mkEvent({
room: roomId,
user: client.getUserId(),
event: true,
type: callStateEventType,
content: {},
ts: 100,
skey: "call2",
});
room.getLiveTimeline().addEvent(callEvent2, {
toStartOfTimeline: false,
});
// terminated call - should never be returned
callEvent3 = mkEvent({
room: roomId,
user: client.getUserId(),
event: true,
type: callStateEventType,
content: {
["m.terminated"]: "time's up",
},
ts: 500,
skey: "call3",
});
room.getLiveTimeline().addEvent(callEvent3, {
toStartOfTimeline: false,
});
});
it("should return the newest call state event (1)", () => {
expect(getGroupCall(client, roomId)).toBe(callEvent1);
});
it("should return the newest call state event (2)", () => {
callEvent2.getTs = () => 200;
expect(getGroupCall(client, roomId)).toBe(callEvent2);
});
});
});
});
describe("useConnectedMembers", () => {
describe("for a non-call event", () => {
beforeEach(() => {
setUpNonCallStateEvent();
});
it("should return an empty list", () => {
expect(useConnectedMembers(client, callEvent)).toEqual([]);
});
});
describe("for an empty state key", () => {
beforeEach(() => {
setUpEmptyStateKeyCallEvent();
});
it("should return an empty list", () => {
expect(useConnectedMembers(client, callEvent)).toEqual([]);
});
});
describe("for a valid call state event", () => {
beforeEach(() => {
setUpValidCallEvent();
});
describe("and a non-existing room", () => {
beforeEach(() => {
mocked(client.getRoom).mockReturnValue(null);
});
it("should return an empty list", () => {
expect(useConnectedMembers(client, callEvent)).toEqual([]);
});
});
describe("and an existing room", () => {
let room: Room;
beforeEach(() => {
room = new Room(roomId, client, client.getUserId());
mocked(client.getRoom).mockImplementation((rid: string) => {
return rid === roomId
? room
: null;
});
});
it("should return an empty list if no call member state events exist", () => {
expect(useConnectedMembers(client, callEvent)).toEqual([]);
});
describe("and some call member state events", () => {
const userId2 = "@user2:example.com";
const userId3 = "@user3:example.com";
const userId4 = "@user4:example.com";
let expectedEvent1: MatrixEvent;
let expectedEvent2: MatrixEvent;
beforeEach(() => {
jest.useFakeTimers()
.setSystemTime(now);
expectedEvent1 = mkEvent({
event: true,
room: roomId,
user: userId1,
skey: userId1,
type: callMemberStateEventType,
content: {
["m.expires_ts"]: now + 100,
["m.calls"]: [
{
["m.call_id"]: callId2,
},
{
["m.call_id"]: callId,
},
],
},
});
room.getLiveTimeline().addEvent(expectedEvent1, { toStartOfTimeline: false });
expectedEvent2 = mkEvent({
event: true,
room: roomId,
user: userId2,
skey: userId2,
type: callMemberStateEventType,
content: {
["m.expires_ts"]: now + 100,
["m.calls"]: [
{
["m.call_id"]: callId,
},
],
},
});
room.getLiveTimeline().addEvent(expectedEvent2, { toStartOfTimeline: false });
// expired event
const event3 = mkEvent({
event: true,
room: roomId,
user: userId3,
skey: userId3,
type: callMemberStateEventType,
content: {
["m.expires_ts"]: now - 100,
["m.calls"]: [
{
["m.call_id"]: callId,
},
],
},
});
room.getLiveTimeline().addEvent(event3, { toStartOfTimeline: false });
// other call
const event4 = mkEvent({
event: true,
room: roomId,
user: userId4,
skey: userId4,
type: callMemberStateEventType,
content: {
["m.expires_ts"]: now + 100,
["m.calls"]: [
{
["m.call_id"]: callId2,
},
],
},
});
room.getLiveTimeline().addEvent(event4, { toStartOfTimeline: false });
// empty calls
const event5 = mkEvent({
event: true,
room: roomId,
user: userId4,
skey: userId4,
type: callMemberStateEventType,
content: {
["m.expires_ts"]: now + 100,
["m.calls"]: [],
},
});
room.getLiveTimeline().addEvent(event5, { toStartOfTimeline: false });
// no calls prop
const event6 = mkEvent({
event: true,
room: roomId,
user: userId4,
skey: userId4,
type: callMemberStateEventType,
content: {
["m.expires_ts"]: now + 100,
},
});
room.getLiveTimeline().addEvent(event6, { toStartOfTimeline: false });
});
it("should return the expected call member events", () => {
const callMemberEvents = useConnectedMembers(client, callEvent);
expect(callMemberEvents).toHaveLength(2);
expect(callMemberEvents).toContain(expectedEvent1);
expect(callMemberEvents).toContain(expectedEvent2);
});
});
});
});
});
describe("removeOurDevice", () => {
describe("for a non-call event", () => {
beforeEach(() => {
setUpNonCallStateEvent();
});
it("should not update the state", () => {
removeOurDevice(client, callEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("for an empty state key", () => {
beforeEach(() => {
setUpEmptyStateKeyCallEvent();
});
it("should not update the state", () => {
removeOurDevice(client, callEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("for a valid call state event", () => {
beforeEach(() => {
setUpValidCallEvent();
});
describe("and a non-existing room", () => {
beforeEach(() => {
mocked(client.getRoom).mockReturnValue(null);
});
it("should not update the state", () => {
removeOurDevice(client, callEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("and an existing room", () => {
let room: Room;
beforeEach(() => {
room = new Room(roomId, client, client.getUserId());
room.getLiveTimeline().addEvent(callEvent, { toStartOfTimeline: false });
mocked(client.getRoom).mockImplementation((rid: string) => {
return rid === roomId
? room
: null;
});
});
it("should not update the state if no call member event exists", () => {
removeOurDevice(client, callEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
describe("and a call member state event", () => {
beforeEach(() => {
jest.useFakeTimers()
.setSystemTime(now);
const callMemberEvent = mkEvent({
event: true,
room: roomId,
user: client.getUserId(),
skey: client.getUserId(),
type: callMemberStateEventType,
content: {
["m.expires_ts"]: now - 100,
["m.calls"]: [
{
["m.call_id"]: callId,
["m.devices"]: [
// device to be removed
{ "m.device_id": client.getDeviceId() },
{ "m.device_id": "device 2" },
],
},
{
// no device list
["m.call_id"]: callId,
},
{
// other call
["m.call_id"]: callId2,
["m.devices"]: [
{ "m.device_id": client.getDeviceId() },
],
},
],
},
});
room.getLiveTimeline().addEvent(callMemberEvent, { toStartOfTimeline: false });
});
it("should remove the device from the call", async () => {
await removeOurDevice(client, callEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(client.sendStateEvent).toHaveBeenCalledWith(
roomId,
CALL_MEMBER_STATE_EVENT_TYPE.name,
{
["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
["m.calls"]: [
{
["m.call_id"]: callId,
["m.devices"]: [
{ "m.device_id": "device 2" },
],
},
{
// no device list
["m.call_id"]: callId,
},
{
// other call
["m.call_id"]: callId2,
["m.devices"]: [
{ "m.device_id": client.getDeviceId() },
],
},
],
},
client.getUserId(),
);
});
});
});
});
});
describe("fixStuckDevices", () => {
let thisDevice: IMyDevice;
let otherDevice: IMyDevice;
let noLastSeenTsDevice: IMyDevice;
let stuckDevice: IMyDevice;
beforeEach(() => {
jest.useFakeTimers()
.setSystemTime(now);
thisDevice = { device_id: "ABCDEFGHI", last_seen_ts: now - STUCK_DEVICE_TIMEOUT_MS - 100 };
otherDevice = { device_id: "ABCDEFGHJ", last_seen_ts: now };
noLastSeenTsDevice = { device_id: "ABCDEFGHK" };
stuckDevice = { device_id: "ABCDEFGHL", last_seen_ts: now - STUCK_DEVICE_TIMEOUT_MS - 100 };
mocked(client.getDeviceId).mockReturnValue(thisDevice.device_id);
mocked(client.getDevices).mockResolvedValue({
devices: [
thisDevice,
otherDevice,
noLastSeenTsDevice,
stuckDevice,
],
});
});
describe("for a non-call event", () => {
beforeEach(() => {
setUpNonCallStateEvent();
});
it("should not update the state", () => {
fixStuckDevices(client, callEvent, true);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("for an empty state key", () => {
beforeEach(() => {
setUpEmptyStateKeyCallEvent();
});
it("should not update the state", () => {
fixStuckDevices(client, callEvent, true);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("for a valid call state event", () => {
beforeEach(() => {
setUpValidCallEvent();
});
describe("and a non-existing room", () => {
beforeEach(() => {
mocked(client.getRoom).mockReturnValue(null);
});
it("should not update the state", () => {
fixStuckDevices(client, callEvent, true);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("and an existing room", () => {
let room: Room;
beforeEach(() => {
room = new Room(roomId, client, client.getUserId());
room.getLiveTimeline().addEvent(callEvent, { toStartOfTimeline: false });
mocked(client.getRoom).mockImplementation((rid: string) => {
return rid === roomId
? room
: null;
});
});
it("should not update the state if no call member event exists", () => {
fixStuckDevices(client, callEvent, true);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
describe("and a call member state event", () => {
beforeEach(() => {
const callMemberEvent = mkEvent({
event: true,
room: roomId,
user: client.getUserId(),
skey: client.getUserId(),
type: callMemberStateEventType,
content: {
["m.expires_ts"]: now - 100,
["m.calls"]: [
{
["m.call_id"]: callId,
["m.devices"]: [
{ "m.device_id": thisDevice.device_id },
{ "m.device_id": otherDevice.device_id },
{ "m.device_id": noLastSeenTsDevice.device_id },
{ "m.device_id": stuckDevice.device_id },
],
},
{
// no device list
["m.call_id"]: callId,
},
{
// other call
["m.call_id"]: callId2,
["m.devices"]: [
{ "m.device_id": stuckDevice.device_id },
],
},
],
},
});
room.getLiveTimeline().addEvent(callMemberEvent, { toStartOfTimeline: false });
});
it("should remove stuck devices from the call, except this device", async () => {
await fixStuckDevices(client, callEvent, false);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(client.sendStateEvent).toHaveBeenCalledWith(
roomId,
CALL_MEMBER_STATE_EVENT_TYPE.name,
{
["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
["m.calls"]: [
{
["m.call_id"]: callId,
["m.devices"]: [
{ "m.device_id": thisDevice.device_id },
{ "m.device_id": otherDevice.device_id },
{ "m.device_id": noLastSeenTsDevice.device_id },
],
},
{
// no device list
["m.call_id"]: callId,
},
{
// other call
["m.call_id"]: callId2,
["m.devices"]: [
{ "m.device_id": stuckDevice.device_id },
],
},
],
},
client.getUserId(),
);
});
it("should remove stuck devices from the call, including this device", async () => {
await fixStuckDevices(client, callEvent, true);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(client.sendStateEvent).toHaveBeenCalledWith(
roomId,
CALL_MEMBER_STATE_EVENT_TYPE.name,
{
["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
["m.calls"]: [
{
["m.call_id"]: callId,
["m.devices"]: [
{ "m.device_id": otherDevice.device_id },
{ "m.device_id": noLastSeenTsDevice.device_id },
],
},
{
// no device list
["m.call_id"]: callId,
},
{
// other call
["m.call_id"]: callId2,
["m.devices"]: [
{ "m.device_id": stuckDevice.device_id },
],
},
],
},
client.getUserId(),
);
});
});
});
});
});
});
});