-
-
Notifications
You must be signed in to change notification settings - Fork 362
feat: Element Call #2536
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
hanthor
wants to merge
4
commits into
cinnyapp:dev
Choose a base branch
from
hanthor:dev
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+8,011
−3
Draft
feat: Element Call #2536
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Submodule element-web
added at
dd89ce
Submodule matrix-react-sdk
added at
b67a23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.