@ -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
room Url : string | null
room Name : 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 {
room Url : null ,
room Name : 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' ) ;
}
// 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 ( ) } ` ;
}
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
generateRoomName ( shapeId : string ) : string {
// Extract board ID from URL
let boardId = 'default' ;
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' ;
const boardMatch = currentUrl . match ( /\/board\/([^\/]+)/ ) ;
if ( boardMatch ) {
boardId = boardMatch [ 1 ] . substring ( 0 , 8 ) ; // First 8 chars
}
// 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'
] ;
// Clean the shape ID (remove 'shape:' prefix and special chars)
const cleanShapeId = shapeId . replace ( /^shape:/ , '' ) . replace ( /[^A-Za-z0-9]/g , '' ) . substring ( 0 , 8 ) ;
oldStorageKeys . forEach ( key = > {
if ( localStorage . getItem ( key ) ) {
localStorage . removeItem ( key ) ;
localStorage . removeItem ( ` ${ key } _token ` ) ;
// Create a readable room name
return ` canvas- ${ boardId } - ${ cleanShapeId } ` ;
}
} ) ;
// 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 > ( {
id : shape.id ,
type : shape . type ,
props : {
. . . shape . props ,
roomUrl : url ,
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' ) ;
}
// 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 [ 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 )
// 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 )
// Initialize room name if not set
useEffect ( ( ) = > {
let mounted = true ;
if ( ! roomName ) {
const newRoomName = this . generateRoomName ( shape . id ) ;
setRoomName ( newRoomName ) ;
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 ) ;
// Update shape props with room name
this . editor . updateShape < IVideoChatShape > ( {
id : shape.id ,
type : shape . type ,
props : {
. . . shape . props ,
roomName : newRoomName ,
} ,
} ) ;
}
} catch ( err ) {
if ( mounted ) {
console . error ( "Error creating room:" , err ) ;
setError ( err as Error ) ;
}
} finally {
if ( mounted ) {
setIsLoading ( false ) ;
}
}
} ;
} , [ shape . id ] ) ;
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,10 +99,10 @@ 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' ) {
if ( isLoading || ! roomName ) {
return (
< div
style = { {
@ -416,56 +115,34 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
borderRadius : "4px" ,
} }
>
{ isLoading ? "Creating room... Please wait" : "Error: No room URL available" }
Initializing Jeffsi Meet . . .
< / 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 > ;
// 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' ,
}
// 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
// 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 Mee t"
primaryColor = { VideoChatShape . PRIMARY_COLOR }
isSelected = { isSelected }
width = { shape . props . w }
@ -527,7 +213,6 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
overflow : "hidden" ,
} }
>
{ /* Video Container */ }
< div
style = { {
@ -535,13 +220,12 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
flex : 1 ,
position : "relative" ,
overflow : "hidden" ,
minHeight : 0 , // Allow flex item to shrink below content size
minHeight : 0 ,
} }
>
{ ! useFallback ? (
< iframe
key = { ` iframe- ${ retryCount } ` }
src = { roomUrlWithParams . toString ( ) }
ref = { iframeRef }
src = { jitsiUrl . toString ( ) }
width = "100%"
height = "100%"
style = { {
@ -551,159 +235,37 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
left : 0 ,
right : 0 ,
bottom : 0 ,
// Only enable pointer events when selected, so canvas can pan when not selected
pointerEvents : isSelected ? "all" : "none" ,
} }
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"
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 ) ;
setIframeError ( true ) ;
if ( retryCount < 2 ) {
setTimeout ( ( ) = > {
setRetryCount ( prev = > prev + 1 ) ;
setIframeError ( false ) ;
} , 2000 ) ;
} else {
setUseFallback ( true ) ;
setIframeError ( false ) ;
setRetryCount ( 0 ) ;
}
setError ( new Error ( 'Failed to load video chat' ) ) ;
} }
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 = { {
background : '#007bff' ,
color : 'white' ,
border : 'none' ,
padding : '10px 20px' ,
borderRadius : '5px' ,
cursor : 'pointer' ,
marginTop : '10px'
} }
>
Open in New Tab
< / button >
< button
onClick = { ( ) = > {
setUseFallback ( ! useFallback ) ;
setRetryCount ( 0 ) ;
setIframeError ( false ) ;
} }
style = { {
background : '#28a745' ,
color : 'white' ,
border : 'none' ,
padding : '10px 20px' ,
borderRadius : '5px' ,
cursor : 'pointer' ,
marginTop : '10px' ,
marginLeft : '10px'
} }
>
Try { useFallback ? 'Normal' : 'Fallback' } Mode
< / button >
< / div >
) }
/ >
< / div >
{ /* URL Bubble - Overlay on bottom of video */}
< p
{ /* Bottom Bar with Room Info and Actions */ }
< div
style = { {
position : "absolute" ,
bottom : "8px" ,
left : "8px" ,
right : "8px" ,
display : "flex" ,
alignItems : "center" ,
justifyContent : "space-between" ,
gap : "8px" ,
zIndex : 1 ,
} }
>
{ /* Room Name */ }
< p
style = { {
margin : 0 ,
padding : "4px 8px" ,
background : "rgba(255, 255, 255, 0.9)" ,
@ -712,16 +274,49 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
pointerEvents : "all" ,
cursor : "text" ,
userSelect : "text" ,
zIndex : 1 ,
maxWidth : "calc(100% - 16px)" ,
overflow : "hidden" ,
textOverflow : "ellipsis" ,
whiteSpace : "nowrap" ,
maxWidth : "60%" ,
} }
>
url : { currentRoomUrl }
{ shape . props . isOwner && " (Owner)" }
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 >
< / StandardizedToolWrapper >
< / HTMLContainer >