import React, { useState, useEffect, useRef, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useAuth } from '../../context/AuthContext'; import { useEditor, useValue } from 'tldraw'; import CryptID from './CryptID'; import { GoogleDataService, type GoogleService } from '../../lib/google'; import { GoogleExportBrowser } from '../GoogleExportBrowser'; import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from '../../lib/fathomApiKey'; import { isMiroApiKeyConfigured } from '../../lib/miroApiKey'; import { MiroIntegrationModal } from '../MiroIntegrationModal'; import { getMyConnections, createConnection, removeConnection, updateTrustLevel, updateEdgeMetadata } from '../../lib/networking/connectionService'; import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from '../../lib/networking/types'; interface CryptIDDropdownProps { isDarkMode?: boolean; } /** * CryptID dropdown component for the top-right corner. * Shows logged-in user with dropdown containing account info and integrations. */ const CryptIDDropdown: React.FC = ({ isDarkMode = false }) => { const { session, logout, updateSession } = useAuth(); const [showDropdown, setShowDropdown] = useState(false); const [showCryptIDModal, setShowCryptIDModal] = useState(false); const [showGoogleBrowser, setShowGoogleBrowser] = useState(false); const [showObsidianModal, setShowObsidianModal] = useState(false); const [showMiroModal, setShowMiroModal] = useState(false); const [obsidianVaultUrl, setObsidianVaultUrl] = useState(''); const [googleConnected, setGoogleConnected] = useState(false); const [googleLoading, setGoogleLoading] = useState(false); const [googleCounts, setGoogleCounts] = useState>({ gmail: 0, drive: 0, photos: 0, calendar: 0, }); const dropdownRef = useRef(null); const dropdownMenuRef = useRef(null); // Expanded sections (only integrations and connections now) const [expandedSection, setExpandedSection] = useState<'none' | 'integrations' | 'connections'>('none'); // Fathom API key state 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 editor - will throw if outside tldraw context, but that's handled by ErrorBoundary // Note: These hooks must always be called unconditionally const editorFromHook = useEditor(); const collaborators = useValue('collaborators', () => editorFromHook?.getCollaborators() || [], [editorFromHook]) || []; // 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 || c.name || 'Anonymous', color: c.color || '#888', connectionStatus: (connection?.trustLevel || 'unconnected') as CanvasUser['connectionStatus'], connectionId: connection?.id, }; }).filter((user) => user.name !== session.username); }, [collaborators, connections, session.username]); // Check Google connection on mount useEffect(() => { const checkGoogleStatus = async () => { try { const service = GoogleDataService.getInstance(); const isAuthed = await service.isAuthenticated(); setGoogleConnected(isAuthed); if (isAuthed) { const counts = await service.getStoredCounts(); setGoogleCounts(counts); } } catch (error) { console.warn('Failed to check Google status:', error); } }; checkGoogleStatus(); }, []); // Check Fathom API key useEffect(() => { if (session.authed && session.username) { setHasFathomApiKey(isFathomApiKeyConfigured(session.username)); } }, [session.authed, session.username]); // Load connections when authenticated useEffect(() => { const loadConnections = async () => { if (!session.authed || !session.username) return; setConnectionsLoading(true); try { const myConnections = await getMyConnections(); setConnections(myConnections as UserConnectionWithProfile[]); } catch (error) { console.error('Failed to load connections:', error); } finally { setConnectionsLoading(false); } }; loadConnections(); }, [session.authed, session.username]); // Connection handlers const handleConnect = async (userId: string, trustLevel: TrustLevel) => { if (!session.authed || !session.username) return; setConnectingUserId(userId); try { const newConnection = await createConnection(userId, trustLevel); if (newConnection) { setConnections(prev => [...prev, newConnection as UserConnectionWithProfile]); } } catch (error) { console.error('Failed to create connection:', error); } finally { setConnectingUserId(null); } }; const handleDisconnect = async (connectionId: string, userId: string) => { setConnectingUserId(userId); try { await removeConnection(connectionId); setConnections(prev => prev.filter(c => c.id !== connectionId)); } catch (error) { console.error('Failed to remove connection:', error); } finally { setConnectingUserId(null); } }; const handleChangeTrust = async (connectionId: string, userId: string, newLevel: TrustLevel) => { setConnectingUserId(userId); try { const updated = await updateTrustLevel(connectionId, newLevel); if (updated) { setConnections(prev => prev.map(c => c.id === connectionId ? updated : c)); } } catch (error) { console.error('Failed to update trust level:', error); } finally { setConnectingUserId(null); } }; const handleSaveMetadata = async (connectionId: string) => { setSavingMetadata(true); try { const updatedMetadata = await updateEdgeMetadata(connectionId, editingMetadata); if (updatedMetadata) { setConnections(prev => prev.map(c => c.id === connectionId ? { ...c, metadata: updatedMetadata } : c )); } setEditingConnectionId(null); setEditingMetadata({}); } catch (error) { console.error('Failed to save metadata:', error); } finally { setSavingMetadata(false); } }; // Close dropdown when clicking outside or pressing ESC useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; // Check if click is inside trigger button OR the portal dropdown menu const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target); const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target); if (!isInsideTrigger && !isInsideMenu) { setShowDropdown(false); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setShowDropdown(false); } }; if (showDropdown) { document.addEventListener('mousedown', handleClickOutside); // Use capture phase to intercept before tldraw document.addEventListener('keydown', handleKeyDown, true); } return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleKeyDown, true); }; }, [showDropdown]); const handleGoogleConnect = async () => { setGoogleLoading(true); try { const service = GoogleDataService.getInstance(); await service.authenticate(['gmail', 'drive', 'photos', 'calendar']); setGoogleConnected(true); const counts = await service.getStoredCounts(); setGoogleCounts(counts); // After successful connection, open the Google Export Browser setShowGoogleBrowser(true); setShowDropdown(false); } catch (error) { console.error('Google auth failed:', error); } finally { setGoogleLoading(false); } }; const handleGoogleDisconnect = async () => { try { const service = GoogleDataService.getInstance(); await service.signOut(); setGoogleConnected(false); setGoogleCounts({ gmail: 0, drive: 0, photos: 0, calendar: 0 }); } catch (error) { console.error('Google disconnect failed:', error); } }; const handleAddToCanvas = async (items: any[], position: { x: number; y: number }) => { // Emit event for canvas to handle window.dispatchEvent(new CustomEvent('add-google-items-to-canvas', { detail: { items, position } })); setShowGoogleBrowser(false); setShowDropdown(false); }; const totalGoogleItems = Object.values(googleCounts).reduce((a, b) => a + b, 0); // Get initials for avatar const getInitials = (name: string) => { return name.charAt(0).toUpperCase(); }; // Ref for the trigger button to calculate dropdown position const triggerRef = useRef(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null); // Update dropdown position when it opens useEffect(() => { if (showDropdown && triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect(); setDropdownPosition({ top: rect.bottom + 8, right: window.innerWidth - rect.right, }); } }, [showDropdown]); // Close dropdown when user logs out useEffect(() => { if (!session.authed) { setShowDropdown(false); } }, [session.authed]); return (
{/* Trigger button - opens modal directly for unauthenticated users, dropdown for authenticated */} {/* Dropdown menu - rendered via portal to break out of parent container */} {showDropdown && dropdownPosition && createPortal(
{ // Stop wheel events from propagating to canvas when over menu e.stopPropagation(); }} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onTouchMove={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} > {session.authed ? ( <> {/* Account section */}
{getInitials(session.username)}
{session.username}
CryptID secured
{/* Quick actions */} {/* Integrations section */}
Integrations
{/* Google Workspace */}
G
Google Workspace
{googleConnected ? `${totalGoogleItems} items imported` : 'Not connected'}
{googleConnected && ( )}
{googleConnected ? ( <> ) : ( )}
{/* Obsidian Vault */}
📁
Obsidian Vault
{session.obsidianVaultName || 'Not connected'}
{session.obsidianVaultName && ( )}
{/* Fathom Meetings */}
🎥
Fathom Meetings
{hasFathomApiKey ? 'Connected' : 'Not connected'}
{hasFathomApiKey && ( )}
{showFathomInput ? (
setFathomKeyInput(e.target.value)} placeholder="Enter Fathom API key..." style={{ width: '100%', padding: '6px 8px', fontSize: '11px', border: '1px solid var(--color-grid)', borderRadius: '4px', marginBottom: '6px', background: 'var(--color-background)', color: 'var(--color-text)', boxSizing: 'border-box', }} onKeyDown={(e) => { if (e.key === 'Enter' && fathomKeyInput.trim()) { saveFathomApiKey(fathomKeyInput.trim(), session.username); setHasFathomApiKey(true); setShowFathomInput(false); setFathomKeyInput(''); } else if (e.key === 'Escape') { setShowFathomInput(false); setFathomKeyInput(''); } }} autoFocus />
) : (
{hasFathomApiKey && ( )}
)}
{/* Miro Board Import */}
Miro Boards
{isMiroApiKeyConfigured(session.username) ? 'API connected' : 'Import via JSON'}
{isMiroApiKeyConfigured(session.username) && ( )}
{/* Sign out */}
) : null}
, document.body )} {/* Google Export Browser Modal */} {showGoogleBrowser && createPortal( setShowGoogleBrowser(false)} onAddToCanvas={handleAddToCanvas} isDarkMode={isDarkMode} />, document.body )} {/* Obsidian Vault Connection Modal */} {showObsidianModal && createPortal(
{ if (e.target === e.currentTarget) { setShowObsidianModal(false); } }} >
e.stopPropagation()} >
📁

Connect Obsidian Vault

Import your notes to the canvas

{session.obsidianVaultName && (
Currently connected
{session.obsidianVaultName}
)}
{/* Option 1: Select Local Folder */} {/* Option 2: Enter URL */}
setObsidianVaultUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && obsidianVaultUrl.trim()) { updateSession({ obsidianVaultPath: obsidianVaultUrl.trim(), obsidianVaultName: new URL(obsidianVaultUrl.trim()).hostname, }); setShowObsidianModal(false); setObsidianVaultUrl(''); window.dispatchEvent(new CustomEvent('open-obsidian-browser')); } }} style={{ width: '100%', padding: '12px 14px', fontSize: '13px', borderRadius: '8px', border: '1px solid var(--color-panel-contrast)', background: 'var(--color-panel)', color: 'var(--color-text)', outline: 'none', }} /> {obsidianVaultUrl && ( )}
{session.obsidianVaultName && ( )}
, document.body )} {/* Miro Integration Modal */} {showMiroModal && createPortal( setShowMiroModal(false)} username={session.username} />, document.body )} {/* CryptID Sign In Modal - rendered via portal */} {showCryptIDModal && createPortal(
{ if (e.target === e.currentTarget) { setShowCryptIDModal(false); } }} >
e.stopPropagation()} > {/* Close button */} setShowCryptIDModal(false)} onCancel={() => setShowCryptIDModal(false)} />
, document.body )}
); }; export default CryptIDDropdown;