/** * Global Profile App * Handles profile management UI with new SDK */ const API_BASE = '/api'; // State let currentProfile = null; let peerId = null; // Profile list state management let allProfiles = []; let filteredProfiles = []; let displayedCount = 0; let searchQuery = ''; let scrollObserver = null; const BATCH_SIZE = 25; // Number of profiles to load per batch // Connection status elements let statusIndicator = null; let statusText = null; let profilesCountValue = null; // WebSocket connection let ws = null; let reconnectTimeout = null; let reconnectDelay = 1000; const MAX_RECONNECT_DELAY = 30000; /** * Get avatar URL for a peer */ function getAvatarUrl(peerId, size = 64) { if (!peerId) return ''; return `${API_BASE}/profile/avatar/${encodeURIComponent(peerId)}/${size}`; } /** * Initialize the app */ async function init() { try { // Get connection status elements statusIndicator = document.getElementById('statusIndicator'); statusText = document.getElementById('statusText'); profilesCountValue = document.getElementById('profilesCountValue'); // Load current profile await loadMyProfile(); // Load all profiles await loadProfiles(); // Set up event listeners setupEventListeners(); // Initialize tag editor initTagEditor(); // Connect to WebSocket for real-time updates connectWebSocket(); } catch (err) { console.error('Error initializing app:', err); showToast('Failed to initialize app', 'error'); } } /** * Connect to WebSocket server */ function connectWebSocket() { // Don't connect if already connected or connecting if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { return; } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws`; try { ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('WebSocket connected'); reconnectDelay = 1000; // Reset delay on successful connection updateStatus(true); // WebSocket is now only used for real-time updates // Initial profile data is loaded via REST API }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); handleWebSocketMessage(data); } catch (err) { console.error('Error parsing WebSocket message:', err); } }; ws.onclose = () => { console.log('WebSocket disconnected'); updateStatus(false); ws = null; // Attempt to reconnect with exponential backoff reconnectTimeout = setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); connectWebSocket(); }, reconnectDelay); }; ws.onerror = (err) => { console.error('WebSocket error:', err); }; } catch (err) { console.error('Error creating WebSocket:', err); } } /** * Handle WebSocket messages */ function handleWebSocketMessage(data) { console.log('Received WebSocket message:', data.type, data); switch (data.type) { case 'profile-update': // Profile was updated if (data.profile) { const index = allProfiles.findIndex(p => p.peerId === data.profile.peerId); if (index >= 0) { // Update existing profile allProfiles[index] = data.profile; } else { // Add new profile allProfiles.push(data.profile); } // If it's the current user's profile, reload it if (data.profile.peerId === peerId) { loadMyProfile(); } // Refresh the display updateProfilesCount(); applyFilter(); } break; case 'profile-deleted': // Profile was deleted if (data.peerId) { allProfiles = allProfiles.filter(p => p.peerId !== data.peerId); // If it's the current user's profile, reload it if (data.peerId === peerId) { loadMyProfile(); } // Refresh the display updateProfilesCount(); applyFilter(); } break; default: console.log('Unknown WebSocket message type:', data.type); } } /** * Load current user's profile */ async function loadMyProfile() { try { const response = await fetch(`${API_BASE}/profile`); if (!response.ok) { throw new Error('Failed to load profile'); } currentProfile = await response.json(); peerId = currentProfile.peerId; // Populate form document.getElementById('display-name').value = currentProfile.displayName || ''; document.getElementById('bio').value = currentProfile.bio || ''; document.getElementById('peer-id').value = currentProfile.peerId || ''; // Populate additional details with toggle state // Store enabled state in customFields if not present const emailEnabled = currentProfile.customFields?.emailEnabled ?? (currentProfile.email ? true : false); const websiteEnabled = currentProfile.customFields?.websiteEnabled ?? (currentProfile.website ? true : false); const xHandleEnabled = currentProfile.customFields?.xHandleEnabled ?? (currentProfile.xUsername ? true : false); populateField('email', currentProfile.email || '', emailEnabled); populateField('website', currentProfile.website || '', websiteEnabled); populateField('xUsername', currentProfile.xUsername || '', xHandleEnabled); // Handle tags - populate tag editor let tagsArray = []; if (Array.isArray(currentProfile.tags)) { tagsArray = currentProfile.tags; } else if (typeof currentProfile.tags === 'string') { try { const parsed = JSON.parse(currentProfile.tags); tagsArray = Array.isArray(parsed) ? parsed : []; } catch { // If it's a comma-separated string, split it if (currentProfile.tags.trim()) { tagsArray = currentProfile.tags.split(',').map(t => t.trim()).filter(t => t); } } } updateTagEditor(tagsArray); // Update avatar (use 128x128 for preview) if (peerId) { const avatarImg = document.getElementById('avatar-preview'); avatarImg.src = getAvatarUrl(peerId, 128) + '?t=' + Date.now(); avatarImg.style.display = 'block'; document.getElementById('avatar-placeholder').style.display = 'none'; } else { document.getElementById('avatar-preview').style.display = 'none'; document.getElementById('avatar-placeholder').style.display = 'block'; } } catch (err) { console.error('Error loading profile:', err); showToast('Failed to load profile', 'error'); } } /** * Load all profiles from network */ async function loadProfiles() { try { const response = await fetch(`${API_BASE}/profiles`); if (!response.ok) { const errorText = await response.text(); let errorMessage = 'Failed to load profiles'; try { const errorJson = JSON.parse(errorText); errorMessage = errorJson.error || errorJson.message || errorMessage; } catch (e) { errorMessage = errorText || errorMessage; } console.error('Error loading profiles:', errorMessage, 'Status:', response.status); throw new Error(errorMessage); } const data = await response.json(); console.log('Profiles response:', data); // Handle both old format (data.profiles) and new format (data.profiles with pagination) if (data.profiles && Array.isArray(data.profiles)) { allProfiles = data.profiles; } else if (Array.isArray(data)) { // Fallback: if response is directly an array allProfiles = data; } else { console.warn('Unexpected response format:', data); allProfiles = []; } // Update profiles count updateProfilesCount(); // Initial filter with empty query (show all) searchQuery = ''; applyFilter(); } catch (err) { console.error('Error loading profiles:', err); const profilesList = document.getElementById('profiles-list'); profilesList.innerHTML = `
Failed to load profiles: ${err.message || 'Unknown error'}
`; } } /** * Update the profiles count display */ function updateProfilesCount() { if (profilesCountValue) { profilesCountValue.textContent = allProfiles.length; } } /** * Update connection status indicator * @param {boolean} connected - Whether connected */ function updateStatus(connected) { if (!statusIndicator || !statusText) return; if (connected) { statusIndicator.classList.remove('disconnected'); statusText.textContent = 'Connected'; } else { statusIndicator.classList.add('disconnected'); statusText.textContent = 'Disconnected - Reconnecting...'; } } /** * Filter profiles based on search query * @param {string} query - Search query string * @param {Array} profiles - Array of profiles to filter * @returns {Array} Filtered profiles */ function filterProfiles(query, profiles) { if (!query || query.trim() === '') { return profiles; } const searchTerm = query.toLowerCase().trim(); return profiles.filter(profile => { // Search in displayName if (profile.displayName && profile.displayName.toLowerCase().includes(searchTerm)) { return true; } // Search in bio if (profile.bio && profile.bio.toLowerCase().includes(searchTerm)) { return true; } // Search in email if (profile.email && profile.email.toLowerCase().includes(searchTerm)) { return true; } // Search in website if (profile.website && profile.website.toLowerCase().includes(searchTerm)) { return true; } // Search in xUsername if (profile.xUsername && profile.xUsername.toLowerCase().includes(searchTerm)) { return true; } // Search in tags if (profile.tags) { const tagsArray = Array.isArray(profile.tags) ? profile.tags : (typeof profile.tags === 'string' ? JSON.parse(profile.tags || '[]') : []); if (tagsArray.some(tag => tag.toLowerCase().includes(searchTerm))) { return true; } } // Search in peerId if (profile.peerId && profile.peerId.toLowerCase().includes(searchTerm)) { return true; } return false; }); } /** * Apply filter and reset display */ function applyFilter() { filteredProfiles = filterProfiles(searchQuery, allProfiles); displayedCount = 0; // Clear the list and reset scroll const profilesList = document.getElementById('profiles-list'); profilesList.innerHTML = ''; profilesList.scrollTop = 0; // Clean up existing observer if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; } // Display first batch loadMoreProfiles(); } /** * Load more profiles into the display (incremental loading) */ function loadMoreProfiles() { const profilesList = document.getElementById('profiles-list'); // Check if we've displayed all filtered profiles if (displayedCount >= filteredProfiles.length) { // Remove sentinel if it exists const sentinel = document.getElementById('scroll-sentinel'); if (sentinel) { sentinel.remove(); } // Show message if no profiles match if (filteredProfiles.length === 0) { const noResultsMsg = searchQuery.trim() !== '' ? '
No profiles match your search
' : '
No profiles found
'; if (profilesList.children.length === 0) { profilesList.innerHTML = noResultsMsg; } } return; } // Calculate how many profiles to display in this batch const nextBatch = filteredProfiles.slice(displayedCount, displayedCount + BATCH_SIZE); const renderTimestamp = Date.now(); // Generate HTML for this batch const html = nextBatch.map((profile, index) => { const displayName = profile.displayName || 'Anonymous'; const isBot = profile.customFields && (profile.customFields.isBot === true || profile.customFields.botTag === 'BOT'); const botTag = isBot ? 'BOT' : ''; const bio = profile.bio || 'No bio'; // Use 64x64 for profile list items const avatarUrl = getAvatarUrl(profile.peerId, 64); const cacheBuster = profile.lastUpdated || (renderTimestamp + index); // Build additional details (email, website, xUsername) const details = []; if (profile.email) { details.push(`📧 ${escapeHtml(profile.email)}`); } if (profile.website) { details.push(`🌐 ${escapeHtml(profile.website)}`); } if (profile.xUsername) { const xUrl = `https://x.com/${profile.xUsername.replace('@', '')}`; details.push(`🐦 ${escapeHtml(profile.xUsername)}`); } // Handle tags - render as actual tag badges let tagsArray = []; if (profile.tags) { if (Array.isArray(profile.tags)) { tagsArray = profile.tags; } else if (typeof profile.tags === 'string') { try { tagsArray = JSON.parse(profile.tags || '[]'); } catch { tagsArray = []; } } } const tagsHtml = tagsArray.length > 0 ? `
${tagsArray.map(tag => `${escapeHtml(tag)}`).join('')}
` : ''; return `
${displayName}
${escapeHtml(displayName)} ${botTag}
${escapeHtml(bio)}
${details.length > 0 ? `
${details.join(' • ')}
` : ''} ${tagsHtml}
${profile.peerId}
`; }).join(''); // Remove any existing sentinel const existingSentinel = document.getElementById('scroll-sentinel'); if (existingSentinel) { existingSentinel.remove(); } // Append new profiles profilesList.insertAdjacentHTML('beforeend', html); // Update displayed count displayedCount += nextBatch.length; // Add sentinel if there are more profiles to load if (displayedCount < filteredProfiles.length) { profilesList.insertAdjacentHTML('beforeend', '
'); setupInfiniteScroll(); } } /** * Set up infinite scroll using Intersection Observer */ function setupInfiniteScroll() { // Clean up existing observer if (scrollObserver) { scrollObserver.disconnect(); } const sentinel = document.getElementById('scroll-sentinel'); if (!sentinel) { return; } // Create new observer scrollObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { loadMoreProfiles(); } }); }, { root: null, rootMargin: '100px', // Start loading 100px before reaching the bottom threshold: 0.1 }); scrollObserver.observe(sentinel); } /** * Populate a field with value and enabled state * @param {string} dataField - Data field name (email, website, xUsername) * @param {string} value - Field value * @param {boolean} enabled - Whether field is enabled */ function populateField(dataField, value, enabled) { // Map data field names to HTML IDs const fieldMap = { 'email': 'email', 'website': 'website', 'xUsername': 'x-handle' }; const htmlId = fieldMap[dataField] || dataField; const input = document.getElementById(htmlId); const toggle = document.getElementById(`${htmlId}-toggle`); if (input && toggle) { input.value = value || ''; setFieldEnabled(dataField, enabled === true); } } /** * Set field enabled/disabled state * @param {string} dataField - Data field name (email, website, xUsername) * @param {boolean} enabled - Whether field should be enabled */ function setFieldEnabled(dataField, enabled) { // Map data field names to HTML IDs const fieldMap = { 'email': 'email', 'website': 'website', 'xUsername': 'x-handle' }; const htmlId = fieldMap[dataField] || dataField; const input = document.getElementById(htmlId); const toggle = document.getElementById(`${htmlId}-toggle`); if (input && toggle) { input.disabled = !enabled; if (enabled) { toggle.classList.add('enabled'); toggle.innerHTML = 'Disable'; } else { toggle.classList.remove('enabled'); toggle.innerHTML = '+Enable'; } } } /** * Debounce function to limit how often a function is called * @param {Function} func - Function to debounce * @param {number} wait - Wait time in milliseconds * @returns {Function} Debounced function */ function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } /** * Handle search input changes */ const handleSearchInput = debounce((e) => { searchQuery = e.target.value; applyFilter(); }, 300); /** * Set up event listeners */ function setupEventListeners() { // Profile form submission const profileForm = document.getElementById('profile-form'); profileForm.addEventListener('submit', async (e) => { e.preventDefault(); await saveProfile(); }); // Avatar upload const avatarUpload = document.getElementById('avatar-upload'); avatarUpload.addEventListener('change', async (e) => { const file = e.target.files[0]; if (file) { await uploadAvatar(file); } }); // Search input const searchInput = document.getElementById('profile-search'); if (searchInput) { searchInput.addEventListener('input', handleSearchInput); } // Toggle buttons for additional fields const toggleButtons = [ { htmlId: 'email', dataField: 'email' }, { htmlId: 'website', dataField: 'website' }, { htmlId: 'x-handle', dataField: 'xUsername' } ]; toggleButtons.forEach(({ htmlId, dataField }) => { const toggle = document.getElementById(`${htmlId}-toggle`); if (toggle) { toggle.addEventListener('click', () => { const input = document.getElementById(htmlId); const isCurrentlyEnabled = !input.disabled; setFieldEnabled(dataField, !isCurrentlyEnabled); }); } }); // Delete profile button const deleteBtn = document.getElementById('delete-btn'); if (deleteBtn) { deleteBtn.addEventListener('click', deleteProfile); } } /** * Cleanup WebSocket connection */ function cleanupWebSocket() { if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } if (ws) { ws.close(); ws = null; } } // Cleanup on page unload window.addEventListener('beforeunload', cleanupWebSocket); /** * Save profile */ async function saveProfile() { const saveBtn = document.getElementById('save-btn'); const btnText = saveBtn.querySelector('.btn-text'); const btnLoader = saveBtn.querySelector('.btn-loader'); console.log('[SaveProfile] Starting save operation...'); const startTime = Date.now(); try { saveBtn.disabled = true; btnText.style.display = 'none'; btnLoader.style.display = 'inline'; const displayName = document.getElementById('display-name').value; const bio = document.getElementById('bio').value; // Get additional details with enabled state const emailInput = document.getElementById('email'); const websiteInput = document.getElementById('website'); const xHandleInput = document.getElementById('x-handle'); const emailEnabled = !emailInput.disabled; const websiteEnabled = !websiteInput.disabled; const xHandleEnabled = !xHandleInput.disabled; // Get tags from tag editor const tags = getTagsFromEditor(); console.log('[SaveProfile] Tags to save:', tags); // Build customFields to store enabled states const customFields = currentProfile?.customFields || {}; customFields.emailEnabled = emailEnabled; customFields.websiteEnabled = websiteEnabled; customFields.xHandleEnabled = xHandleEnabled; const requestBody = { displayName, bio, email: emailEnabled ? emailInput.value.trim() : '', website: websiteEnabled ? websiteInput.value.trim() : '', xUsername: xHandleEnabled ? xHandleInput.value.trim() : '', tags, customFields }; console.log('[SaveProfile] Request body:', JSON.stringify(requestBody, null, 2)); console.log('[SaveProfile] Sending PUT request to', `${API_BASE}/profile`); const fetchStartTime = Date.now(); const response = await authenticatedFetch(`${API_BASE}/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); console.log('[SaveProfile] Response received after', Date.now() - fetchStartTime, 'ms, status:', response.status); if (!response.ok) { const errorText = await response.text(); console.error('[SaveProfile] Error response:', errorText); throw new Error('Failed to save profile: ' + errorText); } console.log('[SaveProfile] Parsing response JSON...'); const updatedProfile = await response.json(); console.log('[SaveProfile] Updated profile received:', updatedProfile); currentProfile = updatedProfile; console.log('[SaveProfile] Save completed in', Date.now() - startTime, 'ms'); showToast('Profile saved successfully', 'success'); // Profile update will be handled via WebSocket broadcast } catch (err) { console.error('[SaveProfile] Error saving profile after', Date.now() - startTime, 'ms:', err); showToast('Failed to save profile', 'error'); } finally { saveBtn.disabled = false; btnText.style.display = 'inline'; btnLoader.style.display = 'none'; } } /** * Upload avatar */ async function uploadAvatar(file) { try { // Validate file type if (!file.type.startsWith('image/')) { showToast('Please select an image file', 'error'); return; } // Validate file size (max 5MB) if (file.size > 5 * 1024 * 1024) { showToast('Image size must be less than 5MB', 'error'); return; } showToast('Uploading avatar...', 'success'); // Create form data const formData = new FormData(); formData.append('avatar', file); const response = await authenticatedFetch(`${API_BASE}/profile/avatar`, { method: 'POST', body: formData }); if (!response.ok) { const errorText = await response.text(); let errorMessage = 'Failed to upload avatar'; try { const errorJson = JSON.parse(errorText); errorMessage = errorJson.error || errorJson.message || errorMessage; } catch (e) { errorMessage = errorText || errorMessage; } throw new Error(errorMessage); } const result = await response.json(); // Update avatar preview (use 128x128 for preview) const avatarImg = document.getElementById('avatar-preview'); if (peerId) { avatarImg.src = getAvatarUrl(peerId, 128) + '?t=' + Date.now(); avatarImg.style.display = 'block'; document.getElementById('avatar-placeholder').style.display = 'none'; } // Update current profile if (currentProfile) { currentProfile.avatarHash = result.avatarHash; } showToast('Avatar uploaded successfully', 'success'); // Avatar update will be handled via WebSocket broadcast } catch (err) { console.error('Error uploading avatar:', err); showToast(err.message || 'Failed to upload avatar', 'error'); } } /** * Show toast notification */ function showToast(message, type = 'success') { const toast = document.getElementById('toast'); toast.textContent = message; toast.className = `toast ${type} show`; setTimeout(() => { toast.classList.remove('show'); }, 3000); } /** * Escape HTML to prevent XSS */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Tag Editor Functions */ let currentTags = []; /** * Update the tag editor display */ function updateTagEditor(tags) { currentTags = [...tags]; const tagList = document.getElementById('tag-list'); if (!tagList) return; tagList.innerHTML = currentTags.map(tag => ` ${escapeHtml(tag)} `).join(''); // Add event listeners to remove buttons tagList.querySelectorAll('.tag-remove').forEach(btn => { btn.addEventListener('click', () => { const tagToRemove = btn.getAttribute('data-tag'); removeTag(tagToRemove); }); }); } /** * Add a tag to the editor */ function addTag(tag) { const trimmedTag = tag.trim(); if (!trimmedTag) return false; // Check if tag already exists (case-insensitive) if (currentTags.some(t => t.toLowerCase() === trimmedTag.toLowerCase())) { return false; } // Limit tag length if (trimmedTag.length > 30) { showToast('Tag is too long (max 30 characters)', 'error'); return false; } // Limit number of tags if (currentTags.length >= 10) { showToast('Maximum 10 tags allowed', 'error'); return false; } currentTags.push(trimmedTag); updateTagEditor(currentTags); return true; } /** * Remove a tag from the editor */ function removeTag(tag) { currentTags = currentTags.filter(t => t !== tag); updateTagEditor(currentTags); } /** * Get tags array from editor */ function getTagsFromEditor() { return [...currentTags]; } /** * Initialize tag editor */ function initTagEditor() { const tagsInput = document.getElementById('tags-input'); if (!tagsInput) return; // Handle Enter key to add tag tagsInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const tag = tagsInput.value.trim(); if (tag) { if (addTag(tag)) { tagsInput.value = ''; } else { showToast('Tag already exists', 'error'); } } } else if (e.key === 'Backspace' && tagsInput.value === '' && currentTags.length > 0) { // Remove last tag if input is empty and backspace is pressed removeTag(currentTags[currentTags.length - 1]); } }); // Handle paste to add multiple tags tagsInput.addEventListener('paste', (e) => { e.preventDefault(); const pastedText = (e.clipboardData || window.clipboardData).getData('text'); const tags = pastedText.split(/[,\n]/).map(t => t.trim()).filter(t => t); let added = 0; tags.forEach(tag => { if (addTag(tag)) { added++; } }); if (added > 0) { tagsInput.value = ''; if (added < tags.length) { showToast(`Added ${added} tag(s), some were duplicates`, 'success'); } } }); } /** * Delete profile */ async function deleteProfile() { // Confirm deletion const confirmed = confirm('Are you sure you want to delete your profile? This action cannot be undone. Your display name, avatar, and all profile information will be permanently removed.'); if (!confirmed) { return; } const deleteBtn = document.getElementById('delete-btn'); const originalText = deleteBtn.textContent; try { deleteBtn.disabled = true; deleteBtn.textContent = 'Deleting...'; const response = await authenticatedFetch(`${API_BASE}/profile`, { method: 'DELETE' }); if (!response.ok) { throw new Error('Failed to delete profile'); } const result = await response.json(); showToast('Profile deleted successfully', 'success'); // Reset form to default state currentProfile = null; document.getElementById('display-name').value = ''; document.getElementById('bio').value = ''; document.getElementById('peer-id').value = ''; // Reset additional fields populateField('email', null, false); populateField('website', null, false); populateField('xUsername', null, false); // Reset tags updateTagEditor([]); // Reset avatar document.getElementById('avatar-preview').style.display = 'none'; document.getElementById('avatar-placeholder').style.display = 'block'; // Profile deletion will be handled via WebSocket broadcast } catch (err) { console.error('Error deleting profile:', err); showToast('Failed to delete profile', 'error'); } finally { deleteBtn.disabled = false; deleteBtn.textContent = originalText; } } // Initialize app when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }