@oxyhq/services
Version:
Reusable OxyHQ module to handle authentication, user management, karma system, device-based session management and more 🚀
440 lines (431 loc) • 15.5 kB
JavaScript
"use strict";
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Platform, Animated, ScrollView } from 'react-native';
import AnimatedReanimated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';
import { useOxy } from '../context/OxyContext';
import Avatar from '../components/Avatar';
import { Ionicons } from '@expo/vector-icons';
import { toast } from '../../lib/sonner';
import { useAuthStore } from '../stores/authStore';
import { useThemeColors } from '../styles';
import GroupedPillButtons from '../components/internal/GroupedPillButtons';
import { useI18n } from '../hooks/useI18n';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const GAP = 12;
const INNER_GAP = 8;
// Individual animated progress dot
const AnimatedProgressDot = ({
isActive,
colors,
styles
}) => {
const width = useSharedValue(isActive ? 12 : 6);
const backgroundColor = useSharedValue(isActive ? colors.primary : colors.border);
useEffect(() => {
width.value = withTiming(isActive ? 12 : 6, {
duration: 300
});
backgroundColor.value = withTiming(isActive ? colors.primary : colors.border, {
duration: 300
});
}, [isActive, colors.primary, colors.border, width, backgroundColor]);
const animatedStyle = useAnimatedStyle(() => ({
width: width.value,
backgroundColor: backgroundColor.value
}));
return /*#__PURE__*/_jsx(AnimatedReanimated.View, {
style: [styles.progressDot, animatedStyle]
});
};
/**
* Post-signup welcome & onboarding screen.
* - Greets the newly registered user
* - Lets them immediately set / change their avatar using existing FileManagement picker
* - Only when the user presses "Continue" do we invoke onAuthenticated to finish flow & close sheet
*/
const WelcomeNewUserScreen = ({
navigate,
onAuthenticated,
theme,
newUser
}) => {
const {
user,
oxyServices
} = useOxy();
const {
t
} = useI18n();
const updateUser = useAuthStore(s => s.updateUser);
const currentUser = user || newUser; // fallback
const colors = useThemeColors(theme);
const styles = useMemo(() => createStyles(theme), [theme]);
// Animation state
const fadeAnim = useRef(new Animated.Value(1)).current;
const slideAnim = useRef(new Animated.Value(0)).current;
const [currentStep, setCurrentStep] = useState(0);
// Track avatar separately to ensure it updates immediately after selection
const [selectedAvatarId, setSelectedAvatarId] = useState(currentUser?.avatar);
// Update selectedAvatarId when user changes
useEffect(() => {
if (user?.avatar) {
setSelectedAvatarId(user.avatar);
} else if (newUser?.avatar) {
setSelectedAvatarId(newUser.avatar);
}
}, [user?.avatar, newUser?.avatar]);
const avatarUri = selectedAvatarId ? oxyServices.getFileDownloadUrl(selectedAvatarId, 'thumb') : undefined;
// Steps content
const welcomeTitle = currentUser?.username ? t('welcomeNew.welcome.titleWithName', {
username: currentUser.username
}) || `Welcome, ${currentUser.username} 👋` : t('welcomeNew.welcome.title') || 'Welcome 👋';
const steps = [{
key: 'welcome',
title: welcomeTitle,
body: t('welcomeNew.welcome.body') || "You just created an account in a calm, ethical space. A few quick things — then you're in."
}, {
key: 'account',
title: t('welcomeNew.account.title') || 'One Account. Simple.',
bullets: [t('welcomeNew.account.bullets.0') || 'One identity across everything', t('welcomeNew.account.bullets.1') || 'No re‑signing in all the time', t('welcomeNew.account.bullets.2') || 'Your preferences stay with you']
}, {
key: 'principles',
title: t('welcomeNew.principles.title') || 'What We Stand For',
bullets: [t('welcomeNew.principles.bullets.0') || 'Privacy by default', t('welcomeNew.principles.bullets.1') || 'No manipulative feeds', t('welcomeNew.principles.bullets.2') || 'You decide what to share', t('welcomeNew.principles.bullets.3') || 'No selling your data']
}, {
key: 'karma',
title: t('welcomeNew.karma.title') || 'Karma = Trust & Growth',
body: t('welcomeNew.karma.body') || 'Oxy Karma is a points system that reacts to what you do. Helpful, respectful, constructive actions earn it. Harmful or low‑effort stuff chips it away. More karma can unlock benefits; low karma can limit features. It keeps things fair and rewards real contribution.'
}, {
key: 'avatar',
title: t('welcomeNew.avatar.title') || 'Make It Yours',
body: t('welcomeNew.avatar.body') || 'Add an avatar so people recognize you. It will show anywhere you show up here. Skip if you want — you can add it later.',
showAvatar: true
}, {
key: 'ready',
title: t('welcomeNew.ready.title') || "You're Ready",
body: t('welcomeNew.ready.body') || 'Explore. Contribute. Earn karma. Stay in control.'
}];
const totalSteps = steps.length;
const avatarStepIndex = steps.findIndex(s => s.showAvatar);
const animateToStepCallback = useCallback(next => {
Animated.timing(fadeAnim, {
toValue: 0,
duration: 180,
useNativeDriver: Platform.OS !== 'web'
}).start(() => {
setCurrentStep(next);
slideAnim.setValue(-40);
Animated.parallel([Animated.timing(fadeAnim, {
toValue: 1,
duration: 220,
useNativeDriver: Platform.OS !== 'web'
}), Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: Platform.OS !== 'web',
friction: 9
})]).start();
});
}, [fadeAnim, slideAnim]);
const nextStep = useCallback(() => {
if (currentStep < totalSteps - 1) animateToStepCallback(currentStep + 1);
}, [currentStep, totalSteps, animateToStepCallback]);
const prevStep = useCallback(() => {
if (currentStep > 0) animateToStepCallback(currentStep - 1);
}, [currentStep, animateToStepCallback]);
const skipToAvatar = useCallback(() => {
if (avatarStepIndex >= 0) animateToStepCallback(avatarStepIndex);
}, [avatarStepIndex, animateToStepCallback]);
const finish = useCallback(() => {
if (onAuthenticated && currentUser) onAuthenticated(currentUser);
}, [onAuthenticated, currentUser]);
const openAvatarPicker = useCallback(() => {
// Ensure we're on the avatar step before opening picker
if (avatarStepIndex >= 0 && currentStep !== avatarStepIndex) {
animateToStepCallback(avatarStepIndex);
}
navigate('FileManagement', {
selectMode: true,
multiSelect: false,
disabledMimeTypes: ['video/', 'audio/', 'application/pdf'],
afterSelect: 'none',
// Don't navigate away - stay on current screen
onSelect: async file => {
if (!file.contentType.startsWith('image/')) {
toast.error(t('editProfile.toasts.selectImage') || 'Please select an image file');
return;
}
try {
// Update file visibility to public for avatar (skip if temporary asset ID)
if (file.id && !file.id.startsWith('temp-')) {
try {
await oxyServices.assetUpdateVisibility(file.id, 'public');
console.log('[WelcomeNewUser] Avatar visibility updated to public');
} catch (visError) {
// Only log non-404 errors (404 means asset doesn't exist yet, which is OK)
if (visError?.response?.status !== 404) {
console.warn('[WelcomeNewUser] Failed to update avatar visibility, continuing anyway:', visError);
}
}
}
// Update the avatar immediately in local state
setSelectedAvatarId(file.id);
// Update user in store
await updateUser({
avatar: file.id
}, oxyServices);
toast.success(t('editProfile.toasts.avatarUpdated') || 'Avatar updated');
// Ensure we stay on the avatar step
if (avatarStepIndex >= 0 && currentStep !== avatarStepIndex) {
animateToStepCallback(avatarStepIndex);
}
} catch (e) {
toast.error(e.message || t('editProfile.toasts.updateAvatarFailed') || 'Failed to update avatar');
}
}
});
}, [navigate, updateUser, oxyServices, currentStep, avatarStepIndex, animateToStepCallback, t]);
const step = steps[currentStep];
const pillButtons = useMemo(() => {
if (currentStep === totalSteps - 1) {
return [{
text: t('welcomeNew.actions.back') || 'Back',
onPress: prevStep,
icon: 'arrow-back',
variant: 'transparent'
}, {
text: t('welcomeNew.actions.enter') || 'Enter',
onPress: finish,
icon: 'log-in-outline',
variant: 'primary'
}];
}
if (currentStep === 0) {
const arr = [];
if (avatarStepIndex > 0) arr.push({
text: t('welcomeNew.actions.skip') || 'Skip',
onPress: skipToAvatar,
icon: 'play-skip-forward',
variant: 'transparent'
});
arr.push({
text: t('welcomeNew.actions.next') || 'Next',
onPress: nextStep,
icon: 'arrow-forward',
variant: 'primary'
});
return arr;
}
if (step.showAvatar) {
return [{
text: t('welcomeNew.actions.back') || 'Back',
onPress: prevStep,
icon: 'arrow-back',
variant: 'transparent'
}, {
text: avatarUri ? t('welcomeNew.actions.continue') || 'Continue' : t('welcomeNew.actions.skip') || 'Skip',
onPress: nextStep,
icon: 'arrow-forward',
variant: 'primary'
}];
}
return [{
text: t('welcomeNew.actions.back') || 'Back',
onPress: prevStep,
icon: 'arrow-back',
variant: 'transparent'
}, {
text: t('welcomeNew.actions.next') || 'Next',
onPress: nextStep,
icon: 'arrow-forward',
variant: 'primary'
}];
}, [currentStep, totalSteps, prevStep, nextStep, finish, skipToAvatar, avatarStepIndex, step.showAvatar, avatarUri]);
return /*#__PURE__*/_jsxs(View, {
style: styles.container,
children: [/*#__PURE__*/_jsx(View, {
style: styles.progressContainer,
children: steps.map((s, i) => /*#__PURE__*/_jsx(AnimatedProgressDot, {
isActive: i === currentStep,
colors: colors,
styles: styles
}, s.key))
}), /*#__PURE__*/_jsx(Animated.View, {
style: {
opacity: fadeAnim,
transform: [{
translateX: slideAnim
}]
},
children: /*#__PURE__*/_jsx(ScrollView, {
contentContainerStyle: styles.scrollInner,
showsVerticalScrollIndicator: false,
children: /*#__PURE__*/_jsxs(View, {
style: styles.contentContainer,
children: [/*#__PURE__*/_jsxs(View, {
style: [styles.header, styles.sectionSpacing],
children: [/*#__PURE__*/_jsx(Text, {
style: [styles.title, {
color: colors.text
}],
children: step.title
}), step.body && /*#__PURE__*/_jsx(Text, {
style: [styles.body, {
color: colors.secondaryText
}],
children: step.body
})]
}), Array.isArray(step.bullets) && step.bullets.length > 0 && /*#__PURE__*/_jsx(View, {
style: [styles.bulletContainer, styles.sectionSpacing],
children: step.bullets.map(b => /*#__PURE__*/_jsxs(View, {
style: styles.bulletRow,
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "ellipse",
size: 8,
color: colors.primary,
style: {
marginTop: 6
}
}), /*#__PURE__*/_jsx(Text, {
style: [styles.bulletText, {
color: colors.secondaryText
}],
children: b
})]
}, b))
}), step.showAvatar && /*#__PURE__*/_jsxs(View, {
style: [styles.avatarSection, styles.sectionSpacing],
children: [/*#__PURE__*/_jsx(Avatar, {
size: 120,
name: currentUser?.name?.full || currentUser?.name?.first || currentUser?.username,
uri: avatarUri,
theme: theme,
backgroundColor: colors.primary + '20',
style: styles.avatar
}), /*#__PURE__*/_jsxs(TouchableOpacity, {
style: [styles.changeAvatarButton, {
backgroundColor: colors.primary
}],
onPress: openAvatarPicker,
children: [/*#__PURE__*/_jsx(Ionicons, {
name: "image-outline",
size: 18,
color: "#FFFFFF"
}), /*#__PURE__*/_jsx(Text, {
style: styles.changeAvatarText,
children: avatarUri ? t('welcomeNew.avatar.change') || 'Change Avatar' : t('welcomeNew.avatar.add') || 'Add Avatar'
})]
})]
}), /*#__PURE__*/_jsx(View, {
style: styles.sectionSpacing,
children: /*#__PURE__*/_jsx(GroupedPillButtons, {
buttons: pillButtons,
colors: colors
})
})]
})
})
})]
});
};
const createStyles = theme => {
const isDark = theme === 'dark';
const border = isDark ? '#333333' : '#E0E0E0';
return StyleSheet.create({
container: {
width: '100%',
paddingHorizontal: 20
},
scrollInner: {
paddingBottom: 12,
paddingTop: 0
},
contentContainer: {
width: '100%',
maxWidth: 420,
alignSelf: 'center'
},
sectionSpacing: {
marginBottom: GAP
},
header: {
alignItems: 'flex-start',
width: '100%',
gap: INNER_GAP / 2
},
title: {
fontSize: 42,
fontFamily: Platform.OS === 'web' ? 'Phudu' : 'Phudu-Bold',
fontWeight: Platform.OS === 'web' ? 'bold' : undefined,
letterSpacing: -1,
textAlign: 'left'
},
body: {
fontSize: 16,
lineHeight: 22,
textAlign: 'left',
maxWidth: 320,
alignSelf: 'flex-start'
},
bulletContainer: {
gap: INNER_GAP,
width: '100%'
},
bulletRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10
},
bulletText: {
flex: 1,
fontSize: 15,
lineHeight: 20
},
avatarSection: {
width: '100%',
alignItems: 'center'
},
avatar: {
marginBottom: INNER_GAP
},
changeAvatarButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 10,
borderRadius: 28,
gap: 8,
shadowOpacity: 0,
shadowRadius: 0,
shadowOffset: {
width: 0,
height: 0
},
elevation: 0,
...(Platform.OS === 'web' ? {
boxShadow: 'none'
} : null)
},
changeAvatarText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '600'
},
progressContainer: {
flexDirection: 'row',
width: '100%',
justifyContent: 'center',
marginTop: 24,
// Space for bottom sheet handle (~20px) + small buffer
marginBottom: 24 // Equal spacing below dots
},
progressDot: {
height: 6,
width: 6,
borderRadius: 3,
marginHorizontal: 3,
backgroundColor: border
}
});
};
export default WelcomeNewUserScreen;
//# sourceMappingURL=WelcomeNewUserScreen.js.map