/**
* 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;