diff --git a/__tests__/jitsi/JitsiMeeting.test.jsx b/__tests__/jitsi/JitsiMeeting.test.jsx
new file mode 100644
index 00000000..7131405f
--- /dev/null
+++ b/__tests__/jitsi/JitsiMeeting.test.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const sdkMeetingMock = vi.fn();
+
+vi.mock('@jitsi/react-sdk', () => ({
+ JitsiMeeting: (props) => {
+ sdkMeetingMock(props);
+ return ;
+ },
+}));
+
+import JitsiMeetComponent from '@/components/organisms/jitsi/JitsiMeeting';
+
+describe('JitsiMeetComponent', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ });
+
+ it('uses the React SDK with a normalized domain and room settings', () => {
+ render(
+
+ );
+
+ expect(sdkMeetingMock).toHaveBeenCalled();
+ const props = sdkMeetingMock.mock.calls.at(-1)[0];
+ expect(props.domain).toBe('meet.example.com');
+ expect(props.roomName).toBe('space-42');
+ expect(props.jwt).toBe('signed-token');
+ expect(props.userInfo).toEqual({ displayName: 'Amina' });
+ expect(screen.getByRole('status')).toHaveTextContent('Loading video room');
+ });
+
+ it('shows a retryable message when the SDK never becomes ready', () => {
+ render();
+
+ act(() => vi.advanceTimersByTime(20000));
+
+ expect(screen.getByRole('alert')).toHaveTextContent('video room could not be loaded');
+ expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /try again/i }));
+ expect(screen.getByTestId('jitsi-frame')).toBeInTheDocument();
+ });
+
+ it('surfaces Jitsi connection errors instead of leaving an empty iframe', () => {
+ render();
+ const api = { addEventListener: vi.fn(), dispose: vi.fn() };
+ const props = sdkMeetingMock.mock.calls.at(-1)[0];
+
+ act(() => props.onApiReady(api));
+ const errorHandler = api.addEventListener.mock.calls.find(
+ ([eventName]) => eventName === 'errorOccurred'
+ )[1];
+ act(() => errorHandler({ name: 'conference.connectionError' }));
+
+ expect(screen.getByRole('alert')).toHaveTextContent('could not connect to the meeting');
+ });
+});
diff --git a/components/organisms/jitsi/JitsiMeeting.jsx b/components/organisms/jitsi/JitsiMeeting.jsx
index a102fb65..37496d85 100644
--- a/components/organisms/jitsi/JitsiMeeting.jsx
+++ b/components/organisms/jitsi/JitsiMeeting.jsx
@@ -1,133 +1,181 @@
'use client';
-import React, { useEffect, useMemo, useRef } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { JitsiMeeting } from '@jitsi/react-sdk';
+
+const INITIALIZATION_TIMEOUT_MS = 20000;
const getNormalizedDomain = (domain = 'meet.jit.si') =>
- domain.replace(/^https?:\/\//i, '').replace(/\/+$/g, '');
-
-const loadExternalApi = (domain) =>
- new Promise((resolve, reject) => {
- if (typeof window === 'undefined') {
- reject(new Error('Window is undefined'));
- return;
- }
-
- if (window.JitsiMeetExternalAPI) {
- resolve();
- return;
- }
-
- const normalizedDomain = getNormalizedDomain(domain);
- const script = document.createElement('script');
- script.src = `https://${normalizedDomain}/external_api.js`;
- script.async = true;
- script.onload = resolve;
- script.onerror = (err) => reject(err);
- document.body.appendChild(script);
- });
+ domain.replace(/^https?:\/\//i, '').replace(/\/+$/g, '');
+
+const getErrorMessage = (error) => {
+ const name = error?.name || error?.type;
+
+ if (name === 'conference.connectionError' || name === 'CONNECTION_ERROR') {
+ return 'We could not connect to the meeting. Check your connection and try again.';
+ }
+
+ return 'The video room could not be loaded. Please try again or open it in a new window.';
+};
+/**
+ * Mounts Jitsi through its React SDK instead of manually injecting
+ * external_api.js. The SDK owns script loading and iframe teardown, which
+ * prevents duplicate-script races when a meeting is reopened.
+ */
const JitsiMeetComponent = ({
- roomName,
- displayName = 'Guest User',
- domain = 'meet.jit.si',
- height = '80vh',
- onReadyToClose,
- className,
- jwt,
- requiresJwt = false,
+ roomName,
+ displayName = 'Guest User',
+ domain = 'meet.jit.si',
+ height = '80vh',
+ onReadyToClose,
+ className,
+ jwt,
+ requiresJwt = false,
}) => {
- const containerRef = useRef(null);
- const apiRef = useRef(null);
- const normalizedDomain = useMemo(() => getNormalizedDomain(domain), [domain]);
-
- useEffect(() => {
- if (!roomName || typeof window === 'undefined') return undefined;
- if (requiresJwt && !jwt) return undefined;
-
- let isMounted = true;
-
- const initializeMeeting = async () => {
- try {
- await loadExternalApi(normalizedDomain);
-
- if (!isMounted || !containerRef.current || !window.JitsiMeetExternalAPI) {
- return;
- }
-
- containerRef.current.innerHTML = '';
-
- const options = {
- roomName,
- parentNode: containerRef.current,
- width: '100%',
- height: '100%',
- userInfo: {
- displayName,
- },
- configOverwrite: {
- prejoinPageEnabled: false,
- startWithAudioMuted: true,
- startWithVideoMuted: false,
- },
- interfaceConfigOverwrite: {
- DEFAULT_REMOTE_DISPLAY_NAME: 'Guest',
- SHOW_JITSI_WATERMARK: false,
- SHOW_BRAND_WATERMARK: false,
- SHOW_POWERED_BY: false,
- SHOW_CHROME_EXTENSION_BANNER: false,
- SUPPORT_URL: 'https://deenbridge.com/support',
- },
- };
-
- if (jwt) {
- options.jwt = jwt;
- }
-
- const api = new window.JitsiMeetExternalAPI(normalizedDomain, options);
-
- apiRef.current = api;
-
- const handleReadyToClose = () => {
- apiRef.current?.dispose();
- apiRef.current = null;
- onReadyToClose?.();
- };
-
- api.addEventListener('readyToClose', handleReadyToClose);
-
- return () => {
- api.removeEventListener('readyToClose', handleReadyToClose);
- };
- } catch (error) {
- console.error('Failed to initialize Jitsi meeting:', error);
- }
- };
-
- initializeMeeting();
-
- return () => {
- isMounted = false;
- if (apiRef.current) {
- apiRef.current.dispose();
- apiRef.current = null;
- }
- };
- }, [roomName, displayName, normalizedDomain, onReadyToClose, jwt, requiresJwt]);
+ const apiRef = useRef(null);
+ const apiErrorListenerRef = useRef(null);
+ const timeoutRef = useRef(null);
+ const [attempt, setAttempt] = useState(0);
+ const [status, setStatus] = useState('loading');
+ const [errorMessage, setErrorMessage] = useState('');
+ const normalizedDomain = getNormalizedDomain(domain);
+
+ const clearInitializationTimeout = useCallback(() => {
+ if (timeoutRef.current) {
+ window.clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ }, []);
+
+ const showError = useCallback(
+ (error) => {
+ clearInitializationTimeout();
+ setStatus('error');
+ setErrorMessage(getErrorMessage(error));
+ },
+ [clearInitializationTimeout]
+ );
+
+ useEffect(() => {
+ if (!roomName || (requiresJwt && !jwt)) return undefined;
+
+ setStatus('loading');
+ setErrorMessage('');
+ timeoutRef.current = window.setTimeout(() => showError(), INITIALIZATION_TIMEOUT_MS);
+
+ return () => {
+ clearInitializationTimeout();
+ if (apiRef.current && apiErrorListenerRef.current) {
+ apiRef.current.removeEventListener?.('errorOccurred', apiErrorListenerRef.current);
+ }
+ apiRef.current = null;
+ apiErrorListenerRef.current = null;
+ };
+ }, [attempt, roomName, normalizedDomain, jwt, requiresJwt, clearInitializationTimeout, showError]);
+
+ const handleApiReady = useCallback(
+ (api) => {
+ apiRef.current = api;
+ apiErrorListenerRef.current = showError;
+ clearInitializationTimeout();
+ setStatus('ready');
+ api.addEventListener?.('errorOccurred', showError);
+ },
+ [clearInitializationTimeout, showError]
+ );
+
+ const handleReadyToClose = useCallback(() => {
+ clearInitializationTimeout();
+ apiRef.current = null;
+ onReadyToClose?.();
+ }, [clearInitializationTimeout, onReadyToClose]);
+
+ const retry = useCallback(() => {
+ apiRef.current?.dispose?.();
+ apiRef.current = null;
+ apiErrorListenerRef.current = null;
+ setStatus('loading');
+ setErrorMessage('');
+ setAttempt((value) => value + 1);
+ }, []);
+
+ if (!roomName) {
+ return (
+
+ This live session does not have a meeting room yet. Please contact the host.
+
+ );
+ }
+
+ if (requiresJwt && !jwt) {
+ return (
+
+ Preparing your secure meeting session...
+
+ );
+ }
+ if (status === 'error') {
return (
-
+
+
{errorMessage}
+
+
);
+ }
+
+ return (
+
+
{
+ if (iframe) {
+ iframe.style.height = '100%';
+ iframe.style.width = '100%';
+ }
+ }}
+ />
+ {status === 'loading' && (
+ Loading video room…
+ )}
+
+ );
};
export default JitsiMeetComponent;