Compare commits

...

3 Commits

Author SHA1 Message Date
Jeff Emmett 5b2de78677 fix: CORS and IndexedDB sync for canvas.jeffemmett.com
- Add canvas.jeffemmett.com to CORS allowed origins
- Fix IndexedDB sync to prefer server data when local has no shapes
- Handle case where local cache has stale/minimal data but server has full board
- Add console logging for sync debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:32:50 +01:00
Jeff Emmett 854ce9aa50 fix: enable canvas panning when VideoChat shape not selected
Add conditional pointer-events to iframe - only enabled when shape is
selected, allowing normal canvas pan/zoom when not interacting with video.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:45:30 +01:00
Jeff Emmett 30daf2a8cb 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>
2026-01-08 19:45:50 +01:00
3 changed files with 216 additions and 598 deletions

View File

@ -479,8 +479,9 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
// Merge server data with local data
// Strategy:
// 1. If local is EMPTY, use server data (bootstrap from R2)
// 2. If local HAS data, only add server records that don't exist locally
// 1. If local has NO SHAPES (only ephemeral records), use server data
// 2. If server has SIGNIFICANTLY MORE shapes (10x), prefer server (stale local cache)
// 3. Otherwise, only add server records that don't exist locally
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
if (serverDoc.store && serverRecordCount > 0) {
handle.change((doc: any) => {
@ -489,15 +490,35 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
doc.store = {}
}
// Count LOCAL SHAPES (not just records - ignore ephemeral camera/instance records)
const localShapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
const localIsEmpty = Object.keys(doc.store).length === 0
// Server has significantly more shapes - local is likely stale cache
// Use 10x threshold or server has shapes but local has none
const serverHasSignificantlyMore = (
localShapeCount === 0 && serverShapeCount > 0
) || (
serverShapeCount > 0 && localShapeCount > 0 && serverShapeCount >= localShapeCount * 10
)
// If local has no shapes but server does, or server has 10x more,
// replace local with server data (but keep local ephemeral records)
const shouldPreferServer = localIsEmpty || localShapeCount === 0 || serverHasSignificantlyMore
let addedFromServer = 0
let skippedExisting = 0
let replacedFromServer = 0
Object.entries(serverDoc.store).forEach(([id, record]) => {
if (localIsEmpty) {
// Local is empty - bootstrap everything from server
if (shouldPreferServer) {
// Prefer server data - bootstrap or replace stale local
if (doc.store[id]) {
replacedFromServer++
} else {
addedFromServer++
}
doc.store[id] = record
addedFromServer++
} else if (!doc.store[id]) {
// Local has data but missing this record - add from server
// This handles: shapes created on another device and synced to R2
@ -511,6 +532,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
}
})
console.log(`🔄 Server sync: added=${addedFromServer}, replaced=${replacedFromServer}, skipped=${skippedExisting}, shouldPreferServer=${shouldPreferServer}`)
})
const finalDoc = handle.doc()

View File

@ -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<IVideoChatShape> {
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<Error | null>(null)
const [roomName, setRoomName] = useState<string | null>(props.roomName)
const iframeRef = useRef<HTMLIFrameElement>(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<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>({
// Update shape props with room name
this.editor.updateShape<IVideoChatShape>({
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<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
// 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<IVideoChatShape> {
})
if (error) {
return <div>Error creating room: {error.message}</div>
return <div>Error: {error.message}</div>
}
if (isLoading || !currentRoomUrl || currentRoomUrl === 'undefined') {
return (
<div
style={{
width: shape.props.w,
height: shape.props.h,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
}}
>
{isLoading ? "Creating room... Please wait" : "Error: No room URL available"}
</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),
if (isLoading || !roomName) {
return (
<div
style={{
width: shape.props.w,
height: shape.props.h,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
}}
>
Initializing Jeffsi Meet...
</div>
)
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<IVideoChatShape> {
})
}
const handleCopyLink = () => {
const shareUrl = `https://${JITSI_DOMAIN}/${roomName}`
navigator.clipboard.writeText(shareUrl)
}
const handleOpenInNewTab = () => {
window.open(`https://${JITSI_DOMAIN}/${roomName}`, '_blank')
}
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h + 40 }}>
<StandardizedToolWrapper
title="Video Chat"
title="Jeffsi Meet"
primaryColor={VideoChatShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
@ -527,202 +213,111 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
overflow: "hidden",
}}
>
{/* Video Container */}
<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')}
{/* Video Container */}
<div
style={{
background: '#007bff',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer',
marginTop: '10px'
width: '100%',
flex: 1,
position: "relative",
overflow: "hidden",
minHeight: 0,
}}
>
Open in New Tab
</button>
<button
onClick={() => {
setUseFallback(!useFallback);
setRetryCount(0);
setIframeError(false);
}}
<iframe
ref={iframeRef}
src={jitsiUrl.toString()}
width="100%"
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
// Only enable pointer events when selected, so canvas can pan when not selected
pointerEvents: isSelected ? "all" : "none",
}}
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={{
background: '#28a745',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer',
marginTop: '10px',
marginLeft: '10px'
position: "absolute",
bottom: "8px",
left: "8px",
right: "8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
zIndex: 1,
}}
>
Try {useFallback ? 'Normal' : 'Fallback'} Mode
</button>
{/* Room Name */}
<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>
{/* 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>
</HTMLContainer>
)

View File

@ -98,6 +98,7 @@ const { preflight, corsify } = cors({
const allowedOrigins = [
"https://jeffemmett.com",
"https://www.jeffemmett.com",
"https://canvas.jeffemmett.com",
"https://jeffemmett-canvas.jeffemmett.workers.dev",
"https://jeffemmett.com/board/*",
"http://localhost:5173",