/** * NetworkGraphPanel Component * * Wrapper that integrates the NetworkGraphMinimap with tldraw. * Extracts room participants from the editor and provides connection actions. */ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useEditor, useValue } from 'tldraw'; import { NetworkGraphMinimap } from './NetworkGraphMinimap'; import { useNetworkGraph } from './useNetworkGraph'; import { useAuth } from '../../context/AuthContext'; import type { GraphEdge, TrustLevel } from '../../lib/networking'; // ============================================================================= // Broadcast Mode Indicator Component // ============================================================================= interface BroadcastIndicatorProps { followingUser: { id: string; username: string; color?: string } | null; onStop: () => void; isDarkMode: boolean; } function BroadcastIndicator({ followingUser, onStop, isDarkMode }: BroadcastIndicatorProps) { if (!followingUser) return null; return (
{/* Live indicator */}
{/* User avatar */}
{/* Text */}
Viewing as{' '} {followingUser.username}
{/* Exit hint */}
ESC to exit
{/* Close button */}
); } // ============================================================================= // Types // ============================================================================= interface NetworkGraphPanelProps { onExpand?: () => void; } // ============================================================================= // Component // ============================================================================= export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) { const editor = useEditor(); const { session } = useAuth(); // Start collapsed on mobile for less cluttered UI const isMobile = typeof window !== 'undefined' && window.innerWidth < 640; const [isCollapsed, setIsCollapsed] = useState(isMobile); const [selectedEdge, setSelectedEdge] = useState(null); // Broadcast mode state - tracks who we're following const [followingUser, setFollowingUser] = useState<{ id: string; username: string; color?: string; } | null>(null); // Detect dark mode const [isDarkMode, setIsDarkMode] = useState( typeof document !== 'undefined' && document.documentElement.classList.contains('dark') ); // Listen for theme changes useEffect(() => { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === 'class') { setIsDarkMode(document.documentElement.classList.contains('dark')); } }); }); observer.observe(document.documentElement, { attributes: true }); return () => observer.disconnect(); }, []); // Stop following user - cleanup function const stopFollowingUser = useCallback(() => { if (!editor) return; editor.stopFollowingUser(); setFollowingUser(null); // Remove followId from URL if present const url = new URL(window.location.href); if (url.searchParams.has('followId')) { url.searchParams.delete('followId'); window.history.replaceState(null, '', url.toString()); } }, [editor]); // Keyboard handler for ESC and X to exit broadcast mode useEffect(() => { if (!followingUser) return; const handleKeyDown = (e: KeyboardEvent) => { // ESC or X (lowercase or uppercase) stops following if (e.key === 'Escape' || e.key === 'x' || e.key === 'X') { e.preventDefault(); e.stopPropagation(); stopFollowingUser(); } }; // Use capture phase to intercept before tldraw window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => { window.removeEventListener('keydown', handleKeyDown, { capture: true }); }; }, [followingUser, stopFollowingUser]); // Cleanup on unmount useEffect(() => { return () => { if (followingUser && editor) { editor.stopFollowingUser(); } }; }, [followingUser, editor]); // Get collaborators from tldraw const collaborators = useValue( 'collaborators', () => editor.getCollaborators(), [editor] ); const myColor = useValue('myColor', () => editor.user.getColor(), [editor]); const myName = useValue('myName', () => editor.user.getName() || 'Anonymous', [editor]); // Convert collaborators to room participants format const roomParticipants = useMemo(() => { // Add current user const participants = [ { id: session.username || 'me', // Use CryptID username if available username: myName, color: myColor, }, ]; // Add collaborators - TLInstancePresence has userId and userName collaborators.forEach((c: any) => { participants.push({ id: c.userId || c.id, username: c.userName || 'Anonymous', color: c.color, }); }); return participants; }, [session.username, myName, myColor, collaborators]); // Use the network graph hook const { nodes, edges, myConnections, isLoading, error, connect, disconnect, } = useNetworkGraph({ roomParticipants, refreshInterval: 30000, // Refresh every 30 seconds useCache: true, }); // Handle connect with optional trust level const handleConnect = useCallback(async (userId: string, trustLevel: TrustLevel = 'connected') => { await connect(userId, trustLevel); }, [connect]); // Handle disconnect const handleDisconnect = useCallback(async (connectionId: string) => { await disconnect(connectionId); }, [disconnect]); // Handle node click const handleNodeClick = useCallback((_node: any) => { // Could open a profile modal or navigate to user }, []); // Handle going to a user's cursor on canvas (navigate/pan to their location) const handleGoToUser = useCallback((node: any) => { if (!editor) return; // Find the collaborator's cursor position // TLInstancePresence has userId and userName properties const targetCollaborator = collaborators.find((c: any) => c.id === node.id || c.userId === node.id || c.userName === node.username ); if (targetCollaborator && targetCollaborator.cursor) { // Pan to the user's cursor position const { x, y } = targetCollaborator.cursor; editor.centerOnPoint({ x, y }); } else { // If no cursor position, try to find any presence data } }, [editor, collaborators]); // Handle screen following a user (camera follows their view) const handleFollowUser = useCallback((node: any) => { if (!editor) return; // Find the collaborator to follow // TLInstancePresence has userId and userName properties const targetCollaborator = collaborators.find((c: any) => c.id === node.id || c.userId === node.id || c.userName === node.username ); if (targetCollaborator) { // Use tldraw's built-in follow functionality - needs userId const userId = targetCollaborator.userId || targetCollaborator.id; editor.startFollowingUser(userId); // Set state to show broadcast indicator and enable keyboard exit setFollowingUser({ id: userId, username: node.username || node.displayName || 'User', color: targetCollaborator.color || node.avatarColor || node.roomPresenceColor, }); // Optionally add followId to URL for deep linking const url = new URL(window.location.href); url.searchParams.set('followId', userId); window.history.replaceState(null, '', url.toString()); } else { } }, [editor, collaborators]); // Handle opening a user's profile const handleOpenProfile = useCallback((node: any) => { // Open user profile in a new tab or modal const username = node.username || node.id; // Navigate to user profile page window.open(`/profile/${username}`, '_blank'); }, []); // Handle edge click const handleEdgeClick = useCallback((edge: GraphEdge) => { setSelectedEdge(edge); // Could open an edge metadata editor modal }, []); // Handle expand to full 3D view const handleExpand = useCallback(() => { if (onExpand) { onExpand(); } else { // Default: open in new tab window.open('/graph', '_blank'); } }, [onExpand]); // Show loading state briefly if (isLoading && nodes.length === 0) { return (
Loading network...
); } return ( <> {/* Broadcast mode indicator - shows when following a user */} {/* Network graph minimap */} setIsCollapsed(!isCollapsed)} isDarkMode={isDarkMode} /> ); } export default NetworkGraphPanel;