import { BaseBoxShapeUtil, TLBaseShape } from "tldraw" import { useEffect, useState } from "react" interface DailyApiResponse { url: string; } interface DailyRecordingResponse { id: string; } export type IVideoChatShape = TLBaseShape< "VideoChat", { w: number h: number roomUrl: string | null allowCamera: boolean allowMicrophone: boolean enableRecording: boolean recordingId: string | null // Track active recording enableTranscription: boolean isTranscribing: boolean transcriptionHistory: Array<{ sender: string message: string id: string }> meetingToken: string | null isOwner: boolean } > export class VideoChatShape extends BaseBoxShapeUtil { static override type = "VideoChat" indicator(_shape: IVideoChatShape) { return null } getDefaultProps(): IVideoChatShape["props"] { const props = { roomUrl: null, w: 800, h: 600, allowCamera: false, allowMicrophone: false, enableRecording: true, recordingId: null, enableTranscription: true, isTranscribing: false, transcriptionHistory: [], meetingToken: null, isOwner: false }; console.log('๐Ÿ”ง getDefaultProps called, returning:', props); return props; } async generateMeetingToken(roomName: string) { const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; if (!apiKey) { throw new Error('Daily.co API key not configured'); } if (!workerUrl) { throw new Error('Worker URL is not configured'); } // For now, let's skip token generation and use a simpler approach // We'll use the room URL directly and handle owner permissions differently console.log('Skipping meeting token generation for now'); return `token_${roomName}_${Date.now()}`; } async ensureRoomExists(shape: IVideoChatShape) { const boardId = this.editor.getCurrentPageId(); if (!boardId) { throw new Error('Board ID is undefined'); } // Try to get existing room URL from localStorage first const storageKey = `videoChat_room_${boardId}`; const existingRoomUrl = localStorage.getItem(storageKey); const existingToken = localStorage.getItem(`${storageKey}_token`); if (existingRoomUrl && existingRoomUrl !== 'undefined' && existingToken) { console.log("Using existing room from storage:", existingRoomUrl); 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) { console.log("Room already exists:", shape.props.roomUrl); localStorage.setItem(storageKey, shape.props.roomUrl); localStorage.setItem(`${storageKey}_token`, shape.props.meetingToken); return; } try { const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; if (!apiKey) { throw new Error('Daily.co API key not configured'); } if (!workerUrl) { throw new Error('Worker URL is not configured'); } // Create room name based on board ID and timestamp // Sanitize boardId to only use valid Daily.co characters (A-Z, a-z, 0-9, '-', '_') const sanitizedBoardId = boardId.replace(/[^A-Za-z0-9\-_]/g, '_'); const roomName = `board_${sanitizedBoardId}_${Date.now()}`; console.log('๐Ÿ”ง Room name generation:'); console.log('Original boardId:', boardId); console.log('Sanitized boardId:', sanitizedBoardId); console.log('Final roomName:', roomName); const response = await fetch(`${workerUrl}/daily/rooms`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify({ name: roomName, properties: { enable_chat: true, enable_screenshare: true, start_video_off: true, start_audio_off: true } }) }); if (!response.ok) { const error = await response.json() throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`) } const data = (await response.json()) as DailyApiResponse; const url = data.url; if (!url) throw new Error("Room URL is missing") // Generate meeting token for the owner // First ensure the room exists, then generate token const meetingToken = await this.generateMeetingToken(roomName); // Store the room URL and token in localStorage localStorage.setItem(storageKey, url); localStorage.setItem(`${storageKey}_token`, meetingToken); console.log("Room created successfully:", url) console.log("Meeting token generated:", meetingToken) console.log("Updating shape with new URL and token") console.log("Setting isOwner to true") await this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, roomUrl: url, meetingToken: meetingToken, isOwner: true, }, }) console.log("Shape updated:", this.editor.getShape(shape.id)) const updatedShape = this.editor.getShape(shape.id) as IVideoChatShape; console.log("Updated shape isOwner:", updatedShape?.props.isOwner) } catch (error) { console.error("Error in ensureRoomExists:", error) throw error } } async startRecording(shape: IVideoChatShape) { if (!shape.props.roomUrl) return; const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; 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'); } const response = await fetch(`${workerUrl}/daily/recordings/start`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, '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 = import.meta.env.VITE_TLDRAW_WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; try { await fetch(`${workerUrl}/daily/recordings/${shape.props.recordingId}/stop`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}` } }); 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; } } async startTranscription(shape: IVideoChatShape) { console.log('๐ŸŽค startTranscription method called'); console.log('Shape props:', shape.props); console.log('Room URL:', shape.props.roomUrl); console.log('Is owner:', shape.props.isOwner); if (!shape.props.roomUrl || !shape.props.isOwner) { console.log('โŒ Early return - missing roomUrl or not owner'); console.log('roomUrl exists:', !!shape.props.roomUrl); console.log('isOwner:', shape.props.isOwner); return; } try { const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; console.log('๐Ÿ”ง Environment variables:'); console.log('Worker URL:', workerUrl); console.log('API Key exists:', !!apiKey); // Extract room name from URL const roomName = shape.props.roomUrl.split('/').pop(); console.log('๐Ÿ“ Extracted room name:', roomName); if (!roomName) { throw new Error('Could not extract room name from URL'); } console.log('๐ŸŒ Making API request to start transcription...'); console.log('Request URL:', `${workerUrl}/daily/rooms/${roomName}/start-transcription`); const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/start-transcription`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` } }); console.log('๐Ÿ“ก Response status:', response.status); console.log('๐Ÿ“ก Response ok:', response.ok); if (!response.ok) { const error = await response.json(); console.error('โŒ API error response:', error); throw new Error(`Failed to start transcription: ${JSON.stringify(error)}`); } console.log('โœ… API call successful, updating shape...'); await this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, isTranscribing: true, } }); console.log('โœ… Shape updated with isTranscribing: true'); } catch (error) { console.error('โŒ Error starting transcription:', error); throw error; } } async stopTranscription(shape: IVideoChatShape) { console.log('๐Ÿ›‘ stopTranscription method called'); console.log('Shape props:', shape.props); if (!shape.props.roomUrl || !shape.props.isOwner) { console.log('โŒ Early return - missing roomUrl or not owner'); return; } try { const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; // Extract room name from URL const roomName = shape.props.roomUrl.split('/').pop(); console.log('๐Ÿ“ Extracted room name:', roomName); if (!roomName) { throw new Error('Could not extract room name from URL'); } console.log('๐ŸŒ Making API request to stop transcription...'); const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/stop-transcription`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` } }); console.log('๐Ÿ“ก Response status:', response.status); if (!response.ok) { const error = await response.json(); console.error('โŒ API error response:', error); throw new Error(`Failed to stop transcription: ${JSON.stringify(error)}`); } console.log('โœ… API call successful, updating shape...'); await this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, isTranscribing: false, } }); console.log('โœ… Shape updated with isTranscribing: false'); } catch (error) { console.error('โŒ Error stopping transcription:', error); throw error; } } addTranscriptionMessage(shape: IVideoChatShape, sender: string, message: string) { console.log('๐Ÿ“ addTranscriptionMessage called'); console.log('Sender:', sender); console.log('Message:', message); console.log('Current transcription history length:', shape.props.transcriptionHistory.length); const newMessage = { sender, message, id: `${Date.now()}_${Math.random()}` }; console.log('๐Ÿ“ Adding new message:', newMessage); this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, transcriptionHistory: [...shape.props.transcriptionHistory, newMessage] } }); console.log('โœ… Transcription message added to shape'); } component(shape: IVideoChatShape) { const [hasPermissions, setHasPermissions] = useState(false) const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(true) const [roomUrl, setRoomUrl] = useState(shape.props.roomUrl) 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) { setRoomUrl((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]) if (error) { return
Error creating room: {error.message}
} if (isLoading || !roomUrl || roomUrl === 'undefined') { return (
{isLoading ? "Creating room... Please wait" : "Error: No room URL available"}
) } // Construct URL with permission parameters const roomUrlWithParams = new URL(roomUrl) roomUrlWithParams.searchParams.set( "allow_camera", String(shape.props.allowCamera), ) roomUrlWithParams.searchParams.set( "allow_mic", String(shape.props.allowMicrophone), ) console.log(roomUrl) return (
{/* Recording Button */} {shape.props.enableRecording && ( )} {/* Test Button - Always visible for debugging */} {/* Transcription Button - Only for owners */} {(() => { console.log('๐Ÿ” Checking transcription button conditions:'); console.log('enableTranscription:', shape.props.enableTranscription); console.log('isOwner:', shape.props.isOwner); console.log('Button should render:', shape.props.enableTranscription && shape.props.isOwner); return shape.props.enableTranscription && shape.props.isOwner; })() && ( )} {/* Transcription History */} {shape.props.transcriptionHistory.length > 0 && (
Live Transcription:
{shape.props.transcriptionHistory.slice(-10).map((msg) => (
{msg.sender}: {" "} {msg.message}
))}
)}

url: {roomUrl} {shape.props.isOwner && " (Owner)"}

) } }