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;
|
color: $secondary-content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* XXX Remove this when video rooms leave beta */
|
|
||||||
.mx_BetaCard_betaPill {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
align-self: start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_RoomPreviewCard_avatar {
|
.mx_RoomPreviewCard_avatar {
|
||||||
|
@ -104,6 +98,13 @@ limitations under the License.
|
||||||
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
|
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* XXX Remove this when video rooms leave beta */
|
||||||
|
.mx_BetaCard_betaPill {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: $spacing-32;
|
||||||
|
inset-inline-end: $spacing-24;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1.mx_RoomPreviewCard_name {
|
h1.mx_RoomPreviewCard_name {
|
||||||
|
|
|
@ -116,6 +116,9 @@ export interface IConfigOptions {
|
||||||
voip?: {
|
voip?: {
|
||||||
obey_asserted_identity?: boolean; // MSC3086
|
obey_asserted_identity?: boolean; // MSC3086
|
||||||
};
|
};
|
||||||
|
element_call: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
logout_redirect_url?: string;
|
logout_redirect_url?: string;
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,9 @@ export const DEFAULTS: IConfigOptions = {
|
||||||
jitsi: {
|
jitsi: {
|
||||||
preferred_domain: "meet.element.io",
|
preferred_domain: "meet.element.io",
|
||||||
},
|
},
|
||||||
|
element_call: {
|
||||||
|
url: "https://call.element.io",
|
||||||
|
},
|
||||||
|
|
||||||
// @ts-ignore - we deliberately use the camelCase version here so we trigger
|
// @ts-ignore - we deliberately use the camelCase version here so we trigger
|
||||||
// the fallback behaviour. If we used the snake_case version then we'd break
|
// the fallback behaviour. If we used the snake_case version then we'd break
|
||||||
|
@ -79,14 +82,8 @@ export default class SdkConfig {
|
||||||
return val === undefined ? undefined : null;
|
return val === undefined ? undefined : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static put(cfg: IConfigOptions) {
|
public static put(cfg: Partial<IConfigOptions>) {
|
||||||
const defaultKeys = Object.keys(DEFAULTS);
|
SdkConfig.setInstance({ ...DEFAULTS, ...cfg });
|
||||||
for (let i = 0; i < defaultKeys.length; ++i) {
|
|
||||||
if (cfg[defaultKeys[i]] === undefined) {
|
|
||||||
cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SdkConfig.setInstance(cfg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -97,9 +94,7 @@ export default class SdkConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static add(cfg: Partial<IConfigOptions>) {
|
public static add(cfg: Partial<IConfigOptions>) {
|
||||||
const liveConfig = SdkConfig.get();
|
SdkConfig.put({ ...SdkConfig.get(), ...cfg });
|
||||||
const newConfig = Object.assign({}, liveConfig, cfg);
|
|
||||||
SdkConfig.put(newConfig);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -119,6 +119,7 @@ import { isLocalRoom } from '../../utils/localRoom/isLocalRoom';
|
||||||
import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
|
import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
|
||||||
import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages';
|
import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages';
|
||||||
import { LargeLoader } from './LargeLoader';
|
import { LargeLoader } from './LargeLoader';
|
||||||
|
import { isVideoRoom } from '../../utils/video-rooms';
|
||||||
|
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
let debuglog = function(msg: string) {};
|
let debuglog = function(msg: string) {};
|
||||||
|
@ -514,7 +515,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private getMainSplitContentType = (room: Room) => {
|
private getMainSplitContentType = (room: Room) => {
|
||||||
if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) {
|
if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) {
|
||||||
return MainSplitContentType.Video;
|
return MainSplitContentType.Video;
|
||||||
}
|
}
|
||||||
if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
|
if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
|
||||||
|
@ -2015,8 +2016,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||||
|
|
||||||
const myMembership = this.state.room.getMyMembership();
|
const myMembership = this.state.room.getMyMembership();
|
||||||
if (
|
if (
|
||||||
this.state.room.isElementVideoRoom() &&
|
isVideoRoom(this.state.room)
|
||||||
!(SettingsStore.getValue("feature_video_rooms") && myMembership === "join")
|
&& !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join")
|
||||||
) {
|
) {
|
||||||
return <ErrorBoundary>
|
return <ErrorBoundary>
|
||||||
<div className="mx_MainSplit">
|
<div className="mx_MainSplit">
|
||||||
|
|
|
@ -108,8 +108,9 @@ const SpaceLandingAddButton = ({ space }) => {
|
||||||
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
|
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
|
||||||
const canCreateSpace = shouldShowComponent(UIComponent.CreateSpaces);
|
const canCreateSpace = shouldShowComponent(UIComponent.CreateSpaces);
|
||||||
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
||||||
|
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||||
|
|
||||||
let contextMenu;
|
let contextMenu: JSX.Element | null = null;
|
||||||
if (menuDisplayed) {
|
if (menuDisplayed) {
|
||||||
const rect = handle.current.getBoundingClientRect();
|
const rect = handle.current.getBoundingClientRect();
|
||||||
contextMenu = <IconizedContextMenu
|
contextMenu = <IconizedContextMenu
|
||||||
|
@ -145,7 +146,12 @@ const SpaceLandingAddButton = ({ space }) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
|
|
||||||
if (await showCreateNewRoom(space, RoomType.ElementVideo)) {
|
if (
|
||||||
|
await showCreateNewRoom(
|
||||||
|
space,
|
||||||
|
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
|
||||||
|
)
|
||||||
|
) {
|
||||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -105,10 +105,14 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||||
const isVideoRoom = useFeatureEnabled("feature_video_rooms") && room.isElementVideoRoom();
|
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
||||||
|
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||||
|
const isVideoRoom = videoRoomsEnabled && (
|
||||||
|
room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom())
|
||||||
|
);
|
||||||
|
|
||||||
let inviteOption: JSX.Element;
|
let inviteOption: JSX.Element;
|
||||||
if (room.canInvite(cli.getUserId()) && !isDm) {
|
if (room.canInvite(cli.getUserId()!) && !isDm) {
|
||||||
const onInviteClick = (ev: ButtonEvent) => {
|
const onInviteClick = (ev: ButtonEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
|
@ -35,6 +35,7 @@ import { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||||
import { BetaPill } from "../beta/BetaCard";
|
import { BetaPill } from "../beta/BetaCard";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
|
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
||||||
import { UIComponent } from "../../../settings/UIFeature";
|
import { UIComponent } from "../../../settings/UIFeature";
|
||||||
|
@ -48,9 +49,9 @@ interface IProps extends IContextMenuProps {
|
||||||
|
|
||||||
const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => {
|
const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
const userId = cli.getUserId();
|
const userId = cli.getUserId()!;
|
||||||
|
|
||||||
let inviteOption;
|
let inviteOption: JSX.Element | null = null;
|
||||||
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
|
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
|
||||||
const onInviteClick = (ev: ButtonEvent) => {
|
const onInviteClick = (ev: ButtonEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -71,8 +72,8 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let settingsOption;
|
let settingsOption: JSX.Element | null = null;
|
||||||
let leaveOption;
|
let leaveOption: JSX.Element | null = null;
|
||||||
if (shouldShowSpaceSettings(space)) {
|
if (shouldShowSpaceSettings(space)) {
|
||||||
const onSettingsClick = (ev: ButtonEvent) => {
|
const onSettingsClick = (ev: ButtonEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -110,7 +111,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let devtoolsOption;
|
let devtoolsOption: JSX.Element | null = null;
|
||||||
if (SettingsStore.getValue("developerMode")) {
|
if (SettingsStore.getValue("developerMode")) {
|
||||||
const onViewTimelineClick = (ev: ButtonEvent) => {
|
const onViewTimelineClick = (ev: ButtonEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -134,12 +135,15 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
||||||
|
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||||
|
|
||||||
const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||||
const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms);
|
const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms);
|
||||||
const canAddVideoRooms = canAddRooms && SettingsStore.getValue("feature_video_rooms");
|
const canAddVideoRooms = canAddRooms && videoRoomsEnabled;
|
||||||
const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces);
|
const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces);
|
||||||
|
|
||||||
let newRoomSection: JSX.Element;
|
let newRoomSection: JSX.Element | null = null;
|
||||||
if (canAddRooms || canAddSubSpaces) {
|
if (canAddRooms || canAddSubSpaces) {
|
||||||
const onNewRoomClick = (ev: ButtonEvent) => {
|
const onNewRoomClick = (ev: ButtonEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -154,7 +158,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
|
||||||
showCreateNewRoom(space, RoomType.ElementVideo);
|
showCreateNewRoom(space, elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo);
|
||||||
onFinished();
|
onFinished();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -266,4 +270,3 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SpaceContextMenu;
|
export default SpaceContextMenu;
|
||||||
|
|
||||||
|
|
|
@ -146,10 +146,9 @@ const WidgetContextMenu: React.FC<IProps> = ({
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId];
|
const isAllowedWidget =
|
||||||
if (isAllowedWidget === undefined) {
|
(app.eventId !== undefined && (SettingsStore.getValue("allowedWidgets", roomId)[app.eventId] ?? false))
|
||||||
isAllowedWidget = app.creatorUserId === cli.getUserId();
|
|| app.creatorUserId === cli.getUserId();
|
||||||
}
|
|
||||||
|
|
||||||
const isLocalWidget = WidgetType.JITSI.matches(app.type);
|
const isLocalWidget = WidgetType.JITSI.matches(app.type);
|
||||||
let revokeButton;
|
let revokeButton;
|
||||||
|
@ -157,7 +156,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
|
||||||
const onRevokeClick = () => {
|
const onRevokeClick = () => {
|
||||||
logger.info("Revoking permission for widget to load: " + app.eventId);
|
logger.info("Revoking permission for widget to load: " + app.eventId);
|
||||||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||||||
current[app.eventId] = false;
|
if (app.eventId !== undefined) current[app.eventId] = false;
|
||||||
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
|
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
|
||||||
SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => {
|
SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => {
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
|
|
|
@ -78,7 +78,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
const driver = new StopGapWidgetDriver([], this.widget, WidgetKind.Modal);
|
const driver = new StopGapWidgetDriver([], this.widget, WidgetKind.Modal, false);
|
||||||
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
|
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
|
||||||
this.setState({ messaging });
|
this.setState({ messaging });
|
||||||
}
|
}
|
||||||
|
|
|
@ -165,10 +165,8 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
if (!props.room) return true; // user widgets always have permissions
|
if (!props.room) return true; // user widgets always have permissions
|
||||||
|
|
||||||
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
|
const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
|
||||||
if (currentlyAllowedWidgets[props.app.eventId] === undefined) {
|
const allowed = props.app.eventId !== undefined && (currentlyAllowedWidgets[props.app.eventId] ?? false);
|
||||||
return props.userId === props.creatorUserId;
|
return allowed || props.userId === props.creatorUserId;
|
||||||
}
|
|
||||||
return !!currentlyAllowedWidgets[props.app.eventId];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private onUserLeftRoom() {
|
private onUserLeftRoom() {
|
||||||
|
@ -442,7 +440,7 @@ export default class AppTile extends React.Component<IProps, IState> {
|
||||||
const roomId = this.props.room?.roomId;
|
const roomId = this.props.room?.roomId;
|
||||||
logger.info("Granting permission for widget to load: " + this.props.app.eventId);
|
logger.info("Granting permission for widget to load: " + this.props.app.eventId);
|
||||||
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
const current = SettingsStore.getValue("allowedWidgets", roomId);
|
||||||
current[this.props.app.eventId] = true;
|
if (this.props.app.eventId !== undefined) current[this.props.app.eventId] = true;
|
||||||
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
|
const level = SettingsStore.firstSupportedLevel("allowedWidgets");
|
||||||
SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => {
|
SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => {
|
||||||
this.setState({ hasPermissionToLoad: true });
|
this.setState({ hasPermissionToLoad: true });
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
|
||||||
import WidgetUtils from '../../../utils/WidgetUtils';
|
import WidgetUtils from '../../../utils/WidgetUtils';
|
||||||
import AppTile from "./AppTile";
|
import AppTile from "./AppTile";
|
||||||
import { IApp } from '../../../stores/WidgetStore';
|
import WidgetStore from '../../../stores/WidgetStore';
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
@ -37,44 +37,27 @@ export default class PersistentApp extends React.Component<IProps> {
|
||||||
|
|
||||||
constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
|
constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
this.room = context.getRoom(this.props.persistentRoomId);
|
this.room = context.getRoom(this.props.persistentRoomId)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get app(): IApp | null {
|
public render(): JSX.Element | null {
|
||||||
// get the widget data
|
const app = WidgetStore.instance.get(this.props.persistentWidgetId, this.props.persistentRoomId);
|
||||||
const appEvent = WidgetUtils.getRoomWidgets(this.room).find(ev =>
|
if (!app) return null;
|
||||||
ev.getStateKey() === this.props.persistentWidgetId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (appEvent) {
|
return <AppTile
|
||||||
return WidgetUtils.makeAppConfig(
|
key={app.id}
|
||||||
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
|
app={app}
|
||||||
this.room.roomId, appEvent.getId(),
|
fullWidth={true}
|
||||||
);
|
room={this.room}
|
||||||
} else {
|
userId={this.context.credentials.userId}
|
||||||
return null;
|
creatorUserId={app.creatorUserId}
|
||||||
}
|
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||||
}
|
waitForIframeLoad={app.waitForIframeLoad}
|
||||||
|
miniMode={true}
|
||||||
public render(): JSX.Element {
|
showMenubar={false}
|
||||||
const app = this.app;
|
pointerEvents={this.props.pointerEvents}
|
||||||
if (app) {
|
movePersistedElement={this.props.movePersistedElement}
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -262,7 +262,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
|
||||||
const isRoomEncrypted = useIsEncrypted(cli, room);
|
const isRoomEncrypted = useIsEncrypted(cli, room);
|
||||||
const roomContext = useContext(RoomContext);
|
const roomContext = useContext(RoomContext);
|
||||||
const e2eStatus = roomContext.e2eStatus;
|
const e2eStatus = roomContext.e2eStatus;
|
||||||
const isVideoRoom = useFeatureEnabled("feature_video_rooms") && room.isElementVideoRoom();
|
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
||||||
|
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||||
|
const isVideoRoom = videoRoomsEnabled && (
|
||||||
|
room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom())
|
||||||
|
);
|
||||||
|
|
||||||
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
|
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
|
||||||
const header = <React.Fragment>
|
const header = <React.Fragment>
|
||||||
|
|
|
@ -47,6 +47,7 @@ import RoomLiveShareWarning from '../beacon/RoomLiveShareWarning';
|
||||||
import { BetaPill } from "../beta/BetaCard";
|
import { BetaPill } from "../beta/BetaCard";
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||||
|
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
|
||||||
|
|
||||||
export interface ISearchInfo {
|
export interface ISearchInfo {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
|
@ -312,7 +313,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
|
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
|
||||||
|
|
||||||
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && this.props.room.isElementVideoRoom();
|
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
|
||||||
const viewLabs = () => defaultDispatcher.dispatch({
|
const viewLabs = () => defaultDispatcher.dispatch({
|
||||||
action: Action.ViewUserSettings,
|
action: Action.ViewUserSettings,
|
||||||
initialTabId: UserTab.Labs,
|
initialTabId: UserTab.Labs,
|
||||||
|
|
|
@ -23,6 +23,7 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||||
import { useRoomState } from "../../../hooks/useRoomState";
|
import { useRoomState } from "../../../hooks/useRoomState";
|
||||||
|
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||||
import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers";
|
import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers";
|
||||||
import AccessibleButton from "../elements/AccessibleButton";
|
import AccessibleButton from "../elements/AccessibleButton";
|
||||||
|
|
||||||
|
@ -44,9 +45,12 @@ const RoomInfoLine: FC<IProps> = ({ room }) => {
|
||||||
const membership = useMyRoomMembership(room);
|
const membership = useMyRoomMembership(room);
|
||||||
const memberCount = useRoomMemberCount(room);
|
const memberCount = useRoomMemberCount(room);
|
||||||
|
|
||||||
|
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||||
|
const isVideoRoom = room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom());
|
||||||
|
|
||||||
let iconClass: string;
|
let iconClass: string;
|
||||||
let roomType: string;
|
let roomType: string;
|
||||||
if (room.isElementVideoRoom()) {
|
if (isVideoRoom) {
|
||||||
iconClass = "mx_RoomInfoLine_video";
|
iconClass = "mx_RoomInfoLine_video";
|
||||||
roomType = _t("Video room");
|
roomType = _t("Video room");
|
||||||
} else if (joinRule === JoinRule.Public) {
|
} else if (joinRule === JoinRule.Public) {
|
||||||
|
|
|
@ -32,6 +32,7 @@ import { _t, _td } from "../../../languageHandler";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
import PosthogTrackers from "../../../PosthogTrackers";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
|
import { useFeatureEnabled } from "../../../hooks/useSettings";
|
||||||
import { UIComponent } from "../../../settings/UIFeature";
|
import { UIComponent } from "../../../settings/UIFeature";
|
||||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||||
import { ITagMap } from "../../../stores/room-list/algorithms/models";
|
import { ITagMap } from "../../../stores/room-list/algorithms/models";
|
||||||
|
@ -200,8 +201,10 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
|
const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
|
||||||
|
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
||||||
|
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||||
|
|
||||||
let contextMenuContent: JSX.Element;
|
let contextMenuContent: JSX.Element | null = null;
|
||||||
if (menuDisplayed && activeSpace) {
|
if (menuDisplayed && activeSpace) {
|
||||||
const canAddRooms = activeSpace.currentState.maySendStateEvent(EventType.SpaceChild,
|
const canAddRooms = activeSpace.currentState.maySendStateEvent(EventType.SpaceChild,
|
||||||
MatrixClientPeg.get().getUserId());
|
MatrixClientPeg.get().getUserId());
|
||||||
|
@ -239,7 +242,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
|
||||||
tooltip={canAddRooms ? undefined
|
tooltip={canAddRooms ? undefined
|
||||||
: _t("You do not have permissions to create new rooms in this space")}
|
: _t("You do not have permissions to create new rooms in this space")}
|
||||||
/>
|
/>
|
||||||
{ SettingsStore.getValue("feature_video_rooms") && (
|
{ videoRoomsEnabled && (
|
||||||
<IconizedContextMenuOption
|
<IconizedContextMenuOption
|
||||||
label={_t("New video room")}
|
label={_t("New video room")}
|
||||||
iconClassName="mx_RoomList_iconNewVideoRoom"
|
iconClassName="mx_RoomList_iconNewVideoRoom"
|
||||||
|
@ -247,7 +250,10 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
closeMenu();
|
closeMenu();
|
||||||
showCreateNewRoom(activeSpace, RoomType.ElementVideo);
|
showCreateNewRoom(
|
||||||
|
activeSpace,
|
||||||
|
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={!canAddRooms}
|
disabled={!canAddRooms}
|
||||||
tooltip={canAddRooms ? undefined
|
tooltip={canAddRooms ? undefined
|
||||||
|
@ -287,7 +293,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
|
||||||
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
|
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{ SettingsStore.getValue("feature_video_rooms") && (
|
{ videoRoomsEnabled && (
|
||||||
<IconizedContextMenuOption
|
<IconizedContextMenuOption
|
||||||
label={_t("New video room")}
|
label={_t("New video room")}
|
||||||
iconClassName="mx_RoomList_iconNewVideoRoom"
|
iconClassName="mx_RoomList_iconNewVideoRoom"
|
||||||
|
@ -297,7 +303,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
defaultDispatcher.dispatch({
|
defaultDispatcher.dispatch({
|
||||||
action: "view_create_room",
|
action: "view_create_room",
|
||||||
type: RoomType.ElementVideo,
|
type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -319,7 +325,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
|
||||||
</IconizedContextMenuOptionList>;
|
</IconizedContextMenuOptionList>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let contextMenu: JSX.Element;
|
let contextMenu: JSX.Element | null = null;
|
||||||
if (menuDisplayed) {
|
if (menuDisplayed) {
|
||||||
contextMenu = <IconizedContextMenu {...auxButtonContextMenuPosition(handle)} onFinished={closeMenu} compact>
|
contextMenu = <IconizedContextMenu {...auxButtonContextMenuPosition(handle)} onFinished={closeMenu} compact>
|
||||||
{ contextMenuContent }
|
{ contextMenuContent }
|
||||||
|
|
|
@ -127,6 +127,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
|
||||||
return SpaceStore.instance.allRoomsInHome;
|
return SpaceStore.instance.allRoomsInHome;
|
||||||
});
|
});
|
||||||
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
||||||
|
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||||
const pendingActions = usePendingActions();
|
const pendingActions = usePendingActions();
|
||||||
|
|
||||||
const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home;
|
const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home;
|
||||||
|
@ -211,7 +212,10 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
showCreateNewRoom(activeSpace, RoomType.ElementVideo);
|
showCreateNewRoom(
|
||||||
|
activeSpace,
|
||||||
|
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
|
||||||
|
);
|
||||||
closePlusMenu();
|
closePlusMenu();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -310,7 +314,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
defaultDispatcher.dispatch({
|
defaultDispatcher.dispatch({
|
||||||
action: "view_create_room",
|
action: "view_create_room",
|
||||||
type: RoomType.ElementVideo,
|
type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
|
||||||
});
|
});
|
||||||
closePlusMenu();
|
closePlusMenu();
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -51,6 +51,8 @@ interface IProps {
|
||||||
const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButtonClicked }) => {
|
const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
|
||||||
|
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
|
||||||
|
const isVideoRoom = room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom());
|
||||||
const myMembership = useMyRoomMembership(room);
|
const myMembership = useMyRoomMembership(room);
|
||||||
useDispatcher(defaultDispatcher, payload => {
|
useDispatcher(defaultDispatcher, payload => {
|
||||||
if (payload.action === Action.JoinRoomError && payload.roomId === room.roomId) {
|
if (payload.action === Action.JoinRoomError && payload.roomId === room.roomId) {
|
||||||
|
@ -69,7 +71,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
|
||||||
initialTabId: UserTab.Labs,
|
initialTabId: UserTab.Labs,
|
||||||
});
|
});
|
||||||
|
|
||||||
let inviterSection: JSX.Element;
|
let inviterSection: JSX.Element | null = null;
|
||||||
let joinButtons: JSX.Element;
|
let joinButtons: JSX.Element;
|
||||||
if (myMembership === "join") {
|
if (myMembership === "join") {
|
||||||
joinButtons = (
|
joinButtons = (
|
||||||
|
@ -86,10 +88,11 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
);
|
);
|
||||||
} else if (myMembership === "invite") {
|
} else if (myMembership === "invite") {
|
||||||
const inviteSender = room.getMember(cli.getUserId())?.events.member?.getSender();
|
const inviteSender = room.getMember(cli.getUserId()!)?.events.member?.getSender();
|
||||||
const inviter = inviteSender && room.getMember(inviteSender);
|
|
||||||
|
|
||||||
if (inviteSender) {
|
if (inviteSender) {
|
||||||
|
const inviter = room.getMember(inviteSender);
|
||||||
|
|
||||||
inviterSection = <div className="mx_RoomPreviewCard_inviter">
|
inviterSection = <div className="mx_RoomPreviewCard_inviter">
|
||||||
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
|
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
|
||||||
<div>
|
<div>
|
||||||
|
@ -102,10 +105,6 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
|
||||||
{ inviteSender }
|
{ inviteSender }
|
||||||
</div> : null }
|
</div> : null }
|
||||||
</div>
|
</div>
|
||||||
{ room.isElementVideoRoom()
|
|
||||||
? <BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,10 +151,11 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
|
||||||
}
|
}
|
||||||
|
|
||||||
let avatarRow: JSX.Element;
|
let avatarRow: JSX.Element;
|
||||||
if (room.isElementVideoRoom()) {
|
if (isVideoRoom) {
|
||||||
avatarRow = <>
|
avatarRow = <>
|
||||||
<RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />
|
<RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />
|
||||||
<div className="mx_RoomPreviewCard_video" />
|
<div className="mx_RoomPreviewCard_video" />
|
||||||
|
<BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
|
||||||
</>;
|
</>;
|
||||||
} else if (room.isSpaceRoom()) {
|
} else if (room.isSpaceRoom()) {
|
||||||
avatarRow = <RoomAvatar room={room} height={80} width={80} viewAvatarOnClick />;
|
avatarRow = <RoomAvatar room={room} height={80} width={80} viewAvatarOnClick />;
|
||||||
|
@ -163,12 +163,12 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
|
||||||
avatarRow = <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />;
|
avatarRow = <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let notice: string;
|
let notice: string | null = null;
|
||||||
if (cannotJoin) {
|
if (cannotJoin) {
|
||||||
notice = _t("To view %(roomName)s, you need an invite", {
|
notice = _t("To view %(roomName)s, you need an invite", {
|
||||||
roomName: room.name,
|
roomName: room.name,
|
||||||
});
|
});
|
||||||
} else if (room.isElementVideoRoom() && !videoRoomsEnabled) {
|
} else if (isVideoRoom && !videoRoomsEnabled) {
|
||||||
notice = myMembership === "join"
|
notice = myMembership === "join"
|
||||||
? _t("To view, please enable video rooms in Labs first")
|
? _t("To view, please enable video rooms in Labs first")
|
||||||
: _t("To join, please enable video rooms in Labs first");
|
: _t("To join, please enable video rooms in Labs first");
|
||||||
|
|
|
@ -37,7 +37,7 @@ import { getAddressType } from "./UserAddress";
|
||||||
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
|
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
|
||||||
import SpaceStore from "./stores/spaces/SpaceStore";
|
import SpaceStore from "./stores/spaces/SpaceStore";
|
||||||
import { makeSpaceParentEvent } from "./utils/space";
|
import { makeSpaceParentEvent } from "./utils/space";
|
||||||
import { JitsiCall } from "./models/Call";
|
import { JitsiCall, ElementCall } from "./models/Call";
|
||||||
import { Action } from "./dispatcher/actions";
|
import { Action } from "./dispatcher/actions";
|
||||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||||
import Spinner from "./components/views/elements/Spinner";
|
import Spinner from "./components/views/elements/Spinner";
|
||||||
|
@ -67,6 +67,17 @@ export interface IOpts {
|
||||||
joinRule?: JoinRule;
|
joinRule?: JoinRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EVENT_POWER_LEVELS = {
|
||||||
|
[EventType.RoomName]: 50,
|
||||||
|
[EventType.RoomAvatar]: 50,
|
||||||
|
[EventType.RoomPowerLevels]: 100,
|
||||||
|
[EventType.RoomHistoryVisibility]: 100,
|
||||||
|
[EventType.RoomCanonicalAlias]: 50,
|
||||||
|
[EventType.RoomTombstone]: 100,
|
||||||
|
[EventType.RoomServerAcl]: 100,
|
||||||
|
[EventType.RoomEncryption]: 100,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new room, and switch to it.
|
* Create a new room, and switch to it.
|
||||||
*
|
*
|
||||||
|
@ -131,23 +142,29 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
|
||||||
if (opts.roomType === RoomType.ElementVideo) {
|
if (opts.roomType === RoomType.ElementVideo) {
|
||||||
createOpts.power_level_content_override = {
|
createOpts.power_level_content_override = {
|
||||||
events: {
|
events: {
|
||||||
|
...DEFAULT_EVENT_POWER_LEVELS,
|
||||||
// Allow all users to send call membership updates
|
// Allow all users to send call membership updates
|
||||||
[JitsiCall.MEMBER_EVENT_TYPE]: 0,
|
[JitsiCall.MEMBER_EVENT_TYPE]: 0,
|
||||||
// Make widgets immutable, even to admins
|
// Make widgets immutable, even to admins
|
||||||
"im.vector.modular.widgets": 200,
|
"im.vector.modular.widgets": 200,
|
||||||
// Annoyingly, we have to reiterate all the defaults here
|
|
||||||
[EventType.RoomName]: 50,
|
|
||||||
[EventType.RoomAvatar]: 50,
|
|
||||||
[EventType.RoomPowerLevels]: 100,
|
|
||||||
[EventType.RoomHistoryVisibility]: 100,
|
|
||||||
[EventType.RoomCanonicalAlias]: 50,
|
|
||||||
[EventType.RoomTombstone]: 100,
|
|
||||||
[EventType.RoomServerAcl]: 100,
|
|
||||||
[EventType.RoomEncryption]: 100,
|
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
// Temporarily give ourselves the power to set up a widget
|
// Temporarily give ourselves the power to set up a widget
|
||||||
[client.getUserId()]: 200,
|
[client.getUserId()!]: 200,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (opts.roomType === RoomType.UnstableCall) {
|
||||||
|
createOpts.power_level_content_override = {
|
||||||
|
events: {
|
||||||
|
...DEFAULT_EVENT_POWER_LEVELS,
|
||||||
|
// Allow all users to send call membership updates
|
||||||
|
"org.matrix.msc3401.call.member": 0,
|
||||||
|
// Make calls immutable, even to admins
|
||||||
|
"org.matrix.msc3401.call": 200,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
// Temporarily give ourselves the power to set up a call
|
||||||
|
[client.getUserId()!]: 200,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -281,11 +298,18 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
|
||||||
}
|
}
|
||||||
}).then(async () => {
|
}).then(async () => {
|
||||||
if (opts.roomType === RoomType.ElementVideo) {
|
if (opts.roomType === RoomType.ElementVideo) {
|
||||||
// Set up video rooms with a Jitsi call
|
// Set up this video room with a Jitsi call
|
||||||
await JitsiCall.create(await room);
|
await JitsiCall.create(await room);
|
||||||
|
|
||||||
// Reset our power level back to admin so that the widget becomes immutable
|
// Reset our power level back to admin so that the widget becomes immutable
|
||||||
const plEvent = (await room)?.currentState.getStateEvents(EventType.RoomPowerLevels, "");
|
const plEvent = (await room).currentState.getStateEvents(EventType.RoomPowerLevels, "");
|
||||||
|
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
|
||||||
|
} else if (opts.roomType === RoomType.UnstableCall) {
|
||||||
|
// Set up this video room with an Element call
|
||||||
|
await ElementCall.create(await room);
|
||||||
|
|
||||||
|
// Reset our power level back to admin so that the call becomes immutable
|
||||||
|
const plEvent = (await room).currentState.getStateEvents(EventType.RoomPowerLevels, "");
|
||||||
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
|
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
|
||||||
}
|
}
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
|
|
|
@ -909,6 +909,7 @@
|
||||||
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
|
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
|
||||||
"Send read receipts": "Send read receipts",
|
"Send read receipts": "Send read receipts",
|
||||||
"Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)",
|
"Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)",
|
||||||
|
"Element Call video rooms": "Element Call video rooms",
|
||||||
"Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)",
|
"Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)",
|
||||||
"Favourite Messages (under active development)": "Favourite Messages (under active development)",
|
"Favourite Messages (under active development)": "Favourite Messages (under active development)",
|
||||||
"Voice broadcast (under active development)": "Voice broadcast (under active development)",
|
"Voice broadcast (under active development)": "Voice broadcast (under active development)",
|
||||||
|
|
|
@ -16,18 +16,23 @@ limitations under the License.
|
||||||
|
|
||||||
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
|
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { RoomEvent } from "matrix-js-sdk/src/models/room";
|
import { RoomEvent } from "matrix-js-sdk/src/models/room";
|
||||||
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
||||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||||
import { IWidgetApiRequest } from "matrix-widget-api";
|
import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
|
||||||
|
import { IWidgetApiRequest, MatrixWidgetType } from "matrix-widget-api";
|
||||||
|
|
||||||
import type EventEmitter from "events";
|
import type EventEmitter from "events";
|
||||||
import type { IMyDevice } from "matrix-js-sdk/src/client";
|
import type { IMyDevice } from "matrix-js-sdk/src/client";
|
||||||
|
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
import type { ClientWidgetApi } from "matrix-widget-api";
|
import type { ClientWidgetApi } from "matrix-widget-api";
|
||||||
import type { IApp } from "../stores/WidgetStore";
|
import type { IApp } from "../stores/WidgetStore";
|
||||||
|
import SdkConfig from "../SdkConfig";
|
||||||
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
|
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
|
||||||
import { timeout } from "../utils/promise";
|
import { timeout } from "../utils/promise";
|
||||||
import WidgetUtils from "../utils/WidgetUtils";
|
import WidgetUtils from "../utils/WidgetUtils";
|
||||||
|
@ -40,15 +45,19 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidge
|
||||||
const TIMEOUT_MS = 16000;
|
const TIMEOUT_MS = 16000;
|
||||||
|
|
||||||
// Waits until an event is emitted satisfying the given predicate
|
// Waits until an event is emitted satisfying the given predicate
|
||||||
const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => {
|
const waitForEvent = async (
|
||||||
let listener: (...args) => void;
|
emitter: EventEmitter,
|
||||||
|
event: string,
|
||||||
|
pred: (...args: any[]) => boolean = () => true,
|
||||||
|
): Promise<void> => {
|
||||||
|
let listener: (...args: any[]) => void;
|
||||||
const wait = new Promise<void>(resolve => {
|
const wait = new Promise<void>(resolve => {
|
||||||
listener = (...args) => { if (pred(...args)) resolve(); };
|
listener = (...args) => { if (pred(...args)) resolve(); };
|
||||||
emitter.on(event, listener);
|
emitter.on(event, listener);
|
||||||
});
|
});
|
||||||
|
|
||||||
const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
|
const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
|
||||||
emitter.off(event, listener);
|
emitter.off(event, listener!);
|
||||||
if (timedOut) throw new Error("Timed out");
|
if (timedOut) throw new Error("Timed out");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -74,18 +83,17 @@ interface CallEventHandlerMap {
|
||||||
[CallEvent.Destroy]: () => void;
|
[CallEvent.Destroy]: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JitsiCallMemberContent {
|
|
||||||
// Connected device IDs
|
|
||||||
devices: string[];
|
|
||||||
// Time at which this state event should be considered stale
|
|
||||||
expires_ts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A group call accessed through a widget.
|
* A group call accessed through a widget.
|
||||||
*/
|
*/
|
||||||
export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
|
export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
|
||||||
protected readonly widgetUid = WidgetUtils.getWidgetUid(this.widget);
|
protected readonly widgetUid = WidgetUtils.getWidgetUid(this.widget);
|
||||||
|
protected readonly room = this.client.getRoom(this.roomId)!;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time after which device member state should be considered expired.
|
||||||
|
*/
|
||||||
|
public abstract readonly STUCK_DEVICE_TIMEOUT_MS: number;
|
||||||
|
|
||||||
private _messaging: ClientWidgetApi | null = null;
|
private _messaging: ClientWidgetApi | null = null;
|
||||||
/**
|
/**
|
||||||
|
@ -130,6 +138,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
* The widget used to access this call.
|
* The widget used to access this call.
|
||||||
*/
|
*/
|
||||||
public readonly widget: IApp,
|
public readonly widget: IApp,
|
||||||
|
protected readonly client: MatrixClient,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
@ -140,21 +149,77 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
* @returns {Call | null} The call.
|
* @returns {Call | null} The call.
|
||||||
*/
|
*/
|
||||||
public static get(room: Room): Call | null {
|
public static get(room: Room): Call | null {
|
||||||
// There's currently only one implementation
|
return ElementCall.get(room) ?? JitsiCall.get(room);
|
||||||
return JitsiCall.get(room);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the connected devices associated with the given user in room state.
|
||||||
|
* @param userId The user's ID.
|
||||||
|
* @returns The IDs of the user's connected devices.
|
||||||
|
*/
|
||||||
|
protected abstract getDevices(userId: string): string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the connected devices associated with ourselves in room state.
|
||||||
|
* @param devices The devices with which we're connected.
|
||||||
|
*/
|
||||||
|
protected abstract setDevices(devices: string[]): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates our member state with the devices returned by the given function.
|
||||||
|
* @param fn A function from the current devices to the new devices. If it
|
||||||
|
* returns null, the update is skipped.
|
||||||
|
*/
|
||||||
|
protected async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
|
||||||
|
if (this.room.getMyMembership() !== "join") return;
|
||||||
|
|
||||||
|
const devices = fn(this.getDevices(this.client.getUserId()!));
|
||||||
|
if (devices) {
|
||||||
|
await this.setDevices(devices);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs a routine check of the call's associated room state, cleaning up
|
* Performs a routine check of the call's associated room state, cleaning up
|
||||||
* any data left over from an unclean disconnection.
|
* any data left over from an unclean disconnection.
|
||||||
*/
|
*/
|
||||||
public abstract clean(): Promise<void>;
|
public async clean(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const { devices: myDevices } = await this.client.getDevices();
|
||||||
|
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
|
||||||
|
|
||||||
|
// Clean up our member state by filtering out logged out devices,
|
||||||
|
// inactive devices, and our own device (if we're disconnected)
|
||||||
|
await this.updateDevices(devices => {
|
||||||
|
const newDevices = devices.filter(d => {
|
||||||
|
const device = deviceMap.get(d);
|
||||||
|
return device?.last_seen_ts !== undefined
|
||||||
|
&& !(d === this.client.getDeviceId() && !this.connected)
|
||||||
|
&& (now - device.last_seen_ts) < this.STUCK_DEVICE_TIMEOUT_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip the update if the devices are unchanged
|
||||||
|
return newDevices.length === devices.length ? null : newDevices;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async addOurDevice(): Promise<void> {
|
||||||
|
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async removeOurDevice(): Promise<void> {
|
||||||
|
await this.updateDevices(devices => {
|
||||||
|
const devicesSet = new Set(devices);
|
||||||
|
devicesSet.delete(this.client.getDeviceId());
|
||||||
|
return Array.from(devicesSet);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contacts the widget to connect to the call.
|
* Contacts the widget to connect to the call.
|
||||||
* @param {MediaDeviceInfo | null} audioDevice The audio input to use, or
|
* @param {MediaDeviceInfo | null} audioInput The audio input to use, or
|
||||||
* null to start muted.
|
* null to start muted.
|
||||||
* @param {MediaDeviceInfo | null} audioDevice The video input to use, or
|
* @param {MediaDeviceInfo | null} audioInput The video input to use, or
|
||||||
* null to start muted.
|
* null to start muted.
|
||||||
*/
|
*/
|
||||||
protected abstract performConnection(
|
protected abstract performConnection(
|
||||||
|
@ -219,6 +284,8 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
|
||||||
|
window.addEventListener("beforeunload", this.beforeUnload);
|
||||||
this.connectionState = ConnectionState.Connected;
|
this.connectionState = ConnectionState.Connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,6 +304,8 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
* Manually marks the call as disconnected and cleans up.
|
* Manually marks the call as disconnected and cleans up.
|
||||||
*/
|
*/
|
||||||
public setDisconnected() {
|
public setDisconnected() {
|
||||||
|
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
|
||||||
|
window.removeEventListener("beforeunload", this.beforeUnload);
|
||||||
this.messaging = null;
|
this.messaging = null;
|
||||||
this.connectionState = ConnectionState.Disconnected;
|
this.connectionState = ConnectionState.Disconnected;
|
||||||
}
|
}
|
||||||
|
@ -248,6 +317,19 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
if (this.connected) this.setDisconnected();
|
if (this.connected) this.setDisconnected();
|
||||||
this.emit(CallEvent.Destroy);
|
this.emit(CallEvent.Destroy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onMyMembership = async (_room: Room, membership: string) => {
|
||||||
|
if (membership !== "join") this.setDisconnected();
|
||||||
|
};
|
||||||
|
|
||||||
|
private beforeUnload = () => this.setDisconnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JitsiCallMemberContent {
|
||||||
|
// Connected device IDs
|
||||||
|
devices: string[];
|
||||||
|
// Time at which this state event should be considered stale
|
||||||
|
expires_ts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -255,14 +337,13 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||||
*/
|
*/
|
||||||
export class JitsiCall extends Call {
|
export class JitsiCall extends Call {
|
||||||
public static readonly MEMBER_EVENT_TYPE = "io.element.video.member";
|
public static readonly MEMBER_EVENT_TYPE = "io.element.video.member";
|
||||||
public static readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
||||||
|
|
||||||
private room: Room = this.client.getRoom(this.roomId)!;
|
|
||||||
private resendDevicesTimer: number | null = null;
|
private resendDevicesTimer: number | null = null;
|
||||||
private participantsExpirationTimer: number | null = null;
|
private participantsExpirationTimer: number | null = null;
|
||||||
|
|
||||||
private constructor(widget: IApp, private readonly client: MatrixClient) {
|
private constructor(widget: IApp, client: MatrixClient) {
|
||||||
super(widget);
|
super(widget, client);
|
||||||
|
|
||||||
this.room.on(RoomStateEvent.Update, this.onRoomState);
|
this.room.on(RoomStateEvent.Update, this.onRoomState);
|
||||||
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
||||||
|
@ -270,10 +351,15 @@ export class JitsiCall extends Call {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static get(room: Room): JitsiCall | null {
|
public static get(room: Room): JitsiCall | null {
|
||||||
const apps = WidgetStore.instance.getApps(room.roomId);
|
// Only supported in video rooms
|
||||||
// The isVideoChannel field differentiates rich Jitsi calls from bare Jitsi widgets
|
if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) {
|
||||||
const jitsiWidget = apps.find(app => WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel);
|
const apps = WidgetStore.instance.getApps(room.roomId);
|
||||||
return jitsiWidget ? new JitsiCall(jitsiWidget, room.client) : null;
|
// 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> {
|
public static async create(room: Room): Promise<void> {
|
||||||
|
@ -293,15 +379,15 @@ export class JitsiCall extends Call {
|
||||||
for (const e of this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE)) {
|
for (const e of this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE)) {
|
||||||
const member = this.room.getMember(e.getStateKey()!);
|
const member = this.room.getMember(e.getStateKey()!);
|
||||||
const content = e.getContent<JitsiCallMemberContent>();
|
const content = e.getContent<JitsiCallMemberContent>();
|
||||||
let devices = Array.isArray(content.devices) ? content.devices : [];
|
|
||||||
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
|
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
|
||||||
|
let devices = expiresAt > now && Array.isArray(content.devices) ? content.devices : [];
|
||||||
|
|
||||||
// Apply local echo for the disconnected case
|
// Apply local echo for the disconnected case
|
||||||
if (!this.connected && member?.userId === this.client.getUserId()) {
|
if (!this.connected && member?.userId === this.client.getUserId()) {
|
||||||
devices = devices.filter(d => d !== this.client.getDeviceId());
|
devices = devices.filter(d => d !== this.client.getDeviceId());
|
||||||
}
|
}
|
||||||
// Must have a connected device, be unexpired, and still be joined to the room
|
// Must have a connected device and still be joined to the room
|
||||||
if (devices.length && expiresAt > now && member?.membership === "join") {
|
if (devices.length && member?.membership === "join") {
|
||||||
members.add(member);
|
members.add(member);
|
||||||
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
|
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
|
||||||
}
|
}
|
||||||
|
@ -316,59 +402,22 @@ export class JitsiCall extends Call {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method that updates our member state with the devices returned by
|
protected getDevices(userId: string): string[] {
|
||||||
// the given function. If it returns null, the update is skipped.
|
const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, userId);
|
||||||
private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
|
const content = event?.getContent<JitsiCallMemberContent>();
|
||||||
if (this.room.getMyMembership() !== "join") return;
|
const expiresAt = typeof content?.expires_ts === "number" ? content.expires_ts : -Infinity;
|
||||||
|
return expiresAt > Date.now() && Array.isArray(content?.devices) ? content.devices : [];
|
||||||
|
}
|
||||||
|
|
||||||
const devicesState = this.room.currentState.getStateEvents(
|
protected async setDevices(devices: string[]): Promise<void> {
|
||||||
JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!,
|
const 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(
|
protected async performConnection(
|
||||||
|
@ -433,8 +482,6 @@ export class JitsiCall extends Call {
|
||||||
|
|
||||||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
|
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
|
||||||
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
|
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
|
||||||
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
|
|
||||||
window.addEventListener("beforeunload", this.beforeUnload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async performDisconnection(): Promise<void> {
|
protected async performDisconnection(): Promise<void> {
|
||||||
|
@ -459,14 +506,12 @@ export class JitsiCall extends Call {
|
||||||
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
||||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
|
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
|
||||||
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
|
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
|
||||||
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
|
|
||||||
window.removeEventListener("beforeunload", this.beforeUnload);
|
|
||||||
|
|
||||||
super.setDisconnected();
|
super.setDisconnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.room.off(RoomStateEvent.Update, this.updateParticipants);
|
this.room.off(RoomStateEvent.Update, this.onRoomState);
|
||||||
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
||||||
if (this.participantsExpirationTimer !== null) {
|
if (this.participantsExpirationTimer !== null) {
|
||||||
clearTimeout(this.participantsExpirationTimer);
|
clearTimeout(this.participantsExpirationTimer);
|
||||||
|
@ -483,8 +528,8 @@ export class JitsiCall extends Call {
|
||||||
private onRoomState = () => this.updateParticipants();
|
private onRoomState = () => this.updateParticipants();
|
||||||
|
|
||||||
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
|
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
|
||||||
if (state === ConnectionState.Connected && prevState === ConnectionState.Connecting) {
|
if (state === ConnectionState.Connected && !isConnected(prevState)) {
|
||||||
this.updateParticipants();
|
this.updateParticipants(); // Local echo
|
||||||
|
|
||||||
// Tell others that we're connected, by adding our device to room state
|
// Tell others that we're connected, by adding our device to room state
|
||||||
await this.addOurDevice();
|
await this.addOurDevice();
|
||||||
|
@ -492,12 +537,14 @@ export class JitsiCall extends Call {
|
||||||
this.resendDevicesTimer = setInterval(async () => {
|
this.resendDevicesTimer = setInterval(async () => {
|
||||||
logger.log(`Resending video member event for ${this.roomId}`);
|
logger.log(`Resending video member event for ${this.roomId}`);
|
||||||
await this.addOurDevice();
|
await this.addOurDevice();
|
||||||
}, (JitsiCall.STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
|
}, (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
|
||||||
} else if (state === ConnectionState.Disconnected && isConnected(prevState)) {
|
} else if (state === ConnectionState.Disconnected && isConnected(prevState)) {
|
||||||
this.updateParticipants();
|
this.updateParticipants(); // Local echo
|
||||||
|
|
||||||
clearInterval(this.resendDevicesTimer);
|
if (this.resendDevicesTimer !== null) {
|
||||||
this.resendDevicesTimer = null;
|
clearInterval(this.resendDevicesTimer);
|
||||||
|
this.resendDevicesTimer = null;
|
||||||
|
}
|
||||||
// Tell others that we're disconnected, by removing our device from room state
|
// Tell others that we're disconnected, by removing our device from room state
|
||||||
await this.removeOurDevice();
|
await this.removeOurDevice();
|
||||||
}
|
}
|
||||||
|
@ -514,12 +561,6 @@ export class JitsiCall extends Call {
|
||||||
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
|
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
|
||||||
};
|
};
|
||||||
|
|
||||||
private onMyMembership = async (room: Room, membership: string) => {
|
|
||||||
if (membership !== "join") this.setDisconnected();
|
|
||||||
};
|
|
||||||
|
|
||||||
private beforeUnload = () => this.setDisconnected();
|
|
||||||
|
|
||||||
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
|
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||||
// If we're already in the middle of a client-initiated disconnection,
|
// If we're already in the middle of a client-initiated disconnection,
|
||||||
// ignore the event
|
// ignore the event
|
||||||
|
@ -537,3 +578,239 @@ export class JitsiCall extends Call {
|
||||||
this.setDisconnected();
|
this.setDisconnected();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElementCallMemberContent {
|
||||||
|
"m.expires_ts": number;
|
||||||
|
"m.calls": {
|
||||||
|
"m.call_id": string;
|
||||||
|
"m.devices": {
|
||||||
|
device_id: string;
|
||||||
|
session_id: string;
|
||||||
|
feeds: unknown[]; // We don't care about what these are
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A group call using MSC3401 and Element Call as a backend.
|
||||||
|
* (somewhat cheekily named)
|
||||||
|
*/
|
||||||
|
export class ElementCall extends Call {
|
||||||
|
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call");
|
||||||
|
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member");
|
||||||
|
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
||||||
|
|
||||||
|
private participantsExpirationTimer: number | null = null;
|
||||||
|
|
||||||
|
private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
|
||||||
|
// Splice together the Element Call URL for this call
|
||||||
|
const url = new URL(SdkConfig.get("element_call").url);
|
||||||
|
url.pathname = "/room";
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
embed: "",
|
||||||
|
preload: "",
|
||||||
|
hideHeader: "",
|
||||||
|
userId: client.getUserId()!,
|
||||||
|
deviceId: client.getDeviceId(),
|
||||||
|
roomId: groupCall.getRoomId()!,
|
||||||
|
});
|
||||||
|
url.hash = `#?${params.toString()}`;
|
||||||
|
|
||||||
|
// To use Element Call without touching room state, we create a virtual
|
||||||
|
// widget (one that doesn't have a corresponding state event)
|
||||||
|
super(
|
||||||
|
WidgetStore.instance.addVirtualWidget({
|
||||||
|
id: randomString(24), // So that it's globally unique
|
||||||
|
creatorUserId: client.getUserId()!,
|
||||||
|
name: "Element Call",
|
||||||
|
type: MatrixWidgetType.Custom,
|
||||||
|
url: url.toString(),
|
||||||
|
}, groupCall.getRoomId()!),
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.room.on(RoomStateEvent.Update, this.onRoomState);
|
||||||
|
this.on(CallEvent.ConnectionState, this.onConnectionState);
|
||||||
|
this.updateParticipants();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static get(room: Room): ElementCall | null {
|
||||||
|
// Only supported in video rooms (for now)
|
||||||
|
if (
|
||||||
|
SettingsStore.getValue("feature_video_rooms")
|
||||||
|
&& SettingsStore.getValue("feature_element_call_video_rooms")
|
||||||
|
&& room.isCallRoom()
|
||||||
|
) {
|
||||||
|
const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType =>
|
||||||
|
room.currentState.getStateEvents(eventType),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the newest unterminated call
|
||||||
|
let groupCall: MatrixEvent | null = null;
|
||||||
|
for (const event of groupCalls) {
|
||||||
|
if (
|
||||||
|
!("m.terminated" in event.getContent())
|
||||||
|
&& (groupCall === null || event.getTs() > groupCall.getTs())
|
||||||
|
) {
|
||||||
|
groupCall = event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupCall !== null) return new ElementCall(groupCall, room.client);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async create(room: Room): Promise<void> {
|
||||||
|
await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, {
|
||||||
|
"m.intent": "m.room",
|
||||||
|
"m.type": "m.video",
|
||||||
|
}, randomString(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateParticipants() {
|
||||||
|
if (this.participantsExpirationTimer !== null) {
|
||||||
|
clearTimeout(this.participantsExpirationTimer);
|
||||||
|
this.participantsExpirationTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = new Set<RoomMember>();
|
||||||
|
const now = Date.now();
|
||||||
|
let allExpireAt = Infinity;
|
||||||
|
|
||||||
|
const memberEvents = ElementCall.MEMBER_EVENT_TYPE.names.flatMap(eventType =>
|
||||||
|
this.room.currentState.getStateEvents(eventType),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const e of memberEvents) {
|
||||||
|
const member = this.room.getMember(e.getStateKey()!);
|
||||||
|
const content = e.getContent<ElementCallMemberContent>();
|
||||||
|
const expiresAt = typeof content["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
|
||||||
|
const calls = expiresAt > now && Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
|
||||||
|
const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
|
||||||
|
let devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||||
|
|
||||||
|
// Apply local echo for the disconnected case
|
||||||
|
if (!this.connected && member?.userId === this.client.getUserId()) {
|
||||||
|
devices = devices.filter(d => d.device_id !== this.client.getDeviceId());
|
||||||
|
}
|
||||||
|
// Must have a connected device and still be joined to the room
|
||||||
|
if (devices.length && member?.membership === "join") {
|
||||||
|
members.add(member);
|
||||||
|
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply local echo for the connected case
|
||||||
|
if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
|
||||||
|
|
||||||
|
this.participants = members;
|
||||||
|
if (allExpireAt < Infinity) {
|
||||||
|
this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCallsState(userId: string): ElementCallMemberContent["m.calls"] {
|
||||||
|
const event = (() => {
|
||||||
|
for (const eventType of ElementCall.MEMBER_EVENT_TYPE.names) {
|
||||||
|
const e = this.room.currentState.getStateEvents(eventType, userId);
|
||||||
|
if (e) return e;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
const content = event?.getContent<ElementCallMemberContent>();
|
||||||
|
const expiresAt = typeof content?.["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
|
||||||
|
return expiresAt > Date.now() && Array.isArray(content?.["m.calls"]) ? content!["m.calls"] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDevices(userId: string): string[] {
|
||||||
|
const calls = this.getCallsState(userId);
|
||||||
|
const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
|
||||||
|
const devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||||
|
return devices.map(d => d.device_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async setDevices(devices: string[]): Promise<void> {
|
||||||
|
const calls = this.getCallsState(this.client.getUserId()!);
|
||||||
|
const call = calls.find(c => c["m.call_id"] === this.groupCall.getStateKey())!;
|
||||||
|
const prevDevices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
|
||||||
|
const prevDevicesMap = new Map(prevDevices.map(d => [d.device_id, d]));
|
||||||
|
|
||||||
|
const newContent: ElementCallMemberContent = {
|
||||||
|
"m.expires_ts": Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
|
||||||
|
"m.calls": [
|
||||||
|
{
|
||||||
|
"m.call_id": this.groupCall.getStateKey()!,
|
||||||
|
// This method will only ever be used to remove devices, so
|
||||||
|
// it's safe to assume that all requested devices are
|
||||||
|
// present in the map
|
||||||
|
"m.devices": devices.map(d => prevDevicesMap.get(d)!),
|
||||||
|
},
|
||||||
|
...calls.filter(c => c !== call),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.client.sendStateEvent(
|
||||||
|
this.roomId, ElementCall.MEMBER_EVENT_TYPE.name, newContent, this.client.getUserId()!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async performConnection(
|
||||||
|
audioInput: MediaDeviceInfo | null,
|
||||||
|
videoInput: MediaDeviceInfo | null,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
|
||||||
|
audioInput: audioInput?.deviceId ?? null,
|
||||||
|
videoInput: videoInput?.deviceId ?? null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async performDisconnection(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setDisconnected() {
|
||||||
|
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
|
||||||
|
super.setDisconnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
|
||||||
|
this.room.off(RoomStateEvent.Update, this.onRoomState);
|
||||||
|
this.off(CallEvent.ConnectionState, this.onConnectionState);
|
||||||
|
if (this.participantsExpirationTimer !== null) {
|
||||||
|
clearTimeout(this.participantsExpirationTimer);
|
||||||
|
this.participantsExpirationTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onRoomState = () => this.updateParticipants();
|
||||||
|
|
||||||
|
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
|
||||||
|
if (
|
||||||
|
(state === ConnectionState.Connected && !isConnected(prevState))
|
||||||
|
|| (state === ConnectionState.Disconnected && isConnected(prevState))
|
||||||
|
) {
|
||||||
|
this.updateParticipants(); // Local echo
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
await this.messaging!.transport.reply(ev.detail, {}); // ack
|
||||||
|
this.setDisconnected();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -423,6 +423,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
||||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
|
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
"feature_element_call_video_rooms": {
|
||||||
|
isFeature: true,
|
||||||
|
supportedLevels: LEVELS_FEATURE,
|
||||||
|
labsGroup: LabGroup.Rooms,
|
||||||
|
displayName: _td("Element Call video rooms"),
|
||||||
|
controller: new ReloadOnChangeController(),
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
"feature_location_share_live": {
|
"feature_location_share_live": {
|
||||||
isFeature: true,
|
isFeature: true,
|
||||||
labsGroup: LabGroup.Messaging,
|
labsGroup: LabGroup.Messaging,
|
||||||
|
|
|
@ -22,7 +22,6 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import type { RoomState } from "matrix-js-sdk/src/models/room-state";
|
import type { RoomState } from "matrix-js-sdk/src/models/room-state";
|
||||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||||
import { ActionPayload } from "../dispatcher/payloads";
|
|
||||||
import { UPDATE_EVENT } from "./AsyncStore";
|
import { UPDATE_EVENT } from "./AsyncStore";
|
||||||
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
||||||
import WidgetStore from "./WidgetStore";
|
import WidgetStore from "./WidgetStore";
|
||||||
|
@ -51,7 +50,7 @@ export class CallStore extends AsyncStoreWithClient<{}> {
|
||||||
super(defaultDispatcher);
|
super(defaultDispatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async onAction(payload: ActionPayload): Promise<void> {
|
protected async onAction(): Promise<void> {
|
||||||
// nothing to do
|
// nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,11 +30,11 @@ import WidgetUtils from "../utils/WidgetUtils";
|
||||||
import { WidgetType } from "../widgets/WidgetType";
|
import { WidgetType } from "../widgets/WidgetType";
|
||||||
import { UPDATE_EVENT } from "./AsyncStore";
|
import { UPDATE_EVENT } from "./AsyncStore";
|
||||||
|
|
||||||
interface IState {}
|
interface IState { }
|
||||||
|
|
||||||
export interface IApp extends IWidget {
|
export interface IApp extends IWidget {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
eventId: string;
|
eventId?: string; // not present on virtual widgets
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
|
avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
|
||||||
}
|
}
|
||||||
|
@ -118,7 +118,12 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
|
||||||
// otherwise we are out of sync with the rest of the app with stale widget events during removal
|
// otherwise we are out of sync with the rest of the app with stale widget events during removal
|
||||||
Array.from(this.widgetMap.values()).forEach(app => {
|
Array.from(this.widgetMap.values()).forEach(app => {
|
||||||
if (app.roomId !== room.roomId) return; // skip - wrong room
|
if (app.roomId !== room.roomId) return; // skip - wrong room
|
||||||
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;
|
let edited = false;
|
||||||
|
@ -169,16 +174,38 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
|
||||||
this.emit(UPDATE_EVENT, roomId);
|
this.emit(UPDATE_EVENT, roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
public getRoom = (roomId: string, initIfNeeded = false) => {
|
public get(widgetId: string, roomId: string | undefined): IApp | undefined {
|
||||||
|
return this.widgetMap.get(WidgetUtils.calcWidgetUid(widgetId, roomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRoom(roomId: string, initIfNeeded = false): IRoomWidgets {
|
||||||
if (initIfNeeded) this.initRoom(roomId); // internally handles "if needed"
|
if (initIfNeeded) this.initRoom(roomId); // internally handles "if needed"
|
||||||
return this.roomMap.get(roomId);
|
return this.roomMap.get(roomId)!;
|
||||||
};
|
}
|
||||||
|
|
||||||
public getApps(roomId: string): IApp[] {
|
public getApps(roomId: string): IApp[] {
|
||||||
const roomInfo = this.getRoom(roomId);
|
const roomInfo = this.getRoom(roomId);
|
||||||
return roomInfo?.widgets || [];
|
return roomInfo?.widgets || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public addVirtualWidget(widget: IWidget, roomId: string): IApp {
|
||||||
|
this.initRoom(roomId);
|
||||||
|
const app = WidgetUtils.makeAppConfig(widget.id, widget, widget.creatorUserId, roomId, undefined);
|
||||||
|
this.widgetMap.set(WidgetUtils.getWidgetUid(app), app);
|
||||||
|
this.roomMap.get(roomId)!.widgets.push(app);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeVirtualWidget(widgetId: string, roomId: string): void {
|
||||||
|
this.widgetMap.delete(WidgetUtils.calcWidgetUid(widgetId, roomId));
|
||||||
|
const roomApps = this.roomMap.get(roomId);
|
||||||
|
if (roomApps) {
|
||||||
|
roomApps.widgets = roomApps.widgets.filter(app =>
|
||||||
|
!(app.id === widgetId && app.roomId === roomId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public doesRoomHaveConference(room: Room): boolean {
|
public doesRoomHaveConference(room: Room): boolean {
|
||||||
const roomInfo = this.getRoom(room.roomId);
|
const roomInfo = this.getRoom(room.roomId);
|
||||||
if (!roomInfo) return false;
|
if (!roomInfo) return false;
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
import { IWidgetApiRequest } from "matrix-widget-api";
|
import { IWidgetApiRequest } from "matrix-widget-api";
|
||||||
|
|
||||||
export enum ElementWidgetActions {
|
export enum ElementWidgetActions {
|
||||||
// All of these actions are currently specific to Jitsi
|
// All of these actions are currently specific to Jitsi and Element Call
|
||||||
JoinCall = "io.element.join",
|
JoinCall = "io.element.join",
|
||||||
HangupCall = "im.vector.hangup",
|
HangupCall = "im.vector.hangup",
|
||||||
CallParticipants = "io.element.participants",
|
CallParticipants = "io.element.participants",
|
||||||
|
|
|
@ -54,6 +54,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
|
import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
|
||||||
import { ModalWidgetStore } from "../ModalWidgetStore";
|
import { ModalWidgetStore } from "../ModalWidgetStore";
|
||||||
|
import { IApp } from "../WidgetStore";
|
||||||
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
|
||||||
import { getCustomTheme } from "../../theme";
|
import { getCustomTheme } from "../../theme";
|
||||||
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
|
import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
|
||||||
|
@ -69,7 +70,7 @@ import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
|
||||||
|
|
||||||
interface IAppTileProps {
|
interface IAppTileProps {
|
||||||
// Note: these are only the props we care about
|
// Note: these are only the props we care about
|
||||||
app: IWidget;
|
app: IApp;
|
||||||
room?: Room; // without a room it is a user widget
|
room?: Room; // without a room it is a user widget
|
||||||
userId: string;
|
userId: string;
|
||||||
creatorUserId: string;
|
creatorUserId: string;
|
||||||
|
@ -155,6 +156,7 @@ export class StopGapWidget extends EventEmitter {
|
||||||
private scalarToken: string;
|
private scalarToken: string;
|
||||||
private roomId?: string;
|
private roomId?: string;
|
||||||
private kind: WidgetKind;
|
private kind: WidgetKind;
|
||||||
|
private readonly virtual: boolean;
|
||||||
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
||||||
|
|
||||||
constructor(private appTileProps: IAppTileProps) {
|
constructor(private appTileProps: IAppTileProps) {
|
||||||
|
@ -171,6 +173,7 @@ export class StopGapWidget extends EventEmitter {
|
||||||
this.mockWidget = new ElementWidget(app);
|
this.mockWidget = new ElementWidget(app);
|
||||||
this.roomId = appTileProps.room?.roomId;
|
this.roomId = appTileProps.room?.roomId;
|
||||||
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
|
this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
|
||||||
|
this.virtual = app.eventId === undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get eventListenerRoomId(): string {
|
private get eventListenerRoomId(): string {
|
||||||
|
@ -265,14 +268,18 @@ export class StopGapWidget extends EventEmitter {
|
||||||
if (this.started) return;
|
if (this.started) return;
|
||||||
|
|
||||||
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
|
const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
|
||||||
const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId);
|
const driver = new StopGapWidgetDriver(
|
||||||
|
allowedCapabilities, this.mockWidget, this.kind, this.virtual, this.roomId,
|
||||||
|
);
|
||||||
|
|
||||||
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
|
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
|
||||||
this.messaging.on("preparing", () => this.emit("preparing"));
|
this.messaging.on("preparing", () => this.emit("preparing"));
|
||||||
this.messaging.on("ready", () => this.emit("ready"));
|
this.messaging.on("ready", () => {
|
||||||
|
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging);
|
||||||
|
this.emit("ready");
|
||||||
|
});
|
||||||
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
|
this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
|
||||||
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
|
this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
|
||||||
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging);
|
|
||||||
|
|
||||||
// Always attach a handler for ViewRoom, but permission check it internally
|
// Always attach a handler for ViewRoom, but permission check it internally
|
||||||
this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => {
|
this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => {
|
||||||
|
|
|
@ -40,6 +40,7 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
|
||||||
import { Direction } from "matrix-js-sdk/src/matrix";
|
import { Direction } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import SdkConfig from "../../SdkConfig";
|
||||||
import { iterableDiff, iterableIntersection } from "../../utils/iterables";
|
import { iterableDiff, iterableIntersection } from "../../utils/iterables";
|
||||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||||
import Modal from "../../Modal";
|
import Modal from "../../Modal";
|
||||||
|
@ -80,6 +81,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
||||||
allowedCapabilities: Capability[],
|
allowedCapabilities: Capability[],
|
||||||
private forWidget: Widget,
|
private forWidget: Widget,
|
||||||
private forWidgetKind: WidgetKind,
|
private forWidgetKind: WidgetKind,
|
||||||
|
virtual: boolean,
|
||||||
private inRoomId?: string,
|
private inRoomId?: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
@ -102,6 +104,50 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
||||||
// Auto-approve the legacy visibility capability. We send it regardless of capability.
|
// Auto-approve the legacy visibility capability. We send it regardless of capability.
|
||||||
// Widgets don't technically need to request this capability, but Scalar still does.
|
// Widgets don't technically need to request this capability, but Scalar still does.
|
||||||
this.allowedCapabilities.add("visibility");
|
this.allowedCapabilities.add("visibility");
|
||||||
|
} else if (virtual && new URL(SdkConfig.get("element_call").url).origin === this.forWidget.origin) {
|
||||||
|
// This is a trusted Element Call widget that we control
|
||||||
|
this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
|
||||||
|
this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
|
||||||
|
this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
|
||||||
|
|
||||||
|
this.allowedCapabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw,
|
||||||
|
);
|
||||||
|
this.allowedCapabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Send, "org.matrix.msc3401.call").raw,
|
||||||
|
);
|
||||||
|
this.allowedCapabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, "org.matrix.msc3401.call").raw,
|
||||||
|
);
|
||||||
|
this.allowedCapabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(
|
||||||
|
EventDirection.Send, "org.matrix.msc3401.call.member", MatrixClientPeg.get().getUserId()!,
|
||||||
|
).raw,
|
||||||
|
);
|
||||||
|
this.allowedCapabilities.add(
|
||||||
|
WidgetEventCapability.forStateEvent(EventDirection.Receive, "org.matrix.msc3401.call.member").raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendRecvToDevice = [
|
||||||
|
EventType.CallInvite,
|
||||||
|
EventType.CallCandidates,
|
||||||
|
EventType.CallAnswer,
|
||||||
|
EventType.CallHangup,
|
||||||
|
EventType.CallReject,
|
||||||
|
EventType.CallSelectAnswer,
|
||||||
|
EventType.CallNegotiate,
|
||||||
|
EventType.CallSDPStreamMetadataChanged,
|
||||||
|
EventType.CallSDPStreamMetadataChangedPrefix,
|
||||||
|
EventType.CallReplaces,
|
||||||
|
];
|
||||||
|
for (const eventType of sendRecvToDevice) {
|
||||||
|
this.allowedCapabilities.add(
|
||||||
|
WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw,
|
||||||
|
);
|
||||||
|
this.allowedCapabilities.add(
|
||||||
|
WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
appId: string,
|
||||||
app: Partial<IApp>,
|
app: Partial<IApp>,
|
||||||
senderUserId: string,
|
senderUserId: string,
|
||||||
roomId: string | null,
|
roomId: string | undefined,
|
||||||
eventId: string,
|
eventId: string | undefined,
|
||||||
): IApp {
|
): IApp {
|
||||||
if (!senderUserId) {
|
if (!senderUserId) {
|
||||||
throw new Error("Widgets must be created by someone - provide a senderUserId");
|
throw new Error("Widgets must be created by someone - provide a senderUserId");
|
||||||
|
|
21
src/utils/video-rooms.ts
Normal file
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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mocked } from "jest-mock";
|
import { mocked, Mocked } from "jest-mock";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo";
|
import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo";
|
||||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
@ -23,25 +23,26 @@ import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg } from "./test-u
|
||||||
import { MatrixClientPeg } from "../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../src/MatrixClientPeg";
|
||||||
import WidgetStore from "../src/stores/WidgetStore";
|
import WidgetStore from "../src/stores/WidgetStore";
|
||||||
import WidgetUtils from "../src/utils/WidgetUtils";
|
import WidgetUtils from "../src/utils/WidgetUtils";
|
||||||
import { JitsiCall } from "../src/models/Call";
|
import { JitsiCall, ElementCall } from "../src/models/Call";
|
||||||
import createRoom, { canEncryptToAllUsers } from '../src/createRoom';
|
import createRoom, { canEncryptToAllUsers } from '../src/createRoom';
|
||||||
|
|
||||||
describe("createRoom", () => {
|
describe("createRoom", () => {
|
||||||
mockPlatformPeg();
|
mockPlatformPeg();
|
||||||
|
|
||||||
let client: MatrixClient;
|
let client: Mocked<MatrixClient>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stubClient();
|
stubClient();
|
||||||
client = MatrixClientPeg.get();
|
client = mocked(MatrixClientPeg.get());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => jest.clearAllMocks());
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it("sets up video rooms correctly", async () => {
|
it("sets up Jitsi video rooms correctly", async () => {
|
||||||
setupAsyncStoreWithClient(WidgetStore.instance, client);
|
setupAsyncStoreWithClient(WidgetStore.instance, client);
|
||||||
jest.spyOn(WidgetUtils, "waitForRoomWidget").mockResolvedValue();
|
jest.spyOn(WidgetUtils, "waitForRoomWidget").mockResolvedValue();
|
||||||
|
const createCallSpy = jest.spyOn(JitsiCall, "create");
|
||||||
|
|
||||||
const userId = client.getUserId();
|
const userId = client.getUserId()!;
|
||||||
const roomId = await createRoom({ roomType: RoomType.ElementVideo });
|
const roomId = await createRoom({ roomType: RoomType.ElementVideo });
|
||||||
|
|
||||||
const [[{
|
const [[{
|
||||||
|
@ -51,25 +52,63 @@ describe("createRoom", () => {
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
"im.vector.modular.widgets": widgetPower,
|
"im.vector.modular.widgets": widgetPower,
|
||||||
[JitsiCall.MEMBER_EVENT_TYPE]: jitsiMemberPower,
|
[JitsiCall.MEMBER_EVENT_TYPE]: callMemberPower,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}]] = mocked(client.createRoom).mock.calls as any; // no good type
|
}]] = client.createRoom.mock.calls as any; // no good type
|
||||||
const [[widgetRoomId, widgetStateKey]] = mocked(client.sendStateEvent).mock.calls;
|
|
||||||
|
|
||||||
// We should have had enough power to be able to set up the Jitsi widget
|
// We should have had enough power to be able to set up the widget
|
||||||
expect(userPower).toBeGreaterThanOrEqual(widgetPower);
|
expect(userPower).toBeGreaterThanOrEqual(widgetPower);
|
||||||
// and should have actually set it up
|
// and should have actually set it up
|
||||||
expect(widgetRoomId).toEqual(roomId);
|
expect(createCallSpy).toHaveBeenCalled();
|
||||||
expect(widgetStateKey).toEqual("im.vector.modular.widgets");
|
|
||||||
|
|
||||||
// All members should be able to update their connected devices
|
// All members should be able to update their connected devices
|
||||||
expect(jitsiMemberPower).toEqual(0);
|
expect(callMemberPower).toEqual(0);
|
||||||
// Jitsi widget should be immutable for admins
|
// widget should be immutable for admins
|
||||||
expect(widgetPower).toBeGreaterThan(100);
|
expect(widgetPower).toBeGreaterThan(100);
|
||||||
// and we should have been reset back to admin
|
// and we should have been reset back to admin
|
||||||
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
|
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets up Element video rooms correctly", async () => {
|
||||||
|
const userId = client.getUserId()!;
|
||||||
|
const createCallSpy = jest.spyOn(ElementCall, "create");
|
||||||
|
const roomId = await createRoom({ roomType: RoomType.UnstableCall });
|
||||||
|
|
||||||
|
const [[{
|
||||||
|
power_level_content_override: {
|
||||||
|
users: {
|
||||||
|
[userId]: userPower,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
[ElementCall.CALL_EVENT_TYPE.name]: callPower,
|
||||||
|
[ElementCall.MEMBER_EVENT_TYPE.name]: callMemberPower,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]] = client.createRoom.mock.calls as any; // no good type
|
||||||
|
|
||||||
|
// We should have had enough power to be able to set up the call
|
||||||
|
expect(userPower).toBeGreaterThanOrEqual(callPower);
|
||||||
|
// and should have actually set it up
|
||||||
|
expect(createCallSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// All members should be able to update their connected devices
|
||||||
|
expect(callMemberPower).toEqual(0);
|
||||||
|
// call should be immutable for admins
|
||||||
|
expect(callPower).toBeGreaterThan(100);
|
||||||
|
// and we should have been reset back to admin
|
||||||
|
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't create calls in non-video-rooms", async () => {
|
||||||
|
const createJitsiCallSpy = jest.spyOn(JitsiCall, "create");
|
||||||
|
const createElementCallSpy = jest.spyOn(ElementCall, "create");
|
||||||
|
|
||||||
|
await createRoom({});
|
||||||
|
|
||||||
|
expect(createJitsiCallSpy).not.toHaveBeenCalled();
|
||||||
|
expect(createElementCallSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("canEncryptToAllUsers", () => {
|
describe("canEncryptToAllUsers", () => {
|
||||||
|
@ -83,20 +122,20 @@ describe("canEncryptToAllUsers", () => {
|
||||||
"@badUser:localhost": {},
|
"@badUser:localhost": {},
|
||||||
};
|
};
|
||||||
|
|
||||||
let client: MatrixClient;
|
let client: Mocked<MatrixClient>;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stubClient();
|
stubClient();
|
||||||
client = MatrixClientPeg.get();
|
client = mocked(MatrixClientPeg.get());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true if all devices have crypto", async () => {
|
it("returns true if all devices have crypto", async () => {
|
||||||
mocked(client.downloadKeys).mockResolvedValue(trueUser);
|
client.downloadKeys.mockResolvedValue(trueUser);
|
||||||
const response = await canEncryptToAllUsers(client, ["@goodUser:localhost"]);
|
const response = await canEncryptToAllUsers(client, ["@goodUser:localhost"]);
|
||||||
expect(response).toBe(true);
|
expect(response).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false if not all users have crypto", async () => {
|
it("returns false if not all users have crypto", async () => {
|
||||||
mocked(client.downloadKeys).mockResolvedValue({ ...trueUser, ...falseUser });
|
client.downloadKeys.mockResolvedValue({ ...trueUser, ...falseUser });
|
||||||
const response = await canEncryptToAllUsers(client, ["@goodUser:localhost", "@badUser:localhost"]);
|
const response = await canEncryptToAllUsers(client, ["@goodUser:localhost", "@badUser:localhost"]);
|
||||||
expect(response).toBe(false);
|
expect(response).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -39,6 +39,7 @@ describe("StopGapWidget", () => {
|
||||||
creatorUserId: "@alice:example.org",
|
creatorUserId: "@alice:example.org",
|
||||||
type: "example",
|
type: "example",
|
||||||
url: "https://example.org",
|
url: "https://example.org",
|
||||||
|
roomId: "!1:example.org",
|
||||||
},
|
},
|
||||||
room: mkRoom(client, "!1:example.org"),
|
room: mkRoom(client, "!1:example.org"),
|
||||||
userId: "@alice:example.org",
|
userId: "@alice:example.org",
|
||||||
|
|
|
@ -15,10 +15,10 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mocked, MockedObject } from "jest-mock";
|
import { mocked, MockedObject } from "jest-mock";
|
||||||
import { ClientEvent, ITurnServer as IClientTurnServer, MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client";
|
||||||
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
|
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
|
||||||
import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { ITurnServer, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api";
|
import { Widget, MatrixWidgetType, WidgetKind, WidgetDriver, ITurnServer } from "matrix-widget-api";
|
||||||
|
|
||||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||||
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
|
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
|
||||||
|
@ -27,22 +27,75 @@ import { stubClient } from "../../test-utils";
|
||||||
|
|
||||||
describe("StopGapWidgetDriver", () => {
|
describe("StopGapWidgetDriver", () => {
|
||||||
let client: MockedObject<MatrixClient>;
|
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(() => {
|
beforeEach(() => {
|
||||||
stubClient();
|
stubClient();
|
||||||
client = mocked(MatrixClientPeg.get());
|
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({
|
new Widget({
|
||||||
id: "test",
|
id: "group_call",
|
||||||
creatorUserId: "@alice:example.org",
|
creatorUserId: "@alice:example.org",
|
||||||
type: "example",
|
type: MatrixWidgetType.Custom,
|
||||||
url: "https://example.org",
|
url: "https://call.element.io",
|
||||||
}),
|
}),
|
||||||
WidgetKind.Room,
|
WidgetKind.Room,
|
||||||
|
true,
|
||||||
|
"!1:example.org",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// These are intentionally raw identifiers rather than constants, so it's obvious what's being requested
|
||||||
|
const requestedCapabilities = new Set([
|
||||||
|
"m.always_on_screen",
|
||||||
|
"town.robin.msc3846.turn_servers",
|
||||||
|
"org.matrix.msc2762.timeline:!1:example.org",
|
||||||
|
"org.matrix.msc2762.receive.state_event:m.room.member",
|
||||||
|
"org.matrix.msc2762.send.state_event:org.matrix.msc3401.call",
|
||||||
|
"org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call",
|
||||||
|
"org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#@alice:example.org",
|
||||||
|
"org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call.member",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.invite",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.invite",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.candidates",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.candidates",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.answer",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.answer",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.hangup",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.hangup",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.reject",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.reject",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.select_answer",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.select_answer",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.negotiate",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.negotiate",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.sdp_stream_metadata_changed",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.sdp_stream_metadata_changed",
|
||||||
|
"org.matrix.msc3819.send.to_device:org.matrix.call.sdp_stream_metadata_changed",
|
||||||
|
"org.matrix.msc3819.receive.to_device:org.matrix.call.sdp_stream_metadata_changed",
|
||||||
|
"org.matrix.msc3819.send.to_device:m.call.replaces",
|
||||||
|
"org.matrix.msc3819.receive.to_device:m.call.replaces",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// As long as this resolves, we'll know that it didn't try to pop up a modal
|
||||||
|
const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities);
|
||||||
|
expect(approvedCapabilities).toEqual(requestedCapabilities);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendToDevice", () => {
|
describe("sendToDevice", () => {
|
||||||
|
@ -59,6 +112,10 @@ describe("StopGapWidgetDriver", () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let driver: WidgetDriver;
|
||||||
|
|
||||||
|
beforeEach(() => { driver = mkDefaultDriver(); });
|
||||||
|
|
||||||
it("sends unencrypted messages", async () => {
|
it("sends unencrypted messages", async () => {
|
||||||
await driver.sendToDevice("org.example.foo", false, contentMap);
|
await driver.sendToDevice("org.example.foo", false, contentMap);
|
||||||
expect(client.queueToDevice.mock.calls).toMatchSnapshot();
|
expect(client.queueToDevice.mock.calls).toMatchSnapshot();
|
||||||
|
@ -80,6 +137,10 @@ describe("StopGapWidgetDriver", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getTurnServers", () => {
|
describe("getTurnServers", () => {
|
||||||
|
let driver: WidgetDriver;
|
||||||
|
|
||||||
|
beforeEach(() => { driver = mkDefaultDriver(); });
|
||||||
|
|
||||||
it("stops if VoIP isn't supported", async () => {
|
it("stops if VoIP isn't supported", async () => {
|
||||||
jest.spyOn(client, "pollingTurnServers", "get").mockReturnValue(false);
|
jest.spyOn(client, "pollingTurnServers", "get").mockReturnValue(false);
|
||||||
const servers = driver.getTurnServers();
|
const servers = driver.getTurnServers();
|
||||||
|
@ -135,6 +196,10 @@ describe("StopGapWidgetDriver", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("readEventRelations", () => {
|
describe("readEventRelations", () => {
|
||||||
|
let driver: WidgetDriver;
|
||||||
|
|
||||||
|
beforeEach(() => { driver = mkDefaultDriver(); });
|
||||||
|
|
||||||
it('reads related events from the current room', async () => {
|
it('reads related events from the current room', async () => {
|
||||||
jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id');
|
jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id');
|
||||||
|
|
||||||
|
|
|
@ -23,17 +23,21 @@ import { Call } from "../../src/models/Call";
|
||||||
|
|
||||||
export class MockedCall extends Call {
|
export class MockedCall extends Call {
|
||||||
private static EVENT_TYPE = "org.example.mocked_call";
|
private static EVENT_TYPE = "org.example.mocked_call";
|
||||||
|
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
||||||
|
|
||||||
private constructor(private readonly room: Room, private readonly id: string) {
|
private constructor(room: Room, id: string) {
|
||||||
super({
|
super(
|
||||||
id,
|
{
|
||||||
eventId: "$1:example.org",
|
id,
|
||||||
roomId: room.roomId,
|
eventId: "$1:example.org",
|
||||||
type: MatrixWidgetType.Custom,
|
roomId: room.roomId,
|
||||||
url: "https://example.org",
|
type: MatrixWidgetType.Custom,
|
||||||
name: "Group call",
|
url: "https://example.org",
|
||||||
creatorUserId: "@alice:example.org",
|
name: "Group call",
|
||||||
});
|
creatorUserId: "@alice:example.org",
|
||||||
|
},
|
||||||
|
room.client,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static get(room: Room): MockedCall | null {
|
public static get(room: Room): MockedCall | null {
|
||||||
|
@ -61,12 +65,10 @@ export class MockedCall extends Call {
|
||||||
}
|
}
|
||||||
|
|
||||||
// No action needed for any of the following methods since this is just a mock
|
// No action needed for any of the following methods since this is just a mock
|
||||||
public async clean(): Promise<void> {}
|
protected getDevices(): string[] { return []; }
|
||||||
|
protected async setDevices(): Promise<void> { }
|
||||||
// Public to allow spying
|
// Public to allow spying
|
||||||
public async performConnection(
|
public async performConnection(): Promise<void> {}
|
||||||
audioInput: MediaDeviceInfo | null,
|
|
||||||
videoInput: MediaDeviceInfo | null,
|
|
||||||
): Promise<void> {}
|
|
||||||
public async performDisconnection(): Promise<void> {}
|
public async performDisconnection(): Promise<void> {}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
|
@ -77,7 +79,7 @@ export class MockedCall extends Call {
|
||||||
room: this.room.roomId,
|
room: this.room.roomId,
|
||||||
user: "@alice:example.org",
|
user: "@alice:example.org",
|
||||||
content: { terminated: true },
|
content: { terminated: true },
|
||||||
skey: this.id,
|
skey: this.widget.id,
|
||||||
})]);
|
})]);
|
||||||
|
|
||||||
super.destroy();
|
super.destroy();
|
||||||
|
|
|
@ -99,7 +99,7 @@ export function createTestClient(): MatrixClient {
|
||||||
},
|
},
|
||||||
|
|
||||||
getPushActionsForEvent: jest.fn(),
|
getPushActionsForEvent: jest.fn(),
|
||||||
getRoom: jest.fn().mockImplementation(mkStubRoom),
|
getRoom: jest.fn().mockImplementation(roomId => mkStubRoom(roomId, "My room", client)),
|
||||||
getRooms: jest.fn().mockReturnValue([]),
|
getRooms: jest.fn().mockReturnValue([]),
|
||||||
getVisibleRooms: jest.fn().mockReturnValue([]),
|
getVisibleRooms: jest.fn().mockReturnValue([]),
|
||||||
loginFlows: jest.fn(),
|
loginFlows: jest.fn(),
|
||||||
|
@ -335,8 +335,10 @@ export function mkRoomMember(roomId: string, userId: string, membership = "join"
|
||||||
name: userId,
|
name: userId,
|
||||||
rawDisplayName: userId,
|
rawDisplayName: userId,
|
||||||
roomId,
|
roomId,
|
||||||
|
events: {},
|
||||||
getAvatarUrl: () => {},
|
getAvatarUrl: () => {},
|
||||||
getMxcAvatarUrl: () => {},
|
getMxcAvatarUrl: () => {},
|
||||||
|
getDMInviter: () => {},
|
||||||
} as unknown as RoomMember;
|
} as unknown as RoomMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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…
Add table
Add a link
Reference in a new issue