Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions element-web
Submodule element-web added at dd89ce
1 change: 1 addition & 0 deletions matrix-react-sdk
Submodule matrix-react-sdk added at b67a23
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@element-hq/element-call-embedded": "0.16.1",
"@fontsource/inter": "4.5.14",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
Expand Down Expand Up @@ -54,7 +55,8 @@
"jotai": "2.6.0",
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "38.2.0",
"matrix-js-sdk": "34.11.0",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it intended that you seem to be downgrading matrix-js-sdk here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No this was just me copy pasting stuff over from element web to get it to work. It can get updated.

The widget box needs a lot of improvement too. It could have a picture in picture and a fullscreen. I think fullscreen is necessary

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I would agree. I have also been looking at #2512 (which looked promising) and having not much luck running that one either. :/

I'd like to use cinny but the lack of video calling is a major blocker.

"matrix-widget-api": "1.9.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
Expand Down
61 changes: 61 additions & 0 deletions src/app/components/widget/WidgetContainer.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';

export const WidgetOverlay = style({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1000,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: config.space.S400,
});

export const WidgetContainer = style({
position: 'relative',
width: '100%',
height: '100%',
maxWidth: '1400px',
maxHeight: '900px',
backgroundColor: color.Background.Container,
borderRadius: config.radii.R400,
overflow: 'hidden',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
display: 'flex',
flexDirection: 'column',
});

export const WidgetHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: config.space.S200,
borderBottom: `1px solid ${color.Background.ContainerLine}`,
backgroundColor: color.Background.Container,
});

export const WidgetTitle = style({
fontWeight: 600,
fontSize: '14px',
color: color.Background.OnContainer,
});

export const WidgetIframe = style({
flex: 1,
border: 'none',
width: '100%',
height: '100%',
});

export const LoadingContainer = style({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: config.space.S300,
});
96 changes: 96 additions & 0 deletions src/app/components/widget/WidgetContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Widget Container Component
* Displays widgets (like Element Call) in an iframe overlay
*/

import React, { useEffect, useRef, useState } from 'react';
import { Box, IconButton, Icon, Icons, Text, Spinner } from 'folds';
import { IApp } from '../../../types/widget';
import * as css from './WidgetContainer.css';

export interface WidgetContainerProps {
widget: IApp;
onClose: () => void;
onLoad?: (iframe: HTMLIFrameElement) => void;
}

export function WidgetContainer({ widget, onClose, onLoad }: WidgetContainerProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};

window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);

// Initialize widget API as soon as iframe is mounted
// This prevents race conditions where the widget sends capabilities request before we're listening
useEffect(() => {
if (iframeRef.current && onLoad) {
onLoad(iframeRef.current);
}
}, [onLoad]);

const handleIframeLoad = () => {
setIsLoading(false);
setError(null);
};

const handleIframeError = () => {
setIsLoading(false);
setError('Failed to load widget');
};

const handleOverlayClick = (e: React.MouseEvent) => {
// Close if clicking the overlay background (not the container)
if (e.target === e.currentTarget) {
onClose();
}
};

return (
<div className={css.WidgetOverlay} onClick={handleOverlayClick}>
<div className={css.WidgetContainer}>
<div className={css.WidgetHeader}>
<Text className={css.WidgetTitle}>{widget.name || 'Widget'}</Text>
<IconButton onClick={onClose} aria-label="Close widget">
<Icon src={Icons.Cross} size="400" />
</IconButton>
</div>

{isLoading && (
<div className={css.LoadingContainer}>
<Spinner variant="Secondary" size="600" />
<Text>Loading {widget.name}...</Text>
</div>
)}

{error && (
<div className={css.LoadingContainer}>
<Icon src={Icons.Warning} size="600" />
<Text>{error}</Text>
</div>
)}

<iframe
ref={iframeRef}
src={widget.url}
className={css.WidgetIframe}
title={widget.name || 'Widget'}
sandbox="allow-forms allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-downloads"
allow="camera; microphone; display-capture; fullscreen"
onLoad={handleIframeLoad}
onError={handleIframeError}
style={{ display: isLoading ? 'none' : 'block' }}
/>
</div>
</div>
);
}
212 changes: 212 additions & 0 deletions src/app/features/calls/elementCall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* Element Call integration utilities
* Based on Element Web's Call.ts implementation
*/

import { MatrixClient, Room } from 'matrix-js-sdk';
import { secureRandomString } from 'matrix-js-sdk/lib/randomstring';
import { IApp, ElementCallIntent, WidgetGenerationParameters, WIDGET_TYPE } from '../../../types/widget';

/**
* Get the current language for Element Call
*/
function getCurrentLanguage(): string {
return navigator.language || 'en';
}

/**
* Get browser default font size
*/
function getBrowserDefaultFontSize(): number {
return 16; // Standard browser default
}

/**
* Get current root font size
*/
function getRootFontSize(): number {
return parseFloat(getComputedStyle(document.documentElement).fontSize);
}

/**
* Check if a room is a DM room
*/
function isDMRoom(client: MatrixClient, roomId: string): boolean {
const mDirectEvent = client.getAccountData('m.direct' as any);
if (!mDirectEvent) return false;

const mDirect = mDirectEvent.getContent();
// Check if roomId is in any of the DM lists
for (const userId in mDirect) {
const rooms = mDirect[userId];
if (Array.isArray(rooms) && rooms.includes(roomId)) {
return true;
}
}
return false;
}

/**
* Check if there's an ongoing call in the room
*/
export function hasOngoingCall(room: Room): boolean {
try {
const rtcSession = room.client.matrixRTC?.getRoomSession(room);
if (!rtcSession) return false;

const memberships = rtcSession.memberships;
return memberships && memberships.length > 0;
} catch (error) {
// MatrixRTC might not be available
return false;
}
}

/**
* Determine the correct intent for an Element Call
*/
function calculateIntent(
client: MatrixClient,
roomId: string,
opts: WidgetGenerationParameters = {}
): ElementCallIntent {
const room = client.getRoom(roomId);
if (!room) {
return ElementCallIntent.StartCall;
}

const isDM = isDMRoom(client, roomId);
const { voiceOnly = false } = opts;

try {
const rtcSession = client.matrixRTC?.getRoomSession(room);
const oldestMembership = rtcSession?.getOldestMembership?.();
const hasCallStarted = !!oldestMembership && oldestMembership.sender !== client.getSafeUserId();

if (isDM) {
if (hasCallStarted) {
return voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM;
} else {
return voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM;
}
} else {
// Group calls don't have voice-only option
return hasCallStarted ? ElementCallIntent.JoinExisting : ElementCallIntent.StartCall;
}
} catch (error) {
// If MatrixRTC is not available, default to starting a call
return ElementCallIntent.StartCall;
}
}

/**
* Generate the Element Call widget URL
*/
export function generateElementCallUrl(
client: MatrixClient,
roomId: string,
widgetId: string,
opts: WidgetGenerationParameters = {}
): URL {
// Use Element Call embedded
// In dev, serve directly from node_modules to avoid static copy issues
// In prod, serve from the copied assets directory
const basePath = import.meta.env.DEV
? '/node_modules/@element-hq/element-call-embedded/dist'
: '/assets/element-call';
const url = new URL(`${window.location.origin}${basePath}/index.html`);

// Add widget parameters as query params (before the hash)
// These tell Element Call it's running as a widget
url.searchParams.set('widgetId', widgetId);
url.searchParams.set('parentUrl', window.location.origin + window.location.pathname);

// Element Call expects parameters in the URL hash
// Template variables like $perParticipantE2EE are replaced by widget data
const params = new URLSearchParams({
perParticipantE2EE: '$perParticipantE2EE',
userId: client.getUserId()!,
deviceId: client.getDeviceId()!,
roomId: roomId,
baseUrl: client.baseUrl,
lang: getCurrentLanguage().replace('_', '-'),
fontScale: (getRootFontSize() / getBrowserDefaultFontSize()).toString(),
theme: '$org.matrix.msc2873.client_theme',
debug: 'true', // Enable debug logging
});

// Set skip lobby if specified
if (typeof opts.skipLobby === 'boolean') {
params.set('skipLobby', opts.skipLobby.toString());
}

// Calculate intent
const intent = calculateIntent(client, roomId, opts);
params.set('intent', intent);

// Element Call reads params from hash
// Replace %24 with $ for template variables
const replacedUrl = params.toString().replace(/%24/g, '$');
url.hash = `#?${replacedUrl}`;

console.log('Generated Element Call URL:', url.toString());
console.log('Widget ID:', widgetId);
console.log('Room ID:', roomId);

return url;
}

/**
* Get widget data for Element Call
*/
function getWidgetData(client: MatrixClient, roomId: string): Record<string, any> {
return {
// Widget data that Element Call expects
perParticipantE2EE: true,
'org.matrix.msc2873.client_theme': 'dark', // or 'light' based on user preference
};
}

/**
* Create or get the Element Call widget for a room
*/
export function createElementCallWidget(
client: MatrixClient,
roomId: string,
opts: WidgetGenerationParameters = {}
): IApp {
const widgetId = `ec-${secureRandomString(16)}`;
const url = generateElementCallUrl(client, roomId, widgetId, opts);

return {
id: widgetId,
type: WIDGET_TYPE.CALL,
url: url.toString(),
name: 'Element Call',
roomId: roomId,
creatorUserId: client.getUserId()!,
data: getWidgetData(client, roomId),
waitForIframeLoad: false,
};
}

/**
* Check if user has permission to start calls in a room
*/
export function canStartCall(room: Room): boolean {
const powerLevels = room.currentState.getStateEvents('m.room.power_levels', '');
if (!powerLevels) return true; // No power levels = anyone can call

const content = powerLevels.getContent();
const userId = room.client.getUserId();
if (!userId) return false;

const userPowerLevel = content.users?.[userId] ?? content.users_default ?? 0;

// Check permission to send the call member state event
const requiredLevel = content.events?.['org.matrix.msc3401.call.member'] ??
content.state_default ??
50;

return userPowerLevel >= requiredLevel;
}
Loading