@oxyhq/services
Version:
Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀
1,116 lines (1,071 loc) • 93.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _react = _interopRequireWildcard(require("react"));
var _reactNative = require("react-native");
var _OxyContext = require("../context/OxyContext");
var _OxyIcon = _interopRequireDefault(require("../components/icon/OxyIcon"));
var _vectorIcons = require("@expo/vector-icons");
var _sonner = require("../../lib/sonner");
var _fonts = require("../styles/fonts");
var _confirmAction = require("../utils/confirmAction");
var _authStore = require("../stores/authStore");
var _components = require("../components");
var _useI18n = require("../hooks/useI18n");
var _reactNativeQrcodeSvg = _interopRequireDefault(require("react-native-qrcode-svg"));
var _cache = require("../../utils/cache");
var _jsxRuntime = require("react/jsx-runtime");
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
// Caches for link metadata and location searches
const linkMetadataCache = new _cache.TTLCache(30 * 60 * 1000); // 30 minutes cache for link metadata
const locationSearchCache = new _cache.TTLCache(60 * 60 * 1000); // 1 hour cache for location searches
(0, _cache.registerCacheForCleanup)(linkMetadataCache);
(0, _cache.registerCacheForCleanup)(locationSearchCache);
const AccountSettingsScreen = ({
onClose,
theme,
goBack,
navigate,
initialField,
initialSection
}) => {
const {
user: userFromContext,
oxyServices,
isLoading: authLoading,
isAuthenticated,
showBottomSheet,
activeSessionId
} = (0, _OxyContext.useOxy)();
const {
t
} = (0, _useI18n.useI18n)();
const updateUser = (0, _authStore.useAuthStore)(state => state.updateUser);
// Get user directly from store to ensure reactivity to avatar changes
const user = (0, _authStore.useAuthStore)(state => state.user) || userFromContext;
const [isLoading, setIsLoading] = (0, _react.useState)(false);
const [isSaving, setIsSaving] = (0, _react.useState)(false);
const [isUpdatingAvatar, setIsUpdatingAvatar] = (0, _react.useState)(false);
const [optimisticAvatarId, setOptimisticAvatarId] = (0, _react.useState)(null);
const scrollViewRef = (0, _react.useRef)(null);
const avatarSectionRef = (0, _react.useRef)(null);
const [avatarSectionY, setAvatarSectionY] = (0, _react.useState)(null);
// Section refs for navigation
const profilePictureSectionRef = (0, _react.useRef)(null);
const basicInfoSectionRef = (0, _react.useRef)(null);
const aboutSectionRef = (0, _react.useRef)(null);
const quickActionsSectionRef = (0, _react.useRef)(null);
const securitySectionRef = (0, _react.useRef)(null);
// Section Y positions for scrolling
const [profilePictureSectionY, setProfilePictureSectionY] = (0, _react.useState)(null);
const [basicInfoSectionY, setBasicInfoSectionY] = (0, _react.useState)(null);
const [aboutSectionY, setAboutSectionY] = (0, _react.useState)(null);
const [quickActionsSectionY, setQuickActionsSectionY] = (0, _react.useState)(null);
const [securitySectionY, setSecuritySectionY] = (0, _react.useState)(null);
// Two-Factor (TOTP) state
const [totpSetupUrl, setTotpSetupUrl] = (0, _react.useState)(null);
const [totpCode, setTotpCode] = (0, _react.useState)('');
const [isTotpBusy, setIsTotpBusy] = (0, _react.useState)(false);
const [showRecoveryModal, setShowRecoveryModal] = (0, _react.useState)(false);
const [generatedBackupCodes, setGeneratedBackupCodes] = (0, _react.useState)(null);
const [generatedRecoveryKey, setGeneratedRecoveryKey] = (0, _react.useState)(null);
// Animation refs
const saveButtonScale = (0, _react.useRef)(new _reactNative.Animated.Value(1)).current;
// Form state
const [displayName, setDisplayName] = (0, _react.useState)('');
const [lastName, setLastName] = (0, _react.useState)('');
const [username, setUsername] = (0, _react.useState)('');
const [email, setEmail] = (0, _react.useState)('');
const [bio, setBio] = (0, _react.useState)('');
const [location, setLocation] = (0, _react.useState)('');
const [links, setLinks] = (0, _react.useState)([]);
const [avatarFileId, setAvatarFileId] = (0, _react.useState)('');
// Editing states
const [editingField, setEditingField] = (0, _react.useState)(null);
const [tempDisplayName, setTempDisplayName] = (0, _react.useState)('');
const [tempLastName, setTempLastName] = (0, _react.useState)('');
const [tempUsername, setTempUsername] = (0, _react.useState)('');
const [tempEmail, setTempEmail] = (0, _react.useState)('');
const [tempBio, setTempBio] = (0, _react.useState)('');
const [tempLocation, setTempLocation] = (0, _react.useState)('');
const [tempLinks, setTempLinks] = (0, _react.useState)([]);
const [tempLinksWithMetadata, setTempLinksWithMetadata] = (0, _react.useState)([]);
const [isAddingLink, setIsAddingLink] = (0, _react.useState)(false);
const [newLinkUrl, setNewLinkUrl] = (0, _react.useState)('');
const [isFetchingMetadata, setIsFetchingMetadata] = (0, _react.useState)(false);
// Location management state
const [tempLocations, setTempLocations] = (0, _react.useState)([]);
const [isAddingLocation, setIsAddingLocation] = (0, _react.useState)(false);
const [newLocationQuery, setNewLocationQuery] = (0, _react.useState)('');
const [locationSearchResults, setLocationSearchResults] = (0, _react.useState)([]);
const [isSearchingLocations, setIsSearchingLocations] = (0, _react.useState)(false);
// Memoize theme-related calculations to prevent unnecessary recalculations
const themeStyles = (0, _react.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 = (0, _react.useCallback)((toValue, onComplete) => {
_reactNative.Animated.spring(saveButtonScale, {
toValue,
useNativeDriver: _reactNative.Platform.OS !== 'web',
tension: 150,
friction: 8
}).start(onComplete ? finished => {
if (finished) {
onComplete();
}
} : undefined);
}, [saveButtonScale]);
// Track initialization to prevent unnecessary resets
const isInitializedRef = (0, _react.useRef)(false);
const previousUserIdRef = (0, _react.useRef)(null);
const previousAvatarRef = (0, _react.useRef)(null);
// Load user data - only reset fields when user actually changes (not just avatar)
(0, _react.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 = (0, _react.useRef)(false);
const previousInitialFieldRef = (0, _react.useRef)(undefined);
const initialFieldTimeoutRef = (0, _react.useRef)(null);
// Delay constant for scroll completion
const SCROLL_DELAY_MS = 600;
// Helper to get current value for a field
const getFieldCurrentValue = (0, _react.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 = (0, _react.useRef)(false);
const previousInitialSectionRef = (0, _react.useRef)(undefined);
const SCROLL_OFFSET = 100; // Offset to show section near top of viewport
// Map section names to their Y positions
const sectionYPositions = (0, _react.useMemo)(() => ({
profilePicture: profilePictureSectionY,
basicInfo: basicInfoSectionY,
about: aboutSectionY,
quickActions: quickActionsSectionY,
security: securitySectionY
}), [profilePictureSectionY, basicInfoSectionY, aboutSectionY, quickActionsSectionY, securitySectionY]);
(0, _react.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);
_sonner.toast.success(t('editProfile.toasts.profileUpdated') || 'Profile updated successfully');
animateSaveButton(1); // Scale back to normal
if (onClose) {
onClose();
} else if (goBack) {
goBack();
}
} catch (error) {
_sonner.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 = () => {
(0, _confirmAction.confirmAction)(t('editProfile.confirms.removeAvatar') || 'Remove your profile picture?', () => {
setAvatarFileId('');
_sonner.toast.success(t('editProfile.toasts.avatarRemoved') || 'Avatar removed');
});
};
const openAvatarPicker = (0, _react.useCallback)(() => {
showBottomSheet?.({
screen: 'FileManagement',
props: {
selectMode: true,
multiSelect: false,
afterSelect: 'back',
onSelect: async file => {
if (!file.contentType.startsWith('image/')) {
_sonner.toast.error(t('editProfile.toasts.selectImage') || 'Please select an image file');
return;
}
// If already selected, do nothing
if (file.id === avatarFileId) {
_sonner.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 = _authStore.useAuthStore.getState().user;
if (currentUser) {
_authStore.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);
_sonner.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);
_sonner.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 = (0, _react.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
(0, _react.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__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: [styles.editingFieldContainer, {
backgroundColor: themeStyles.backgroundColor
}],
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.editingFieldContent,
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.newValueSection,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.editingFieldHeader,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: [styles.editingFieldLabel, {
color: themeStyles.isDarkTheme ? '#FFFFFF' : '#1A1A1A'
}],
children: "Two\u2011Factor Authentication (TOTP)"
})
}), !enabled ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.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__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
style: styles.primaryButton,
disabled: isTotpBusy,
onPress: async () => {
if (!activeSessionId) {
_sonner.toast.error(t('editProfile.toasts.noActiveSession') || 'No active session');
return;
}
setIsTotpBusy(true);
try {
const {
otpauthUrl
} = await oxyServices.startTotpEnrollment(activeSessionId);
setTotpSetupUrl(otpauthUrl);
} catch (e) {
_sonner.toast.error(e?.message || t('editProfile.toasts.totpStartFailed') || 'Failed to start TOTP enrollment');
} finally {
setIsTotpBusy(false);
}
},
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, {
name: "shield-checkmark",
size: 18,
color: "#fff"
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.primaryButtonText,
children: "Generate QR Code"
})]
}) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: {
alignItems: 'center',
gap: 16
},
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: {
padding: 16,
backgroundColor: '#fff',
borderRadius: 16
},
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeQrcodeSvg.default, {
value: totpSetupUrl,
size: 180
})
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.editingFieldLabel,
children: "Enter 6\u2011digit code"
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TextInput, {
style: styles.editingFieldInput,
keyboardType: "number-pad",
placeholder: "123456",
value: totpCode,
onChangeText: setTotpCode,
maxLength: 6
})]
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
style: styles.primaryButton,
disabled: isTotpBusy || totpCode.length !== 6,
onPress: async () => {
if (!activeSessionId) {
_sonner.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 {
_sonner.toast.success(t('editProfile.toasts.twoFactorEnabled') || 'Two‑Factor Authentication enabled');
setEditingField(null);
}
} catch (e) {
_sonner.toast.error(e?.message || t('editProfile.toasts.invalidCode') || 'Invalid code');
} finally {
setIsTotpBusy(false);
}
},
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, {
name: "checkmark-circle",
size: 18,
color: "#fff"
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.primaryButtonText,
children: "Verify & Enable"
})]
})]
})]
}) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.editingFieldDescription,
children: "Two\u2011Factor Authentication is currently enabled. To disable, enter a code from your authenticator app."
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.editingFieldLabel,
children: "Enter 6\u2011digit code"
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TextInput, {
style: styles.editingFieldInput,
keyboardType: "number-pad",
placeholder: "123456",
value: totpCode,
onChangeText: setTotpCode,
maxLength: 6
})]
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
style: [styles.primaryButton, {
backgroundColor: '#d9534f'
}],
disabled: isTotpBusy || totpCode.length !== 6,
onPress: async () => {
if (!activeSessionId) {
_sonner.toast.error(t('editProfile.toasts.noActiveSession') || 'No active session');
return;
}
setIsTotpBusy(true);
try {
await oxyServices.disableTotp(activeSessionId, totpCode);
await updateUser({
privacySettings: {
twoFactorEnabled: false
}
}, oxyServices);
_sonner.toast.success(t('editProfile.toasts.twoFactorDisabled') || 'Two‑Factor Authentication disabled');
setEditingField(null);
} catch (e) {
_sonner.toast.error(e?.message || t('editProfile.toasts.disableFailed') || 'Failed to disable');
} finally {
setIsTotpBusy(false);
}
},
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_vectorIcons.Ionicons, {
name: "close-circle",
size: 18,
color: "#fff"
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.primaryButtonText,
children: "Disable 2FA"
})]
})]
})]
})
})
});
}
if (type === 'displayName') {
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: [styles.editingFieldContainer, {
backgroundColor: themeStyles.backgroundColor
}],
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.editingFieldContent,
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.newValueSection,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.editingFieldHeader,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: [styles.editingFieldLabel, {
color: themeStyles.isDarkTheme ? '#FFFFFF' : '#1A1A1A'
}],
children: "Edit Full Name"
})
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: {
flexDirection: 'row',
gap: 12
},
children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: {
flex: 1
},
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.editingFieldLabel,
children: "First Name"
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TextInput, {
style: styles.editingFieldInput,
value: tempDisplayName,
onChangeText: setTempDisplayName,
placeholder: "Enter your first name",
placeholderTextColor: themeStyles.isDarkTheme ? '#aaa' : '#999',
autoFocus: true,
selectionColor: themeStyles.primaryColor
})]
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: {
flex: 1
},
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.editingFieldLabel,
children: "Last Name"
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.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__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: [styles.editingFieldContainer, {
backgroundColor: themeStyles.backgroundColor
}],
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.editingFieldContent,
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.newValueSection,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.editingFieldHeader,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: [styles.editingFieldLabel, {
color: themeStyles.isDarkTheme ? '#FFFFFF' : '#1A1A1A'
}],
children: "Manage Your Locations"
})
}), isAddingLocation ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.addLocationSection,
children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
style: styles.addLocationLabel,
children: ["Add New Location", isSearchingLocations && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.searchingText,
children: " \u2022 Searching..."
})]
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.addLocationInputContainer,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.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__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.addLocationButtons,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
style: [styles.addLocationButton, styles.cancelButton],
onPress: () => {
setIsAddingLocation(false);
setNewLocationQuery('');
setLocationSearchResults([]);
},
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.cancelButtonText,
children: "Cancel"
})
})
})]
}), locationSearchResults.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.searchResults,
children: locationSearchResults.map(result => /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
style: styles.searchResultItem,
onPress: () => addLocation(result),
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.searchResultName,
numberOfLines: 2,
children: result.display_name
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.searchResultType,
children: result.type
})]
}, result.place_id))
})]
}) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.TouchableOpacity, {
style: styles.addLocationTrigger,
onPress: () => setIsAddingLocation(true),
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_OxyIcon.default, {
name: "add",
size: 20,
color: themeStyles.primaryColor
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.addLocationTriggerText,
children: "Add a new location"
})]
}), tempLocations.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.locationsList,
children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
style: styles.locationsListTitle,
children: ["Your Locations (", tempLocations.length, ")"]
}), tempLocations.map((location, index) => /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.locationItem,
children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.locationItemContent,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.locationItemDragHandle,
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.reorderButtons,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
style: [styles.reorderButton, index === 0 && styles.reorderButtonDisabled],
onPress: () => index > 0 && moveLocation(index, index - 1),
disabled: index === 0,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_OxyIcon.default, {
name: "chevron-up",
size: 12,
color: index === 0 ? "#ccc" : "#666"
})
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.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__*/(0, _jsxRuntime.jsx)(_OxyIcon.default, {
name: "chevron-down",
size: 12,
color: index === tempLocations.length - 1 ? "#ccc" : "#666"
})
})]
})
}), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.locationItemInfo,
children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.locationItemHeader,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.locationItemName,
numberOfLines: 1,
children: location.name
}), location.label && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.locationLabel,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.locationLabelText,
children: location.label
})
})]
}), location.coordinates && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
style: styles.locationCoordinates,
children: [location.coordinates.lat.toFixed(4), ", ", location.coordinates.lon.toFixed(4)]
})]
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.locationItemActions,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
style: styles.locationItemButton,
onPress: () => removeLocation(location.id),
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_OxyIcon.default, {
name: "trash",
size: 14,
color: "#FF3B30"
})
})
})]
}), index < tempLocations.length - 1 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.locationItemDivider
})]
}, location.id)), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.reorderHint,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.reorderHintText,
children: "Use \u2191\u2193 buttons to reorder your locations"
})
})]
})]
})
})
});
}
if (type === 'links') {
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: [styles.editingFieldContainer, {
backgroundColor: themeStyles.backgroundColor
}],
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.editingFieldContent,
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.newValueSection,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
style: styles.editingFieldHeader,
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: [styles.editingFieldLabel, {
color: themeStyles.isDarkTheme ? '#FFFFFF' : '#1A1A1A'
}],
children: "Manage Your Links"
})
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.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__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.addLinkInputContainer,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.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__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.addLinkButtons,
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
style: [styles.addLinkButton, styles.cancelButton],
onPress: () => {
setIsAddingLink(false);
setNewLinkUrl('');
},
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.cancelButtonText,
children: "Cancel"
})
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
style: [styles.addLinkButton, styles.addButton, {
opacity: isFetchingMetadata ? 0.5 : 1
}],
onPress: addLink,
disabled: isFetchingMetadata,
children: isFetchingMetadata ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ActivityIndicator, {
size: "small",
color: "#fff"
}) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.addButtonText,
children: "Add"