"use client"; import { useEffect, useState, useCallback, useRef, Suspense, useMemo, } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardFooter, } from "@/components/ui/card"; import { WifiOff, RefreshCw, TriangleAlert, } from "lucide-react"; import { CommentsPanel } from "@/components/RightPanel"; import { CodeEditor, CodeEditorRef } from "@/components/Editor"; import { LeftPanel } from "@/components/LeftPanel"; import RecordingPopup from "@/components/RecordingPopup"; import { getThemeById, getNextTheme, saveThemeToCookie, getThemeFromCookie, applyTheme, } from "@/lib/themes"; import debounce from "lodash/debounce"; import dotenv from "dotenv"; import { JetBrains_Mono } from "next/font/google"; import { ThemeProvider } from "next-themes"; import { ContentWarningModal } from "@/components/ContentWarningModal"; import { BetterHoverCard, HoverCardProvider } from "@/components/ui/BetterHoverCard"; import { getCookie, setCookie } from "@/lib/cookies"; dotenv.config(); const jetbrainsMono = JetBrains_Mono({ weight: ["400", "500", "700"], subsets: ["latin"], variable: "--font-jetbrains-mono", }); interface TextUpdate { type: "text-update"; content: string; code: string; } interface InitialContent { type: "initial-content"; content: string; code: string; } interface JoinRoom { type: "join-room"; code: string; user?: User; } interface PingMessage { type: "ping"; code: string; } interface PongMessage { type: "pong"; code: string; } interface User { id: string; name: string; color: string; lastSeen: Date; isTyping?: boolean; currentLine?: number; } interface Comment { id: string; lineNumber: number | null; lineRange?: string; author: string; authorId: string; content: string; timestamp: Date; } interface CommentMessage { type: "comment-add" | "comment-update" | "comment-delete"; code: string; comment: Comment; } interface CommentsSync { type: "comments-sync"; code: string; comments: Comment[]; } interface UserMessage { type: "user-joined" | "user-left"; code: string; user: User; } interface UsersSync { type: "users-sync"; code: string; users: User[]; } interface UserActivity { type: "user-activity"; code: string; userId: string; isTyping: boolean; currentLine?: number; } interface MediaFile { id: string; name: string; type: string; size: number; url: string; uploadedAt: Date; uploadedBy: string; } interface MediaMessage { type: "media-upload" | "media-delete"; code: string; media: MediaFile; } interface MediaSync { type: "media-sync"; code: string; mediaFiles: MediaFile[]; } type Message = | TextUpdate | InitialContent | JoinRoom | PingMessage | PongMessage | CommentMessage | CommentsSync | UserMessage | UsersSync | UserActivity | MediaMessage | MediaSync; const WS_URL = `${process.env.NEXT_PUBLIC_WS_URL}`; // Utility functions const generateUserId = () => `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const generateUserName = () => { const adjectives = [ "Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Brown", ]; const nouns = ["Cat", "Dog", "Bird", "Fish", "Bear", "Lion", "Tiger", "Wolf"]; return `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${ nouns[Math.floor(Math.random() * nouns.length)] }`; }; const generateUserColor = () => { const colors = [ "#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c", "#e67e22", "#34495e", ]; return colors[Math.floor(Math.random() * colors.length)]; }; // User persistence helpers using cookies const restoreUser = (): User | null => { if (typeof window === 'undefined') return null; try { const stored = getCookie("osborne-user"); if (stored) { const parsed = JSON.parse(stored); return { ...parsed, lastSeen: new Date(parsed.lastSeen), }; } } catch (error) { console.error('Error loading user from storage:', error); } return null; }; const saveUser = (user: User) => { if (typeof window === 'undefined') return; try { setCookie("osborne-user", JSON.stringify(user), 30); // 30 days } catch (error) { console.error('Error saving user to storage:', error); } }; let filesCopy: Array; const Room = () => { const router = useRouter(); const searchParams = useSearchParams(); const roomCode = searchParams.get("code"); const socketRef = useRef(null); const editorRef = useRef(null); const fileInputRef = useRef(null); const currentUserRef = useRef(null); const [isClient, setIsClient] = useState(false); const [content, setContent] = useState(""); const [status, setStatus] = useState("Disconnected"); const [error, setError] = useState(""); const [isModalOpen, setIsModalOpen] = useState(false); const [isPurgeModalOpen, setIsPurgeModalOpen] = useState(false); const [showReconnectOverlay, setShowReconnectOverlay] = useState(false); const [currentThemeId, setCurrentThemeId] = useState("one-dark"); const [selectedLineStart, setSelectedLineStart] = useState(); const [selectedLineEnd, setSelectedLineEnd] = useState(); const [comments, setComments] = useState([]); const [users, setUsers] = useState([]); const [mediaFiles, setMediaFiles] = useState([]); const [currentUser, setCurrentUser] = useState(null); const [windowWidth, setWindowWidth] = useState(0); const [leftPanelForced, setLeftPanelForced] = useState(false); const [rightPanelForced, setRightPanelForced] = useState(false); const [isMobile, setIsMobile] = useState(false); const [fileSizeError, setFileSizeError] = useState(null); const [purgeError, setPurgeError] = useState(null); const [uploadProgress, setUploadProgress] = useState>([]); const [isRecordingOpen, setIsRecordingOpen] = useState(false); const [reconnectAttempts, setReconnectAttempts] = useState(0); const [isReconnecting, setIsReconnecting] = useState(false); const [gotClicked, click] = useState(false); // Detect mobile screen size useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth < 768); // md breakpoint }; checkMobile(); window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, []); // Mobile swipe gesture handling & Escape key to close panels useEffect(() => { // Swipe gesture (mobile only) if (isMobile) { let touchStartX = 0; let touchStartY = 0; let touchStartTime = 0; const handleTouchStart = (e: TouchEvent) => { const touch = e.touches[0]; touchStartX = touch.clientX; touchStartY = touch.clientY; touchStartTime = Date.now(); }; const handleTouchEnd = (e: TouchEvent) => { const touch = e.changedTouches[0]; const touchEndX = touch.clientX; const touchEndY = touch.clientY; const touchEndTime = Date.now(); const deltaX = touchEndX - touchStartX; const deltaY = touchEndY - touchStartY; const deltaTime = touchEndTime - touchStartTime; // Only consider it a swipe if: // 1. The gesture is fast enough (less than 500ms) // 2. The horizontal distance is significant (at least 100px) // 3. The vertical distance is less than horizontal (to avoid conflicting with scrolling) if ( deltaTime < 500 && Math.abs(deltaX) > 100 && Math.abs(deltaX) > Math.abs(deltaY) ) { if (deltaX < 0 && leftPanelForced) { // Swipe left - close left panel setLeftPanelForced(false); } else if (deltaX > 0 && rightPanelForced) { // Swipe right - close right panel setRightPanelForced(false); } } }; document.addEventListener('touchstart', handleTouchStart, { passive: true }); document.addEventListener('touchend', handleTouchEnd, { passive: true }); // Clean up swipe listeners return () => { document.removeEventListener('touchstart', handleTouchStart); document.removeEventListener('touchend', handleTouchEnd); }; } // Escape key closes panels (all devices) const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (leftPanelForced) setLeftPanelForced(false); if (rightPanelForced) setRightPanelForced(false); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isMobile, leftPanelForced, rightPanelForced]); const contentRef = useRef(content); useEffect(() => { contentRef.current = content; }, [content]); useEffect(() => { currentUserRef.current = currentUser; }, [currentUser]); useEffect(() => { setIsClient(true); // Load user from storage if available if (roomCode) { const storedUser = restoreUser(); if (storedUser) { setCurrentUser(storedUser); } } // Set initial window width const handleResize = () => { const newWidth = window.innerWidth; setWindowWidth(newWidth); // Force immediate panel state reset when crossing the breakpoint to larger if (newWidth >= 1280) { setLeftPanelForced(false); setRightPanelForced(false); } }; // Set initial value handleResize(); // Add event listener window.addEventListener('resize', handleResize); // Cleanup return () => { window.removeEventListener('resize', handleResize); }; }, [roomCode]); // Calculate panel visibility based on window width // Minimum width needed: 320px (left) + 640px (main content) + 320px (right) = 1280px const showSidePanels = windowWidth >= 1280; // Auto-hide forced panels when screen size increases (do this before calculating visibility) useEffect(() => { if (showSidePanels) { setLeftPanelForced(false); setRightPanelForced(false); } }, [showSidePanels]); // Calculate final panel visibility - when shouldShowPanels is true, always show panels regardless of forced state const showLeftPanel = showSidePanels || (!showSidePanels && leftPanelForced); const showRightPanel = showSidePanels || (!showSidePanels && rightPanelForced); // Initialize theme from cookie useEffect(() => { if (isClient) { const savedThemeId = getThemeFromCookie(); if (savedThemeId) { const savedTheme = getThemeById(savedThemeId); if (savedTheme) { setCurrentThemeId(savedThemeId); applyTheme(savedTheme); } } else { // Apply default theme const defaultTheme = getThemeById("one-dark-pro"); if (defaultTheme) { applyTheme(defaultTheme); } } } }, [isClient]); // Apply theme when currentThemeId changes useEffect(() => { const currentTheme = getThemeById(currentThemeId); if (currentTheme) { applyTheme(currentTheme); } }, [currentThemeId]); // Show reconnect overlay only if still disconnected after a delay useEffect(() => { let showTimer: NodeJS.Timeout | null = null; if (status === "Disconnected") { // Wait 800ms before showing overlay showTimer = setTimeout(() => { setShowReconnectOverlay(true); }, 800); } else { setShowReconnectOverlay(false); } return () => { if (showTimer) clearTimeout(showTimer); }; }, [status]); // Calculate panel visibility based on viewport width // Left panel (256px) + Right panel (320px) + Main content (1280px) + padding (~100px) = ~1956px const debouncedSend = useMemo( () => debounce((ws: WebSocket, content: string, code: string) => { if (ws.readyState === WebSocket.OPEN) { const message: TextUpdate = { type: "text-update", content, code, }; ws.send(JSON.stringify(message)); } }, 100), [] ); const connectSocket = useCallback(() => { if (!roomCode || socketRef.current?.readyState === WebSocket.OPEN) return; if (socketRef.current) { socketRef.current.close(); } const ws = new WebSocket(WS_URL); socketRef.current = ws; ws.onopen = () => { setStatus("Connected"); setError(""); setIsReconnecting(false); setReconnectAttempts(0); // Reset on successful connection // Use existing user, check storage, or create new one let user = currentUserRef.current; if (!user && roomCode) { user = restoreUser(); } if (!user) { user = { id: generateUserId(), name: generateUserName(), color: generateUserColor(), lastSeen: new Date(), isTyping: false, }; } // Update lastSeen for reconnection user.lastSeen = new Date(); setCurrentUser(user); // Save to storage for persistence across sessions saveUser(user); const message: JoinRoom = { type: "join-room", code: roomCode, user: user, }; ws.send(JSON.stringify(message)); }; ws.onmessage = (event) => { const message: Message = JSON.parse(event.data); switch (message.type) { case "initial-content": case "text-update": if (message.content !== contentRef.current) { setContent(message.content); } break; case "pong": // Handle pong response break; case "comments-sync": setComments( message.comments ? message.comments.map((c) => ({ ...c, timestamp: new Date(c.timestamp), })) : [] ); break; case "comment-add": setComments((prev) => [ ...prev, { ...message.comment, timestamp: new Date(message.comment.timestamp), }, ]); break; case "comment-update": setComments((prev) => prev.map((c) => c.id === message.comment.id ? { ...message.comment, timestamp: new Date(message.comment.timestamp), } : c ) ); break; case "comment-delete": setComments((prev) => prev.filter((c) => c.id !== message.comment.id) ); break; case "users-sync": setUsers( message.users ? message.users.map((u) => ({ ...u, lastSeen: new Date(u.lastSeen), })) : [] ); break; case "user-joined": setUsers((prev) => [ ...prev, { ...message.user, lastSeen: new Date(message.user.lastSeen), }, ]); break; case "user-left": setUsers((prev) => prev.filter((u) => u.id !== message.user.id)); break; case "user-activity": setUsers((prev) => prev.map((u) => u.id === message.userId ? { ...u, isTyping: message.isTyping, currentLine: message.currentLine, lastSeen: new Date(), } : u ) ); break; case "media-sync": setMediaFiles( message.mediaFiles ? message.mediaFiles.map((m) => ({ ...m, uploadedAt: new Date(m.uploadedAt), })) : [] ); break; case "media-upload": setMediaFiles((prev) => [ ...prev, { ...message.media, uploadedAt: new Date(message.media.uploadedAt), }, ]); break; case "media-delete": setMediaFiles((prev) => prev.filter((m) => m.id !== message.media.id) ); break; } }; ws.onclose = () => { setStatus("Disconnected"); setIsReconnecting(false); setTimeout(() => { if (socketRef.current === ws) { socketRef.current = null; } }, 0); }; ws.onerror = () => { setIsReconnecting(true); const backoffDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); // Max 10 seconds setTimeout(() => { if (socketRef.current === ws) { setReconnectAttempts(prev => prev + 1); connectSocket(); } }, backoffDelay); }; }, [roomCode, reconnectAttempts]); useEffect(() => { if (!isClient || !roomCode) return; connectSocket(); const pingInterval = setInterval(() => { if (socketRef.current?.readyState === WebSocket.OPEN) { socketRef.current.send(JSON.stringify({ type: "ping", code: roomCode })); // Also send user activity update to keep status current if (currentUserRef.current) { const activityMessage: UserActivity = { type: "user-activity", code: roomCode, userId: currentUserRef.current.id, isTyping: false, currentLine: undefined }; socketRef.current.send(JSON.stringify(activityMessage)); } } }, 30000); return () => { clearInterval(pingInterval); if (socketRef.current) { socketRef.current.close(); socketRef.current = null; } debouncedSend.cancel(); }; }, [roomCode, isClient, connectSocket, debouncedSend]); const handleContentChange = (newContent: string) => { setContent(newContent); if (socketRef.current?.readyState === WebSocket.OPEN) { debouncedSend(socketRef.current, newContent, roomCode!); } else if (status === "Disconnected") { debouncedSend.cancel(); connectSocket(); } }; const handleSelectionChange = (lineStart: number, lineEnd: number) => { setSelectedLineStart(lineStart); setSelectedLineEnd(lineEnd); }; const handleCommentSelect = (lineNumber: number, lineRange?: string) => { if (editorRef.current) { if (lineRange) { // Parse line range like "5-8" const [start, end] = lineRange .split("-") .map((n) => parseInt(n.trim())); editorRef.current.selectLines(start, end); } else { editorRef.current.selectLines(lineNumber); } } }; const handleAddComment = ( content: string, lineNumber?: number, lineRange?: string ) => { if (!socketRef.current || !currentUser) return; const comment: Comment = { id: "", // Will be set by server lineNumber: lineNumber || null, lineRange: lineRange, author: currentUser.name, authorId: currentUser.id, content: content, timestamp: new Date(), }; const message: CommentMessage = { type: "comment-add", code: roomCode!, comment: comment, }; socketRef.current.send(JSON.stringify(message)); }; const handleDeleteComment = (commentId: string) => { if (!socketRef.current || !currentUser) return; const message: CommentMessage = { type: "comment-delete", code: roomCode!, comment: { id: commentId, lineNumber: null, author: "", authorId: "", content: "", timestamp: new Date(), }, }; socketRef.current.send(JSON.stringify(message)); }; const handlePurgeRoom = async () => { if (!roomCode) return; try { const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8090"; const response = await fetch(`${httpUrl}/purge/${roomCode}`, { method: "DELETE", }); if (!response.ok) { throw new Error(`Purge failed: ${response.statusText}`); } router.push("/"); } catch (error) { console.error("Error purging room:", error); setPurgeError("Failed to purge room"); } setIsPurgeModalOpen(false); }; const handleFileUpload = async (files: FileList) => { if (!files || files.length === 0 || !currentUser) return; const maxFileSize = 10 * 1024 * 1024; // 10MB in bytes const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8090"; // Initialize progress for all files const initialProgress = Array.from(files).map(file => ({ id: `${file.name}-${Date.now()}-${Math.random()}`, fileName: file.name, progress: 0, status: 'uploading' as const })); setUploadProgress(prev => [...prev, ...initialProgress]); for (let i = 0; i < files.length; i++) { const file = files[i]; const uploadId = initialProgress[i].id; // Check file size limit if (file.size > maxFileSize) { const fileSizeInMB = (file.size / (1024 * 1024)).toFixed(2); setFileSizeError(`File "${file.name}" (${fileSizeInMB}MB) exceeds 10MB limit`); setUploadProgress(prev => prev.map(p => p.id === uploadId ? { ...p, status: 'error' as const } : p )); continue; // Skip this file and continue with others } try { // Create form data for file upload const formData = new FormData(); formData.append("file", file); formData.append("roomCode", roomCode!); formData.append("uploadedBy", currentUser.name); // Use XMLHttpRequest for progress tracking await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const progress = Math.round((e.loaded / e.total) * 100); setUploadProgress(prev => prev.map(p => p.id === uploadId ? { ...p, progress } : p )); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { setUploadProgress(prev => prev.map(p => p.id === uploadId ? { ...p, progress: 100, status: 'completed' as const } : p )); resolve(); } else { reject(new Error(`Upload failed: ${xhr.statusText}`)); } }); xhr.addEventListener('error', () => { reject(new Error('Upload failed')); }); xhr.open('POST', `${httpUrl}/upload`); xhr.send(formData); }); console.log("File uploaded successfully:", file.name); } catch (error) { console.error("Error uploading file:", error); setUploadProgress(prev => prev.map(p => p.id === uploadId ? { ...p, status: 'error' as const } : p )); } } // Remove completed files from this batch after a delay const uploadIds = initialProgress.map(p => p.id); setTimeout(() => { setUploadProgress(prev => prev.filter(p => !uploadIds.includes(p.id))); }, 2000); }; const handleFileDelete = async (fileId: string) => { if (!roomCode) return; const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8090"; try { const response = await fetch(`${httpUrl}/delete/${roomCode}/${fileId}`, { method: "DELETE", }); if (!response.ok) { throw new Error(`Delete failed: ${response.statusText}`); } // Don't remove from local state here - the WebSocket broadcast will handle it // This prevents issues when the server broadcasts the deletion console.log("File deleted successfully"); } catch (error) { console.error("Error deleting file:", error); } }; if (!isClient) return null; if (!roomCode) { router.push("/"); return null; } return (
{ navigator.clipboard.writeText(window.location.href); click(true); setTimeout(() => click(false), 2000); }} > {gotClicked ? 'copied!' : 'share'} } contentClassName="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground" > copy link to clipboard setIsPurgeModalOpen(true)} > purge } contentClassName="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground" > permanently delete this room router.push("/")} > exit } contentClassName="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground" > return to home
{ console.log("Upload button clicked"); fileInputRef.current?.click(); }} > upload } contentClassName="py-1 px-2 w-auto text-xs border-foreground" > upload files { const nextTheme = getNextTheme(currentThemeId); setCurrentThemeId(nextTheme.id); applyTheme(nextTheme); saveThemeToCookie(nextTheme.id); }} > theme } contentClassName="py-1 px-2 w-auto text-xs border-foreground" > {`switch to ${getThemeById(getNextTheme(currentThemeId)?.id)?.name}`} setIsRecordingOpen(true)} > record } contentClassName="py-1 px-2 w-auto text-xs border-foreground" > record audio {/* Panel Controls for mobile and when panels are hidden due to width */} {(isMobile || !showSidePanels) && ( <> setLeftPanelForced(!leftPanelForced)} > media } contentClassName="py-1 px-2 w-auto text-xs border-foreground z-[999]" > show media setRightPanelForced(!rightPanelForced)} > notes } contentClassName="py-1 px-2 w-auto text-xs border-foreground" > show comments )}
{error && status !== "Connected" && (
{error}
)} {isMobile ? (