@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
JavaScript
"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: '