diff --git a/netlify.toml b/netlify.toml index a8710303..ff4a38e1 100644 --- a/netlify.toml +++ b/netlify.toml @@ -29,6 +29,11 @@ to = "/assets/:splat" status = 200 +[[redirects]] + from = "/widgets/*" + to = "/widgets/:splat" + status = 200 + [[redirects]] from = "/*" to = "/index.html" diff --git a/package-lock.json b/package-lock.json index 6db1e02d..2424272e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,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.0", "@fontsource/inter": "4.5.14", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", @@ -44,6 +45,7 @@ "linkify-react": "4.1.3", "linkifyjs": "4.1.3", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.13.1", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", @@ -1649,6 +1651,11 @@ "node": ">=6.9.0" } }, + "node_modules/@element-hq/element-call-embedded": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.0.tgz", + "integrity": "sha512-cTJwW9cmQHrTUvcJm0xv9uSTMYiToDEaw5AuxXQS9jVjHQT8B3W3DWtKYAzq1PRRH13JZkcwXbHjdFpkxzhzCQ==" + }, "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", diff --git a/package.json b/package.json index 33fe68c1..7fe4bf2e 100644 --- a/package.json +++ b/package.json @@ -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.0", "@fontsource/inter": "4.5.14", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", @@ -55,6 +56,7 @@ "linkify-react": "4.1.3", "linkifyjs": "4.1.3", "matrix-js-sdk": "38.2.0", + "matrix-widget-api": "1.13.1", "millify": "6.1.0", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", diff --git a/src/app/components/element-call/CallView.tsx b/src/app/components/element-call/CallView.tsx new file mode 100644 index 00000000..7aff6f49 --- /dev/null +++ b/src/app/components/element-call/CallView.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ClientWidgetApi } from 'matrix-widget-api'; +import { Button, Text } from 'folds'; +import ElementCall from './ElementCall'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useEventEmitter } from './utils'; +import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; +import { useCallOngoing } from '../../hooks/useCallOngoing'; + +export enum CallWidgetActions { + // All of these actions are currently specific to Jitsi and Element Call + JoinCall = 'io.element.join', + HangupCall = 'im.vector.hangup', + Close = 'io.element.close', +} +export function action(type: CallWidgetActions): string { + return `action:${type}`; +} + +const iframeFeatures = + 'microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; ' + + 'clipboard-read;'; +const sandboxFlags = + 'allow-forms allow-popups allow-popups-to-escape-sandbox ' + + 'allow-same-origin allow-scripts allow-presentation allow-downloads'; +const iframeStyles = { flex: '1 1', border: 'none' }; +const containerStyle = (hidden: boolean): React.CSSProperties => ({ + display: 'flex', + flexDirection: 'column', + position: 'relative', + height: '100%', + width: '100%', + ...(hidden + ? { + overflow: 'hidden', + width: 0, + height: 0, + } + : {}), +}); +const closeButtonStyle: React.CSSProperties = { + position: 'absolute', + top: 8, + right: 8, + zIndex: 100, + maxWidth: 'fit-content', +}; + +enum State { + Preparing = 'preparing', + Lobby = 'lobby', + Joined = 'joined', + HungUp = 'hung_up', + CanClose = 'can_close', +} + +/** + * Shows a call for this room. Rendering this component will + * automatically create a call widget and join the call in the room. + * @returns + */ +export interface IRoomCallViewProps { + onClose?: () => void; + onJoin?: () => void; + onHangup?: (errorMessage?: string) => void; +} + +export function CallView({ + onJoin = undefined, + onClose = undefined, + onHangup = undefined, +}: IRoomCallViewProps): JSX.Element { + // Construct required variables + const room = useRoom(); + const client = useMatrixClient(); + const iframe = useRef(null); + + // Model state + const [elementCall, setElementCall] = useState(); + const [widgetApi, setWidgetApi] = useState(null); + const [state, setState] = useState(State.Preparing); + + // Initialization parameters + const isDirect = useIsDirectRoom(); + const callOngoing = useCallOngoing(room); + const initialCallOngoing = React.useRef(callOngoing); + const initialIsDirect = React.useRef(isDirect); + useEffect(() => { + if (client && room && !elementCall) { + const e = new ElementCall(client, room, initialIsDirect.current, initialCallOngoing.current); + setElementCall(e); + } + }, [client, room, setElementCall, elementCall]); + + // Start the messaging over the widget api. + useEffect(() => { + if (iframe.current && elementCall) { + elementCall.startMessaging(iframe.current); + } + return () => { + elementCall?.stopMessaging(); + }; + }, [iframe, elementCall]); + + // Widget api ready + useEventEmitter(elementCall, 'ready', () => { + setWidgetApi(elementCall?.widgetApi ?? null); + setState(State.Lobby); + }); + + // Use widget api to listen for hangup/join/close actions + useEventEmitter(widgetApi, action(CallWidgetActions.HangupCall), () => { + setState(State.HungUp); + onHangup?.(); + }); + useEventEmitter(widgetApi, action(CallWidgetActions.JoinCall), () => { + setState(State.Joined); + onJoin?.(); + }); + useEventEmitter(widgetApi, action(CallWidgetActions.Close), () => { + setState(State.CanClose); + onClose?.(); + }); + + // render component + return ( +
+ {/* Exit button for lobby state */} + {state === State.Lobby && ( + + )} +