diff --git a/src/shapes/VideoChatShapeUtil.tsx b/src/shapes/VideoChatShapeUtil.tsx index ca05365..58855ad 100644 --- a/src/shapes/VideoChatShapeUtil.tsx +++ b/src/shapes/VideoChatShapeUtil.tsx @@ -1,30 +1,20 @@ import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw" -import { useEffect, useState } from "react" -import { WORKER_URL } from "../constants/workerUrl" +import { useEffect, useState, useRef } from "react" import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" import { usePinnedToView } from "../hooks/usePinnedToView" import { useMaximize } from "../hooks/useMaximize" -interface DailyApiResponse { - url: string; -} - -interface DailyRecordingResponse { - id: string; -} +// Jeffsi Meet domain (self-hosted Jitsi) +const JITSI_DOMAIN = "meet.jeffemmett.com" export type IVideoChatShape = TLBaseShape< "VideoChat", { w: number h: number - roomUrl: string | null + roomName: string | null allowCamera: boolean allowMicrophone: boolean - enableRecording: boolean - recordingId: string | null // Track active recording - meetingToken: string | null - isOwner: boolean pinnedToView: boolean tags: string[] } @@ -42,355 +32,64 @@ export class VideoChatShape extends BaseBoxShapeUtil { getDefaultProps(): IVideoChatShape["props"] { return { - roomUrl: null, + roomName: null, w: 800, - h: 560, // Reduced from 600 to account for header (40px) and avoid scrollbars - allowCamera: false, - allowMicrophone: false, - enableRecording: true, - recordingId: null, - meetingToken: null, - isOwner: false, + h: 560, + allowCamera: true, + allowMicrophone: true, pinnedToView: false, tags: ['video-chat'] }; } - async generateMeetingToken(roomName: string) { - const workerUrl = WORKER_URL; - - if (!workerUrl) { - throw new Error('Worker URL is not configured'); + generateRoomName(shapeId: string): string { + // Extract board ID from URL + let boardId = 'default'; + const currentUrl = window.location.pathname; + 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 - // We'll use the room URL directly and handle owner permissions differently - return `token_${roomName}_${Date.now()}`; + // Clean the shape ID (remove 'shape:' prefix and special chars) + const cleanShapeId = shapeId.replace(/^shape:/, '').replace(/[^A-Za-z0-9]/g, '').substring(0, 8); + + // Create a readable room name + return `canvas-${boardId}-${cleanShapeId}`; } - async ensureRoomExists(shape: IVideoChatShape) { - // Try to get the actual room ID from the URL or use a fallback - let roomId = 'default-room'; - - // Try to extract room ID from the current URL - const currentUrl = window.location.pathname; - const roomMatch = currentUrl.match(/\/board\/([^\/]+)/); - if (roomMatch) { - roomId = roomMatch[1]; - } else { - // Fallback: try to get from localStorage or use a default - roomId = localStorage.getItem('currentRoomId') || 'default-room'; - } + component(shape: IVideoChatShape) { + const props = shape.props || {} + const [isMinimized, setIsMinimized] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [roomName, setRoomName] = useState(props.roomName) + const iframeRef = useRef(null) + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) - // Clear old storage entries that use the old boardId format - // This ensures we don't load old rooms with the wrong naming convention - const oldStorageKeys = [ - 'videoChat_room_page_page', - 'videoChat_room_page:page', - 'videoChat_room_board_page_page' - ]; - - oldStorageKeys.forEach(key => { - if (localStorage.getItem(key)) { - localStorage.removeItem(key); - localStorage.removeItem(`${key}_token`); - } - }); + // Initialize room name if not set + useEffect(() => { + if (!roomName) { + const newRoomName = this.generateRoomName(shape.id); + setRoomName(newRoomName); - // Try to get existing room URL from localStorage first - const storageKey = `videoChat_room_${roomId}`; - 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({ - 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({ + // Update shape props with room name + this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, - roomUrl: url, - meetingToken: meetingToken, - isOwner: isNewRoom, // Only owner if we created the room + roomName: newRoomName, }, - }) - } 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 - 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({ - 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({ - 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(null) - const [isLoading, setIsLoading] = useState(true) - const [currentRoomUrl, setCurrentRoomUrl] = useState(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 + // Use the pinning hook usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) - // Use the maximize hook for fullscreen functionality + // Use the maximize hook const { isMaximized, toggleMaximize } = useMaximize({ editor: this.editor, shapeId: shape.id, @@ -400,72 +99,50 @@ export class VideoChatShape extends BaseBoxShapeUtil { }) if (error) { - return
Error creating room: {error.message}
+ return
Error: {error.message}
} - if (isLoading || !currentRoomUrl || currentRoomUrl === 'undefined') { - return ( -
- {isLoading ? "Creating room... Please wait" : "Error: No room URL available"} -
- ) - } - - // Validate room URL format - if (!currentRoomUrl || !currentRoomUrl.startsWith('http')) { - console.error('Invalid room URL format:', currentRoomUrl); - return
Error: Invalid room URL format
; - } - - // 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), + if (isLoading || !roomName) { + return ( +
+ Initializing Jeffsi Meet... +
) - 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 = () => { this.editor.deleteShape(shape.id) @@ -486,10 +163,19 @@ export class VideoChatShape extends BaseBoxShapeUtil { }) } + const handleCopyLink = () => { + const shareUrl = `https://${JITSI_DOMAIN}/${roomName}` + navigator.clipboard.writeText(shareUrl) + } + + const handleOpenInNewTab = () => { + window.open(`https://${JITSI_DOMAIN}/${roomName}`, '_blank') + } + return ( { overflow: "hidden", }} > - - {/* Video Container */} -
- {!useFallback ? ( - - ) : ( - - )} - - {/* Loading indicator */} - {iframeError && retryCount < 3 && ( -
- Retrying connection... (Attempt {retryCount + 1}/3) -
- )} - - - {/* Fallback button if iframe fails */} - {iframeError && retryCount >= 3 && ( -
-

Video chat failed to load in iframe

- {isNetworkIP && ( -

- ⚠️ 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. -

- )} - {isNonLocalhost && !isNetworkIP && ( -

- ⚠️ CORS issue detected: Try accessing via localhost:5173 instead of {window.location.hostname}:5173 -

- )} -

- URL: {roomUrlWithParams.toString()} -

- -