import { BaseBoxShapeUtil, HTMLContainer, TLBaseShape, createShapeId, Box, } from "tldraw" import React, { useState, useContext } from "react" import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel" import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" import { usePinnedToView } from "../hooks/usePinnedToView" import { FathomNoteShape } from "./FathomNoteShapeUtil" import { WORKER_URL, LOCAL_WORKER_URL } from "../constants/workerUrl" import { getFathomApiKey } from "../lib/fathomApiKey" import { AuthContext } from "../context/AuthContext" type IFathomMeetingsBrowser = TLBaseShape< "FathomMeetingsBrowser", { w: number h: number pinnedToView: boolean tags: string[] } > export class FathomMeetingsBrowserShape extends BaseBoxShapeUtil { static override type = "FathomMeetingsBrowser" as const getDefaultProps(): IFathomMeetingsBrowser["props"] { return { w: 800, h: 600, pinnedToView: false, tags: ['fathom', 'meetings', 'browser'], } } // Fathom theme color: Blue (Rainbow) static readonly PRIMARY_COLOR = "#3b82f6" component(shape: IFathomMeetingsBrowser) { const { w, h } = shape.props const [isOpen, setIsOpen] = useState(true) const [isMinimized, setIsMinimized] = useState(false) const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) // Use the pinning hook to keep the shape fixed to viewport when pinned usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) const handleClose = () => { setIsOpen(false) // Delete the browser shape immediately so it's tracked in undo/redo history this.editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handlePinToggle = () => { this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, pinnedToView: !shape.props.pinnedToView, }, }) } // Wrapper component to access auth context and create handler const FathomBrowserContent: React.FC = () => { const authContext = useContext(AuthContext) const fallbackSession = { username: '', authed: false, loading: false, backupCreated: null, } const session = authContext?.session || fallbackSession const handleMeetingSelect = async ( meeting: any, options: { summary: boolean; transcript: boolean; actionItems: boolean; video: boolean }, _format: 'fathom' | 'note' ) => { try { // CRITICAL: Store meeting data immediately to avoid closure issues // Extract all needed values before any async operations const meetingRecordingId = meeting?.recording_id const meetingTitle = meeting?.title if (!meetingRecordingId) { console.error('❌ No recording_id found in meeting object:', meeting) return } // Log to verify the correct meeting is being received console.log('🔵 handleMeetingSelect called with meeting:', { recording_id: meetingRecordingId, title: meetingTitle, options, fullMeetingObject: meeting }) // Get API key from user identity const apiKey = getFathomApiKey(session.username) if (!apiKey) { console.error('No Fathom API key found') return } // IMPORTANT: Each meeting row fetches its own data using the meeting's recording_id // This ensures each meeting's buttons pull data from the correct Fathom API endpoint // Always fetch full meeting details from API (summary and action items are included by default) // Only include transcript parameter if transcript is specifically requested const includeTranscript = options.transcript // Use the stored meetingRecordingId (already extracted above) console.log('🔵 Fetching data for meeting recording_id:', meetingRecordingId) let response try { // Fetch data for THIS specific meeting using its recording_id const apiUrl = `${WORKER_URL}/fathom/meetings/${meetingRecordingId}${includeTranscript ? '?include_transcript=true' : ''}` console.log('🔵 API URL:', apiUrl) response = await fetch(apiUrl, { headers: { 'X-Api-Key': apiKey, 'Content-Type': 'application/json' } }) } catch (error) { // Use the stored meetingRecordingId to ensure we fetch the correct meeting response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meetingRecordingId}${includeTranscript ? '?include_transcript=true' : ''}`, { headers: { 'X-Api-Key': apiKey, 'Content-Type': 'application/json' } }) } if (!response.ok) { console.error(`Failed to fetch meeting details: ${response.status}`) return } const fullMeeting = await response.json() as any // Debug: Log the meeting response structure console.log('Full meeting response:', fullMeeting) console.log('Meeting keys:', Object.keys(fullMeeting)) console.log('Has default_summary:', !!fullMeeting.default_summary) console.log('Has action_items:', !!fullMeeting.action_items) if (fullMeeting.default_summary) { console.log('default_summary structure:', fullMeeting.default_summary) } if (fullMeeting.action_items) { console.log('action_items length:', fullMeeting.action_items.length) } // Helper function to format date as YYYY.MM.DD const formatDateForTitle = (dateString: string | undefined): string => { if (!dateString) return '' try { const date = new Date(dateString) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}.${month}.${day}` } catch { return '' } } // Get meeting name and date for title formatting // Use the stored meetingRecordingId to ensure we're using the correct meeting // Also use the stored meetingTitle as fallback const meetingName = fullMeeting.title || meetingTitle || 'Meeting' const meetingDate = formatDateForTitle(fullMeeting.recording_start_time || fullMeeting.created_at) // Get browser shape bounds for positioning const browserShapeBounds = this.editor.getShapePageBounds(shape.id) let startX: number let startY: number if (!browserShapeBounds) { const viewport = this.editor.getViewportPageBounds() startX = viewport.x + viewport.w / 2 startY = viewport.y + viewport.h / 2 } else { // Position notes close to the browser (reduced spacing for closer positioning) const browserSpacing = 30 startX = browserShapeBounds.x + browserShapeBounds.w + browserSpacing startY = browserShapeBounds.y } // Track existing shapes by meeting ID for proper grouping const allShapes = this.editor.getCurrentPageShapes() const browserSpacing = 30 const expectedStartX = browserShapeBounds ? browserShapeBounds.x + browserShapeBounds.w + browserSpacing : startX // Find existing shapes for this specific meeting // Use meetingRecordingId to ensure we're using the correct meeting ID const currentMeetingId = meetingRecordingId const existingShapesForThisMeeting = allShapes.filter(s => { if (s.type !== 'FathomNote') return false // Check if shape belongs to this meeting by checking the noteId prop const noteId = (s as any).props?.noteId || '' return noteId.includes(`fathom-${currentMeetingId}`) || noteId.includes(`fathom-summary-${currentMeetingId}`) || noteId.includes(`fathom-transcript-${currentMeetingId}`) || noteId.includes(`fathom-actions-${currentMeetingId}`) }) // Find all existing Fathom shapes to determine vertical positioning const allExistingFathomShapes = allShapes.filter(s => { if (s.type !== 'FathomNote') return false const noteId = (s as any).props?.noteId || '' return noteId.startsWith('fathom-') }) // Calculate which meeting row this is (0 = first meeting row) const meetingIds = new Set() allExistingFathomShapes.forEach(s => { const noteId = (s as any).props?.noteId || '' const match = noteId.match(/fathom-(?:summary|transcript|actions)-(.+)/) if (match) { meetingIds.add(match[1]) } }) const meetingRowIndex = Array.from(meetingIds).indexOf(currentMeetingId) const actualMeetingRowIndex = meetingRowIndex >= 0 ? meetingRowIndex : meetingIds.size // Shape dimensions - all shapes are the same size const shapeWidth = 500 const shapeHeight = 600 const horizontalSpacing = 20 const verticalSpacing = 30 // Space between meeting rows const shapesToCreate: any[] = [] // Calculate Y position for this meeting's shapes // If this meeting already has shapes, use the Y position of the first existing shape // Otherwise, calculate based on meeting row index let baseY: number if (existingShapesForThisMeeting.length > 0) { // Use the Y position of existing shapes for this meeting to ensure they're on the same line const firstExistingShapeBounds = this.editor.getShapePageBounds(existingShapesForThisMeeting[0].id) baseY = firstExistingShapeBounds ? firstExistingShapeBounds.y : startY + actualMeetingRowIndex * (shapeHeight + verticalSpacing) } else { // New meeting row - calculate position based on row index baseY = startY + actualMeetingRowIndex * (shapeHeight + verticalSpacing) } // Calculate horizontal positions for this meeting's shapes // Summary, Transcript, Action Items will be side by side on the same horizontal line // Each meeting row is positioned below the previous one let currentX = startX // If this meeting already has shapes, position new shapes after the existing ones if (existingShapesForThisMeeting.length > 0) { // Find the rightmost existing shape for this meeting let rightmostX = startX existingShapesForThisMeeting.forEach(s => { const bounds = this.editor.getShapePageBounds(s.id) if (bounds) { const shapeRight = bounds.x + bounds.w if (shapeRight > rightmostX) { rightmostX = shapeRight } } }) // Start new shapes after the rightmost existing shape currentX = rightmostX + horizontalSpacing } // Create shapes for each selected data type in button order: Summary, Transcript, Action Items // Position shapes horizontally for the same meeting, vertically for different meetings // Blue shades match button colors: Summary (#3b82f6), Transcript (#2563eb), Actions (#1d4ed8) if (options.summary) { // Check for summary in various possible formats from Fathom API const summaryText = fullMeeting.default_summary?.markdown_formatted || fullMeeting.default_summary?.text || fullMeeting.summary?.markdown_formatted || fullMeeting.summary?.text || fullMeeting.summary || '' if (summaryText) { const xPos = currentX const yPos = baseY // Create Fathom note shape for summary with lightest blue (#3b82f6) // Format: date in top right, title in content const contentWithHeader = meetingDate ? `

${meetingName}: Fathom Summary

${meetingDate}
\n\n${summaryText}` : `# ${meetingName}: Fathom Summary\n\n${summaryText}` const noteShape = FathomNoteShape.createFromData( { id: `fathom-summary-${meetingRecordingId}`, title: 'Fathom Meeting Object: Summary', content: contentWithHeader, tags: ['fathom', 'summary'], primaryColor: '#3b82f6', // Lightest blue - matches Summary button }, xPos, yPos ) // Update the shape dimensions - all shapes same size const updatedNoteShape = { ...noteShape, props: { ...noteShape.props, w: shapeWidth, h: shapeHeight, } } shapesToCreate.push(updatedNoteShape) currentX += shapeWidth + horizontalSpacing } else { console.warn('Summary requested but no summary data found in meeting response') } } if (options.transcript) { // Check for transcript data const transcript = fullMeeting.transcript || [] if (transcript.length > 0) { const xPos = currentX const yPos = baseY // Create Fathom note shape for transcript with medium blue (#2563eb) const transcriptText = transcript.map((entry: any) => { const speaker = entry.speaker?.display_name || 'Unknown' const text = entry.text || '' const timestamp = entry.timestamp || '' return timestamp ? `**${speaker}** (${timestamp}): ${text}` : `**${speaker}**: ${text}` }).join('\n\n') // Format: date in top right, title in content const contentWithHeader = meetingDate ? `

${meetingName}: Fathom Transcript

${meetingDate}
\n\n${transcriptText}` : `# ${meetingName}: Fathom Transcript\n\n${transcriptText}` const noteShape = FathomNoteShape.createFromData( { id: `fathom-transcript-${meetingRecordingId}`, title: 'Fathom Meeting Object: Transcript', content: contentWithHeader, tags: ['fathom', 'transcript'], primaryColor: '#2563eb', // Medium blue - matches Transcript button }, xPos, yPos ) // Update the shape dimensions - same size as others const updatedNoteShape = { ...noteShape, props: { ...noteShape.props, w: shapeWidth, h: shapeHeight, } } shapesToCreate.push(updatedNoteShape) currentX += shapeWidth + horizontalSpacing } else { console.warn('Transcript requested but no transcript data found in meeting response') } } if (options.actionItems) { // Check for action items in various possible formats from Fathom API const actionItems = fullMeeting.action_items || fullMeeting.actionItems || [] if (actionItems.length > 0) { const xPos = currentX const yPos = baseY // Create Fathom note shape for action items with darker blue (#1d4ed8) const actionItemsText = actionItems.map((item: any) => { const description = item.description || item.text || item.title || '' const assignee = item.assignee?.name || item.assignee || item.owner?.name || item.owner || '' const dueDate = item.due_date || item.dueDate || item.due || '' let itemText = `- [ ] ${description}` if (assignee) itemText += ` (@${assignee})` if (dueDate) itemText += ` - Due: ${dueDate}` return itemText }).join('\n') // Format: date in top right, title in content const contentWithHeader = meetingDate ? `

${meetingName}: Fathom Action Items

${meetingDate}
\n\n${actionItemsText}` : `# ${meetingName}: Fathom Action Items\n\n${actionItemsText}` const noteShape = FathomNoteShape.createFromData( { id: `fathom-actions-${meetingRecordingId}`, title: 'Fathom Meeting Object: Action Items', content: contentWithHeader, tags: ['fathom', 'action-items'], primaryColor: '#1d4ed8', // Darker blue - matches Action Items button }, xPos, yPos ) // Update the shape dimensions - same size as others const updatedNoteShape = { ...noteShape, props: { ...noteShape.props, w: shapeWidth, h: shapeHeight, } } shapesToCreate.push(updatedNoteShape) currentX += shapeWidth + horizontalSpacing } else { console.warn('Action items requested but no action items found in meeting response') } } if (options.video) { // Open Fathom video URL directly in a new tab instead of creating a note shape // Try multiple sources for the correct video URL // The Fathom API may provide url, share_url, or we may need to construct from call_id or id const callId = fullMeeting.call_id || fullMeeting.id || fullMeeting.recording_id || meeting.call_id || meeting.id || meeting.recording_id // Check if URL fields contain valid meeting URLs (contain /calls/) const isValidMeetingUrl = (url: string) => url && url.includes('/calls/') // Prioritize valid meeting URLs, then construct from call ID const videoUrl = (fullMeeting.url && isValidMeetingUrl(fullMeeting.url)) ? fullMeeting.url : (fullMeeting.share_url && isValidMeetingUrl(fullMeeting.share_url)) ? fullMeeting.share_url : (meeting.url && isValidMeetingUrl(meeting.url)) ? meeting.url : (meeting.share_url && isValidMeetingUrl(meeting.share_url)) ? meeting.share_url : (callId ? `https://fathom.video/calls/${callId}` : null) if (videoUrl) { console.log('Opening Fathom video URL:', videoUrl, 'for meeting:', { callId, recording_id: meeting.recording_id }) window.open(videoUrl, '_blank', 'noopener,noreferrer') } else { console.error('Could not determine Fathom video URL for meeting:', { meeting, fullMeeting }) } } // Create all shapes at once if (shapesToCreate.length > 0) { this.editor.createShapes(shapesToCreate) // Animate camera to the first created note // Animate camera to show the note setTimeout(() => { const firstShapeId = shapesToCreate[0].id // getShapePageBounds works with raw ID, setSelectedShapes needs "shape:" prefix const rawShapeId = firstShapeId.startsWith('shape:') ? firstShapeId.replace('shape:', '') : firstShapeId const shapeIdWithPrefix = `shape:${rawShapeId}` const firstShapeBounds = this.editor.getShapePageBounds(rawShapeId) if (firstShapeBounds) { let boundsToShow = firstShapeBounds if (browserShapeBounds) { const minX = Math.min(browserShapeBounds.x, firstShapeBounds.x) const maxX = Math.max(browserShapeBounds.x + browserShapeBounds.w, firstShapeBounds.x + firstShapeBounds.w) const minY = Math.min(browserShapeBounds.y, firstShapeBounds.y) const maxY = Math.max(browserShapeBounds.y + browserShapeBounds.h, firstShapeBounds.y + firstShapeBounds.h) boundsToShow = Box.Common([browserShapeBounds, firstShapeBounds]) } this.editor.zoomToBounds(boundsToShow, { inset: 50, animation: { duration: 500, easing: (t) => t * (2 - t), }, }) } this.editor.setSelectedShapes([shapeIdWithPrefix] as any) this.editor.setCurrentTool('select') }, 50) } } catch (error) { console.error('Error creating Fathom meeting shapes:', error) } } if (!isOpen) { return null } return ( { this.editor.updateShape({ id: shape.id, type: 'FathomMeetingsBrowser', props: { ...shape.props, tags: newTags, } }) }} tagsEditable={true} > ) } return } indicator(shape: IFathomMeetingsBrowser) { return } }