@oxyhq/services
Version:
609 lines (586 loc) • 24.3 kB
JavaScript
"use strict";
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, ScrollView, Animated, Platform, Image } from 'react-native';
import { toast } from '../../lib/sonner';
import { fontFamilies } from "../styles/fonts.js";
import { confirmAction } from "../utils/confirmAction.js";
import { useAuthStore } from "../stores/authStore.js";
import { GroupedSection } from "../components/index.js";
import { useI18n } from "../hooks/useI18n.js";
import { useThemeStyles } from "../hooks/useThemeStyles.js";
import { useColorScheme } from "../hooks/useColorScheme.js";
import { Colors } from "../constants/theme.js";
import { normalizeColorScheme } from "../utils/themeUtils.js";
import { getDisplayName } from "../utils/userUtils.js";
import { useOxy } from "../context/OxyContext.js";
import { useCurrentUser } from "../hooks/queries/useAccountQueries.js";
import { useUploadAvatar } from "../hooks/mutations/useAccountMutations.js";
import { SECTION_GAP_LARGE, COMPONENT_GAP, HEADER_PADDING_TOP_SETTINGS, createScreenContentStyle } from "../constants/spacing.js";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const AccountSettingsScreen = ({
onClose,
theme,
goBack,
navigate,
initialField,
initialSection,
scrollTo
}) => {
// Use useOxy() hook for OxyContext values
const {
oxyServices,
isAuthenticated
} = useOxy();
const {
t
} = useI18n();
// Use TanStack Query for user data
const {
data: user,
isLoading: userLoading
} = useCurrentUser({
enabled: isAuthenticated
});
const uploadAvatarMutation = useUploadAvatar();
// Fallback to store for backward compatibility
const userFromStore = useAuthStore(state => state.user);
const finalUser = user || userFromStore;
const isUpdatingAvatar = uploadAvatarMutation.isPending;
const [optimisticAvatarId, setOptimisticAvatarId] = useState(null);
const scrollViewRef = useRef(null);
const avatarSectionRef = useRef(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);
// 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('');
// Navigation helper for editing fields
const navigateToEditField = useCallback(fieldType => {
navigate?.('EditProfileField', {
fieldType
});
}, [navigate]);
// Location and links state (for display only - modals handle editing)
const [locations, setLocations] = useState([]);
const [linksMetadata, setLinksMetadata] = useState([]);
// Get theme colors using centralized hook
const colorScheme = useColorScheme();
const themeStyles = useThemeStyles(theme || 'light', colorScheme);
// Extract colors for convenience - ensure it's always defined
// useThemeStyles always returns colors, but add safety check for edge cases
const colors = themeStyles.colors || Colors[normalizeColorScheme(colorScheme, theme || 'light')];
// 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 (finalUser) {
const currentUserId = finalUser.id;
const currentAvatar = typeof finalUser.avatar === 'string' ? finalUser.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 finalUser.name === 'string' ? finalUser.name : finalUser.name?.first || finalUser.name?.full || '';
const userLastName = typeof finalUser.name === 'object' ? finalUser.name?.last || '' : '';
setDisplayName(userDisplayName);
setLastName(userLastName);
setUsername(finalUser.username || '');
setEmail(finalUser.email || '');
setBio(finalUser.bio || '');
setLocation(finalUser.location || '');
// Handle locations - convert single location to array format
if (finalUser.locations && Array.isArray(finalUser.locations)) {
setLocations(finalUser.locations.map((loc, index) => ({
id: loc.id || `existing-${index}`,
name: loc.name,
label: loc.label,
coordinates: loc.coordinates
})));
} else if (finalUser.location) {
// Convert single location string to array format
setLocations([{
id: 'existing-0',
name: finalUser.location,
label: 'Location'
}]);
} else {
setLocations([]);
}
// Handle links - simple and direct like other fields
if (finalUser.linksMetadata && Array.isArray(finalUser.linksMetadata)) {
const urls = finalUser.linksMetadata.map(l => l.url);
setLinks(urls);
const metadataWithIds = finalUser.linksMetadata.map((link, index) => ({
...link,
id: link.id || `existing-${index}`
}));
setLinksMetadata(metadataWithIds);
} else if (Array.isArray(finalUser.links)) {
const simpleLinks = finalUser.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}`
}));
setLinksMetadata(linksWithMetadata);
} else if (finalUser.website) {
setLinks([finalUser.website]);
setLinksMetadata([{
url: finalUser.website,
title: finalUser.website.replace(/^https?:\/\//, '').replace(/\/$/, ''),
description: `Link to ${finalUser.website}`,
image: undefined,
id: 'existing-0'
}]);
} else {
setLinks([]);
setLinksMetadata([]);
}
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;
}
}, [finalUser, avatarFileId, isUpdatingAvatar, optimisticAvatarId]);
// Set initial editing field if provided via props (e.g., from navigation)
// 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 && scrollTo) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollTo(Math.max(0, sectionY - SCROLL_OFFSET), true);
hasScrolledToSectionRef.current = true;
});
});
}
}
}, [initialSection, sectionYPositions]);
const handleAvatarRemove = () => {
confirmAction(t('editProfile.confirms.removeAvatar') || 'Remove your profile picture?', () => {
setAvatarFileId('');
toast.success(t('editProfile.toasts.avatarRemoved') || 'Avatar removed');
});
};
const {
openAvatarPicker
} = useOxy();
// Handlers to navigate to edit screens
const handleOpenDisplayNameModal = useCallback(() => navigateToEditField('displayName'), [navigateToEditField]);
const handleOpenUsernameModal = useCallback(() => navigateToEditField('username'), [navigateToEditField]);
const handleOpenEmailModal = useCallback(() => navigateToEditField('email'), [navigateToEditField]);
const handleOpenBioModal = useCallback(() => navigateToEditField('bio'), [navigateToEditField]);
const handleOpenLocationModal = useCallback(() => navigateToEditField('locations'), [navigateToEditField]);
const handleOpenLinksModal = useCallback(() => navigateToEditField('links'), [navigateToEditField]);
// Handle initialField prop - navigate to appropriate edit screen
useEffect(() => {
if (initialField) {
// Special handling for avatar - open avatar picker directly
if (initialField === 'avatar') {
setTimeout(() => {
openAvatarPicker();
}, 300);
} else {
// Navigate to edit screen
setTimeout(() => {
const fieldTypeMap = {
displayName: 'displayName',
username: 'username',
email: 'email',
bio: 'bio',
location: 'locations',
locations: 'locations',
links: 'links'
};
const fieldType = fieldTypeMap[initialField];
if (fieldType) {
navigateToEditField(fieldType);
}
}, 300);
}
}
}, [initialField, openAvatarPicker, navigateToEditField]);
// Memoize display name for avatar
const displayNameForAvatar = useMemo(() => getDisplayName(finalUser), [finalUser]);
if (userLoading || !isAuthenticated) {
return /*#__PURE__*/_jsx(View, {
style: [styles.container, {
backgroundColor: themeStyles.backgroundColor,
justifyContent: 'center'
}],
children: /*#__PURE__*/_jsx(ActivityIndicator, {
size: "large",
color: themeStyles.primaryColor
})
});
}
return /*#__PURE__*/_jsx(View, {
style: [styles.container, {
backgroundColor: themeStyles.backgroundColor
}],
children: /*#__PURE__*/_jsxs(ScrollView, {
ref: scrollViewRef,
style: styles.content,
contentContainerStyle: styles.scrollContent,
showsVerticalScrollIndicator: false,
children: [/*#__PURE__*/_jsxs(View, {
style: [styles.headerContainer, styles.headerSection],
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.modernTitle, {
color: themeStyles.textColor,
marginBottom: 0,
marginTop: 0
}],
children: t('accountOverview.items.editProfile.title') || t('editProfile.title') || 'Edit Profile'
}), /*#__PURE__*/_jsx(Text, {
style: [styles.modernSubtitle, {
color: colors.secondaryText,
marginBottom: 0,
marginTop: 0
}],
children: t('accountOverview.items.editProfile.subtitle') || t('editProfile.subtitle') || 'Manage your profile and preferences'
})]
}), /*#__PURE__*/_jsxs(View, {
ref: ref => {
avatarSectionRef.current = ref;
profilePictureSectionRef.current = ref;
},
style: styles.section,
onLayout: event => {
const {
y
} = event.nativeEvent.layout;
setProfilePictureSectionY(y);
},
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.sectionTitle, {
color: colors.secondaryText
}],
children: t('editProfile.sections.profilePicture') || 'PROFILE PICTURE'
}), /*#__PURE__*/_jsx(View, {
style: styles.groupedSectionWrapper,
children: /*#__PURE__*/_jsx(GroupedSection, {
items: [{
id: 'profile-photo',
customIcon: optimisticAvatarId || avatarFileId ? isUpdatingAvatar ? /*#__PURE__*/_jsxs(Animated.View, {
style: {
position: 'relative',
width: 36,
height: 36
},
children: [/*#__PURE__*/_jsx(Animated.Image, {
source: {
uri: oxyServices.getFileDownloadUrl(optimisticAvatarId || avatarFileId, 'thumb')
},
style: {
width: 36,
height: 36,
borderRadius: 18,
opacity: 0.6
}
}), /*#__PURE__*/_jsx(View, {
style: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.7)',
borderRadius: 18
},
children: /*#__PURE__*/_jsx(ActivityIndicator, {
size: "small",
color: colors.tint
})
})]
}) : /*#__PURE__*/_jsx(Image, {
source: {
uri: oxyServices.getFileDownloadUrl(optimisticAvatarId || avatarFileId, 'thumb')
},
style: {
width: 36,
height: 36,
borderRadius: 18
}
}) : undefined,
icon: !(optimisticAvatarId || avatarFileId) ? 'account-outline' : undefined,
iconColor: colors.sidebarIconPersonalInfo,
title: 'Profile Photo',
subtitle: isUpdatingAvatar ? 'Updating profile picture...' : avatarFileId ? 'Tap to change your profile picture' : 'Tap to add a profile picture',
onPress: isUpdatingAvatar ? undefined : openAvatarPicker,
disabled: isUpdatingAvatar
}, ...(avatarFileId && !isUpdatingAvatar ? [{
id: 'remove-profile-photo',
icon: 'delete-outline',
iconColor: colors.sidebarIconSharing,
title: 'Remove Photo',
subtitle: 'Delete current profile picture',
onPress: handleAvatarRemove
}] : [])]
})
})]
}), /*#__PURE__*/_jsxs(View, {
ref: basicInfoSectionRef,
style: styles.section,
onLayout: event => {
const {
y
} = event.nativeEvent.layout;
setBasicInfoSectionY(y);
},
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.sectionTitle, {
color: colors.secondaryText
}],
children: t('editProfile.sections.basicInfo') || 'BASIC INFORMATION'
}), /*#__PURE__*/_jsx(View, {
style: styles.groupedSectionWrapper,
children: /*#__PURE__*/_jsx(GroupedSection, {
items: [{
id: 'display-name',
icon: 'account-outline',
iconColor: colors.sidebarIconPersonalInfo,
title: t('editProfile.items.displayName.title') || 'Display Name',
subtitle: [displayName, lastName].filter(Boolean).join(' ') || t('editProfile.items.displayName.add') || 'Add your display name',
onPress: handleOpenDisplayNameModal
}, {
id: 'username',
icon: 'at',
iconColor: colors.sidebarIconData,
title: t('editProfile.items.username.title') || 'Username',
subtitle: username || t('editProfile.items.username.choose') || 'Choose a username',
onPress: handleOpenUsernameModal
}, {
id: 'email',
icon: 'email-outline',
iconColor: colors.sidebarIconSecurity,
title: t('editProfile.items.email.title') || 'Email',
subtitle: email || t('editProfile.items.email.add') || 'Add your email address',
onPress: handleOpenEmailModal
}]
})
})]
}), /*#__PURE__*/_jsxs(View, {
ref: aboutSectionRef,
style: styles.section,
onLayout: event => {
const {
y
} = event.nativeEvent.layout;
setAboutSectionY(y);
},
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.sectionTitle, {
color: colors.secondaryText
}],
children: t('editProfile.sections.about') || 'ABOUT YOU'
}), /*#__PURE__*/_jsx(View, {
style: styles.groupedSectionWrapper,
children: /*#__PURE__*/_jsx(GroupedSection, {
items: [{
id: 'bio',
icon: 'text-box-outline',
iconColor: colors.sidebarIconPersonalInfo,
title: t('editProfile.items.bio.title') || 'Bio',
subtitle: bio || t('editProfile.items.bio.placeholder') || 'Tell people about yourself',
onPress: handleOpenBioModal
}, {
id: 'locations',
icon: 'map-marker-outline',
iconColor: colors.sidebarIconSharing,
title: t('editProfile.items.locations.title') || 'Locations',
subtitle: locations.length > 0 ? locations.length === 1 ? t('editProfile.items.locations.count', {
count: locations.length
}) || `${locations.length} location added` : t('editProfile.items.locations.count_plural', {
count: locations.length
}) || `${locations.length} locations added` : t('editProfile.items.locations.add') || 'Add your locations',
onPress: handleOpenLocationModal
}, {
id: 'links',
icon: 'link-variant',
iconColor: colors.sidebarIconSharing,
title: t('editProfile.items.links.title') || 'Links',
subtitle: linksMetadata.length > 0 ? linksMetadata.length === 1 ? t('editProfile.items.links.count', {
count: linksMetadata.length
}) || `${linksMetadata.length} link added` : t('editProfile.items.links.count_plural', {
count: linksMetadata.length
}) || `${linksMetadata.length} links added` : t('editProfile.items.links.add') || 'Add your links',
onPress: handleOpenLinksModal
}]
})
})]
}), /*#__PURE__*/_jsxs(View, {
ref: quickActionsSectionRef,
style: styles.section,
onLayout: event => {
const {
y
} = event.nativeEvent.layout;
setQuickActionsSectionY(y);
},
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.sectionTitle, {
color: colors.secondaryText
}],
children: t('editProfile.sections.quickActions') || 'QUICK ACTIONS'
}), /*#__PURE__*/_jsx(View, {
style: styles.groupedSectionWrapper,
children: /*#__PURE__*/_jsx(GroupedSection, {
items: [{
id: 'preview-profile',
icon: 'eye',
iconColor: colors.sidebarIconHome,
title: t('editProfile.items.previewProfile.title') || 'Preview Profile',
subtitle: t('editProfile.items.previewProfile.subtitle') || 'See how your profile looks to others',
onPress: () => navigate?.('Profile', {
userId: finalUser?.id
})
}, {
id: 'privacy-settings',
icon: 'shield-check',
iconColor: colors.sidebarIconSecurity,
title: t('editProfile.items.privacySettings.title') || 'Privacy Settings',
subtitle: t('editProfile.items.privacySettings.subtitle') || 'Control who can see your profile',
onPress: () => navigate?.('PrivacySettings')
}, {
id: 'verify-account',
icon: 'check-circle',
iconColor: colors.sidebarIconPersonalInfo,
title: t('editProfile.items.verifyAccount.title') || 'Verify Account',
subtitle: t('editProfile.items.verifyAccount.subtitle') || 'Get a verified badge',
onPress: () => navigate?.('AccountVerification')
}]
})
})]
}), /*#__PURE__*/_jsxs(View, {
ref: securitySectionRef,
style: styles.section,
onLayout: event => {
const {
y
} = event.nativeEvent.layout;
setSecuritySectionY(y);
},
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.sectionTitle, {
color: colors.secondaryText
}],
children: t('editProfile.sections.security') || 'SECURITY'
}), /*#__PURE__*/_jsx(View, {
style: styles.groupedSectionWrapper
})]
})]
})
});
};
const styles = StyleSheet.create({
container: {
flexShrink: 1,
width: '100%'
},
content: {
flexShrink: 1
},
scrollContent: createScreenContentStyle(HEADER_PADDING_TOP_SETTINGS),
headerContainer: {
width: '100%',
maxWidth: 420,
alignSelf: 'center',
marginBottom: SECTION_GAP_LARGE
},
headerSection: {
alignItems: 'flex-start',
width: '100%',
gap: COMPONENT_GAP
},
modernTitle: {
fontFamily: fontFamilies.interBold,
fontWeight: Platform.OS === 'web' ? 'bold' : undefined,
fontSize: 42,
lineHeight: 50.4,
// 42 * 1.2
textAlign: 'left',
letterSpacing: -0.5
},
modernSubtitle: {
fontSize: 18,
lineHeight: 24,
textAlign: 'left',
maxWidth: 320,
alignSelf: 'flex-start',
opacity: 0.8
},
section: {
marginBottom: SECTION_GAP_LARGE
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
marginBottom: 8,
marginTop: 4,
textTransform: 'uppercase',
letterSpacing: 0.5,
fontFamily: fontFamilies.interSemiBold
},
groupedSectionWrapper: {
backgroundColor: 'transparent'
}
});
export default /*#__PURE__*/React.memo(AccountSettingsScreen);
//# sourceMappingURL=AccountSettingsScreen.js.map