From 70085852d8ccd231f701a8bb6deeb9695c52b02b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 5 Dec 2025 23:08:16 -0800 Subject: [PATCH] feat: add canvas users to CryptID connections dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows all collaborators currently on the canvas with their connection status: - Green border: Trusted (edit access) - Yellow border: Connected (view access) - Grey border: Not connected Users can: - Add unconnected users as Connected or Trusted - Upgrade Connected users to Trusted - Downgrade Trusted users to Connected - Remove connections Also fixes TypeScript errors in networking module. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../networking/NetworkGraphMinimap.tsx | 220 +++++++- src/components/networking/useNetworkGraph.ts | 29 +- src/lib/networking/connectionService.ts | 32 +- src/lib/networking/types.ts | 18 +- src/ui/CustomToolbar.tsx | 530 +++++++++++++++++- worker/networkingApi.ts | 6 +- 6 files changed, 812 insertions(+), 23 deletions(-) diff --git a/src/components/networking/NetworkGraphMinimap.tsx b/src/components/networking/NetworkGraphMinimap.tsx index 5efd7d1..ed2d923 100644 --- a/src/components/networking/NetworkGraphMinimap.tsx +++ b/src/components/networking/NetworkGraphMinimap.tsx @@ -169,13 +169,16 @@ export function NetworkGraphMinimap({ const svgRef = useRef(null); const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null); const [isSearchOpen, setIsSearchOpen] = useState(false); + const [selectedNode, setSelectedNode] = useState<{ node: GraphNode; x: number; y: number } | null>(null); + const [isConnecting, setIsConnecting] = useState(false); const simulationRef = useRef | null>(null); // Count stats const inRoomCount = nodes.filter(n => n.isInRoom).length; + const anonymousCount = nodes.filter(n => n.isAnonymous).length; const trustedCount = nodes.filter(n => n.trustLevelTo === 'trusted').length; const connectedCount = nodes.filter(n => n.trustLevelTo === 'connected').length; - const unconnectedCount = nodes.filter(n => !n.trustLevelTo && !n.isCurrentUser).length; + const unconnectedCount = nodes.filter(n => !n.trustLevelTo && !n.isCurrentUser && !n.isAnonymous).length; // Initialize and update the D3 simulation useEffect(() => { @@ -244,22 +247,42 @@ export function NetworkGraphMinimap({ }); // Helper to get node color based on trust level and room status + // Priority: current user (purple) > anonymous (grey) > trust level > unconnected (white) const getNodeColor = (d: SimulationNode) => { if (d.isCurrentUser) { return '#4f46e5'; // Current user is always purple } - // If in room, use presence color - if (d.isInRoom && d.roomPresenceColor) { - return d.roomPresenceColor; + // Anonymous users are grey + if (d.isAnonymous) { + return TRUST_LEVEL_COLORS.anonymous; } + // If in room and has presence color, use it for the stroke/ring instead + // (we still use trust level for fill to maintain visual consistency) // Otherwise use trust level color if (d.trustLevelTo) { return TRUST_LEVEL_COLORS[d.trustLevelTo]; } - // Unconnected + // Authenticated but unconnected = white return TRUST_LEVEL_COLORS.unconnected; }; + // Helper to get node stroke color (for in-room presence indicator) + const getNodeStroke = (d: SimulationNode) => { + if (d.isCurrentUser) return '#fff'; + // Show room presence color as a ring around the node + if (d.isInRoom && d.roomPresenceColor) return d.roomPresenceColor; + // White nodes need a subtle border to be visible + if (!d.isAnonymous && !d.trustLevelTo) return '#e5e7eb'; + return 'none'; + }; + + const getNodeStrokeWidth = (d: SimulationNode) => { + if (d.isCurrentUser) return 2; + if (d.isInRoom && d.roomPresenceColor) return 2; + if (!d.isAnonymous && !d.trustLevelTo) return 1; + return 0; + }; + // Create nodes const node = g.append('g') .attr('class', 'nodes') @@ -268,15 +291,15 @@ export function NetworkGraphMinimap({ .join('circle') .attr('r', d => d.isCurrentUser ? 8 : 6) .attr('fill', d => getNodeColor(d)) - .attr('stroke', d => d.isCurrentUser ? '#fff' : 'none') - .attr('stroke-width', d => d.isCurrentUser ? 2 : 0) + .attr('stroke', d => getNodeStroke(d)) + .attr('stroke-width', d => getNodeStrokeWidth(d)) .style('cursor', 'pointer') .on('mouseenter', (event, d) => { const rect = svgRef.current!.getBoundingClientRect(); setTooltip({ x: event.clientX - rect.left, y: event.clientY - rect.top, - text: d.displayName || d.username, + text: `${d.displayName || d.username}${d.isAnonymous ? ' (anonymous)' : ''}`, }); }) .on('mouseleave', () => { @@ -284,9 +307,18 @@ export function NetworkGraphMinimap({ }) .on('click', (event, d) => { event.stopPropagation(); - if (onNodeClick) { - onNodeClick(d); + // Don't show popup for current user or anonymous users + if (d.isCurrentUser || d.isAnonymous) { + if (onNodeClick) onNodeClick(d); + return; } + // Show connection popup + const rect = svgRef.current!.getBoundingClientRect(); + setSelectedNode({ + node: d, + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }); }) .call(d3.drag() .on('start', (event, d) => { @@ -377,7 +409,7 @@ export function NetworkGraphMinimap({ -
+
setSelectedNode(null)}> )} + + {/* Connection popup when clicking a node */} + {selectedNode && !selectedNode.node.isCurrentUser && !selectedNode.node.isAnonymous && ( +
e.stopPropagation()} + > +
+ {selectedNode.node.displayName || selectedNode.node.username} +
+
+ @{selectedNode.node.username} +
+ + {/* Connection actions */} + {selectedNode.node.trustLevelTo ? ( + // Already connected - show trust level options +
+ + +
+ ) : ( + // Not connected - show connect options +
+ + +
+ )} + + +
+ )}
@@ -411,10 +599,16 @@ export function NetworkGraphMinimap({
{connectedCount}
-
-
+
+
{unconnectedCount}
+ {anonymousCount > 0 && ( +
+
+ {anonymousCount} +
+ )}
diff --git a/src/components/networking/useNetworkGraph.ts b/src/components/networking/useNetworkGraph.ts index 82fdcc4..29e56c6 100644 --- a/src/components/networking/useNetworkGraph.ts +++ b/src/components/networking/useNetworkGraph.ts @@ -123,6 +123,7 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor isInRoom: participantIds.includes(n.id), roomPresenceColor: participantColorMap.get(n.id), isCurrentUser: n.username === session.username, + isAnonymous: false, })), edges: cached.edges, myConnections: (cached as any).myConnections || [], @@ -144,14 +145,40 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor graph = await getMyNetworkGraph(); } - // Enrich nodes with room status and current user flag + // Enrich nodes with room status, current user flag, and anonymous status + const graphNodeIds = new Set(graph.nodes.map(n => n.id)); + const enrichedNodes = graph.nodes.map(node => ({ ...node, isInRoom: participantIds.includes(node.id), roomPresenceColor: participantColorMap.get(node.id), isCurrentUser: node.username === session.username, + isAnonymous: false, // Nodes from the graph are authenticated })); + // Add room participants who are not in the network graph as anonymous nodes + roomParticipants.forEach(participant => { + if (!graphNodeIds.has(participant.id) && participant.id !== session.username) { + // Check if this looks like an anonymous/guest ID + const isAnonymous = participant.username.startsWith('Guest') || + participant.username === 'Anonymous' || + !participant.id.match(/^[a-zA-Z0-9_-]+$/); // CryptID usernames are alphanumeric + + enrichedNodes.push({ + id: participant.id, + username: participant.username, + displayName: participant.username, + avatarColor: participant.color, + isInRoom: true, + roomPresenceColor: participant.color, + isCurrentUser: false, + isAnonymous, + trustLevelTo: undefined, + trustLevelFrom: undefined, + }); + } + }); + setState({ nodes: enrichedNodes, edges: graph.edges, diff --git a/src/lib/networking/connectionService.ts b/src/lib/networking/connectionService.ts index c273497..9f763a9 100644 --- a/src/lib/networking/connectionService.ts +++ b/src/lib/networking/connectionService.ts @@ -212,11 +212,33 @@ export async function getEdgeMetadata(connectionId: string): Promise { - return fetchJson(`${API_BASE}/graph`); + const response = await fetchJson(`${API_BASE}/graph`); + return { + nodes: response.nodes || [], + edges: (response.edges || []).map(transformEdge), + myConnections: response.myConnections || [], + }; } /** @@ -226,10 +248,15 @@ export async function getMyNetworkGraph(): Promise { export async function getRoomNetworkGraph( roomParticipants: string[] ): Promise { - return fetchJson(`${API_BASE}/graph/room`, { + const response = await fetchJson(`${API_BASE}/graph/room`, { method: 'POST', body: JSON.stringify({ participants: roomParticipants }), }); + return { + nodes: response.nodes || [], + edges: (response.edges || []).map(transformEdge), + myConnections: response.myConnections || [], + }; } /** @@ -262,6 +289,7 @@ export function buildGraphNode( isInRoom: options.isInRoom, roomPresenceColor: options.roomPresenceColor, isCurrentUser: options.isCurrentUser, + isAnonymous: false, // Users with profiles are authenticated }; } diff --git a/src/lib/networking/types.ts b/src/lib/networking/types.ts index 0804f70..82bb543 100644 --- a/src/lib/networking/types.ts +++ b/src/lib/networking/types.ts @@ -48,9 +48,14 @@ export type TrustLevel = 'connected' | 'trusted'; /** * Color mapping for trust levels + * - anonymous: grey - not authenticated + * - unconnected: white - authenticated but not connected + * - connected: yellow - one-way or mutual connection (view access) + * - trusted: green - trusted connection (edit access) */ -export const TRUST_LEVEL_COLORS: Record = { - unconnected: '#9ca3af', // grey +export const TRUST_LEVEL_COLORS: Record = { + anonymous: '#9ca3af', // grey - not authenticated + unconnected: '#ffffff', // white - authenticated but not connected connected: '#eab308', // yellow trusted: '#22c55e', // green }; @@ -100,6 +105,14 @@ export interface ConnectionWithMetadata extends Connection { metadata?: EdgeMetadata; } +/** + * Connection with profile information for the connected user + * Used in the connections list UI + */ +export interface UserConnectionWithProfile extends ConnectionWithMetadata { + toProfile?: UserProfile; +} + // ============================================================================= // Graph Types (for visualization) // ============================================================================= @@ -119,6 +132,7 @@ export interface GraphNode { isInRoom: boolean; // Currently in the same room roomPresenceColor?: string; // Color from room presence (if in room) isCurrentUser: boolean; // Is this the logged-in user + isAnonymous: boolean; // User is not authenticated (grey in graph) } /** diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index dd492f5..944fb9c 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -2,7 +2,7 @@ import { TldrawUiMenuItem } from "tldraw" import { DefaultToolbar, DefaultToolbarContent } from "tldraw" import { useTools } from "tldraw" import { useEditor } from "tldraw" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useMemo } from "react" import { useDialogs } from "tldraw" import { SettingsDialog } from "./SettingsDialog" import { useAuth } from "../context/AuthContext" @@ -16,6 +16,9 @@ import type { ObsidianObsNote } from "../lib/obsidianImporter" import { HolonData } from "../lib/HoloSphereService" import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel" import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey" +import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService" +import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types" +import { useValue } from "tldraw" // AI tool model configurations for the dropdown const AI_TOOLS = [ @@ -59,11 +62,54 @@ export function CustomToolbar() { const [isDarkMode, setIsDarkMode] = useState(getDarkMode()) // Dropdown section states - const [expandedSection, setExpandedSection] = useState<'none' | 'ai' | 'integrations'>('none') + const [expandedSection, setExpandedSection] = useState<'none' | 'ai' | 'integrations' | 'connections'>('none') const [hasFathomApiKey, setHasFathomApiKey] = useState(false) const [showFathomInput, setShowFathomInput] = useState(false) const [fathomKeyInput, setFathomKeyInput] = useState('') + // Connections state + const [connections, setConnections] = useState([]) + const [connectionsLoading, setConnectionsLoading] = useState(false) + const [editingConnectionId, setEditingConnectionId] = useState(null) + const [editingMetadata, setEditingMetadata] = useState>({}) + const [savingMetadata, setSavingMetadata] = useState(false) + const [connectingUserId, setConnectingUserId] = useState(null) + + // Get collaborators from tldraw + const collaborators = useValue( + 'collaborators', + () => editor.getCollaborators(), + [editor] + ) + + // Canvas users with their connection status + interface CanvasUser { + id: string + name: string + color: string + connectionStatus: 'trusted' | 'connected' | 'unconnected' + connectionId?: string + } + + const canvasUsers: CanvasUser[] = useMemo(() => { + if (!collaborators || collaborators.length === 0) return [] + + return collaborators.map((c: any) => { + const userId = c.userId || c.id || c.instanceId + const connection = connections.find(conn => conn.toUserId === userId) + + return { + id: userId, + name: c.userName || 'Anonymous', + color: c.color || '#888888', + connectionStatus: connection + ? connection.trustLevel + : 'unconnected' as const, + connectionId: connection?.id, + } + }) + }, [collaborators, connections]) + // Initialize dark mode on mount useEffect(() => { setDarkMode(isDarkMode) @@ -76,6 +122,79 @@ export function CustomToolbar() { } }, [session.authed, session.username]) + // Fetch connections when section is expanded + useEffect(() => { + if (expandedSection === 'connections' && session.authed) { + setConnectionsLoading(true) + getMyConnections() + .then(setConnections) + .catch(console.error) + .finally(() => setConnectionsLoading(false)) + } + }, [expandedSection, session.authed]) + + // Handle saving edge metadata + const handleSaveMetadata = async (connectionId: string) => { + setSavingMetadata(true) + try { + await updateEdgeMetadata(connectionId, editingMetadata) + // Refresh connections to show updated metadata + const updated = await getMyConnections() + setConnections(updated) + setEditingConnectionId(null) + setEditingMetadata({}) + } catch (error) { + console.error('Failed to save metadata:', error) + } finally { + setSavingMetadata(false) + } + } + + // Handle connecting to a canvas user + const handleConnect = async (userId: string, trustLevel: TrustLevel = 'connected') => { + setConnectingUserId(userId) + try { + await createConnection(userId, trustLevel) + // Refresh connections + const updated = await getMyConnections() + setConnections(updated) + } catch (error) { + console.error('Failed to connect:', error) + } finally { + setConnectingUserId(null) + } + } + + // Handle disconnecting from a user + const handleDisconnect = async (connectionId: string, userId: string) => { + setConnectingUserId(userId) + try { + await removeConnection(connectionId) + // Refresh connections + const updated = await getMyConnections() + setConnections(updated) + } catch (error) { + console.error('Failed to disconnect:', error) + } finally { + setConnectingUserId(null) + } + } + + // Handle changing trust level + const handleChangeTrust = async (connectionId: string, userId: string, newLevel: TrustLevel) => { + setConnectingUserId(userId) + try { + await updateTrustLevel(connectionId, newLevel) + // Refresh connections + const updated = await getMyConnections() + setConnections(updated) + } catch (error) { + console.error('Failed to update trust level:', error) + } finally { + setConnectingUserId(null) + } + } + const toggleDarkMode = () => { const newMode = !isDarkMode setIsDarkMode(newMode) @@ -882,6 +1001,413 @@ export function CustomToolbar() {
)} + {/* Connections Section */} + + + {expandedSection === 'connections' && ( +
+ {/* People in Canvas Section */} + {canvasUsers.length > 0 && ( +
+
+ People in Canvas + + {canvasUsers.length} + +
+
+ {canvasUsers.map((user) => ( +
+
+
+ {/* User avatar with presence color */} +
+ {user.name.charAt(0).toUpperCase()} +
+
+
+ {user.name} +
+
+ {user.connectionStatus === 'trusted' ? 'Trusted' : + user.connectionStatus === 'connected' ? 'Connected' : + 'Not connected'} +
+
+
+ + {/* Connection status indicator & actions */} +
+ {connectingUserId === user.id ? ( + ... + ) : user.connectionStatus === 'unconnected' ? ( + <> + + + + ) : ( + <> + {/* Toggle between connected and trusted */} + {user.connectionStatus === 'connected' ? ( + + ) : ( + + )} + + + )} +
+
+
+ ))} +
+
+ )} + + {/* Divider if both sections exist */} + {canvasUsers.length > 0 && connections.length > 0 && ( +
+ )} + + {/* My Connections Section */} +
+ My Connections +
+ + {connectionsLoading ? ( +

+ Loading connections... +

+ ) : connections.length === 0 ? ( +
+

+ No connections yet +

+

+ Connect with people in the canvas above +

+
+ ) : ( +
+ {connections.map((conn) => ( +
+ {/* Connection Header */} +
+
+
+ {(conn.toProfile?.displayName || conn.toUserId).charAt(0).toUpperCase()} +
+
+
+ {conn.toProfile?.displayName || conn.toUserId} +
+
+ @{conn.toUserId} +
+
+
+ + {conn.trustLevel === 'trusted' ? 'Trusted' : 'Connected'} + +
+ + {/* Mutual Connection Badge */} + {conn.isMutual && ( +
+ βœ“ Mutual connection +
+ )} + + {/* Edge Metadata Display/Edit */} + {editingConnectionId === conn.id ? ( +
+
+ + setEditingMetadata({ ...editingMetadata, label: e.target.value })} + placeholder="e.g., Colleague, Friend..." + style={{ width: '100%', padding: '4px 6px', fontSize: '11px', border: '1px solid #ddd', borderRadius: '4px' }} + /> +
+
+ +