UNPKG

@oxyhq/services

Version:

Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀

1,232 lines (1,187 loc) • 86 kB
"use strict"; import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, ScrollView, TextInput, Animated, Platform, Image } from 'react-native'; import { useOxy } from '../context/OxyContext'; import OxyIcon from '../components/icon/OxyIcon'; import { Ionicons } from '@expo/vector-icons'; import { toast } from '../../lib/sonner'; import { fontFamilies } from '../styles/fonts'; import { confirmAction } from '../utils/confirmAction'; import { useAuthStore } from '../stores/authStore'; import { Header, GroupedSection } from '../components'; import { useI18n } from '../hooks/useI18n'; import QRCode from 'react-native-qrcode-svg'; import { TTLCache, registerCacheForCleanup } from '../../utils/cache'; // Caches for link metadata and location searches import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; const linkMetadataCache = new TTLCache(30 * 60 * 1000); // 30 minutes cache for link metadata const locationSearchCache = new TTLCache(60 * 60 * 1000); // 1 hour cache for location searches registerCacheForCleanup(linkMetadataCache); registerCacheForCleanup(locationSearchCache); const AccountSettingsScreen = ({ onClose, theme, goBack, navigate, initialField, initialSection }) => { const { user: userFromContext, oxyServices, isLoading: authLoading, isAuthenticated, showBottomSheet, activeSessionId } = useOxy(); const { t } = useI18n(); const updateUser = useAuthStore(state => state.updateUser); // Get user directly from store to ensure reactivity to avatar changes const user = useAuthStore(state => state.user) || userFromContext; const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isUpdatingAvatar, setIsUpdatingAvatar] = useState(false); const [optimisticAvatarId, setOptimisticAvatarId] = useState(null); const scrollViewRef = useRef(null); const avatarSectionRef = useRef(null); const [avatarSectionY, setAvatarSectionY] = useState(null); // Section refs for navigation const profilePictureSectionRef = useRef(null); const basicInfoSectionRef = useRef(null); const aboutSectionRef = useRef(null); const quickActionsSectionRef = useRef(null); const securitySectionRef = useRef(null); // Section Y positions for scrolling const [profilePictureSectionY, setProfilePictureSectionY] = useState(null); const [basicInfoSectionY, setBasicInfoSectionY] = useState(null); const [aboutSectionY, setAboutSectionY] = useState(null); const [quickActionsSectionY, setQuickActionsSectionY] = useState(null); const [securitySectionY, setSecuritySectionY] = useState(null); // Two-Factor (TOTP) state const [totpSetupUrl, setTotpSetupUrl] = useState(null); const [totpCode, setTotpCode] = useState(''); const [isTotpBusy, setIsTotpBusy] = useState(false); const [showRecoveryModal, setShowRecoveryModal] = useState(false); const [generatedBackupCodes, setGeneratedBackupCodes] = useState(null); const [generatedRecoveryKey, setGeneratedRecoveryKey] = useState(null); // Animation refs const saveButtonScale = useRef(new Animated.Value(1)).current; // Form state const [displayName, setDisplayName] = useState(''); const [lastName, setLastName] = useState(''); const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [bio, setBio] = useState(''); const [location, setLocation] = useState(''); const [links, setLinks] = useState([]); const [avatarFileId, setAvatarFileId] = useState(''); // Editing states const [editingField, setEditingField] = useState(null); const [tempDisplayName, setTempDisplayName] = useState(''); const [tempLastName, setTempLastName] = useState(''); const [tempUsername, setTempUsername] = useState(''); const [tempEmail, setTempEmail] = useState(''); const [tempBio, setTempBio] = useState(''); const [tempLocation, setTempLocation] = useState(''); const [tempLinks, setTempLinks] = useState([]); const [tempLinksWithMetadata, setTempLinksWithMetadata] = useState([]); const [isAddingLink, setIsAddingLink] = useState(false); const [newLinkUrl, setNewLinkUrl] = useState(''); const [isFetchingMetadata, setIsFetchingMetadata] = useState(false); // Location management state const [tempLocations, setTempLocations] = useState([]); const [isAddingLocation, setIsAddingLocation] = useState(false); const [newLocationQuery, setNewLocationQuery] = useState(''); const [locationSearchResults, setLocationSearchResults] = useState([]); const [isSearchingLocations, setIsSearchingLocations] = useState(false); // Memoize theme-related calculations to prevent unnecessary recalculations const themeStyles = useMemo(() => { const isDarkTheme = theme === 'dark'; return { isDarkTheme, backgroundColor: isDarkTheme ? '#121212' : '#f2f2f2', primaryColor: '#007AFF' }; }, [theme]); // Memoize animation function to prevent recreation on every render const animateSaveButton = useCallback((toValue, onComplete) => { Animated.spring(saveButtonScale, { toValue, useNativeDriver: Platform.OS !== 'web', tension: 150, friction: 8 }).start(onComplete ? finished => { if (finished) { onComplete(); } } : undefined); }, [saveButtonScale]); // Track initialization to prevent unnecessary resets const isInitializedRef = useRef(false); const previousUserIdRef = useRef(null); const previousAvatarRef = useRef(null); // Load user data - only reset fields when user actually changes (not just avatar) useEffect(() => { if (user) { const currentUserId = user.id; const currentAvatar = typeof user.avatar === 'string' ? user.avatar : ''; const isNewUser = previousUserIdRef.current !== currentUserId; const isAvatarOnlyUpdate = !isNewUser && previousUserIdRef.current === currentUserId && previousAvatarRef.current !== currentAvatar && previousAvatarRef.current !== null; const shouldInitialize = !isInitializedRef.current || isNewUser; // Only reset all fields if it's a new user or first load // Skip reset if it's just an avatar update if (shouldInitialize && !isAvatarOnlyUpdate) { const userDisplayName = typeof user.name === 'string' ? user.name : user.name?.first || user.name?.full || ''; const userLastName = typeof user.name === 'object' ? user.name?.last || '' : ''; setDisplayName(userDisplayName); setLastName(userLastName); setUsername(user.username || ''); setEmail(user.email || ''); setBio(user.bio || ''); setLocation(user.location || ''); // Handle locations - convert single location to array format if (user.locations && Array.isArray(user.locations)) { setTempLocations(user.locations.map((loc, index) => ({ id: loc.id || `existing-${index}`, name: loc.name, label: loc.label, coordinates: loc.coordinates }))); } else if (user.location) { // Convert single location string to array format setTempLocations([{ id: 'existing-0', name: user.location, label: 'Location' }]); } else { setTempLocations([]); } // Handle links - simple and direct like other fields if (user.linksMetadata && Array.isArray(user.linksMetadata)) { const urls = user.linksMetadata.map(l => l.url); setLinks(urls); const metadataWithIds = user.linksMetadata.map((link, index) => ({ ...link, id: link.id || `existing-${index}` })); setTempLinksWithMetadata(metadataWithIds); } else if (Array.isArray(user.links)) { const simpleLinks = user.links.map(l => typeof l === 'string' ? l : l.link).filter(Boolean); setLinks(simpleLinks); const linksWithMetadata = simpleLinks.map((url, index) => ({ url, title: url.replace(/^https?:\/\//, '').replace(/\/$/, ''), description: `Link to ${url}`, image: undefined, id: `existing-${index}` })); setTempLinksWithMetadata(linksWithMetadata); } else if (user.website) { setLinks([user.website]); setTempLinksWithMetadata([{ url: user.website, title: user.website.replace(/^https?:\/\//, '').replace(/\/$/, ''), description: `Link to ${user.website}`, image: undefined, id: 'existing-0' }]); } else { setLinks([]); setTempLinksWithMetadata([]); } isInitializedRef.current = true; } // Update avatar only if it changed and we're not in optimistic/updating state // This allows the server response to update the avatar without resetting other fields // But don't override if we have a pending optimistic update if (currentAvatar !== avatarFileId && !isUpdatingAvatar && !optimisticAvatarId) { setAvatarFileId(currentAvatar); } // If we just finished updating and the server avatar matches our optimistic one, clear optimistic state // Also clear if the server avatar matches our current avatarFileId (update completed) if (isUpdatingAvatar === false && optimisticAvatarId) { if (currentAvatar === optimisticAvatarId || currentAvatar === avatarFileId) { setOptimisticAvatarId(null); } } previousUserIdRef.current = currentUserId; previousAvatarRef.current = currentAvatar; } }, [user, avatarFileId, isUpdatingAvatar, optimisticAvatarId]); // Set initial editing field if provided via props (e.g., from navigation) // Use a ref to track if we've already set the initial field to avoid loops const hasSetInitialFieldRef = useRef(false); const previousInitialFieldRef = useRef(undefined); const initialFieldTimeoutRef = useRef(null); // Delay constant for scroll completion const SCROLL_DELAY_MS = 600; // Helper to get current value for a field const getFieldCurrentValue = useCallback(field => { switch (field) { case 'displayName': return displayName; case 'username': return username; case 'email': return email; case 'bio': return bio; case 'location': case 'links': case 'twoFactor': return ''; default: return ''; } }, [displayName, username, email, bio]); // Handle initialSection prop to scroll to specific section const hasScrolledToSectionRef = useRef(false); const previousInitialSectionRef = useRef(undefined); const SCROLL_OFFSET = 100; // Offset to show section near top of viewport // Map section names to their Y positions const sectionYPositions = useMemo(() => ({ profilePicture: profilePictureSectionY, basicInfo: basicInfoSectionY, about: aboutSectionY, quickActions: quickActionsSectionY, security: securitySectionY }), [profilePictureSectionY, basicInfoSectionY, aboutSectionY, quickActionsSectionY, securitySectionY]); useEffect(() => { // If initialSection changed, reset the flag if (previousInitialSectionRef.current !== initialSection) { hasScrolledToSectionRef.current = false; previousInitialSectionRef.current = initialSection; } // Scroll to the specified section if initialSection is provided and we haven't scrolled yet if (initialSection && !hasScrolledToSectionRef.current) { const sectionY = sectionYPositions[initialSection]; if (sectionY !== null && sectionY !== undefined && scrollViewRef.current) { requestAnimationFrame(() => { requestAnimationFrame(() => { scrollViewRef.current?.scrollTo({ y: Math.max(0, sectionY - SCROLL_OFFSET), animated: true }); hasScrolledToSectionRef.current = true; }); }); } } }, [initialSection, sectionYPositions]); const handleSave = async () => { if (!user) return; try { setIsSaving(true); animateSaveButton(0.95); // Scale down slightly for animation const updates = { username, email, bio, location: tempLocations.length > 0 ? tempLocations[0].name : '', // Keep backward compatibility locations: tempLocations.length > 0 ? tempLocations : undefined, links, linksMetadata: tempLinksWithMetadata.length > 0 ? tempLinksWithMetadata : undefined }; // Handle name field if (displayName || lastName) { updates.name = { first: displayName, last: lastName }; } // Handle avatar if (avatarFileId !== (typeof user.avatar === 'string' ? user.avatar : '')) { updates.avatar = avatarFileId; } await updateUser(updates, oxyServices); toast.success(t('editProfile.toasts.profileUpdated') || 'Profile updated successfully'); animateSaveButton(1); // Scale back to normal if (onClose) { onClose(); } else if (goBack) { goBack(); } } catch (error) { toast.error(error.message || t('editProfile.toasts.updateFailed') || 'Failed to update profile'); animateSaveButton(1); // Scale back to normal on error } finally { setIsSaving(false); } }; const handleAvatarRemove = () => { confirmAction(t('editProfile.confirms.removeAvatar') || 'Remove your profile picture?', () => { setAvatarFileId(''); toast.success(t('editProfile.toasts.avatarRemoved') || 'Avatar removed'); }); }; const openAvatarPicker = useCallback(() => { showBottomSheet?.({ screen: 'FileManagement', props: { selectMode: true, multiSelect: false, afterSelect: 'back', onSelect: async file => { if (!file.contentType.startsWith('image/')) { toast.error(t('editProfile.toasts.selectImage') || 'Please select an image file'); return; } // If already selected, do nothing if (file.id === avatarFileId) { toast.info?.(t('editProfile.toasts.avatarUnchanged') || 'Avatar unchanged'); return; } // Optimistically update UI immediately setOptimisticAvatarId(file.id); setAvatarFileId(file.id); // Auto-save avatar immediately (does not close edit profile screen) (async () => { try { setIsUpdatingAvatar(true); // Update file visibility to public for avatar try { await oxyServices.assetUpdateVisibility(file.id, 'public'); } catch (visError) { // Continue with avatar update even if visibility update fails } // Update on server directly without using updateUser (which triggers fetchUser) // This prevents the entire component from re-rendering await oxyServices.updateProfile({ avatar: file.id }); // Update the user object in store directly without triggering fetchUser // This prevents isLoading from being set to true, which would show loading screen const currentUser = useAuthStore.getState().user; if (currentUser) { useAuthStore.setState({ user: { ...currentUser, avatar: file.id } // Don't update lastUserFetch to avoid cache issues }); } // Update local state - keep avatarFileId set to the new value // Don't clear optimisticAvatarId yet - let it persist until user object updates // This ensures the avatar displays correctly setAvatarFileId(file.id); toast.success(t('editProfile.toasts.avatarUpdated') || 'Avatar updated'); // Scroll to avatar section after a brief delay to ensure UI is updated requestAnimationFrame(() => { requestAnimationFrame(() => { if (avatarSectionY !== null) { scrollViewRef.current?.scrollTo({ y: Math.max(0, avatarSectionY - 100), // Offset to show section near top animated: true }); } else { // Fallback: scroll to approximate position scrollViewRef.current?.scrollTo({ y: 200, // Approximate position of avatar section animated: true }); } }); }); } catch (e) { // Revert optimistic update on error setAvatarFileId(typeof user?.avatar === 'string' ? user.avatar : ''); setOptimisticAvatarId(null); toast.error(e.message || t('editProfile.toasts.updateAvatarFailed') || 'Failed to update avatar'); } finally { setIsUpdatingAvatar(false); } })(); }, // Limit to images client-side by using photos view if later exposed disabledMimeTypes: ['video/', 'audio/', 'application/pdf'] } }); }, [showBottomSheet, oxyServices, avatarFileId, updateUser, user]); const startEditing = useCallback((type, currentValue) => { switch (type) { case 'displayName': setTempDisplayName(displayName); setTempLastName(lastName); break; case 'username': setTempUsername(currentValue); break; case 'email': setTempEmail(currentValue); break; case 'bio': setTempBio(currentValue); break; case 'location': // Don't reset the locations - keep the existing data break; case 'links': // Don't reset the metadata - keep the existing rich metadata // The tempLinksWithMetadata should already contain the rich data from the database break; case 'twoFactor': // Reset TOTP temp state setTotpSetupUrl(null); setTotpCode(''); break; } setEditingField(type); }, [displayName, lastName]); // Handle initialField prop - must be after startEditing and openAvatarPicker are declared useEffect(() => { // Clear any pending timeout if (initialFieldTimeoutRef.current) { clearTimeout(initialFieldTimeoutRef.current); initialFieldTimeoutRef.current = null; } // If initialField changed, reset the flag if (previousInitialFieldRef.current !== initialField) { hasSetInitialFieldRef.current = false; previousInitialFieldRef.current = initialField; } // Set the editing field if initialField is provided and we haven't set it yet if (initialField && !hasSetInitialFieldRef.current) { // Special handling for avatar - open avatar picker directly if (initialField === 'avatar') { // Wait for section to be scrolled, then open picker initialFieldTimeoutRef.current = setTimeout(() => { openAvatarPicker(); hasSetInitialFieldRef.current = true; }, SCROLL_DELAY_MS); } else { // For other fields, get current value and start editing after scroll const currentValue = getFieldCurrentValue(initialField); // Wait for section to be scrolled, then start editing initialFieldTimeoutRef.current = setTimeout(() => { startEditing(initialField, currentValue); hasSetInitialFieldRef.current = true; }, SCROLL_DELAY_MS); } } return () => { if (initialFieldTimeoutRef.current) { clearTimeout(initialFieldTimeoutRef.current); initialFieldTimeoutRef.current = null; } }; }, [initialField, getFieldCurrentValue, startEditing, openAvatarPicker]); const saveField = type => { animateSaveButton(0.95); // Scale down slightly for animation switch (type) { case 'displayName': setDisplayName(tempDisplayName); setLastName(tempLastName); break; case 'username': setUsername(tempUsername); break; case 'email': setEmail(tempEmail); break; case 'bio': setBio(tempBio); break; case 'location': // Locations are handled in the main save function break; case 'links': // Save both URLs and metadata setLinks(tempLinksWithMetadata.map(link => link.url)); // Store full metadata for database setTempLinksWithMetadata(tempLinksWithMetadata); break; } // Complete animation, then reset and close editing animateSaveButton(1, () => { setEditingField(null); }); }; const cancelEditing = () => { setEditingField(null); }; const fetchLinkMetadata = async url => { // Check cache first const cacheKey = url.toLowerCase().trim(); const cached = linkMetadataCache.get(cacheKey); if (cached) { return cached; } try { setIsFetchingMetadata(true); // Use the backend API to fetch metadata const metadata = await oxyServices.fetchLinkMetadata(url); const result = { ...metadata, id: Date.now().toString() }; // Cache the result linkMetadataCache.set(cacheKey, result); return result; } catch (error) { // Fallback to basic metadata const fallback = { url: url.startsWith('http') ? url : 'https://' + url, title: url.replace(/^https?:\/\//, '').replace(/\/$/, ''), description: 'Link', image: undefined, id: Date.now().toString() }; // Cache fallback too (shorter TTL) linkMetadataCache.set(cacheKey, fallback, 5 * 60 * 1000); // 5 minutes for fallbacks return fallback; } finally { setIsFetchingMetadata(false); } }; const searchLocations = async query => { if (!query.trim() || query.length < 3) { setLocationSearchResults([]); return; } // Check cache first const cacheKey = query.toLowerCase().trim(); const cached = locationSearchCache.get(cacheKey); if (cached) { setLocationSearchResults(cached); return; } try { setIsSearchingLocations(true); const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&addressdetails=1`); const data = await response.json(); // Cache the results locationSearchCache.set(cacheKey, data); setLocationSearchResults(data); } catch (error) { setLocationSearchResults([]); } finally { setIsSearchingLocations(false); } }; const addLocation = locationData => { const newLocation = { id: Date.now().toString(), name: locationData.display_name, label: locationData.type === 'city' ? 'City' : locationData.type === 'country' ? 'Country' : locationData.type === 'state' ? 'State' : 'Location', coordinates: { lat: Number.parseFloat(locationData.lat), lon: Number.parseFloat(locationData.lon) } }; setTempLocations(prev => [...prev, newLocation]); setNewLocationQuery(''); setLocationSearchResults([]); setIsAddingLocation(false); }; const removeLocation = id => { setTempLocations(prev => prev.filter(loc => loc.id !== id)); }; const moveLocation = (fromIndex, toIndex) => { setTempLocations(prev => { const newLocations = [...prev]; const [movedLocation] = newLocations.splice(fromIndex, 1); newLocations.splice(toIndex, 0, movedLocation); return newLocations; }); }; const addLink = async () => { if (!newLinkUrl.trim()) return; const url = newLinkUrl.trim(); const metadata = await fetchLinkMetadata(url); setTempLinksWithMetadata(prev => [...prev, metadata]); setNewLinkUrl(''); setIsAddingLink(false); }; const removeLink = id => { setTempLinksWithMetadata(prev => prev.filter(link => link.id !== id)); }; const moveLink = (fromIndex, toIndex) => { setTempLinksWithMetadata(prev => { const newLinks = [...prev]; const [movedLink] = newLinks.splice(fromIndex, 1); newLinks.splice(toIndex, 0, movedLink); return newLinks; }); }; const renderEditingField = type => { if (type === 'twoFactor') { const enabled = !!user?.privacySettings?.twoFactorEnabled; return /*#__PURE__*/_jsx(View, { style: [styles.editingFieldContainer, { backgroundColor: themeStyles.backgroundColor }], children: /*#__PURE__*/_jsx(View, { style: styles.editingFieldContent, children: /*#__PURE__*/_jsxs(View, { style: styles.newValueSection, children: [/*#__PURE__*/_jsx(View, { style: styles.editingFieldHeader, children: /*#__PURE__*/_jsx(Text, { style: [styles.editingFieldLabel, { color: themeStyles.isDarkTheme ? '#FFFFFF' : '#1A1A1A' }], children: "Two\u2011Factor Authentication (TOTP)" }) }), !enabled ? /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(Text, { style: styles.editingFieldDescription, children: "Protect your account with a 6\u2011digit code from an authenticator app. Scan the QR code then enter the code to enable." }), !totpSetupUrl ? /*#__PURE__*/_jsxs(TouchableOpacity, { style: styles.primaryButton, disabled: isTotpBusy, onPress: async () => { if (!activeSessionId) { toast.error(t('editProfile.toasts.noActiveSession') || 'No active session'); return; } setIsTotpBusy(true); try { const { otpauthUrl } = await oxyServices.startTotpEnrollment(activeSessionId); setTotpSetupUrl(otpauthUrl); } catch (e) { toast.error(e?.message || t('editProfile.toasts.totpStartFailed') || 'Failed to start TOTP enrollment'); } finally { setIsTotpBusy(false); } }, children: [/*#__PURE__*/_jsx(Ionicons, { name: "shield-checkmark", size: 18, color: "#fff" }), /*#__PURE__*/_jsx(Text, { style: styles.primaryButtonText, children: "Generate QR Code" })] }) : /*#__PURE__*/_jsxs(View, { style: { alignItems: 'center', gap: 16 }, children: [/*#__PURE__*/_jsx(View, { style: { padding: 16, backgroundColor: '#fff', borderRadius: 16 }, children: /*#__PURE__*/_jsx(QRCode, { value: totpSetupUrl, size: 180 }) }), /*#__PURE__*/_jsxs(View, { children: [/*#__PURE__*/_jsx(Text, { style: styles.editingFieldLabel, children: "Enter 6\u2011digit code" }), /*#__PURE__*/_jsx(TextInput, { style: styles.editingFieldInput, keyboardType: "number-pad", placeholder: "123456", value: totpCode, onChangeText: setTotpCode, maxLength: 6 })] }), /*#__PURE__*/_jsxs(TouchableOpacity, { style: styles.primaryButton, disabled: isTotpBusy || totpCode.length !== 6, onPress: async () => { if (!activeSessionId) { toast.error(t('editProfile.toasts.noActiveSession') || 'No active session'); return; } setIsTotpBusy(true); try { const result = await oxyServices.verifyTotpEnrollment(activeSessionId, totpCode); await updateUser({ privacySettings: { twoFactorEnabled: true } }, oxyServices); if (result?.backupCodes || result?.recoveryKey) { setGeneratedBackupCodes(result.backupCodes || null); setGeneratedRecoveryKey(result.recoveryKey || null); setShowRecoveryModal(true); } else { toast.success(t('editProfile.toasts.twoFactorEnabled') || 'Two‑Factor Authentication enabled'); setEditingField(null); } } catch (e) { toast.error(e?.message || t('editProfile.toasts.invalidCode') || 'Invalid code'); } finally { setIsTotpBusy(false); } }, children: [/*#__PURE__*/_jsx(Ionicons, { name: "checkmark-circle", size: 18, color: "#fff" }), /*#__PURE__*/_jsx(Text, { style: styles.primaryButtonText, children: "Verify & Enable" })] })] })] }) : /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(Text, { style: styles.editingFieldDescription, children: "Two\u2011Factor Authentication is currently enabled. To disable, enter a code from your authenticator app." }), /*#__PURE__*/_jsxs(View, { children: [/*#__PURE__*/_jsx(Text, { style: styles.editingFieldLabel, children: "Enter 6\u2011digit code" }), /*#__PURE__*/_jsx(TextInput, { style: styles.editingFieldInput, keyboardType: "number-pad", placeholder: "123456", value: totpCode, onChangeText: setTotpCode, maxLength: 6 })] }), /*#__PURE__*/_jsxs(TouchableOpacity, { style: [styles.primaryButton, { backgroundColor: '#d9534f' }], disabled: isTotpBusy || totpCode.length !== 6, onPress: async () => { if (!activeSessionId) { toast.error(t('editProfile.toasts.noActiveSession') || 'No active session'); return; } setIsTotpBusy(true); try { await oxyServices.disableTotp(activeSessionId, totpCode); await updateUser({ privacySettings: { twoFactorEnabled: false } }, oxyServices); toast.success(t('editProfile.toasts.twoFactorDisabled') || 'Two‑Factor Authentication disabled'); setEditingField(null); } catch (e) { toast.error(e?.message || t('editProfile.toasts.disableFailed') || 'Failed to disable'); } finally { setIsTotpBusy(false); } }, children: [/*#__PURE__*/_jsx(Ionicons, { name: "close-circle", size: 18, color: "#fff" }), /*#__PURE__*/_jsx(Text, { style: styles.primaryButtonText, children: "Disable 2FA" })] })] })] }) }) }); } if (type === 'displayName') { return /*#__PURE__*/_jsx(View, { style: [styles.editingFieldContainer, { backgroundColor: themeStyles.backgroundColor }], children: /*#__PURE__*/_jsx(View, { style: styles.editingFieldContent, children: /*#__PURE__*/_jsxs(View, { style: styles.newValueSection, children: [/*#__PURE__*/_jsx(View, { style: styles.editingFieldHeader, children: /*#__PURE__*/_jsx(Text, { style: [styles.editingFieldLabel, { color: themeStyles.isDarkTheme ? '#FFFFFF' : '#1A1A1A' }], children: "Edit Full Name" }) }), /*#__PURE__*/_jsxs(View, { style: { flexDirection: 'row', gap: 12 }, children: [/*#__PURE__*/_jsxs(View, { style: { flex: 1 }, children: [/*#__PURE__*/_jsx(Text, { style: styles.editingFieldLabel, children: "First Name" }), /*#__PURE__*/_jsx(TextInput, { style: styles.editingFieldInput, value: tempDisplayName, onChangeText: setTempDisplayName, placeholder: "Enter your first name", placeholderTextColor: themeStyles.isDarkTheme ? '#aaa' : '#999', autoFocus: true, selectionColor: themeStyles.primaryColor })] }), /*#__PURE__*/_jsxs(View, { style: { flex: 1 }, children: [/*#__PURE__*/_jsx(Text, { style: styles.editingFieldLabel, children: "Last Name" }), /*#__PURE__*/_jsx(TextInput, { style: styles.editingFieldInput, value: tempLastName, onChangeText: setTempLastName, placeholder: "Enter your last name", placeholderTextColor: themeStyles.isDarkTheme ? '#aaa' : '#999', selectionColor: themeStyles.primaryColor })] })] })] }) }) }); } if (type === 'location') { return /*#__PURE__*/_jsx(View, { style: [styles.editingFieldContainer, { backgroundColor: themeStyles.backgroundColor }], children: /*#__PURE__*/_jsx(View, { style: styles.editingFieldContent, children: /*#__PURE__*/_jsxs(View, { style: styles.newValueSection, children: [/*#__PURE__*/_jsx(View, { style: styles.editingFieldHeader, children: /*#__PURE__*/_jsx(Text, { style: [styles.editingFieldLabel, { color: themeStyles.isDarkTheme ? '#FFFFFF' : '#1A1A1A' }], children: "Manage Your Locations" }) }), isAddingLocation ? /*#__PURE__*/_jsxs(View, { style: styles.addLocationSection, children: [/*#__PURE__*/_jsxs(Text, { style: styles.addLocationLabel, children: ["Add New Location", isSearchingLocations && /*#__PURE__*/_jsx(Text, { style: styles.searchingText, children: " \u2022 Searching..." })] }), /*#__PURE__*/_jsxs(View, { style: styles.addLocationInputContainer, children: [/*#__PURE__*/_jsx(TextInput, { style: styles.addLocationInput, value: newLocationQuery, onChangeText: text => { setNewLocationQuery(text); searchLocations(text); }, placeholder: "Search for a location...", placeholderTextColor: themeStyles.isDarkTheme ? '#aaa' : '#999', autoFocus: true, selectionColor: themeStyles.primaryColor }), /*#__PURE__*/_jsx(View, { style: styles.addLocationButtons, children: /*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.addLocationButton, styles.cancelButton], onPress: () => { setIsAddingLocation(false); setNewLocationQuery(''); setLocationSearchResults([]); }, children: /*#__PURE__*/_jsx(Text, { style: styles.cancelButtonText, children: "Cancel" }) }) })] }), locationSearchResults.length > 0 && /*#__PURE__*/_jsx(View, { style: styles.searchResults, children: locationSearchResults.map(result => /*#__PURE__*/_jsxs(TouchableOpacity, { style: styles.searchResultItem, onPress: () => addLocation(result), children: [/*#__PURE__*/_jsx(Text, { style: styles.searchResultName, numberOfLines: 2, children: result.display_name }), /*#__PURE__*/_jsx(Text, { style: styles.searchResultType, children: result.type })] }, result.place_id)) })] }) : /*#__PURE__*/_jsxs(TouchableOpacity, { style: styles.addLocationTrigger, onPress: () => setIsAddingLocation(true), children: [/*#__PURE__*/_jsx(OxyIcon, { name: "add", size: 20, color: themeStyles.primaryColor }), /*#__PURE__*/_jsx(Text, { style: styles.addLocationTriggerText, children: "Add a new location" })] }), tempLocations.length > 0 && /*#__PURE__*/_jsxs(View, { style: styles.locationsList, children: [/*#__PURE__*/_jsxs(Text, { style: styles.locationsListTitle, children: ["Your Locations (", tempLocations.length, ")"] }), tempLocations.map((location, index) => /*#__PURE__*/_jsxs(View, { style: styles.locationItem, children: [/*#__PURE__*/_jsxs(View, { style: styles.locationItemContent, children: [/*#__PURE__*/_jsx(View, { style: styles.locationItemDragHandle, children: /*#__PURE__*/_jsxs(View, { style: styles.reorderButtons, children: [/*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.reorderButton, index === 0 && styles.reorderButtonDisabled], onPress: () => index > 0 && moveLocation(index, index - 1), disabled: index === 0, children: /*#__PURE__*/_jsx(OxyIcon, { name: "chevron-up", size: 12, color: index === 0 ? "#ccc" : "#666" }) }), /*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.reorderButton, index === tempLocations.length - 1 && styles.reorderButtonDisabled], onPress: () => index < tempLocations.length - 1 && moveLocation(index, index + 1), disabled: index === tempLocations.length - 1, children: /*#__PURE__*/_jsx(OxyIcon, { name: "chevron-down", size: 12, color: index === tempLocations.length - 1 ? "#ccc" : "#666" }) })] }) }), /*#__PURE__*/_jsxs(View, { style: styles.locationItemInfo, children: [/*#__PURE__*/_jsxs(View, { style: styles.locationItemHeader, children: [/*#__PURE__*/_jsx(Text, { style: styles.locationItemName, numberOfLines: 1, children: location.name }), location.label && /*#__PURE__*/_jsx(View, { style: styles.locationLabel, children: /*#__PURE__*/_jsx(Text, { style: styles.locationLabelText, children: location.label }) })] }), location.coordinates && /*#__PURE__*/_jsxs(Text, { style: styles.locationCoordinates, children: [location.coordinates.lat.toFixed(4), ", ", location.coordinates.lon.toFixed(4)] })] }), /*#__PURE__*/_jsx(View, { style: styles.locationItemActions, children: /*#__PURE__*/_jsx(TouchableOpacity, { style: styles.locationItemButton, onPress: () => removeLocation(location.id), children: /*#__PURE__*/_jsx(OxyIcon, { name: "trash", size: 14, color: "#FF3B30" }) }) })] }), index < tempLocations.length - 1 && /*#__PURE__*/_jsx(View, { style: styles.locationItemDivider })] }, location.id)), /*#__PURE__*/_jsx(View, { style: styles.reorderHint, children: /*#__PURE__*/_jsx(Text, { style: styles.reorderHintText, children: "Use \u2191\u2193 buttons to reorder your locations" }) })] })] }) }) }); } if (type === 'links') { return /*#__PURE__*/_jsx(View, { style: [styles.editingFieldContainer, { backgroundColor: themeStyles.backgroundColor }], children: /*#__PURE__*/_jsx(View, { style: styles.editingFieldContent, children: /*#__PURE__*/_jsxs(View, { style: styles.newValueSection, children: [/*#__PURE__*/_jsx(View, { style: styles.editingFieldHeader, children: /*#__PURE__*/_jsx(Text, { style: [styles.editingFieldLabel, { color: themeStyles.isDarkTheme ? '#FFFFFF' : '#1A1A1A' }], children: "Manage Your Links" }) }), /*#__PURE__*/_jsx(GroupedSection, { items: [ // Add new link item ...(isAddingLink ? [{ id: 'add-link-input', icon: 'add', iconColor: '#32D74B', title: 'Add New Link', subtitle: isFetchingMetadata ? 'Fetching metadata...' : 'Enter URL to add a new link', multiRow: true, customContent: /*#__PURE__*/_jsxs(View, { style: styles.addLinkInputContainer, children: [/*#__PURE__*/_jsx(TextInput, { style: styles.addLinkInput, value: newLinkUrl, onChangeText: setNewLinkUrl, placeholder: "Enter URL (e.g., https://example.com)", placeholderTextColor: themeStyles.isDarkTheme ? '#aaa' : '#999', keyboardType: "url", autoFocus: true, selectionColor: themeStyles.primaryColor }), /*#__PURE__*/_jsxs(View, { style: styles.addLinkButtons, children: [/*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.addLinkButton, styles.cancelButton], onPress: () => { setIsAddingLink(false); setNewLinkUrl(''); }, children: /*#__PURE__*/_jsx(Text, { style: styles.cancelButtonText, children: "Cancel" }) }), /*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.addLinkButton, styles.addButton, { opacity: isFetchingMetadata ? 0.5 : 1 }], onPress: addLink, disabled: isFetchingMetadata, children: isFetchingMetadata ? /*#__PURE__*/_jsx(ActivityIndicator, { size: "small", color: "#fff" }) : /*#__PURE__*/_jsx(Text, { style: styles.addButtonText, children: "Add" }) })] })] }) }] : [{ id: 'add-link-trigger', icon: 'add', iconColor: '#32D74B', title: 'Add a new link', subtitle: 'Tap to add a new link to your profile', onPress: () => setIsAddingLink(true) }]), // Existing links ...tempLinksWithMetadata.map((link, index) => ({ id: link.id, image: link.image || undefined, imageSize: 32, icon: link.image ? undefined : 'link', iconColor: '#32D74B', title: link.title || link.url, subtitle: link.description && link.description !== link.title ? link.description : link.url, multiRow: true, customContent: /*#__PURE__*/_jsxs(View, { style: styles.linkItemActions, children: [/*#__PURE__*/_jsxs(View, { style: styles.reorderButtons, children: [/*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.reorderButton, index === 0 && styles.reorderButtonDisabled], onPress: () => index > 0 && moveLink(index, index - 1), disabled: index === 0, children: /*#__PURE__*/_jsx(OxyIcon, { name: "chevron-up", size: 12, color: index === 0 ? "#ccc" : "#666" }) }), /*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.reorderButton, index === tempLinksWithMetadata.length - 1 && styles.reorderButtonDisabled], onPress: () => index < tempLinksWithMetadata.length - 1 && moveLink(index, index + 1), disabled: index === tempLinksWithMetadata.length - 1, children: /*#__PURE__*/_jsx(OxyIcon, { name: "chevron-down", size: 12, color: index === tempLinksWithMetadata.length - 1 ? "#ccc" : "#666" }) })] }), /*#__PURE__*/_jsx(TouchableOpacity, { style: styles.linkItemButton, onPress: () => removeLink(link.id), children: /*#__PURE__*/_jsx(OxyIcon, { name: "trash", size: 14, color: "#FF3B30" }) })] }) }))], theme: theme }), tempLinksWithMetadata.length > 0 && /*#__PURE__*/_jsx(View, { style: styles.reorderHint, children: /*#__PURE__*/_jsx(Text, { style: styles.reorderHintText, children: "Use \u2191\u2193 buttons to reorder your links" }) })] }) }) }); } const fieldConfig = { displayName: { label: 'Display Name', value: displayName, placeholder: 'Enter your display name', icon: 'person', color: '#007AFF', multiline: false, keyboardType: 'default' }, username: { label: 'Username', value: username, placeholder: 'Choose a username', icon: 'at', color: '#5856D6', multiline: false, keyboardType: 'default' }, email: { label: 'Email', value: email, placeholder: 'Enter your email address', icon: 'mail', color: '#FF9500', multiline: false, keyboardType: 'email-address' }, bio: { label: 'Bio', value: bio, placeholder: 'Tell people about yourself...', icon: 'document-text', color: '#34C759', multiline: true, keyboardType: 'default' }, location: { label: 'Location', value: location, placeholder: 'Enter your location', icon: 'location', color: '#FF3B30', multiline: false, keyboardType: 'default' }, links: { label: 'Links', value: links.join(', '), placeholder: 'Enter your links (comma separated)', icon: 'link', color: '#32D74B', multiline: false, keyboardType: '