vojo/src/app/plugins/call/CallEmbed.ts

421 lines
13 KiB
TypeScript

import {
ClientEvent,
KnownMembership,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
RoomStateEvent,
} from 'matrix-js-sdk';
import {
ClientWidgetApi,
IRoomEvent,
IWidget,
Widget,
WidgetApiToWidgetAction,
WidgetDriver,
} from 'matrix-widget-api';
import { CallWidgetDriver } from './CallWidgetDriver';
import { trimTrailingSlash } from '../../utils/common';
import {
ElementCallIntent,
ElementCallThemeKind,
ElementMediaStateDetail,
ElementWidgetActions,
} from './types';
import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState';
export class CallEmbed {
private mx: MatrixClient;
private disposed = false;
public readonly call: ClientWidgetApi;
public readonly iframe: HTMLIFrameElement;
public readonly room: Room;
public joined = false;
public readonly voiceOnly: boolean;
public readonly control: CallControl;
private readonly container: HTMLElement;
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
private eventsToFeed = new WeakSet<MatrixEvent>();
private readonly disposables: Array<() => void> = [];
private readonly boundOnEvent = this.onEvent.bind(this);
private readonly boundOnEventDecrypted = this.onEventDecrypted.bind(this);
private readonly boundOnStateUpdate = this.onStateUpdate.bind(this);
private readonly boundOnToDeviceEvent = this.onToDeviceEvent.bind(this);
static getIntent(dm: boolean, ongoing: boolean, voiceOnly = false): ElementCallIntent {
if (ongoing) {
if (dm) {
return voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM;
}
return ElementCallIntent.JoinExisting;
}
if (dm) {
return voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM;
}
return ElementCallIntent.StartCall;
}
static getWidget(
mx: MatrixClient,
room: Room,
intent: ElementCallIntent,
themeKind: ElementCallThemeKind
): Widget {
const userId = mx.getSafeUserId();
const deviceId = mx.getDeviceId() ?? '';
const clientOrigin = window.location.origin;
const widgetId = 'call-embed';
const params = new URLSearchParams({
widgetId,
parentUrl: clientOrigin,
baseUrl: mx.baseUrl,
roomId: room.roomId,
userId,
deviceId,
intent,
skipLobby: 'true',
confineToRoom: 'true',
appPrompt: 'false',
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
lang: 'en-EN',
theme: themeKind,
});
const widgetUrl = new URL(
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
window.location.origin
);
widgetUrl.search = params.toString();
const options: IWidget = {
id: widgetId,
creatorUserId: userId,
name: 'Call',
type: 'm.call',
url: widgetUrl.href,
waitForIframeLoad: false,
data: {},
};
const widget: Widget = new Widget(options);
return widget;
}
static getIframe(url: string): HTMLIFrameElement {
const iframe = document.createElement('iframe');
iframe.title = 'Call Embed';
iframe.sandbox =
'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads';
iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;';
iframe.src = url;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
return iframe;
}
constructor(
mx: MatrixClient,
room: Room,
widget: Widget,
container: HTMLElement,
initialControlState?: CallControlState,
voiceOnly = false
) {
const iframe = CallEmbed.getIframe(
widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() })
);
container.append(iframe);
const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId);
const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver);
this.mx = mx;
this.call = call;
this.room = room;
this.iframe = iframe;
this.container = container;
this.voiceOnly = voiceOnly;
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
let initialMediaEvent = true;
this.disposables.push(
this.listenAction<ElementMediaStateDetail>(ElementWidgetActions.DeviceMute, (evt) => {
if (initialMediaEvent) {
initialMediaEvent = false;
this.control.applyState();
return;
}
this.control.onMediaState(evt);
})
);
this.start();
}
get roomId(): string {
return this.room.roomId;
}
get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
}
public setTheme(theme: ElementCallThemeKind) {
return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, {
name: theme,
});
}
public hangup() {
return this.call.transport.send(ElementWidgetActions.HangupCall, {});
}
public onPreparing(callback: () => void) {
return this.listenEvent('preparing', callback);
}
public onPreparingError(callback: (error: any) => void) {
return this.listenEvent('error:preparing', callback);
}
public onReady(callback: () => void) {
return this.listenEvent('ready', callback);
}
public onCapabilitiesNotified(callback: () => void) {
return this.listenEvent('capabilitiesNotified', callback);
}
private start() {
// Room widgets get locked to the room they were added in
this.call.setViewedRoomId(this.roomId);
this.disposables.push(
this.listenAction(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this))
);
// Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
this.mx.getRooms().forEach((room) => {
// Timelines are most recent last
const events = room.getLiveTimeline()?.getEvents() || [];
const roomEvent = events[events.length - 1];
if (!roomEvent) return; // force later code to think the room is fresh
this.readUpToMap[room.roomId] = roomEvent.getId()!;
});
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
this.mx.on(ClientEvent.Event, this.boundOnEvent);
this.mx.on(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
this.mx.on(RoomStateEvent.Events, this.boundOnStateUpdate);
this.mx.on(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent);
}
/**
* Stops the widget messaging for if it is started. Skips stopping if it is an active
* widget.
* @param opts
*/
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.disposables.forEach((disposable) => {
disposable();
});
this.call.stop();
this.container.removeChild(this.iframe);
this.control.dispose();
this.mx.off(ClientEvent.Event, this.boundOnEvent);
this.mx.off(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
this.mx.off(RoomStateEvent.Events, this.boundOnStateUpdate);
this.mx.off(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent);
// Clear internal state
this.readUpToMap = {};
this.eventsToFeed = new WeakSet<MatrixEvent>();
}
private onCallJoined(): void {
this.joined = true;
this.applyStyles();
this.control.startObserving();
}
private applyStyles(): void {
const doc = this.document;
if (!doc) return;
doc.body.style.setProperty('background', 'none', 'important');
const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement
?.parentElement;
if (controls) {
controls.style.setProperty('position', 'absolute');
controls.style.setProperty('visibility', 'hidden');
}
}
private onEvent(ev: MatrixEvent): void {
this.mx.decryptEventIfNeeded(ev);
this.feedEvent(ev);
}
private onEventDecrypted(ev: MatrixEvent): void {
this.feedEvent(ev);
}
private onStateUpdate(ev: MatrixEvent): void {
if (this.call === null) return;
const raw = ev.getEffectiveEvent();
this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => {
console.error('Error sending state update to widget: ', e);
});
}
private async onToDeviceEvent(ev: MatrixEvent): Promise<void> {
await this.mx.decryptEventIfNeeded(ev);
if (ev.isDecryptionFailure()) return;
await this.call?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
}
/**
* Determines whether the event has a relation to an unknown parent.
*/
private relatesToUnknown(ev: MatrixEvent): boolean {
// Replies to unknown events don't count
if (!ev.relationEventId || ev.replyEventId) return false;
const room = this.mx.getRoom(ev.getRoomId());
return room === null || !room.findEventById(ev.relationEventId);
}
/**
* Advances the "read up to" marker for a room to a certain event. No-ops if
* the event is before the marker.
* @returns Whether the "read up to" marker was advanced.
*/
private advanceReadUpToMarker(ev: MatrixEvent): boolean {
const evId = ev.getId();
if (evId === undefined) return false;
const roomId = ev.getRoomId();
if (roomId === undefined) return false;
const room = this.mx.getRoom(roomId);
if (room === null) return false;
const upToEventId = this.readUpToMap[ev.getRoomId()!];
if (!upToEventId) {
// There's no marker yet; start it at this event
this.readUpToMap[roomId] = evId;
return true;
}
// Small optimization for exact match (skip the search)
if (upToEventId === evId) return false;
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
// to avoid overusing the CPU.
const timeline = room.getLiveTimeline();
const events = [...timeline.getEvents()].reverse().slice(0, 100);
function isRelevantTimelineEvent(timelineEvent: MatrixEvent): boolean {
return timelineEvent.getId() === upToEventId || timelineEvent.getId() === ev.getId();
}
const possibleMarkerEv = events.find(isRelevantTimelineEvent);
if (possibleMarkerEv?.getId() === upToEventId) {
// The event must be somewhere before the "read up to" marker
return false;
}
if (possibleMarkerEv?.getId() === ev.getId()) {
// The event is after the marker; advance it
this.readUpToMap[roomId] = evId;
return true;
}
// We can't say for sure whether the widget has seen the event; let's
// just assume that it has
return false;
}
/**
* Determines whether the event comes from a room that we've been invited to
* (in which case we likely don't have the full timeline).
*/
private isFromInvite(ev: MatrixEvent): boolean {
const room = this.mx.getRoom(ev.getRoomId());
return room?.getMyMembership() === KnownMembership.Invite;
}
private feedEvent(ev: MatrixEvent): void {
if (this.call === null) return;
if (
// If we had decided earlier to feed this event to the widget, but
// it just wasn't ready, give it another try
this.eventsToFeed.delete(ev) ||
// Skip marker timeline check for events with relations to unknown parent because these
// events are not added to the timeline here and will be ignored otherwise:
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
this.relatesToUnknown(ev) ||
// Skip marker timeline check for rooms where membership is
// 'invite', otherwise the membership event from the invitation room
// will advance the marker and new state events will not be
// forwarded to the widget.
this.isFromInvite(ev) ||
// Check whether this event would be before or after our "read up to" marker. If it's
// before, or we can't decide, then we assume the widget will have already seen the event.
// If the event is after, or we don't have a marker for the room,
// then the marker will advance and we'll send it through.
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
// receiving ancient events from backfill and such.
this.advanceReadUpToMarker(ev)
) {
// If the event is still being decrypted, remember that we want to
// feed it to the widget (even if not strictly in the order given by
// the timeline) and get back to it later
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
this.eventsToFeed.add(ev);
} else {
const raw = ev.getEffectiveEvent();
this.call.feedEvent(raw as IRoomEvent).catch((e) => {
console.error('Error sending event to widget: ', e);
});
}
}
}
public listenAction<T>(type: string, callback: (event: CustomEvent<T>) => void) {
return this.listenEvent(`action:${type}`, callback);
}
public listenEvent<T>(type: string, callback: (event: T) => void) {
this.call.on(type, callback);
return () => {
this.call.off(type, callback);
};
}
}