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:
parent
db5716b776
commit
cb735c9439
37 changed files with 1699 additions and 1384 deletions
|
@ -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 {
|
||||
|
|
|
@ -116,6 +116,9 @@ export interface IConfigOptions {
|
|||
voip?: {
|
||||
obey_asserted_identity?: boolean; // MSC3086
|
||||
};
|
||||
element_call: {
|
||||
url: string;
|
||||
};
|
||||
|
||||
logout_redirect_url?: string;
|
||||
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>) => {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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
21
src/utils/video-rooms.ts
Normal 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());
|
120
test/components/views/rooms/RoomPreviewCard-test.tsx
Normal file
120
test/components/views/rooms/RoomPreviewCard-test.tsx
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in a new issue