/** * Connection Service * * Client-side API for user networking features: * - User search * - Connection management (follow/unfollow) * - Edge metadata (labels, notes, colors) * - Network graph retrieval */ import type { UserProfile, UserSearchResult, Connection, ConnectionWithMetadata, EdgeMetadata, NetworkGraph, GraphNode, GraphEdge, TrustLevel, } from './types'; import { WORKER_URL } from '../../constants/workerUrl'; // ============================================================================= // Configuration // ============================================================================= const API_BASE = `${WORKER_URL}/api/networking`; // ============================================================================= // Helper Functions // ============================================================================= /** * Get the current user's CryptID username from localStorage session */ function getCurrentUserId(): string | null { try { // Session is stored as 'canvas_auth_session' by sessionPersistence.ts const sessionStr = localStorage.getItem('canvas_auth_session'); if (sessionStr) { const session = JSON.parse(sessionStr); if (session.authed && session.username) { return session.username; } } } catch { // Ignore parsing errors } return null; } async function fetchJson(url: string, options?: RequestInit): Promise { // Get the current user ID for authentication const userId = getCurrentUserId(); const headers: Record = { 'Content-Type': 'application/json', ...(options?.headers as Record || {}), }; // Add user ID header for authentication if (userId) { headers['X-User-Id'] = userId; } const response = await fetch(url, { ...options, headers, }); // Check content type to ensure we're getting JSON const contentType = response.headers.get('content-type') || ''; const isJson = contentType.includes('application/json'); if (!response.ok) { if (isJson) { const errorData = await response.json().catch(() => ({ message: response.statusText })) as { message?: string }; throw new Error(errorData.message || `HTTP ${response.status}`); } // If we got HTML (like a 404 page), throw a more descriptive error throw new Error(`API unavailable (HTTP ${response.status}). Is the worker running?`); } // Ensure we're getting JSON before parsing if (!isJson) { throw new Error('API returned non-JSON response. Is the worker running on port 5172?'); } return response.json(); } function generateId(): string { return crypto.randomUUID(); } // ============================================================================= // User Search // ============================================================================= /** * Search for users by username or display name */ export async function searchUsers( query: string, limit: number = 20 ): Promise { const params = new URLSearchParams({ q: query, limit: String(limit) }); return fetchJson(`${API_BASE}/users/search?${params}`); } /** * Get a user's public profile */ export async function getUserProfile(userId: string): Promise { try { return await fetchJson(`${API_BASE}/users/${userId}`); } catch (error) { if ((error as Error).message.includes('404')) { return null; } throw error; } } /** * Update current user's profile */ export async function updateMyProfile(updates: Partial<{ displayName: string; bio: string; avatarColor: string; }>): Promise { return fetchJson(`${API_BASE}/users/me`, { method: 'PUT', body: JSON.stringify(updates), }); } // ============================================================================= // Connection Management // ============================================================================= /** * Create a connection (follow a user) * @param toUserId - The user to connect to * @param trustLevel - 'connected' (yellow, view) or 'trusted' (green, edit) */ export async function createConnection( toUserId: string, trustLevel: TrustLevel = 'connected' ): Promise { return fetchJson(`${API_BASE}/connections`, { method: 'POST', body: JSON.stringify({ toUserId, trustLevel }), }); } /** * Update trust level for an existing connection */ export async function updateTrustLevel( connectionId: string, trustLevel: TrustLevel ): Promise { return fetchJson(`${API_BASE}/connections/${connectionId}/trust`, { method: 'PUT', body: JSON.stringify({ trustLevel }), }); } /** * Remove a connection (unfollow a user) */ export async function removeConnection(connectionId: string): Promise { await fetch(`${API_BASE}/connections/${connectionId}`, { method: 'DELETE', }); } /** * Get a specific connection by ID */ export async function getConnection(connectionId: string): Promise { try { return await fetchJson(`${API_BASE}/connections/${connectionId}`); } catch (error) { if ((error as Error).message.includes('404')) { return null; } throw error; } } /** * Get all connections for current user */ export async function getMyConnections(): Promise { return fetchJson(`${API_BASE}/connections`); } /** * Get users who are connected to current user (followers) */ export async function getMyFollowers(): Promise { return fetchJson(`${API_BASE}/connections/followers`); } /** * Check if current user is connected to a specific user */ export async function isConnectedTo(userId: string): Promise { try { const result = await fetchJson<{ connected: boolean }>( `${API_BASE}/connections/check/${userId}` ); return result.connected; } catch { return false; } } // ============================================================================= // Edge Metadata // ============================================================================= /** * Update metadata for a connection edge */ export async function updateEdgeMetadata( connectionId: string, metadata: Partial ): Promise { return fetchJson(`${API_BASE}/connections/${connectionId}/metadata`, { method: 'PUT', body: JSON.stringify(metadata), }); } /** * Get metadata for a connection edge */ export async function getEdgeMetadata(connectionId: string): Promise { try { return await fetchJson(`${API_BASE}/connections/${connectionId}/metadata`); } catch (error) { if ((error as Error).message.includes('404')) { return null; } throw error; } } // ============================================================================= // Network Graph // ============================================================================= /** * Transform API edge format to client format * API uses fromUserId/toUserId, client uses source/target for d3 */ function transformEdge(edge: any): GraphEdge { return { id: edge.id, source: edge.fromUserId || edge.source, target: edge.toUserId || edge.target, trustLevel: edge.trustLevel, isMutual: edge.isMutual, effectiveTrustLevel: edge.effectiveTrustLevel, metadata: edge.metadata, isVisible: true, }; } /** * Get the full network graph for current user */ export async function getMyNetworkGraph(): Promise { const response = await fetchJson(`${API_BASE}/graph`); return { nodes: response.nodes || [], edges: (response.edges || []).map(transformEdge), myConnections: response.myConnections || [], }; } /** * Get network graph scoped to room participants * Returns full network in grey, room participants colored */ export async function getRoomNetworkGraph( roomParticipants: string[] ): Promise { 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 || [], }; } /** * Get mutual connections between current user and another user */ export async function getMutualConnections(userId: string): Promise { return fetchJson(`${API_BASE}/connections/mutual/${userId}`); } // ============================================================================= // Graph Building Helpers (Client-side) // ============================================================================= /** * Build a GraphNode from a UserProfile and room state */ export function buildGraphNode( profile: UserProfile, options: { isInRoom: boolean; roomPresenceColor?: string; isCurrentUser: boolean; } ): GraphNode { return { id: profile.id, username: profile.username, displayName: profile.displayName, avatarColor: profile.avatarColor, isInRoom: options.isInRoom, roomPresenceColor: options.roomPresenceColor, isCurrentUser: options.isCurrentUser, isAnonymous: false, // Users with profiles are authenticated }; } /** * Build a GraphEdge from a Connection */ export function buildGraphEdge( connection: ConnectionWithMetadata, currentUserId: string ): GraphEdge { const isOnEdge = connection.fromUserId === currentUserId || connection.toUserId === currentUserId; return { id: connection.id, source: connection.fromUserId, target: connection.toUserId, trustLevel: connection.trustLevel, effectiveTrustLevel: connection.effectiveTrustLevel, isMutual: connection.isMutual, metadata: isOnEdge ? connection.metadata : undefined, isVisible: true, }; } // ============================================================================= // Local Storage Cache (for offline/fast loading) // ============================================================================= const CACHE_KEY = 'network_graph_cache'; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes interface CachedGraph { graph: NetworkGraph; timestamp: number; } export function getCachedGraph(): NetworkGraph | null { try { const cached = localStorage.getItem(CACHE_KEY); if (!cached) return null; const { graph, timestamp }: CachedGraph = JSON.parse(cached); if (Date.now() - timestamp > CACHE_TTL) { localStorage.removeItem(CACHE_KEY); return null; } return graph; } catch { return null; } } export function setCachedGraph(graph: NetworkGraph): void { try { const cached: CachedGraph = { graph, timestamp: Date.now(), }; localStorage.setItem(CACHE_KEY, JSON.stringify(cached)); } catch { // Ignore storage errors } } export function clearGraphCache(): void { try { localStorage.removeItem(CACHE_KEY); } catch { // Ignore } }