feat: Replace Daily.co with Jeffsi Meet for video calls
- Remove Daily.co API dependencies - Use self-hosted Jeffsi Meet (Jitsi fork) at meet.jeffemmett.com - Simplify room creation (Jitsi creates rooms on-the-fly) - Add Copy Link and Pop Out buttons for sharing - Configure Jitsi embed with custom branding settings - No recurring per-minute costs with self-hosted solution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ed61902fab
commit
30daf2a8cb
|
|
@ -1,30 +1,20 @@
|
||||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
|
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { WORKER_URL } from "../constants/workerUrl"
|
|
||||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||||
import { useMaximize } from "../hooks/useMaximize"
|
import { useMaximize } from "../hooks/useMaximize"
|
||||||
|
|
||||||
interface DailyApiResponse {
|
// Jeffsi Meet domain (self-hosted Jitsi)
|
||||||
url: string;
|
const JITSI_DOMAIN = "meet.jeffemmett.com"
|
||||||
}
|
|
||||||
|
|
||||||
interface DailyRecordingResponse {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IVideoChatShape = TLBaseShape<
|
export type IVideoChatShape = TLBaseShape<
|
||||||
"VideoChat",
|
"VideoChat",
|
||||||
{
|
{
|
||||||
w: number
|
w: number
|
||||||
h: number
|
h: number
|
||||||
roomUrl: string | null
|
roomName: string | null
|
||||||
allowCamera: boolean
|
allowCamera: boolean
|
||||||
allowMicrophone: boolean
|
allowMicrophone: boolean
|
||||||
enableRecording: boolean
|
|
||||||
recordingId: string | null // Track active recording
|
|
||||||
meetingToken: string | null
|
|
||||||
isOwner: boolean
|
|
||||||
pinnedToView: boolean
|
pinnedToView: boolean
|
||||||
tags: string[]
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
@ -42,355 +32,64 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
|
|
||||||
getDefaultProps(): IVideoChatShape["props"] {
|
getDefaultProps(): IVideoChatShape["props"] {
|
||||||
return {
|
return {
|
||||||
roomUrl: null,
|
roomName: null,
|
||||||
w: 800,
|
w: 800,
|
||||||
h: 560, // Reduced from 600 to account for header (40px) and avoid scrollbars
|
h: 560,
|
||||||
allowCamera: false,
|
allowCamera: true,
|
||||||
allowMicrophone: false,
|
allowMicrophone: true,
|
||||||
enableRecording: true,
|
|
||||||
recordingId: null,
|
|
||||||
meetingToken: null,
|
|
||||||
isOwner: false,
|
|
||||||
pinnedToView: false,
|
pinnedToView: false,
|
||||||
tags: ['video-chat']
|
tags: ['video-chat']
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateMeetingToken(roomName: string) {
|
generateRoomName(shapeId: string): string {
|
||||||
const workerUrl = WORKER_URL;
|
// Extract board ID from URL
|
||||||
|
let boardId = 'default';
|
||||||
if (!workerUrl) {
|
const currentUrl = window.location.pathname;
|
||||||
throw new Error('Worker URL is not configured');
|
const boardMatch = currentUrl.match(/\/board\/([^\/]+)/);
|
||||||
|
if (boardMatch) {
|
||||||
|
boardId = boardMatch[1].substring(0, 8); // First 8 chars
|
||||||
}
|
}
|
||||||
|
|
||||||
// For now, let's skip token generation and use a simpler approach
|
// Clean the shape ID (remove 'shape:' prefix and special chars)
|
||||||
// We'll use the room URL directly and handle owner permissions differently
|
const cleanShapeId = shapeId.replace(/^shape:/, '').replace(/[^A-Za-z0-9]/g, '').substring(0, 8);
|
||||||
return `token_${roomName}_${Date.now()}`;
|
|
||||||
|
// Create a readable room name
|
||||||
|
return `canvas-${boardId}-${cleanShapeId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureRoomExists(shape: IVideoChatShape) {
|
component(shape: IVideoChatShape) {
|
||||||
// Try to get the actual room ID from the URL or use a fallback
|
const props = shape.props || {}
|
||||||
let roomId = 'default-room';
|
const [isMinimized, setIsMinimized] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
// Try to extract room ID from the current URL
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const currentUrl = window.location.pathname;
|
const [roomName, setRoomName] = useState<string | null>(props.roomName)
|
||||||
const roomMatch = currentUrl.match(/\/board\/([^\/]+)/);
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
if (roomMatch) {
|
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||||
roomId = roomMatch[1];
|
|
||||||
} else {
|
|
||||||
// Fallback: try to get from localStorage or use a default
|
|
||||||
roomId = localStorage.getItem('currentRoomId') || 'default-room';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear old storage entries that use the old boardId format
|
// Initialize room name if not set
|
||||||
// This ensures we don't load old rooms with the wrong naming convention
|
useEffect(() => {
|
||||||
const oldStorageKeys = [
|
if (!roomName) {
|
||||||
'videoChat_room_page_page',
|
const newRoomName = this.generateRoomName(shape.id);
|
||||||
'videoChat_room_page:page',
|
setRoomName(newRoomName);
|
||||||
'videoChat_room_board_page_page'
|
|
||||||
];
|
|
||||||
|
|
||||||
oldStorageKeys.forEach(key => {
|
|
||||||
if (localStorage.getItem(key)) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
localStorage.removeItem(`${key}_token`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to get existing room URL from localStorage first
|
// Update shape props with room name
|
||||||
const storageKey = `videoChat_room_${roomId}`;
|
this.editor.updateShape<IVideoChatShape>({
|
||||||
const existingRoomUrl = localStorage.getItem(storageKey);
|
|
||||||
const existingToken = localStorage.getItem(`${storageKey}_token`);
|
|
||||||
|
|
||||||
if (existingRoomUrl && existingRoomUrl !== 'undefined' && existingToken) {
|
|
||||||
// Check if the existing room URL uses the old naming pattern
|
|
||||||
if (existingRoomUrl.includes('board_page_page_') || existingRoomUrl.includes('page_page')) {
|
|
||||||
localStorage.removeItem(storageKey);
|
|
||||||
localStorage.removeItem(`${storageKey}_token`);
|
|
||||||
} else {
|
|
||||||
await this.editor.updateShape<IVideoChatShape>({
|
|
||||||
id: shape.id,
|
|
||||||
type: shape.type,
|
|
||||||
props: {
|
|
||||||
...shape.props,
|
|
||||||
roomUrl: existingRoomUrl,
|
|
||||||
meetingToken: existingToken,
|
|
||||||
isOwner: true, // Assume the creator is the owner
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined' && shape.props.meetingToken) {
|
|
||||||
// Check if the shape's room URL uses the old naming pattern
|
|
||||||
if (!shape.props.roomUrl.includes('board_page_page_') && !shape.props.roomUrl.includes('page_page')) {
|
|
||||||
localStorage.setItem(storageKey, shape.props.roomUrl);
|
|
||||||
localStorage.setItem(`${storageKey}_token`, shape.props.meetingToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const workerUrl = WORKER_URL;
|
|
||||||
|
|
||||||
if (!workerUrl) {
|
|
||||||
throw new Error('Worker URL is not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a simple, clean room name
|
|
||||||
// Use a short hash of the room ID to keep URLs readable
|
|
||||||
const shortId = roomId.length > 8 ? roomId.substring(0, 8) : roomId;
|
|
||||||
const cleanId = shortId.replace(/[^A-Za-z0-9]/g, '');
|
|
||||||
const roomName = `canvas-${cleanId}`;
|
|
||||||
|
|
||||||
// Worker uses server-side API key, no need to send it from client
|
|
||||||
const response = await fetch(`${workerUrl}/daily/rooms`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: roomName,
|
|
||||||
properties: {
|
|
||||||
enable_chat: true,
|
|
||||||
enable_screenshare: true,
|
|
||||||
start_video_off: true,
|
|
||||||
start_audio_off: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let url: string;
|
|
||||||
let isNewRoom: boolean = false;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json() as any
|
|
||||||
|
|
||||||
// Check if the room already exists
|
|
||||||
if (response.status === 400 && error.info && error.info.includes('already exists')) {
|
|
||||||
isNewRoom = false;
|
|
||||||
|
|
||||||
// Try to get the existing room info via worker (API key is server-side)
|
|
||||||
try {
|
|
||||||
const getRoomResponse = await fetch(`${workerUrl}/daily/rooms/${roomName}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (getRoomResponse.ok) {
|
|
||||||
const roomData = await getRoomResponse.json() as any;
|
|
||||||
url = roomData.url;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Room ${roomName} already exists but could not retrieve room URL. Please contact support.`);
|
|
||||||
}
|
|
||||||
} catch (getRoomError) {
|
|
||||||
throw new Error(`Room ${roomName} already exists but could not connect to it: ${(getRoomError as Error).message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Some other error occurred
|
|
||||||
throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Room was created successfully
|
|
||||||
isNewRoom = true;
|
|
||||||
const data = (await response.json()) as DailyApiResponse;
|
|
||||||
url = data.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
throw new Error("Room URL is missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate meeting token for the owner
|
|
||||||
const meetingToken = await this.generateMeetingToken(roomName);
|
|
||||||
|
|
||||||
// Store the room URL and token in localStorage
|
|
||||||
localStorage.setItem(storageKey, url);
|
|
||||||
localStorage.setItem(`${storageKey}_token`, meetingToken);
|
|
||||||
|
|
||||||
await this.editor.updateShape<IVideoChatShape>({
|
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
props: {
|
props: {
|
||||||
...shape.props,
|
...shape.props,
|
||||||
roomUrl: url,
|
roomName: newRoomName,
|
||||||
meetingToken: meetingToken,
|
|
||||||
isOwner: isNewRoom, // Only owner if we created the room
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in ensureRoomExists:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async startRecording(shape: IVideoChatShape) {
|
|
||||||
if (!shape.props.roomUrl) return;
|
|
||||||
|
|
||||||
const workerUrl = WORKER_URL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract room name from URL (same as transcription methods)
|
|
||||||
const roomName = shape.props.roomUrl.split('/').pop();
|
|
||||||
if (!roomName) {
|
|
||||||
throw new Error('Could not extract room name from URL');
|
|
||||||
}
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, [shape.id]);
|
||||||
|
|
||||||
// Worker uses server-side API key, no need to send it from client
|
// Use the pinning hook
|
||||||
const response = await fetch(`${workerUrl}/daily/recordings/start`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
room_name: roomName,
|
|
||||||
layout: {
|
|
||||||
preset: "active-speaker"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to start recording');
|
|
||||||
|
|
||||||
const data = await response.json() as DailyRecordingResponse;
|
|
||||||
|
|
||||||
await this.editor.updateShape<IVideoChatShape>({
|
|
||||||
id: shape.id,
|
|
||||||
type: shape.type,
|
|
||||||
props: {
|
|
||||||
...shape.props,
|
|
||||||
recordingId: data.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error starting recording:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async stopRecording(shape: IVideoChatShape) {
|
|
||||||
if (!shape.props.recordingId) return;
|
|
||||||
|
|
||||||
const workerUrl = WORKER_URL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Worker uses server-side API key, no need to send it from client
|
|
||||||
await fetch(`${workerUrl}/daily/recordings/${shape.props.recordingId}/stop`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.editor.updateShape<IVideoChatShape>({
|
|
||||||
id: shape.id,
|
|
||||||
type: shape.type,
|
|
||||||
props: {
|
|
||||||
...shape.props,
|
|
||||||
recordingId: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error stopping recording:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
component(shape: IVideoChatShape) {
|
|
||||||
// Ensure shape props exist with defaults
|
|
||||||
const props = shape.props || {}
|
|
||||||
const roomUrl = props.roomUrl || ""
|
|
||||||
|
|
||||||
const [hasPermissions, setHasPermissions] = useState(false)
|
|
||||||
const [forceRender, setForceRender] = useState(0)
|
|
||||||
const [isMinimized, setIsMinimized] = useState(false)
|
|
||||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
|
||||||
|
|
||||||
// Force re-render function
|
|
||||||
const forceComponentUpdate = () => {
|
|
||||||
setForceRender(prev => prev + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [error, setError] = useState<Error | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [currentRoomUrl, setCurrentRoomUrl] = useState<string | null>(roomUrl)
|
|
||||||
const [iframeError, setIframeError] = useState(false)
|
|
||||||
const [retryCount, setRetryCount] = useState(0)
|
|
||||||
const [useFallback, setUseFallback] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const createRoom = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
await this.ensureRoomExists(shape);
|
|
||||||
|
|
||||||
// Get the updated shape after room creation
|
|
||||||
const updatedShape = this.editor.getShape(shape.id);
|
|
||||||
if (mounted && updatedShape) {
|
|
||||||
setCurrentRoomUrl((updatedShape as IVideoChatShape).props.roomUrl);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (mounted) {
|
|
||||||
console.error("Error creating room:", err);
|
|
||||||
setError(err as Error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
createRoom();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [shape.id]); // Only re-run if shape.id changes
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const requestPermissions = async () => {
|
|
||||||
try {
|
|
||||||
if (shape.props.allowCamera || shape.props.allowMicrophone) {
|
|
||||||
const constraints = {
|
|
||||||
video: shape.props.allowCamera,
|
|
||||||
audio: shape.props.allowMicrophone,
|
|
||||||
}
|
|
||||||
await navigator.mediaDevices.getUserMedia(constraints)
|
|
||||||
if (mounted) {
|
|
||||||
setHasPermissions(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Permission request failed:", err)
|
|
||||||
if (mounted) {
|
|
||||||
setHasPermissions(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestPermissions()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
}
|
|
||||||
}, [shape.props.allowCamera, shape.props.allowMicrophone])
|
|
||||||
|
|
||||||
// CRITICAL: Hooks must be called before any conditional returns
|
|
||||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
|
||||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||||
|
|
||||||
// Use the maximize hook for fullscreen functionality
|
// Use the maximize hook
|
||||||
const { isMaximized, toggleMaximize } = useMaximize({
|
const { isMaximized, toggleMaximize } = useMaximize({
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
shapeId: shape.id,
|
shapeId: shape.id,
|
||||||
|
|
@ -400,72 +99,50 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>Error creating room: {error.message}</div>
|
return <div>Error: {error.message}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading || !currentRoomUrl || currentRoomUrl === 'undefined') {
|
if (isLoading || !roomName) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: shape.props.w,
|
width: shape.props.w,
|
||||||
height: shape.props.h,
|
height: shape.props.h,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#f0f0f0",
|
backgroundColor: "#f0f0f0",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoading ? "Creating room... Please wait" : "Error: No room URL available"}
|
Initializing Jeffsi Meet...
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate room URL format
|
|
||||||
if (!currentRoomUrl || !currentRoomUrl.startsWith('http')) {
|
|
||||||
console.error('Invalid room URL format:', currentRoomUrl);
|
|
||||||
return <div>Error: Invalid room URL format</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're running on a network IP (which can cause WebRTC/CORS issues)
|
|
||||||
const isNonLocalhost = window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1';
|
|
||||||
const isNetworkIP = window.location.hostname.startsWith('172.') || window.location.hostname.startsWith('192.168.') || window.location.hostname.startsWith('10.');
|
|
||||||
|
|
||||||
// Try the original URL first, then add parameters if needed
|
|
||||||
let roomUrlWithParams;
|
|
||||||
try {
|
|
||||||
roomUrlWithParams = new URL(currentRoomUrl)
|
|
||||||
roomUrlWithParams.searchParams.set(
|
|
||||||
"allow_camera",
|
|
||||||
String(shape.props.allowCamera),
|
|
||||||
)
|
)
|
||||||
roomUrlWithParams.searchParams.set(
|
|
||||||
"allow_mic",
|
|
||||||
String(shape.props.allowMicrophone),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Add parameters for better network access
|
|
||||||
if (isNetworkIP) {
|
|
||||||
roomUrlWithParams.searchParams.set("embed", "true")
|
|
||||||
roomUrlWithParams.searchParams.set("iframe", "true")
|
|
||||||
roomUrlWithParams.searchParams.set("show_leave_button", "false")
|
|
||||||
roomUrlWithParams.searchParams.set("show_fullscreen_button", "false")
|
|
||||||
roomUrlWithParams.searchParams.set("show_participants_bar", "true")
|
|
||||||
roomUrlWithParams.searchParams.set("show_local_video", "true")
|
|
||||||
roomUrlWithParams.searchParams.set("show_remote_video", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add embed parameters if the original URL doesn't work
|
|
||||||
if (retryCount > 0) {
|
|
||||||
roomUrlWithParams.searchParams.set("embed", "true")
|
|
||||||
roomUrlWithParams.searchParams.set("iframe", "true")
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error constructing URL:', e);
|
|
||||||
roomUrlWithParams = new URL(currentRoomUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Removed HEAD request test due to CORS issues with non-localhost IPs
|
// Construct Jitsi Meet URL with configuration
|
||||||
|
const jitsiUrl = new URL(`https://${JITSI_DOMAIN}/${roomName}`)
|
||||||
|
|
||||||
|
// Add configuration via URL params (Jitsi supports this)
|
||||||
|
const config = {
|
||||||
|
// UI Configuration
|
||||||
|
'config.prejoinPageEnabled': 'false',
|
||||||
|
'config.startWithAudioMuted': props.allowMicrophone ? 'false' : 'true',
|
||||||
|
'config.startWithVideoMuted': props.allowCamera ? 'false' : 'true',
|
||||||
|
'config.disableModeratorIndicator': 'true',
|
||||||
|
'config.enableWelcomePage': 'false',
|
||||||
|
// Interface configuration
|
||||||
|
'interfaceConfig.SHOW_JITSI_WATERMARK': 'false',
|
||||||
|
'interfaceConfig.SHOW_BRAND_WATERMARK': 'false',
|
||||||
|
'interfaceConfig.SHOW_POWERED_BY': 'false',
|
||||||
|
'interfaceConfig.HIDE_INVITE_MORE_HEADER': 'true',
|
||||||
|
'interfaceConfig.MOBILE_APP_PROMO': 'false',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add config params to URL
|
||||||
|
Object.entries(config).forEach(([key, value]) => {
|
||||||
|
jitsiUrl.hash = `${jitsiUrl.hash}${jitsiUrl.hash ? '&' : ''}${key}=${value}`
|
||||||
|
})
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
this.editor.deleteShape(shape.id)
|
this.editor.deleteShape(shape.id)
|
||||||
|
|
@ -486,10 +163,19 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
const shareUrl = `https://${JITSI_DOMAIN}/${roomName}`
|
||||||
|
navigator.clipboard.writeText(shareUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenInNewTab = () => {
|
||||||
|
window.open(`https://${JITSI_DOMAIN}/${roomName}`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h + 40 }}>
|
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h + 40 }}>
|
||||||
<StandardizedToolWrapper
|
<StandardizedToolWrapper
|
||||||
title="Video Chat"
|
title="Jeffsi Meet"
|
||||||
primaryColor={VideoChatShape.PRIMARY_COLOR}
|
primaryColor={VideoChatShape.PRIMARY_COLOR}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
width={shape.props.w}
|
width={shape.props.w}
|
||||||
|
|
@ -527,202 +213,109 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Video Container */}
|
||||||
{/* Video Container */}
|
<div
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
flex: 1,
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
minHeight: 0, // Allow flex item to shrink below content size
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!useFallback ? (
|
|
||||||
<iframe
|
|
||||||
key={`iframe-${retryCount}`}
|
|
||||||
src={roomUrlWithParams.toString()}
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
allow={isNetworkIP ? "*" : "camera; microphone; fullscreen; display-capture; autoplay; encrypted-media; geolocation; web-share"}
|
|
||||||
referrerPolicy={isNetworkIP ? "unsafe-url" : "no-referrer-when-downgrade"}
|
|
||||||
sandbox={isNetworkIP ? "allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation" : undefined}
|
|
||||||
title="Daily.co Video Chat"
|
|
||||||
loading="lazy"
|
|
||||||
onError={(e) => {
|
|
||||||
console.error('Iframe loading error:', e);
|
|
||||||
setIframeError(true);
|
|
||||||
if (retryCount < 2) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setRetryCount(prev => prev + 1);
|
|
||||||
setIframeError(false);
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
setUseFallback(true);
|
|
||||||
setIframeError(false);
|
|
||||||
setRetryCount(0);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onLoad={() => {
|
|
||||||
setIframeError(false);
|
|
||||||
setRetryCount(0);
|
|
||||||
}}
|
|
||||||
></iframe>
|
|
||||||
) : (
|
|
||||||
<iframe
|
|
||||||
key={`fallback-iframe-${retryCount}`}
|
|
||||||
src={currentRoomUrl}
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
}}
|
|
||||||
allow="*"
|
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation"
|
|
||||||
title="Daily.co Video Chat (Fallback)"
|
|
||||||
onError={(e) => {
|
|
||||||
console.error('Fallback iframe loading error:', e);
|
|
||||||
setIframeError(true);
|
|
||||||
if (retryCount < 3) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setRetryCount(prev => prev + 1);
|
|
||||||
setIframeError(false);
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
setError(new Error('Failed to load video chat room after multiple attempts'));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onLoad={() => {
|
|
||||||
setIframeError(false);
|
|
||||||
setRetryCount(0);
|
|
||||||
}}
|
|
||||||
></iframe>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading indicator */}
|
|
||||||
{iframeError && retryCount < 3 && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
background: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
color: 'white',
|
|
||||||
padding: '10px 20px',
|
|
||||||
borderRadius: '5px',
|
|
||||||
zIndex: 10
|
|
||||||
}}>
|
|
||||||
Retrying connection... (Attempt {retryCount + 1}/3)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{/* Fallback button if iframe fails */}
|
|
||||||
{iframeError && retryCount >= 3 && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
background: 'rgba(0, 0, 0, 0.9)',
|
|
||||||
color: 'white',
|
|
||||||
padding: '20px',
|
|
||||||
borderRadius: '10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
zIndex: 10
|
|
||||||
}}>
|
|
||||||
<p>Video chat failed to load in iframe</p>
|
|
||||||
{isNetworkIP && (
|
|
||||||
<p style={{fontSize: '12px', margin: '10px 0', color: '#ffc107'}}>
|
|
||||||
⚠️ Network access issue detected: Video chat may not work on {window.location.hostname}:5173 due to WebRTC/CORS restrictions. Try accessing via localhost:5173 or use the "Open in New Tab" button below.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{isNonLocalhost && !isNetworkIP && (
|
|
||||||
<p style={{fontSize: '12px', margin: '10px 0', color: '#ffc107'}}>
|
|
||||||
⚠️ CORS issue detected: Try accessing via localhost:5173 instead of {window.location.hostname}:5173
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p style={{fontSize: '12px', margin: '10px 0'}}>
|
|
||||||
URL: {roomUrlWithParams.toString()}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.open(roomUrlWithParams.toString(), '_blank')}
|
|
||||||
style={{
|
style={{
|
||||||
background: '#007bff',
|
width: '100%',
|
||||||
color: 'white',
|
flex: 1,
|
||||||
border: 'none',
|
position: "relative",
|
||||||
padding: '10px 20px',
|
overflow: "hidden",
|
||||||
borderRadius: '5px',
|
minHeight: 0,
|
||||||
cursor: 'pointer',
|
|
||||||
marginTop: '10px'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Open in New Tab
|
<iframe
|
||||||
</button>
|
ref={iframeRef}
|
||||||
<button
|
src={jitsiUrl.toString()}
|
||||||
onClick={() => {
|
width="100%"
|
||||||
setUseFallback(!useFallback);
|
height="100%"
|
||||||
setRetryCount(0);
|
style={{
|
||||||
setIframeError(false);
|
border: "none",
|
||||||
}}
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
allow="camera; microphone; fullscreen; display-capture; autoplay; clipboard-write"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
title="Jeffsi Meet Video Chat"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Iframe loading error:', e);
|
||||||
|
setError(new Error('Failed to load video chat'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Bar with Room Info and Actions */}
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: '#28a745',
|
position: "absolute",
|
||||||
color: 'white',
|
bottom: "8px",
|
||||||
border: 'none',
|
left: "8px",
|
||||||
padding: '10px 20px',
|
right: "8px",
|
||||||
borderRadius: '5px',
|
display: "flex",
|
||||||
cursor: 'pointer',
|
alignItems: "center",
|
||||||
marginTop: '10px',
|
justifyContent: "space-between",
|
||||||
marginLeft: '10px'
|
gap: "8px",
|
||||||
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Try {useFallback ? 'Normal' : 'Fallback'} Mode
|
{/* Room Name */}
|
||||||
</button>
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: "4px 8px",
|
||||||
|
background: "rgba(255, 255, 255, 0.9)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "12px",
|
||||||
|
pointerEvents: "all",
|
||||||
|
cursor: "text",
|
||||||
|
userSelect: "text",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: "60%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Room: {roomName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div style={{ display: "flex", gap: "4px" }}>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
style={{
|
||||||
|
padding: "4px 8px",
|
||||||
|
background: "rgba(255, 255, 255, 0.9)",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "11px",
|
||||||
|
cursor: "pointer",
|
||||||
|
pointerEvents: "all",
|
||||||
|
}}
|
||||||
|
title="Copy invite link"
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenInNewTab}
|
||||||
|
style={{
|
||||||
|
padding: "4px 8px",
|
||||||
|
background: "rgba(255, 255, 255, 0.9)",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "11px",
|
||||||
|
cursor: "pointer",
|
||||||
|
pointerEvents: "all",
|
||||||
|
}}
|
||||||
|
title="Open in new tab"
|
||||||
|
>
|
||||||
|
Pop Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* URL Bubble - Overlay on bottom of video */}
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "8px",
|
|
||||||
left: "8px",
|
|
||||||
margin: 0,
|
|
||||||
padding: "4px 8px",
|
|
||||||
background: "rgba(255, 255, 255, 0.9)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "12px",
|
|
||||||
pointerEvents: "all",
|
|
||||||
cursor: "text",
|
|
||||||
userSelect: "text",
|
|
||||||
zIndex: 1,
|
|
||||||
maxWidth: "calc(100% - 16px)",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
url: {currentRoomUrl}
|
|
||||||
{shape.props.isOwner && " (Owner)"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</StandardizedToolWrapper>
|
</StandardizedToolWrapper>
|
||||||
</HTMLContainer>
|
</HTMLContainer>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue