Element Call video rooms (#9267)

* Add an element_call_url config option

* Add a labs flag for Element Call video rooms

* Add Element Call as another video rooms backend

* Consolidate event power level defaults

* Remember to clean up participantsExpirationTimer

* Fix a code smell

* Test the clean method

* Fix some strict mode errors

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

* Test auto-approval of Element Call widget capabilities

* Deduplicate some code to placate SonarCloud

* Fix more strict mode errors

* Test that calls disconnect when leaving the room

* Test the get methods of JitsiCall and ElementCall more

* Test Call.ts even more

* Test creation of Element video rooms

* Test that createRoom works for non-video-rooms

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

* Ensure that the clean method is able to preserve devices

* Remove duplicate clean method

* Fix lints

* Fix some strict mode errors in RoomPreviewCard

* Test RoomPreviewCard changes

* Quick and dirty hotfix for the community testing session

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

This reverts commit 37056514fbc040aaf1bff2539da770a1c8ba72a2.

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

* Remove org.matrix.call_duplicate_session from Element Call capabilities

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

* Replace element_call_url with a map

* Make PiPs work for virtual widgets

* Auto-approve room timeline capability

Because Element Call uses this now

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

View file

@ -64,12 +64,6 @@ limitations under the License.
color: $secondary-content;
}
}
/* 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 {

View file

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

View file

@ -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<IConfigOptions>) {
SdkConfig.setInstance({ ...DEFAULTS, ...cfg });
}
/**
@ -97,9 +94,7 @@ export default class SdkConfig {
}
public static add(cfg: Partial<IConfigOptions>) {
const liveConfig = SdkConfig.get();
const newConfig = Object.assign({}, liveConfig, cfg);
SdkConfig.put(newConfig);
SdkConfig.put({ ...SdkConfig.get(), ...cfg });
}
}

View file

@ -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<IRoomProps, IRoomState> {
};
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<IRoomProps, IRoomState> {
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 <ErrorBoundary>
<div className="mx_MainSplit">

View file

@ -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 = <IconizedContextMenu
@ -145,7 +146,12 @@ const SpaceLandingAddButton = ({ space }) => {
e.stopPropagation();
closeMenu();
if (await showCreateNewRoom(space, RoomType.ElementVideo)) {
if (
await showCreateNewRoom(
space,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
)
) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
}}

View file

@ -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();

View file

@ -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;

View file

@ -146,10 +146,9 @@ const WidgetContextMenu: React.FC<IProps> = ({
/>;
}
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<IProps> = ({
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);

View file

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

View file

@ -165,10 +165,8 @@ export default class AppTile extends React.Component<IProps, IState> {
if (!props.room) return true; // user widgets always have permissions
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<IProps, IState> {
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 });

View file

@ -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<IProps> {
constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
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,
);
public render(): JSX.Element | null {
const app = WidgetStore.instance.get(this.props.persistentWidgetId, this.props.persistentRoomId);
if (!app) return null;
if (appEvent) {
return WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
this.room.roomId, appEvent.getId(),
);
} else {
return null;
}
}
public render(): JSX.Element {
const app = this.app;
if (app) {
return <AppTile
key={app.id}
app={app}
fullWidth={true}
room={this.room}
userId={this.context.credentials.userId}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
miniMode={true}
showMenubar={false}
pointerEvents={this.props.pointerEvents}
movePersistedElement={this.props.movePersistedElement}
/>;
}
return null;
return <AppTile
key={app.id}
app={app}
fullWidth={true}
room={this.room}
userId={this.context.credentials.userId}
creatorUserId={app.creatorUserId}
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
waitForIframeLoad={app.waitForIframeLoad}
miniMode={true}
showMenubar={false}
pointerEvents={this.props.pointerEvents}
movePersistedElement={this.props.movePersistedElement}
/>;
}
}

View file

@ -262,7 +262,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ 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 = <React.Fragment>

View file

@ -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<IProps, IState> {
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && this.props.room.isElementVideoRoom();
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
const viewLabs = () => defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,

View file

@ -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<IProps> = ({ 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) {

View file

@ -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 && (
<IconizedContextMenuOption
label={_t("New video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
@ -247,7 +250,10 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
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 && (
<IconizedContextMenuOption
label={_t("New video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
@ -297,7 +303,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
closeMenu();
defaultDispatcher.dispatch({
action: "view_create_room",
type: RoomType.ElementVideo,
type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
});
}}
>
@ -319,7 +325,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
</IconizedContextMenuOptionList>;
}
let contextMenu: JSX.Element;
let contextMenu: JSX.Element | null = null;
if (menuDisplayed) {
contextMenu = <IconizedContextMenu {...auxButtonContextMenuPosition(handle)} onFinished={closeMenu} compact>
{ contextMenuContent }

View file

@ -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();
}}

View file

@ -51,6 +51,8 @@ interface IProps {
const RoomPreviewCard: FC<IProps> = ({ 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<IProps> = ({ 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<IProps> = ({ room, onJoinButtonClicked, onRejectButton
</AccessibleButton>
);
} 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 = <div className="mx_RoomPreviewCard_inviter">
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
<div>
@ -102,10 +105,6 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
{ inviteSender }
</div> : null }
</div>
{ room.isElementVideoRoom()
? <BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
: null
}
</div>;
}
@ -152,10 +151,11 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
}
let avatarRow: JSX.Element;
if (room.isElementVideoRoom()) {
if (isVideoRoom) {
avatarRow = <>
<RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />
<div className="mx_RoomPreviewCard_video" />
<BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
</>;
} else if (room.isSpaceRoom()) {
avatarRow = <RoomAvatar room={room} height={80} width={80} viewAvatarOnClick />;
@ -163,12 +163,12 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
avatarRow = <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />;
}
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");

View file

@ -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<string | null> {
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<string | null> {
}
}).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() {

View file

@ -909,6 +909,7 @@
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
"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)",

View file

@ -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<void> => {
let listener: (...args: any[]) => void;
const wait = new Promise<void>(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<CallEvent, CallEventHandlerMap> {
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<CallEvent, CallEventHandler
* The widget used to access this call.
*/
public readonly widget: IApp,
protected readonly client: MatrixClient,
) {
super();
}
@ -140,21 +149,77 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
* @returns {Call | null} The call.
*/
public static get(room: Room): Call | null {
// There's currently only one implementation
return JitsiCall.get(room);
return ElementCall.get(room) ?? JitsiCall.get(room);
}
/**
* Gets the connected devices associated with the given user in room state.
* @param userId The user's ID.
* @returns The IDs of the user's connected devices.
*/
protected abstract getDevices(userId: string): string[];
/**
* Sets the connected devices associated with ourselves in room state.
* @param devices The devices with which we're connected.
*/
protected abstract setDevices(devices: string[]): Promise<void>;
/**
* Updates our member state with the devices returned by the given function.
* @param fn A function from the current devices to the new devices. If it
* returns null, the update is skipped.
*/
protected async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
if (this.room.getMyMembership() !== "join") return;
const devices = fn(this.getDevices(this.client.getUserId()!));
if (devices) {
await this.setDevices(devices);
}
}
/**
* Performs a routine check of the call's associated room state, cleaning up
* any data left over from an unclean disconnection.
*/
public abstract clean(): Promise<void>;
public async clean(): Promise<void> {
const now = Date.now();
const { devices: myDevices } = await this.client.getDevices();
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
// Clean up our member state by filtering out logged out devices,
// inactive devices, and our own device (if we're disconnected)
await this.updateDevices(devices => {
const newDevices = devices.filter(d => {
const device = deviceMap.get(d);
return device?.last_seen_ts !== undefined
&& !(d === this.client.getDeviceId() && !this.connected)
&& (now - device.last_seen_ts) < this.STUCK_DEVICE_TIMEOUT_MS;
});
// Skip the update if the devices are unchanged
return newDevices.length === devices.length ? null : newDevices;
});
}
protected async addOurDevice(): Promise<void> {
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
}
protected async removeOurDevice(): Promise<void> {
await this.updateDevices(devices => {
const devicesSet = new Set(devices);
devicesSet.delete(this.client.getDeviceId());
return Array.from(devicesSet);
});
}
/**
* Contacts the widget to connect to the call.
* @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<CallEvent, CallEventHandler
throw e;
}
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Connected;
}
@ -237,6 +304,8 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
* Manually marks the call as disconnected and cleans up.
*/
public setDisconnected() {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.messaging = null;
this.connectionState = ConnectionState.Disconnected;
}
@ -248,6 +317,19 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
if (this.connected) this.setDisconnected();
this.emit(CallEvent.Destroy);
}
private onMyMembership = async (_room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};
private beforeUnload = () => this.setDisconnected();
}
export interface JitsiCallMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
}
/**
@ -255,14 +337,13 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
*/
export class JitsiCall extends Call {
public static readonly MEMBER_EVENT_TYPE = "io.element.video.member";
public static readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private room: Room = this.client.getRoom(this.roomId)!;
private resendDevicesTimer: number | null = null;
private participantsExpirationTimer: number | null = null;
private constructor(widget: IApp, private readonly client: MatrixClient) {
super(widget);
private constructor(widget: IApp, client: MatrixClient) {
super(widget, client);
this.room.on(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
@ -270,10 +351,15 @@ export class JitsiCall extends Call {
}
public static get(room: Room): JitsiCall | null {
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);
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<void> {
@ -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<JitsiCallMemberContent>();
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<void> {
if (this.room.getMyMembership() !== "join") return;
protected getDevices(userId: string): string[] {
const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, userId);
const content = event?.getContent<JitsiCallMemberContent>();
const expiresAt = typeof content?.expires_ts === "number" ? content.expires_ts : -Infinity;
return expiresAt > Date.now() && Array.isArray(content?.devices) ? content.devices : [];
}
const devicesState = this.room.currentState.getStateEvents(
JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!,
protected async setDevices(devices: string[]): Promise<void> {
const content: JitsiCallMemberContent = {
devices,
expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
};
await this.client.sendStateEvent(
this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
);
const devices = devicesState?.getContent<JitsiCallMemberContent>().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<void> {
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
}
private async removeOurDevice(): Promise<void> {
await this.updateDevices(devices => {
const devicesSet = new Set(devices);
devicesSet.delete(this.client.getDeviceId());
return Array.from(devicesSet);
});
}
public async clean(): Promise<void> {
const now = Date.now();
const { devices: myDevices } = await this.client.getDevices();
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
// Clean up our member state by filtering out logged out devices,
// inactive devices, and our own device (if we're disconnected)
await this.updateDevices(devices => {
const newDevices = devices.filter(d => {
const device = deviceMap.get(d);
return device?.last_seen_ts
&& !(d === this.client.getDeviceId() && !this.connected)
&& (now - device.last_seen_ts) < JitsiCall.STUCK_DEVICE_TIMEOUT_MS;
});
// Skip the update if the devices are unchanged
return newDevices.length === devices.length ? null : newDevices;
});
}
protected async performConnection(
@ -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<void> {
@ -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<IWidgetApiRequest>) => {
// 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<void> {
await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, {
"m.intent": "m.room",
"m.type": "m.video",
}, randomString(24));
}
private updateParticipants() {
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
const members = new Set<RoomMember>();
const now = Date.now();
let allExpireAt = Infinity;
const memberEvents = ElementCall.MEMBER_EVENT_TYPE.names.flatMap(eventType =>
this.room.currentState.getStateEvents(eventType),
);
for (const e of memberEvents) {
const member = this.room.getMember(e.getStateKey()!);
const content = e.getContent<ElementCallMemberContent>();
const expiresAt = typeof content["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
const calls = expiresAt > now && Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
let devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
// Apply local echo for the disconnected case
if (!this.connected && member?.userId === this.client.getUserId()) {
devices = devices.filter(d => d.device_id !== this.client.getDeviceId());
}
// Must have a connected device and still be joined to the room
if (devices.length && member?.membership === "join") {
members.add(member);
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
}
}
// Apply local echo for the connected case
if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
this.participants = members;
if (allExpireAt < Infinity) {
this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
}
}
private getCallsState(userId: string): ElementCallMemberContent["m.calls"] {
const event = (() => {
for (const eventType of ElementCall.MEMBER_EVENT_TYPE.names) {
const e = this.room.currentState.getStateEvents(eventType, userId);
if (e) return e;
}
return null;
})();
const content = event?.getContent<ElementCallMemberContent>();
const expiresAt = typeof content?.["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
return expiresAt > Date.now() && Array.isArray(content?.["m.calls"]) ? content!["m.calls"] : [];
}
protected getDevices(userId: string): string[] {
const calls = this.getCallsState(userId);
const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
const devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
return devices.map(d => d.device_id);
}
protected async setDevices(devices: string[]): Promise<void> {
const calls = this.getCallsState(this.client.getUserId()!);
const call = calls.find(c => c["m.call_id"] === this.groupCall.getStateKey())!;
const prevDevices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
const prevDevicesMap = new Map(prevDevices.map(d => [d.device_id, d]));
const newContent: ElementCallMemberContent = {
"m.expires_ts": Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
"m.calls": [
{
"m.call_id": this.groupCall.getStateKey()!,
// This method will only ever be used to remove devices, so
// it's safe to assume that all requested devices are
// present in the map
"m.devices": devices.map(d => prevDevicesMap.get(d)!),
},
...calls.filter(c => c !== call),
],
};
await this.client.sendStateEvent(
this.roomId, ElementCall.MEMBER_EVENT_TYPE.name, newContent, this.client.getUserId()!,
);
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.deviceId ?? null,
videoInput: videoInput?.deviceId ?? null,
});
} catch (e) {
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
}
protected async performDisconnection(): Promise<void> {
try {
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
}
public setDisconnected() {
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
super.setDisconnected();
}
public destroy() {
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
this.room.off(RoomStateEvent.Update, this.onRoomState);
this.off(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
super.destroy();
}
private onRoomState = () => this.updateParticipants();
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
if (
(state === ConnectionState.Connected && !isConnected(prevState))
|| (state === ConnectionState.Disconnected && isConnected(prevState))
) {
this.updateParticipants(); // Local echo
}
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
await this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
};
}

View file

@ -423,6 +423,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
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,

View file

@ -22,7 +22,6 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { 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<void> {
protected async onAction(): Promise<void> {
// nothing to do
}

View file

@ -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<IState> {
// 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<IState> {
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;

View file

@ -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",

View file

@ -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<IViewRoomApiRequest>) => {

View file

@ -40,6 +40,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { 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,
);
}
}
}

View file

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

View file

@ -482,8 +482,8 @@ export default class WidgetUtils {
appId: string,
app: Partial<IApp>,
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");

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

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

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
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<MatrixClient>;
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<MatrixClient>;
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);
});

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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<MatrixClient>;
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');

View file

@ -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<void> {}
protected getDevices(): string[] { return []; }
protected async setDevices(): Promise<void> { }
// Public to allow spying
public async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {}
public async performConnection(): Promise<void> {}
public async performDisconnection(): Promise<void> {}
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();

View file

@ -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;
}

View file

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