diff --git a/res/css/views/rooms/_RoomPreviewCard.pcss b/res/css/views/rooms/_RoomPreviewCard.pcss index 255a9633b2f..5f93f735902 100644 --- a/res/css/views/rooms/_RoomPreviewCard.pcss +++ b/res/css/views/rooms/_RoomPreviewCard.pcss @@ -64,12 +64,6 @@ limitations under the License. color: $secondary-content; } } - - /* XXX Remove this when video rooms leave beta */ - .mx_BetaCard_betaPill { - margin-inline-start: auto; - align-self: start; - } } .mx_RoomPreviewCard_avatar { @@ -104,6 +98,13 @@ limitations under the License. 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 { diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 395a97d3d42..837d3050f35 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -116,6 +116,9 @@ export interface IConfigOptions { voip?: { obey_asserted_identity?: boolean; // MSC3086 }; + element_call: { + url: string; + }; logout_redirect_url?: string; diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 93b89a92eac..d466a050741 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -30,6 +30,9 @@ export const DEFAULTS: IConfigOptions = { jitsi: { preferred_domain: "meet.element.io", }, + element_call: { + url: "https://call.element.io", + }, // @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 @@ -79,14 +82,8 @@ export default class SdkConfig { return val === undefined ? undefined : null; } - public static put(cfg: IConfigOptions) { - const defaultKeys = Object.keys(DEFAULTS); - for (let i = 0; i < defaultKeys.length; ++i) { - if (cfg[defaultKeys[i]] === undefined) { - cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]]; - } - } - SdkConfig.setInstance(cfg); + public static put(cfg: Partial) { + SdkConfig.setInstance({ ...DEFAULTS, ...cfg }); } /** @@ -97,9 +94,7 @@ export default class SdkConfig { } public static add(cfg: Partial) { - const liveConfig = SdkConfig.get(); - const newConfig = Object.assign({}, liveConfig, cfg); - SdkConfig.put(newConfig); + SdkConfig.put({ ...SdkConfig.get(), ...cfg }); } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 3ce235116b6..780619c7e40 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -119,6 +119,7 @@ import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; import { LargeLoader } from './LargeLoader'; +import { isVideoRoom } from '../../utils/video-rooms'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -514,7 +515,7 @@ export class RoomView extends React.Component { }; private getMainSplitContentType = (room: Room) => { - if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) { + if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) { return MainSplitContentType.Video; } if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { @@ -2015,8 +2016,8 @@ export class RoomView extends React.Component { const myMembership = this.state.room.getMyMembership(); if ( - this.state.room.isElementVideoRoom() && - !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") + isVideoRoom(this.state.room) + && !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join") ) { return
diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index a1ee2d5094b..b8c73ee0df3 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -108,8 +108,9 @@ const SpaceLandingAddButton = ({ space }) => { const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms); const canCreateSpace = shouldShowComponent(UIComponent.CreateSpaces); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); + const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms"); - let contextMenu; + let contextMenu: JSX.Element | null = null; if (menuDisplayed) { const rect = handle.current.getBoundingClientRect(); contextMenu = { e.stopPropagation(); closeMenu(); - if (await showCreateNewRoom(space, RoomType.ElementVideo)) { + if ( + await showCreateNewRoom( + space, + elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, + ) + ) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }} diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index 3dfa9e4350d..b9923d92782 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -105,10 +105,14 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { } 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; - if (room.canInvite(cli.getUserId()) && !isDm) { + if (room.canInvite(cli.getUserId()!) && !isDm) { const onInviteClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index ce28ae32950..3f743d4e692 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -35,6 +35,7 @@ import { ButtonEvent } from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { BetaPill } from "../beta/BetaCard"; import SettingsStore from "../../../settings/SettingsStore"; +import { useFeatureEnabled } from "../../../hooks/useSettings"; import { Action } from "../../../dispatcher/actions"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; @@ -48,9 +49,9 @@ interface IProps extends IContextMenuProps { const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => { 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)) { const onInviteClick = (ev: ButtonEvent) => { ev.preventDefault(); @@ -71,8 +72,8 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = ); } - let settingsOption; - let leaveOption; + let settingsOption: JSX.Element | null = null; + let leaveOption: JSX.Element | null = null; if (shouldShowSpaceSettings(space)) { const onSettingsClick = (ev: ButtonEvent) => { 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")) { const onViewTimelineClick = (ev: ButtonEvent) => { 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 canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms); - const canAddVideoRooms = canAddRooms && SettingsStore.getValue("feature_video_rooms"); + const canAddVideoRooms = canAddRooms && videoRoomsEnabled; const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces); - let newRoomSection: JSX.Element; + let newRoomSection: JSX.Element | null = null; if (canAddRooms || canAddSubSpaces) { const onNewRoomClick = (ev: ButtonEvent) => { ev.preventDefault(); @@ -154,7 +158,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = ev.preventDefault(); ev.stopPropagation(); - showCreateNewRoom(space, RoomType.ElementVideo); + showCreateNewRoom(space, elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo); onFinished(); }; @@ -266,4 +270,3 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) = }; export default SpaceContextMenu; - diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 64640c11635..b03a29a8fd6 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -146,10 +146,9 @@ const WidgetContextMenu: React.FC = ({ />; } - let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId]; - if (isAllowedWidget === undefined) { - isAllowedWidget = app.creatorUserId === cli.getUserId(); - } + const isAllowedWidget = + (app.eventId !== undefined && (SettingsStore.getValue("allowedWidgets", roomId)[app.eventId] ?? false)) + || app.creatorUserId === cli.getUserId(); const isLocalWidget = WidgetType.JITSI.matches(app.type); let revokeButton; @@ -157,7 +156,7 @@ const WidgetContextMenu: React.FC = ({ const onRevokeClick = () => { logger.info("Revoking permission for widget to load: " + app.eventId); const current = SettingsStore.getValue("allowedWidgets", roomId); - current[app.eventId] = false; + if (app.eventId !== undefined) current[app.eventId] = false; const level = SettingsStore.firstSupportedLevel("allowedWidgets"); SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => { logger.error(err); diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 84564c22cfb..faf37bd290a 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -78,7 +78,7 @@ export default class ModalWidgetDialog extends React.PureComponent { if (!props.room) return true; // user widgets always have permissions const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId); - if (currentlyAllowedWidgets[props.app.eventId] === undefined) { - return props.userId === props.creatorUserId; - } - return !!currentlyAllowedWidgets[props.app.eventId]; + const allowed = props.app.eventId !== undefined && (currentlyAllowedWidgets[props.app.eventId] ?? false); + return allowed || props.userId === props.creatorUserId; }; private onUserLeftRoom() { @@ -442,7 +440,7 @@ export default class AppTile extends React.Component { const roomId = this.props.room?.roomId; logger.info("Granting permission for widget to load: " + this.props.app.eventId); 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"); SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => { this.setState({ hasPermissionToLoad: true }); diff --git a/src/components/views/elements/PersistentApp.tsx b/src/components/views/elements/PersistentApp.tsx index f0ad74f09e4..8a7f21e4fe2 100644 --- a/src/components/views/elements/PersistentApp.tsx +++ b/src/components/views/elements/PersistentApp.tsx @@ -20,7 +20,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import WidgetUtils from '../../../utils/WidgetUtils'; import AppTile from "./AppTile"; -import { IApp } from '../../../stores/WidgetStore'; +import WidgetStore from '../../../stores/WidgetStore'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; interface IProps { @@ -37,44 +37,27 @@ export default class PersistentApp extends React.Component { constructor(props: IProps, context: ContextType) { super(props, context); - this.room = context.getRoom(this.props.persistentRoomId); + this.room = context.getRoom(this.props.persistentRoomId)!; } - private get app(): IApp | null { - // get the widget data - const appEvent = WidgetUtils.getRoomWidgets(this.room).find(ev => - 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 ; - } - return null; + public render(): JSX.Element | null { + const app = WidgetStore.instance.get(this.props.persistentWidgetId, this.props.persistentRoomId); + if (!app) return null; + + return ; } } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index f0ff1d9d447..d2429f1a7be 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -262,7 +262,11 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { const isRoomEncrypted = useIsEncrypted(cli, room); const roomContext = useContext(RoomContext); 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 header = diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 22a0a8043a5..d64d3d7e322 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -47,6 +47,7 @@ import RoomLiveShareWarning from '../beacon/RoomLiveShareWarning'; import { BetaPill } from "../beta/BetaCard"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; export interface ISearchInfo { searchTerm: string; @@ -312,7 +313,7 @@ export default class RoomHeader extends React.Component { const e2eIcon = 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({ action: Action.ViewUserSettings, initialTabId: UserTab.Labs, diff --git a/src/components/views/rooms/RoomInfoLine.tsx b/src/components/views/rooms/RoomInfoLine.tsx index 09214043d63..6ebea854a1d 100644 --- a/src/components/views/rooms/RoomInfoLine.tsx +++ b/src/components/views/rooms/RoomInfoLine.tsx @@ -23,6 +23,7 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; import { useRoomState } from "../../../hooks/useRoomState"; +import { useFeatureEnabled } from "../../../hooks/useSettings"; import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers"; import AccessibleButton from "../elements/AccessibleButton"; @@ -44,9 +45,12 @@ const RoomInfoLine: FC = ({ room }) => { const membership = useMyRoomMembership(room); const memberCount = useRoomMemberCount(room); + const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms"); + const isVideoRoom = room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom()); + let iconClass: string; let roomType: string; - if (room.isElementVideoRoom()) { + if (isVideoRoom) { iconClass = "mx_RoomInfoLine_video"; roomType = _t("Video room"); } else if (joinRule === JoinRule.Public) { diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 1353f8f5c8a..0d6756a7e17 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -32,6 +32,7 @@ import { _t, _td } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import PosthogTrackers from "../../../PosthogTrackers"; import SettingsStore from "../../../settings/SettingsStore"; +import { useFeatureEnabled } from "../../../hooks/useSettings"; import { UIComponent } from "../../../settings/UIFeature"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { ITagMap } from "../../../stores/room-list/algorithms/models"; @@ -200,8 +201,10 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => { }); 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) { const canAddRooms = activeSpace.currentState.maySendStateEvent(EventType.SpaceChild, MatrixClientPeg.get().getUserId()); @@ -239,7 +242,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => { tooltip={canAddRooms ? undefined : _t("You do not have permissions to create new rooms in this space")} /> - { SettingsStore.getValue("feature_video_rooms") && ( + { videoRoomsEnabled && ( { e.preventDefault(); e.stopPropagation(); closeMenu(); - showCreateNewRoom(activeSpace, RoomType.ElementVideo); + showCreateNewRoom( + activeSpace, + elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, + ); }} disabled={!canAddRooms} tooltip={canAddRooms ? undefined @@ -287,7 +293,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => { PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e); }} /> - { SettingsStore.getValue("feature_video_rooms") && ( + { videoRoomsEnabled && ( { closeMenu(); defaultDispatcher.dispatch({ action: "view_create_room", - type: RoomType.ElementVideo, + type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, }); }} > @@ -319,7 +325,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => { ; } - let contextMenu: JSX.Element; + let contextMenu: JSX.Element | null = null; if (menuDisplayed) { contextMenu = { contextMenuContent } diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index 3f8986bf67e..f783e628f30 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -127,6 +127,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { return SpaceStore.instance.allRoomsInHome; }); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); + const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms"); const pendingActions = usePendingActions(); const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home; @@ -211,7 +212,10 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - showCreateNewRoom(activeSpace, RoomType.ElementVideo); + showCreateNewRoom( + activeSpace, + elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, + ); closePlusMenu(); }} > @@ -310,7 +314,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { e.stopPropagation(); defaultDispatcher.dispatch({ action: "view_create_room", - type: RoomType.ElementVideo, + type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo, }); closePlusMenu(); }} diff --git a/src/components/views/rooms/RoomPreviewCard.tsx b/src/components/views/rooms/RoomPreviewCard.tsx index b2d9710a7ce..8b105af6309 100644 --- a/src/components/views/rooms/RoomPreviewCard.tsx +++ b/src/components/views/rooms/RoomPreviewCard.tsx @@ -51,6 +51,8 @@ interface IProps { const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButtonClicked }) => { const cli = useContext(MatrixClientContext); 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); useDispatcher(defaultDispatcher, payload => { if (payload.action === Action.JoinRoomError && payload.roomId === room.roomId) { @@ -69,7 +71,7 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton initialTabId: UserTab.Labs, }); - let inviterSection: JSX.Element; + let inviterSection: JSX.Element | null = null; let joinButtons: JSX.Element; if (myMembership === "join") { joinButtons = ( @@ -86,10 +88,11 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton ); } else if (myMembership === "invite") { - const inviteSender = room.getMember(cli.getUserId())?.events.member?.getSender(); - const inviter = inviteSender && room.getMember(inviteSender); + const inviteSender = room.getMember(cli.getUserId()!)?.events.member?.getSender(); if (inviteSender) { + const inviter = room.getMember(inviteSender); + inviterSection =
@@ -102,10 +105,6 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton { inviteSender }
: null }
- { room.isElementVideoRoom() - ? - : null - }
; } @@ -152,10 +151,11 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton } let avatarRow: JSX.Element; - if (room.isElementVideoRoom()) { + if (isVideoRoom) { avatarRow = <>
+ ; } else if (room.isSpaceRoom()) { avatarRow = ; @@ -163,12 +163,12 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton avatarRow = ; } - let notice: string; + let notice: string | null = null; if (cannotJoin) { notice = _t("To view %(roomName)s, you need an invite", { roomName: room.name, }); - } else if (room.isElementVideoRoom() && !videoRoomsEnabled) { + } else if (isVideoRoom && !videoRoomsEnabled) { notice = myMembership === "join" ? _t("To view, please enable video rooms in Labs first") : _t("To join, please enable video rooms in Labs first"); diff --git a/src/createRoom.ts b/src/createRoom.ts index df7361c8e52..c1bcc122ca1 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -37,7 +37,7 @@ import { getAddressType } from "./UserAddress"; import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types"; import SpaceStore from "./stores/spaces/SpaceStore"; import { makeSpaceParentEvent } from "./utils/space"; -import { JitsiCall } from "./models/Call"; +import { JitsiCall, ElementCall } from "./models/Call"; import { Action } from "./dispatcher/actions"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import Spinner from "./components/views/elements/Spinner"; @@ -67,6 +67,17 @@ export interface IOpts { 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. * @@ -131,23 +142,29 @@ export default async function createRoom(opts: IOpts): Promise { if (opts.roomType === RoomType.ElementVideo) { createOpts.power_level_content_override = { events: { + ...DEFAULT_EVENT_POWER_LEVELS, // Allow all users to send call membership updates [JitsiCall.MEMBER_EVENT_TYPE]: 0, // Make widgets immutable, even to admins "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: { // 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 { } }).then(async () => { 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); // 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); } }).then(function() { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e22e6b74993..0aab906f3de 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -909,6 +909,7 @@ "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", "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)", "Favourite Messages (under active development)": "Favourite Messages (under active development)", "Voice broadcast (under active development)": "Voice broadcast (under active development)", diff --git a/src/models/Call.ts b/src/models/Call.ts index 13451ab782c..9b11261e85d 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -16,18 +16,23 @@ limitations under the License. import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { logger } from "matrix-js-sdk/src/logger"; +import { randomString } from "matrix-js-sdk/src/randomstring"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { RoomEvent } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; -import { 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 { 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 { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { ClientWidgetApi } from "matrix-widget-api"; import type { IApp } from "../stores/WidgetStore"; +import SdkConfig from "../SdkConfig"; +import SettingsStore from "../settings/SettingsStore"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; @@ -40,15 +45,19 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidge const TIMEOUT_MS = 16000; // Waits until an event is emitted satisfying the given predicate -const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => { - let listener: (...args) => void; +const waitForEvent = async ( + emitter: EventEmitter, + event: string, + pred: (...args: any[]) => boolean = () => true, +): Promise => { + let listener: (...args: any[]) => void; const wait = new Promise(resolve => { listener = (...args) => { if (pred(...args)) resolve(); }; emitter.on(event, listener); }); const timedOut = await timeout(wait, false, TIMEOUT_MS) === false; - emitter.off(event, listener); + emitter.off(event, listener!); if (timedOut) throw new Error("Timed out"); }; @@ -74,18 +83,17 @@ interface CallEventHandlerMap { [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. */ export abstract class Call extends TypedEventEmitter { 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; /** @@ -130,6 +138,7 @@ export abstract class Call extends TypedEventEmitter; + + /** + * 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 { + if (this.room.getMyMembership() !== "join") return; + + const devices = fn(this.getDevices(this.client.getUserId()!)); + if (devices) { + await this.setDevices(devices); + } } /** * Performs a routine check of the call's associated room state, cleaning up * any data left over from an unclean disconnection. */ - public abstract clean(): Promise; + public async clean(): Promise { + const now = Date.now(); + const { devices: myDevices } = await this.client.getDevices(); + const deviceMap = new Map(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 { + await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()))); + } + + protected async removeOurDevice(): Promise { + 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. - * @param {MediaDeviceInfo | null} audioDevice The audio input to use, or + * @param {MediaDeviceInfo | null} audioInput The audio input to use, or * 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. */ protected abstract performConnection( @@ -219,6 +284,8 @@ export abstract class Call extends TypedEventEmitter { + 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 WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel); - return jitsiWidget ? new JitsiCall(jitsiWidget, room.client) : null; + // Only supported in video rooms + if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) { + const apps = WidgetStore.instance.getApps(room.roomId); + // The isVideoChannel field differentiates rich Jitsi calls from bare Jitsi widgets + const jitsiWidget = apps.find(app => WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel); + if (jitsiWidget) return new JitsiCall(jitsiWidget, room.client); + } + + return null; } public static async create(room: Room): Promise { @@ -293,15 +379,15 @@ export class JitsiCall extends Call { for (const e of this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE)) { const member = this.room.getMember(e.getStateKey()!); const content = e.getContent(); - let devices = Array.isArray(content.devices) ? content.devices : []; 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 if (!this.connected && member?.userId === this.client.getUserId()) { devices = devices.filter(d => d !== this.client.getDeviceId()); } - // Must have a connected device, be unexpired, and still be joined to the room - if (devices.length && expiresAt > now && member?.membership === "join") { + // 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; } @@ -316,59 +402,22 @@ export class JitsiCall extends Call { } } - // Helper method that updates our member state with the devices returned by - // the given function. If it returns null, the update is skipped. - private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise { - if (this.room.getMyMembership() !== "join") return; - - const devicesState = this.room.currentState.getStateEvents( - JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!, - ); - const devices = devicesState?.getContent().devices ?? []; - const newDevices = fn(devices); - - if (newDevices) { - const content: JitsiCallMemberContent = { - devices: newDevices, - expires_ts: Date.now() + JitsiCall.STUCK_DEVICE_TIMEOUT_MS, - }; - - await this.client.sendStateEvent( - this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!, - ); - } - } - - private async addOurDevice(): Promise { - await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()))); - } - - private async removeOurDevice(): Promise { - await this.updateDevices(devices => { - const devicesSet = new Set(devices); - devicesSet.delete(this.client.getDeviceId()); - return Array.from(devicesSet); - }); + protected getDevices(userId: string): string[] { + const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, userId); + const content = event?.getContent(); + const expiresAt = typeof content?.expires_ts === "number" ? content.expires_ts : -Infinity; + return expiresAt > Date.now() && Array.isArray(content?.devices) ? content.devices : []; } - public async clean(): Promise { - const now = Date.now(); - const { devices: myDevices } = await this.client.getDevices(); - const deviceMap = new Map(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; - }); + protected async setDevices(devices: string[]): Promise { + const content: JitsiCallMemberContent = { + devices, + expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS, + }; - // Skip the update if the devices are unchanged - return newDevices.length === devices.length ? null : newDevices; - }); + await this.client.sendStateEvent( + this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!, + ); } protected async performConnection( @@ -433,8 +482,6 @@ export class JitsiCall extends Call { ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); - this.room.on(RoomEvent.MyMembership, this.onMyMembership); - window.addEventListener("beforeunload", this.beforeUnload); } protected async performDisconnection(): Promise { @@ -459,14 +506,12 @@ export class JitsiCall extends Call { this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock); - this.room.off(RoomEvent.MyMembership, this.onMyMembership); - window.removeEventListener("beforeunload", this.beforeUnload); super.setDisconnected(); } public destroy() { - this.room.off(RoomStateEvent.Update, this.updateParticipants); + this.room.off(RoomStateEvent.Update, this.onRoomState); this.on(CallEvent.ConnectionState, this.onConnectionState); if (this.participantsExpirationTimer !== null) { clearTimeout(this.participantsExpirationTimer); @@ -483,8 +528,8 @@ export class JitsiCall extends Call { private onRoomState = () => this.updateParticipants(); private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => { - if (state === ConnectionState.Connected && prevState === ConnectionState.Connecting) { - this.updateParticipants(); + if (state === ConnectionState.Connected && !isConnected(prevState)) { + this.updateParticipants(); // Local echo // Tell others that we're connected, by adding our device to room state await this.addOurDevice(); @@ -492,12 +537,14 @@ export class JitsiCall extends Call { this.resendDevicesTimer = setInterval(async () => { logger.log(`Resending video member event for ${this.roomId}`); await this.addOurDevice(); - }, (JitsiCall.STUCK_DEVICE_TIMEOUT_MS * 3) / 4); + }, (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4); } else if (state === ConnectionState.Disconnected && isConnected(prevState)) { - this.updateParticipants(); + this.updateParticipants(); // Local echo - clearInterval(this.resendDevicesTimer); - this.resendDevicesTimer = null; + if (this.resendDevicesTimer !== null) { + clearInterval(this.resendDevicesTimer); + this.resendDevicesTimer = null; + } // Tell others that we're disconnected, by removing our device from room state await this.removeOurDevice(); } @@ -514,12 +561,6 @@ export class JitsiCall extends Call { 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) => { // If we're already in the middle of a client-initiated disconnection, // ignore the event @@ -537,3 +578,239 @@ export class JitsiCall extends Call { 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 { + 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(); + 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(); + 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(); + 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 { + 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 { + 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 { + 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) => { + ev.preventDefault(); + await this.messaging!.transport.reply(ev.detail, {}); // ack + this.setDisconnected(); + }; +} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index cb661b21697..0567d10fb89 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -423,6 +423,14 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, 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": { isFeature: true, labsGroup: LabGroup.Messaging, diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 7337ffe8969..40b73d3114c 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -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 { RoomState } from "matrix-js-sdk/src/models/room-state"; import defaultDispatcher from "../dispatcher/dispatcher"; -import { ActionPayload } from "../dispatcher/payloads"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import WidgetStore from "./WidgetStore"; @@ -51,7 +50,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { super(defaultDispatcher); } - protected async onAction(payload: ActionPayload): Promise { + protected async onAction(): Promise { // nothing to do } diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index bdb95f1895a..93bf683b29a 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -30,11 +30,11 @@ import WidgetUtils from "../utils/WidgetUtils"; import { WidgetType } from "../widgets/WidgetType"; import { UPDATE_EVENT } from "./AsyncStore"; -interface IState {} +interface IState { } export interface IApp extends IWidget { roomId: string; - eventId: string; + eventId?: string; // not present on virtual widgets // eslint-disable-next-line camelcase avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765 } @@ -118,7 +118,12 @@ export default class WidgetStore extends AsyncStoreWithClient { // 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 => { if (app.roomId !== room.roomId) return; // skip - wrong room - this.widgetMap.delete(WidgetUtils.getWidgetUid(app)); + if (app.eventId === undefined) { + // virtual widget - keep it + roomInfo.widgets.push(app); + } else { + this.widgetMap.delete(WidgetUtils.getWidgetUid(app)); + } }); let edited = false; @@ -169,16 +174,38 @@ export default class WidgetStore extends AsyncStoreWithClient { 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" - return this.roomMap.get(roomId); - }; + return this.roomMap.get(roomId)!; + } public getApps(roomId: string): IApp[] { const roomInfo = this.getRoom(roomId); 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 { const roomInfo = this.getRoom(room.roomId); if (!roomInfo) return false; diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index 8dfced1b709..5e9451efa03 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -17,7 +17,7 @@ import { IWidgetApiRequest } from "matrix-widget-api"; 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", HangupCall = "im.vector.hangup", CallParticipants = "io.element.participants", diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 889a050ebfd..bbf166150c6 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -54,6 +54,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions"; import { ModalWidgetStore } from "../ModalWidgetStore"; +import { IApp } from "../WidgetStore"; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import { getCustomTheme } from "../../theme"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; @@ -69,7 +70,7 @@ import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; interface IAppTileProps { // Note: these are only the props we care about - app: IWidget; + app: IApp; room?: Room; // without a room it is a user widget userId: string; creatorUserId: string; @@ -155,6 +156,7 @@ export class StopGapWidget extends EventEmitter { private scalarToken: string; private roomId?: string; private kind: WidgetKind; + private readonly virtual: boolean; private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID constructor(private appTileProps: IAppTileProps) { @@ -171,6 +173,7 @@ export class StopGapWidget extends EventEmitter { this.mockWidget = new ElementWidget(app); this.roomId = appTileProps.room?.roomId; this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably + this.virtual = app.eventId === undefined; } private get eventListenerRoomId(): string { @@ -265,14 +268,18 @@ export class StopGapWidget extends EventEmitter { if (this.started) return; 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.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(`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 this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 9fbf5e4c564..f0b496f0b7e 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -40,6 +40,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { Direction } from "matrix-js-sdk/src/matrix"; +import SdkConfig from "../../SdkConfig"; import { iterableDiff, iterableIntersection } from "../../utils/iterables"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import Modal from "../../Modal"; @@ -80,6 +81,7 @@ export class StopGapWidgetDriver extends WidgetDriver { allowedCapabilities: Capability[], private forWidget: Widget, private forWidgetKind: WidgetKind, + virtual: boolean, private inRoomId?: string, ) { super(); @@ -102,6 +104,50 @@ export class StopGapWidgetDriver extends WidgetDriver { // 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. 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, + ); + } } } diff --git a/src/utils/GroupCallUtils.ts b/src/utils/GroupCallUtils.ts deleted file mode 100644 index 3af6a2b07a0..00000000000 --- a/src/utils/GroupCallUtils.ts +++ /dev/null @@ -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; -} - -interface MCallMemberContent { - ["m.expires_ts"]: number; - ["m.calls"]: Array; -} - -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, -): 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(); - - // 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 => { - 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(); - 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); -}; diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 60b71e5f655..728a41f6871 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -482,8 +482,8 @@ export default class WidgetUtils { appId: string, app: Partial, senderUserId: string, - roomId: string | null, - eventId: string, + roomId: string | undefined, + eventId: string | undefined, ): IApp { if (!senderUserId) { throw new Error("Widgets must be created by someone - provide a senderUserId"); diff --git a/src/utils/video-rooms.ts b/src/utils/video-rooms.ts new file mode 100644 index 00000000000..7177e0c5e0d --- /dev/null +++ b/src/utils/video-rooms.ts @@ -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()); diff --git a/test/components/views/rooms/RoomPreviewCard-test.tsx b/test/components/views/rooms/RoomPreviewCard-test.tsx new file mode 100644 index 00000000000..a453f70dcc7 --- /dev/null +++ b/test/components/views/rooms/RoomPreviewCard-test.tsx @@ -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; + 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 => { + render( + { }} + 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); + }); +}); diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts index b1873edf42a..7dbd4a2a41c 100644 --- a/test/createRoom-test.ts +++ b/test/createRoom-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { mocked } from "jest-mock"; +import { mocked, Mocked } from "jest-mock"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo"; 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 WidgetStore from "../src/stores/WidgetStore"; 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'; describe("createRoom", () => { mockPlatformPeg(); - let client: MatrixClient; + let client: Mocked; beforeEach(() => { stubClient(); - client = MatrixClientPeg.get(); + client = mocked(MatrixClientPeg.get()); }); afterEach(() => jest.clearAllMocks()); - it("sets up video rooms correctly", async () => { + it("sets up Jitsi video rooms correctly", async () => { setupAsyncStoreWithClient(WidgetStore.instance, client); 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 [[{ @@ -51,25 +52,63 @@ describe("createRoom", () => { }, events: { "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 - const [[widgetRoomId, widgetStateKey]] = mocked(client.sendStateEvent).mock.calls; + }]] = client.createRoom.mock.calls as any; // no good type - // 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); // and should have actually set it up - expect(widgetRoomId).toEqual(roomId); - expect(widgetStateKey).toEqual("im.vector.modular.widgets"); + expect(createCallSpy).toHaveBeenCalled(); // All members should be able to update their connected devices - expect(jitsiMemberPower).toEqual(0); - // Jitsi widget should be immutable for admins + expect(callMemberPower).toEqual(0); + // widget should be immutable for admins expect(widgetPower).toBeGreaterThan(100); // and we should have been reset back to admin 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", () => { @@ -83,20 +122,20 @@ describe("canEncryptToAllUsers", () => { "@badUser:localhost": {}, }; - let client: MatrixClient; + let client: Mocked; beforeEach(() => { stubClient(); - client = MatrixClientPeg.get(); + client = mocked(MatrixClientPeg.get()); }); 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"]); expect(response).toBe(true); }); 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"]); expect(response).toBe(false); }); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 833c723defc..fbbf22eca88 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -18,322 +18,834 @@ import EventEmitter from "events"; import { isEqual } from "lodash"; import { mocked } from "jest-mock"; import { waitFor } from "@testing-library/react"; +import { RoomType } from "matrix-js-sdk/src/@types/event"; 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 { Widget } from "matrix-widget-api"; 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 { 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 MediaDeviceHandler, { MediaDeviceKindEnum } from "../../src/MediaDeviceHandler"; 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 { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore"; import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore"; import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions"; +import SettingsStore from "../../src/settings/SettingsStore"; + +jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ + [MediaDeviceKindEnum.AudioInput]: [ + { deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => { } }, + ], + [MediaDeviceKindEnum.VideoInput]: [ + { deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => { } }, + ], + [MediaDeviceKindEnum.AudioOutput]: [], +}); +jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1"); +jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2"); + +jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName => + settingName === "feature_video_rooms" || settingName === "feature_element_call_video_rooms" ? true : undefined, +); + +const setUpClientRoomAndStores = (roomType: RoomType): { + client: Mocked; + room: Room; + alice: RoomMember; + bob: RoomMember; + carol: RoomMember; +} => { + stubClient(); + const client = mocked(MatrixClientPeg.get()); + + const room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + jest.spyOn(room, "getType").mockReturnValue(roomType); + + 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 => { + switch (userId) { + case alice.userId: return alice; + case bob.userId: return bob; + case carol.userId: return carol; + default: return null; + } + }); + jest.spyOn(room, "getMyMembership").mockReturnValue("join"); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.getUserId.mockReturnValue(alice.userId); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => { + if (roomId !== room.roomId) throw new Error("Unknown room"); + const event = mkEvent({ + event: true, + type: eventType, + room: roomId, + user: alice.userId, + skey: stateKey, + content, + }); + room.addLiveEvents([event]); + return { event_id: event.getId() }; + }); + + setupAsyncStoreWithClient(WidgetStore.instance, client); + setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); + + return { client, room, alice, bob, carol }; +}; + +const cleanUpClientRoomAndStores = ( + client: MatrixClient, + room: Room, +) => { + client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); +}; + +const setUpWidget = (call: Call): { + widget: Widget; + messaging: Mocked; + audioMutedSpy: jest.SpyInstance; + videoMutedSpy: jest.SpyInstance; +} => { + const widget = new Widget(call.widget); + + const eventEmitter = new EventEmitter(); + const messaging = { + on: eventEmitter.on.bind(eventEmitter), + off: eventEmitter.off.bind(eventEmitter), + once: eventEmitter.once.bind(eventEmitter), + emit: eventEmitter.emit.bind(eventEmitter), + stop: jest.fn(), + transport: { + send: jest.fn(), + reply: jest.fn(), + }, + } as unknown as Mocked; + 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, + videoMutedSpy: jest.SpyInstance, +) => { + call.destroy(); + jest.clearAllMocks(); + WidgetMessagingStore.instance.stopMessaging(widget, call.roomId); + audioMutedSpy.mockRestore(); + videoMutedSpy.mockRestore(); +}; describe("JitsiCall", () => { mockPlatformPeg({ supportsJitsiScreensharing: () => true }); - jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ - [MediaDeviceKindEnum.AudioInput]: [ - { deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => {} }, - ], - [MediaDeviceKindEnum.VideoInput]: [ - { deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => {} }, - ], - [MediaDeviceKindEnum.AudioOutput]: [], - }); - jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1"); - jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2"); let client: Mocked; let room: Room; let alice: RoomMember; let bob: RoomMember; let carol: RoomMember; - let call: Call; - let widget: Widget; - let messaging: Mocked; - let audioMutedSpy: jest.SpyInstance; - let videoMutedSpy: jest.SpyInstance; - - beforeEach(async () => { - jest.useFakeTimers(); - jest.setSystemTime(0); - - stubClient(); - client = mocked(MatrixClientPeg.get()); - - room = new Room("!1:example.org", client, "@alice:example.org", { - pendingEventOrdering: PendingEventOrdering.Detached, - }); - alice = mkRoomMember(room.roomId, "@alice:example.org"); - bob = mkRoomMember(room.roomId, "@bob:example.org"); - carol = mkRoomMember(room.roomId, "@carol:example.org"); - jest.spyOn(room, "getMember").mockImplementation(userId => { - switch (userId) { - case alice.userId: return alice; - case bob.userId: return bob; - case carol.userId: return carol; - default: return null; - } - }); - jest.spyOn(room, "getMyMembership").mockReturnValue("join"); - - client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); - client.getRooms.mockReturnValue([room]); - client.getUserId.mockReturnValue(alice.userId); - client.reEmitter.reEmit(room, [RoomStateEvent.Events]); - client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => { - if (roomId !== room.roomId) throw new Error("Unknown room"); - const event = mkEvent({ - event: true, - type: eventType, - room: roomId, - user: alice.userId, - skey: stateKey, - content, - }); - room.addLiveEvents([event]); - return { event_id: event.getId() }; - }); - - setupAsyncStoreWithClient(WidgetStore.instance, client); - setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); - - await JitsiCall.create(room); - call = JitsiCall.get(room); - if (call === null) throw new Error("Failed to create call"); - - widget = new Widget(call.widget); - - const eventEmitter = new EventEmitter(); - messaging = { - on: eventEmitter.on.bind(eventEmitter), - off: eventEmitter.off.bind(eventEmitter), - once: eventEmitter.once.bind(eventEmitter), - emit: eventEmitter.emit.bind(eventEmitter), - stop: jest.fn(), - transport: { - send: jest.fn(async action => { - if (action === ElementWidgetActions.JoinCall) { - messaging.emit( - `action:${ElementWidgetActions.JoinCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - } else if (action === ElementWidgetActions.HangupCall) { - messaging.emit( - `action:${ElementWidgetActions.HangupCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - } - return {}; - }), - reply: jest.fn(), - }, - } as unknown as Mocked; - WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); - - audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get"); - videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get"); - }); - afterEach(() => { - call.destroy(); - client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); - WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); - jest.clearAllMocks(); - audioMutedSpy.mockRestore(); - videoMutedSpy.mockRestore(); + beforeEach(() => { + ({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.ElementVideo)); }); - it("connects muted", async () => { - expect(call.connectionState).toBe(ConnectionState.Disconnected); - audioMutedSpy.mockReturnValue(true); - videoMutedSpy.mockReturnValue(true); + afterEach(() => cleanUpClientRoomAndStores(client, room)); - await call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connected); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: null, - videoInput: null, + describe("get", () => { + it("finds no calls", () => { + expect(Call.get(room)).toBeNull(); }); - }); - it("connects unmuted", async () => { - expect(call.connectionState).toBe(ConnectionState.Disconnected); - audioMutedSpy.mockReturnValue(false); - videoMutedSpy.mockReturnValue(false); + 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()!); - await call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connected); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: "Headphones", - videoInput: "Built-in webcam", + expect(Call.get(room)).toBeNull(); }); }); - 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); + describe("instance", () => { + let call: JitsiCall; + let widget: Widget; + let messaging: Mocked; + let audioMutedSpy: jest.SpyInstance; + let videoMutedSpy: jest.SpyInstance; + + 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) { + messaging.emit( + `action:${ElementWidgetActions.JoinCall}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + } else if (action === ElementWidgetActions.HangupCall) { + messaging.emit( + `action:${ElementWidgetActions.HangupCall}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + } + return {}; + }); + }); - const connect = call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connecting); + afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); - WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); - await connect; - expect(call.connectionState).toBe(ConnectionState.Connected); - }); + it("connects muted", async () => { + expect(call.connectionState).toBe(ConnectionState.Disconnected); + audioMutedSpy.mockReturnValue(true); + videoMutedSpy.mockReturnValue(true); - it("handles remote disconnection", async () => { - expect(call.connectionState).toBe(ConnectionState.Disconnected); + await call.connect(); + expect(call.connectionState).toBe(ConnectionState.Connected); + expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { + audioInput: null, + videoInput: null, + }); + }); - await call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connected); + it("connects unmuted", async () => { + expect(call.connectionState).toBe(ConnectionState.Disconnected); + audioMutedSpy.mockReturnValue(false); + videoMutedSpy.mockReturnValue(false); - messaging.emit( - `action:${ElementWidgetActions.HangupCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 }); - }); + await call.connect(); + expect(call.connectionState).toBe(ConnectionState.Connected); + expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { + audioInput: "Headphones", + videoInput: "Built-in webcam", + }); + }); + + 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("handles instant remote disconnection when connecting", async () => { + mocked(messaging.transport).send.mockImplementation(async action => { + if (action === ElementWidgetActions.JoinCall) { + // Emit the hangup event *before* the join event to fully + // exercise the race condition + messaging.emit( + `action:${ElementWidgetActions.HangupCall}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + messaging.emit( + `action:${ElementWidgetActions.JoinCall}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + } + return {}; + }); + expect(call.connectionState).toBe(ConnectionState.Disconnected); + await call.connect(); + expect(call.connectionState).toBe(ConnectionState.Connected); + // Should disconnect on its own almost instantly + 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("handles instant remote disconnection when connecting", async () => { - mocked(messaging.transport).send.mockImplementation(async action => { - if (action === ElementWidgetActions.JoinCall) { - // Emit the hangup event *before* the join event to fully - // exercise the race condition - messaging.emit( - `action:${ElementWidgetActions.HangupCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), + 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, + JitsiCall.MEMBER_EVENT_TYPE, + { devices: ["bobweb", "bobdesktop"], expires_ts: 1000 * 60 * 10 }, + bob.userId, + ); + // A participant with an expired device (should not show up) + await client.sendStateEvent( + room.roomId, + JitsiCall.MEMBER_EVENT_TYPE, + { devices: ["carolandroid"], expires_ts: -1000 * 60 }, + 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("updates room state when connecting and disconnecting", async () => { + const now1 = Date.now(); + await call.connect(); + await waitFor(() => expect( + room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(), + ).toEqual({ + devices: [client.getDeviceId()], + expires_ts: now1 + call.STUCK_DEVICE_TIMEOUT_MS, + }), { interval: 5 }); + + const now2 = Date.now(); + await call.disconnect(); + await waitFor(() => expect( + room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(), + ).toEqual({ + devices: [], + expires_ts: now2 + call.STUCK_DEVICE_TIMEOUT_MS, + }), { interval: 5 }); + }); + + it("repeatedly updates room state while connected", async () => { + await call.connect(); + await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith( + room.roomId, + JitsiCall.MEMBER_EVENT_TYPE, + { devices: [client.getDeviceId()], expires_ts: expect.any(Number) }, + alice.userId, + ), { interval: 5 }); + + client.sendStateEvent.mockClear(); + jest.advanceTimersByTime(call.STUCK_DEVICE_TIMEOUT_MS); + await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith( + room.roomId, + JitsiCall.MEMBER_EVENT_TYPE, + { devices: [client.getDeviceId()], expires_ts: expect.any(Number) }, + alice.userId, + ), { interval: 5 }); + }); + + 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[] = []; + const onParticipants = (participants: Set) => { + 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()]); + }); + + it("switches to spotlight layout when the widget becomes a PiP", async () => { + await call.connect(); + ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock); + expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); + ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); + 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, ); - messaging.emit( - `action:${ElementWidgetActions.JoinCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), + + 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, ); - } - return {}; - }); - expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.connect(); - expect(call.connectionState).toBe(ConnectionState.Connected); - // Should disconnect on its own almost instantly - 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); - }); + await call.clean(); + expectDevices([aliceDesktop]); + }); - 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, - JitsiCall.MEMBER_EVENT_TYPE, - { devices: ["bobweb", "bobdesktop"], expires_ts: 1000 * 60 * 10 }, - bob.userId, - ); - // A participant with an expired device (should not show up) - await client.sendStateEvent( - room.roomId, - JitsiCall.MEMBER_EVENT_TYPE, - { devices: ["carolandroid"], expires_ts: -1000 * 60 }, - 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("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, + ); - it("updates room state when connecting and disconnecting", async () => { - const now1 = Date.now(); - await call.connect(); - await waitFor(() => expect( - room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(), - ).toEqual({ - devices: [client.getDeviceId()], - expires_ts: now1 + JitsiCall.STUCK_DEVICE_TIMEOUT_MS, - }), { interval: 5 }); - - const now2 = Date.now(); - await call.disconnect(); - await waitFor(() => expect( - room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(), - ).toEqual({ - devices: [], - expires_ts: now2 + JitsiCall.STUCK_DEVICE_TIMEOUT_MS, - }), { interval: 5 }); - }); + 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, + ); - it("repeatedly updates room state while connected", async () => { - await call.connect(); - await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith( - room.roomId, - JitsiCall.MEMBER_EVENT_TYPE, - { devices: [client.getDeviceId()], expires_ts: expect.any(Number) }, - alice.userId, - ), { interval: 5 }); - - client.sendStateEvent.mockClear(); - jest.advanceTimersByTime(JitsiCall.STUCK_DEVICE_TIMEOUT_MS); - await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith( - room.roomId, - JitsiCall.MEMBER_EVENT_TYPE, - { devices: [client.getDeviceId()], expires_ts: expect.any(Number) }, - alice.userId, - ), { interval: 5 }); + 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; + let room: Room; + let alice: RoomMember; + let bob: RoomMember; + let carol: RoomMember; - 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, - ]); + beforeEach(() => { + ({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.UnstableCall)); }); - it("emits events when participants change", async () => { - const events: Set[] = []; - const onParticipants = (participants: Set) => { - if (!isEqual(participants, events[events.length - 1])) events.push(participants); - }; - call.on(CallEvent.Participants, onParticipants); + afterEach(() => cleanUpClientRoomAndStores(client, room)); - await call.connect(); - await call.disconnect(); - expect(events).toEqual([new Set([alice]), new Set()]); + 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(); + }); }); - it("switches to spotlight layout when the widget becomes a PiP", async () => { - await call.connect(); - ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); - ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); + describe("instance", () => { + let call: ElementCall; + let widget: Widget; + let messaging: Mocked; + let audioMutedSpy: jest.SpyInstance; + let videoMutedSpy: jest.SpyInstance; + + 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[] = []; + const onParticipants = (participants: Set) => { + 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); + }); + }); }); }); diff --git a/test/stores/widgets/StopGapWidget-test.ts b/test/stores/widgets/StopGapWidget-test.ts index 40292e451be..9f5ca032808 100644 --- a/test/stores/widgets/StopGapWidget-test.ts +++ b/test/stores/widgets/StopGapWidget-test.ts @@ -39,6 +39,7 @@ describe("StopGapWidget", () => { creatorUserId: "@alice:example.org", type: "example", url: "https://example.org", + roomId: "!1:example.org", }, room: mkRoom(client, "!1:example.org"), userId: "@alice:example.org", diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts index dccd203850b..2d4fe90e1fb 100644 --- a/test/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/stores/widgets/StopGapWidgetDriver-test.ts @@ -15,10 +15,10 @@ limitations under the License. */ 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 { 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 { RoomViewStore } from "../../../src/stores/RoomViewStore"; @@ -27,22 +27,75 @@ import { stubClient } from "../../test-utils"; describe("StopGapWidgetDriver", () => { let client: MockedObject; - let driver: WidgetDriver; + + const mkDefaultDriver = (): WidgetDriver => new StopGapWidgetDriver( + [], + new Widget({ + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org", + }), + WidgetKind.Room, + false, + "!1:example.org", + ); beforeEach(() => { stubClient(); client = mocked(MatrixClientPeg.get()); + client.getUserId.mockReturnValue("@alice:example.org"); + }); - driver = new StopGapWidgetDriver( + it("auto-approves capabilities of virtual Element Call widgets", async () => { + const driver = new StopGapWidgetDriver( [], new Widget({ - id: "test", + id: "group_call", creatorUserId: "@alice:example.org", - type: "example", - url: "https://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", () => { @@ -59,6 +112,10 @@ describe("StopGapWidgetDriver", () => { }, }; + let driver: WidgetDriver; + + beforeEach(() => { driver = mkDefaultDriver(); }); + it("sends unencrypted messages", async () => { await driver.sendToDevice("org.example.foo", false, contentMap); expect(client.queueToDevice.mock.calls).toMatchSnapshot(); @@ -80,6 +137,10 @@ describe("StopGapWidgetDriver", () => { }); describe("getTurnServers", () => { + let driver: WidgetDriver; + + beforeEach(() => { driver = mkDefaultDriver(); }); + it("stops if VoIP isn't supported", async () => { jest.spyOn(client, "pollingTurnServers", "get").mockReturnValue(false); const servers = driver.getTurnServers(); @@ -135,6 +196,10 @@ describe("StopGapWidgetDriver", () => { }); describe("readEventRelations", () => { + let driver: WidgetDriver; + + beforeEach(() => { driver = mkDefaultDriver(); }); + it('reads related events from the current room', async () => { jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id'); diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index 8ebaf5140de..0020ab46005 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -23,17 +23,21 @@ import { Call } from "../../src/models/Call"; export class MockedCall extends 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) { - super({ - id, - eventId: "$1:example.org", - roomId: room.roomId, - type: MatrixWidgetType.Custom, - url: "https://example.org", - name: "Group call", - creatorUserId: "@alice:example.org", - }); + private constructor(room: Room, id: string) { + super( + { + id, + eventId: "$1:example.org", + roomId: room.roomId, + type: MatrixWidgetType.Custom, + url: "https://example.org", + name: "Group call", + creatorUserId: "@alice:example.org", + }, + room.client, + ); } 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 - public async clean(): Promise {} + protected getDevices(): string[] { return []; } + protected async setDevices(): Promise { } // Public to allow spying - public async performConnection( - audioInput: MediaDeviceInfo | null, - videoInput: MediaDeviceInfo | null, - ): Promise {} + public async performConnection(): Promise {} public async performDisconnection(): Promise {} public destroy() { @@ -77,7 +79,7 @@ export class MockedCall extends Call { room: this.room.roomId, user: "@alice:example.org", content: { terminated: true }, - skey: this.id, + skey: this.widget.id, })]); super.destroy(); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 8d330391fdd..ed600403466 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -99,7 +99,7 @@ export function createTestClient(): MatrixClient { }, getPushActionsForEvent: jest.fn(), - getRoom: jest.fn().mockImplementation(mkStubRoom), + getRoom: jest.fn().mockImplementation(roomId => mkStubRoom(roomId, "My room", client)), getRooms: jest.fn().mockReturnValue([]), getVisibleRooms: jest.fn().mockReturnValue([]), loginFlows: jest.fn(), @@ -335,8 +335,10 @@ export function mkRoomMember(roomId: string, userId: string, membership = "join" name: userId, rawDisplayName: userId, roomId, + events: {}, getAvatarUrl: () => {}, getMxcAvatarUrl: () => {}, + getDMInviter: () => {}, } as unknown as RoomMember; } diff --git a/test/utils/GroupCallUtils-test.ts b/test/utils/GroupCallUtils-test.ts deleted file mode 100644 index 971527e8037..00000000000 --- a/test/utils/GroupCallUtils-test.ts +++ /dev/null @@ -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(), - ); - }); - }); - }); - }); - }); - }); -}); -