[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

Profile clean up

C3B b6e86637 07697a16

+963 -626
+272 -521
app/(tabs)/ProfileScreen.tsx
··· 1 - import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; 2 - import { 3 - Platform, 4 - SafeAreaView, 5 - ScrollView, 6 - StyleSheet, 7 - TouchableOpacity, 8 - useColorScheme, 9 - View, 10 - Image, 11 - FlatList, 12 - RefreshControl, 13 - Alert, 14 - ActivityIndicator 15 - } from 'react-native'; 16 - import { Ionicons } from '@expo/vector-icons'; 17 - import { ThemedText } from '@/components/ThemedText'; 18 - import ContentWrapper from '@/components/global/ContentWrapper'; 19 - import { Colors } from '@/constants/Colors'; 20 - import ProfilePicture from '@/components/Profile/ProfilePicture'; 21 - import ProfileInfo from '@/components/Profile/ProfileInfo'; 22 - import ActionButton from '@/components/global/ActionButton'; 23 - import VideoDisplay from '@/components/global/VideoDisplay'; 24 - import PlaceholderVideoDisplay from '@/components/Profile/PlaceholderVideoDisplay'; 25 - import { did } from '@/constants/MockData'; 26 - import { UserProps, PostProps } from '@/types/Interfaces'; 27 - import { router, useRouter, useLocalSearchParams } from 'expo-router'; 28 - import { getProfile, getProfileMedia } from '@/api/profileServices'; 29 - import useAtProto from '@/hooks/useAtProto'; 30 - import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'; 31 - 32 - function padVideosWithPlaceholders( 33 - videos: (PostProps & { isPlaceholder?: boolean })[] 34 - ): (PostProps & { isPlaceholder?: boolean })[] { 35 - const remainder = videos.length % 3; 36 - const placeholdersNeeded = remainder === 0 ? 0 : 3 - remainder; 37 - 38 - const placeholders: (PostProps & { isPlaceholder?: boolean })[] = Array( 39 - placeholdersNeeded 40 - ) 41 - .fill(null) 42 - .map((_, i) => ({ 43 - uri: `placeholder-${i}`, 44 - cid: '', 45 - author: { 46 - did: '', 47 - handle: '', 48 - displayName: '', 49 - avatar: '', 50 - banner: '', 51 - }, 52 - record: { 53 - $type: 'app.bsky.feed.post', 54 - createdAt: '', 55 - text: '', 56 - langs: [], 57 - }, 58 - replyCount: 0, 59 - repostCount: 0, 60 - likeCount: 0, 61 - quoteCount: 0, 62 - indexedAt: '', 63 - labels: [], 64 - isPlaceholder: true, 65 - })); 66 - 67 - return [...videos, ...placeholders]; 68 - } 69 - 70 - export default function ProfileScreen() { 71 - const colorScheme = useColorScheme(); 72 - const route = useRouter(); 73 - const params = useLocalSearchParams(); 74 - 75 - const { isLoggedIn, session, agent, logout } = useAtProto(); 76 - 77 - const profileDid = typeof params.did === 'string' && params.did ? 78 - params.did : 79 - (isLoggedIn && session ? session.did : did); 80 - 81 - const isMine = isLoggedIn && session && profileDid === session.did; 82 - 83 - const [userData, setUserData] = useState<UserProps | null>(null); 84 - const [videoPosts, setVideoPosts] = useState<PostProps[]>([]); 85 - 86 - const bottomSheetRef = useRef<BottomSheet>(null); 87 - const snapPoints = useMemo(() => ['25%'], []); 88 - 89 - const handleSheetChanges = useCallback((index: number) => { 90 - console.log('handleSheetChanges', index); 91 - }, []); 92 - 93 - const openBottomSheet = useCallback(() => { 94 - bottomSheetRef.current?.expand(); 95 - }, []); 96 - 97 - const closeBottomSheet = useCallback(() => { 98 - bottomSheetRef.current?.close(); 99 - }, []); 100 - 101 - const loadVideoPosts = async () => { 102 - try { 103 - const mediaPosts = await getProfileMedia(profileDid, 'video'); 104 - const posts = mediaPosts.map((item: any) => item.post); 105 - setVideoPosts(posts); 106 - } catch (error) { 107 - console.error('Error loading video posts:', error); 108 - } 109 - }; 110 - 111 - useEffect(() => { 112 - if (isLoggedIn || (!isLoggedIn && !isMine)) { 113 - const loadProfileData = async () => { 114 - try { 115 - const profileData = await getProfile(profileDid); 116 - if (profileData) { 117 - setUserData({ 118 - id: profileData.did, 119 - did: profileData.did, 120 - displayName: profileData.displayName || (isMine && session?.handle ? session.handle : ''), 121 - handle: profileData.handle || (isMine && session?.handle ? session.handle : ''), 122 - description: profileData.description || '', 123 - avatar: profileData.avatar || '', 124 - banner: profileData.banner || '', 125 - followersCount: profileData.followersCount || 0, 126 - followsCount: profileData.followsCount || 0, 127 - postsCount: profileData.postsCount || 0, 128 - associated: profileData.associated, 129 - joinedViaStarterPack: profileData.joinedViaStarterPack, 130 - indexedAt: profileData.indexedAt || '', 131 - createdAt: profileData.createdAt || '', 132 - viewer: profileData.viewer, 133 - labels: profileData.labels || [], 134 - pinnedPost: profileData.pinnedPost, 135 - }); 136 - } 137 - } catch (error) { 138 - console.error('Error loading profile:', error); 139 - 140 - if (isLoggedIn && session && isMine) { 141 - setUserData({ 142 - id: session.did, 143 - did: session.did, 144 - displayName: session.handle || 'My Profile', 145 - handle: session.handle || '', 146 - description: '', 147 - avatar: '', 148 - banner: '', 149 - followersCount: 0, 150 - followsCount: 0, 151 - postsCount: 0, 152 - indexedAt: '', 153 - createdAt: '', 154 - labels: [], 155 - }); 156 - } 157 - } 158 - }; 159 - 160 - loadProfileData(); 161 - loadVideoPosts(); 162 - } else if (!isLoggedIn && isMine) { 163 - setUserData({ 164 - id: '', 165 - did: '', 166 - displayName: 'Login ou Registrar', 167 - handle: 'null', 168 - description: '', 169 - avatar: 'https://static.sprk.so/branding/default-profile.png?d', 170 - banner: '', 171 - followersCount: 0, 172 - followsCount: 0, 173 - postsCount: 0, 174 - indexedAt: '', 175 - createdAt: '', 176 - labels: [], 177 - }); 178 - } 179 - }, [isLoggedIn, isMine, session, profileDid]); 180 - 181 - const paddedVideoData = padVideosWithPlaceholders(videoPosts); 182 - 183 - function handleOpenProfileFeed(post: PostProps) { 184 - const index = videoPosts.findIndex((video) => video.uri === post.uri); 185 - route.push({ 186 - pathname: '../ProfileFeed', 187 - params: { 188 - videoData: JSON.stringify(videoPosts), 189 - initialIndex: index.toString(), 190 - animation: Platform.OS === 'android' ? 'fade' : 'none' 191 - }, 192 - }); 193 - } 194 - 195 - function goTo(route: string) { 196 - route = route.toLowerCase(); 197 - if (route === 'register') { 198 - router.push('/(auth)/Register', { relativeToDirectory: true }); 199 - } else { 200 - router.push('/(auth)/Login', { relativeToDirectory: true }); 201 - } 202 - } 203 - 204 - const handleProfileSettings = () => { 205 - closeBottomSheet(); 206 - console.log('Navigate to profile settings'); 207 - }; 208 - 209 - const handleLogout = async () => { 210 - try { 211 - closeBottomSheet(); 212 - await logout(); 213 - console.log('User logged out'); 214 - } catch (error) { 215 - console.error('Error logging out:', error); 216 - } 217 - }; 218 - 219 - const styles = StyleSheet.create({ 220 - container: { 221 - flex: 1, 222 - backgroundColor: Colors[colorScheme ?? 'light'].background, 223 - }, 224 - scrollViewContent: { 225 - flexGrow: 1, 226 - paddingBottom: 20, 227 - }, 228 - profileNavbar: { 229 - flexDirection: 'row', 230 - justifyContent: 'space-between', 231 - alignItems: 'center', 232 - paddingVertical: 10, 233 - paddingHorizontal: 20, 234 - marginBottom: 10, 235 - }, 236 - profileTopText: { 237 - color: Colors[colorScheme ?? 'light'].text, 238 - fontSize: 24, 239 - paddingTop: 2, 240 - }, 241 - profileHeader: { 242 - alignItems: 'center', 243 - width: '100%', 244 - }, 245 - profileHeaderNull: { 246 - alignItems: 'center', 247 - width: '100%', 248 - justifyContent: 'center', 249 - height: '70%', 250 - }, 251 - profileContent: { 252 - marginTop: 20, 253 - height: '100%', 254 - }, 255 - profileTabs: { 256 - flexDirection: 'row', 257 - justifyContent: 'space-around', 258 - marginBottom: 10, 259 - width: '100%', 260 - borderBottomWidth: 1, 261 - borderBottomColor: Colors[colorScheme ?? 'light'].underlineColor, 262 - }, 263 - tabButton: { 264 - flexDirection: 'row', 265 - alignItems: 'center', 266 - justifyContent: 'center', 267 - padding: 10, 268 - }, 269 - videoGrid: { 270 - flexDirection: 'row', 271 - flexWrap: 'wrap', 272 - justifyContent: 'center', 273 - }, 274 - profileActionButtons: { 275 - flexDirection: 'row', 276 - justifyContent: 'center', 277 - gap: 10, 278 - width: '100%', 279 - }, 280 - profileActionButtonsVertical: { 281 - display: 'flex', 282 - flexDirection: 'column', 283 - alignItems: 'center', 284 - gap: 10, 285 - width: '100%', 286 - }, 287 - bottomSheetContent: { 288 - padding: 20, 289 - backgroundColor: Colors[colorScheme ?? 'light'].background, 290 - }, 291 - bottomSheetOption: { 292 - flexDirection: 'row', 293 - alignItems: 'center', 294 - paddingVertical: 15, 295 - borderBottomWidth: 1, 296 - borderBottomColor: Colors[colorScheme ?? 'light'].underlineColor, 297 - }, 298 - bottomSheetOptionText: { 299 - marginLeft: 15, 300 - fontSize: 16, 301 - color: Colors[colorScheme ?? 'light'].text, 302 - }, 303 - bottomSheetOptionTextDanger: { 304 - marginLeft: 15, 305 - fontSize: 16, 306 - color: '#FF3B30', 307 - }, 308 - bottomSheetBackground: { 309 - backgroundColor: Colors[colorScheme ?? 'light'].background, 310 - }, 311 - bottomSheetHandle: { 312 - backgroundColor: Colors[colorScheme ?? 'light'].underlineColor, 313 - }, 314 - }); 315 - 316 - return ( 317 - <SafeAreaView style={styles.container}> 318 - <ContentWrapper> 319 - <ScrollView 320 - contentContainerStyle={styles.scrollViewContent} 321 - showsVerticalScrollIndicator={false} 322 - > 323 - <View style={styles.profileNavbar}> 324 - <TouchableOpacity onPress={() => { 325 - if (params.did) { 326 - router.back(); 327 - } 328 - }}> 329 - <Ionicons 330 - name="chevron-back" 331 - size={24} 332 - color={Colors[colorScheme ?? 'light'].text} 333 - style={{ opacity: params.did ? 1 : 0 }} 334 - /> 335 - </TouchableOpacity> 336 - 337 - <ThemedText style={styles.profileTopText}> 338 - {isMine ? 'My Profile' : userData?.displayName ?? ''} 339 - </ThemedText> 340 - 341 - <TouchableOpacity onPress={openBottomSheet}> 342 - {isLoggedIn && isMine ? ( 343 - <Ionicons 344 - name="settings-outline" 345 - size={24} 346 - color={Colors[colorScheme ?? 'light'].text} 347 - /> 348 - ) : ( 349 - <Ionicons 350 - name="ellipsis-horizontal" 351 - size={24} 352 - color={Colors[colorScheme ?? 'light'].text} 353 - /> 354 - )} 355 - </TouchableOpacity> 356 - </View> 357 - 358 - <View style={!isLoggedIn && isMine ? styles.profileHeaderNull : styles.profileHeader}> 359 - {userData && <ProfilePicture userData={userData} />} 360 - {userData && <ProfileInfo userData={userData} />} 361 - 362 - { 363 - !isLoggedIn && isMine && ( 364 - <View style={styles.profileActionButtonsVertical}> 365 - <ActionButton 366 - type="primary" 367 - title="Registrar" 368 - onPress={() => goTo('register')} 369 - width="60%" 370 - /> 371 - <ActionButton 372 - type="outline" 373 - title="Login" 374 - onPress={() => goTo('login')} 375 - width="60%" 376 - /> 377 - </View> 378 - ) 379 - } 380 - { 381 - !isLoggedIn && !isMine && ( 382 - <ActionButton 383 - type="primary" 384 - title="Follow" 385 - onPress={() => goTo('login')} 386 - width={250} 387 - /> 388 - ) 389 - } 390 - { 391 - isLoggedIn && !isMine && ( 392 - <ActionButton 393 - type="primary" 394 - title="Follow" 395 - onPress={() => { 396 - console.log("followed " + userData?.did); 397 - }} 398 - width={250} 399 - /> 400 - ) 401 - } 402 - { 403 - isLoggedIn && isMine && ( 404 - <View style={styles.profileActionButtons}> 405 - <ActionButton 406 - type="outline" 407 - title="Logout" 408 - onPress={handleLogout} 409 - width="60%" 410 - /> 411 - </View> 412 - ) 413 - } 414 - </View> 415 - 416 - <View style={styles.profileContent}> 417 - { (isLoggedIn || (!isLoggedIn && !isMine)) && 418 - <View style={styles.profileTabs}> 419 - <View style={styles.tabButton}> 420 - <Ionicons 421 - name="film" 422 - size={24} 423 - color={Colors[colorScheme ?? 'light'].selectedIcon} 424 - /> 425 - <ThemedText 426 - style={{ 427 - color: Colors[colorScheme ?? 'light'].selectedIcon, 428 - marginLeft: 5, 429 - }}> 430 - Videos 431 - </ThemedText> 432 - </View> 433 - <View style={styles.tabButton}> 434 - <Ionicons 435 - name="image" 436 - size={24} 437 - color={Colors[colorScheme ?? 'light'].notSelectedIcon} 438 - /> 439 - <ThemedText 440 - style={{ 441 - color: Colors[colorScheme ?? 'light'].notSelectedIcon, 442 - marginLeft: 5, 443 - }}> 444 - Photos 445 - </ThemedText> 446 - </View> 447 - </View> 448 - } 449 - { (isLoggedIn || (!isLoggedIn && !isMine)) && 450 - <View style={styles.videoGrid}> 451 - {paddedVideoData.map((item, index) => { 452 - const key = item.uri ? item.uri : `fallback-${index}`; 453 - 454 - if (item.isPlaceholder) { 455 - return <PlaceholderVideoDisplay key={key} />; 456 - } 457 - return ( 458 - <VideoDisplay 459 - key={key} 460 - videoSource={item} 461 - onVideoPress={handleOpenProfileFeed} 462 - /> 463 - ); 464 - })} 465 - </View> 466 - } 467 - </View> 468 - </ScrollView> 469 - </ContentWrapper> 470 - 471 - <BottomSheet 472 - ref={bottomSheetRef} 473 - index={-1} 474 - snapPoints={snapPoints} 475 - onChange={handleSheetChanges} 476 - enablePanDownToClose 477 - backgroundStyle={styles.bottomSheetBackground} 478 - handleIndicatorStyle={styles.bottomSheetHandle} 479 - > 480 - <BottomSheetView style={styles.bottomSheetContent}> 481 - <TouchableOpacity 482 - style={styles.bottomSheetOption} 483 - onPress={handleProfileSettings} 484 - > 485 - <Ionicons 486 - name="person-circle-outline" 487 - size={24} 488 - color={Colors[colorScheme ?? 'light'].text} 489 - /> 490 - <ThemedText style={styles.bottomSheetOptionText}> 491 - Profile Settings 492 - </ThemedText> 493 - </TouchableOpacity> 494 - 495 - <TouchableOpacity 496 - style={styles.bottomSheetOption} 497 - onPress={handleLogout} 498 - > 499 - <Ionicons 500 - name="log-out-outline" 501 - size={24} 502 - color="#FF3B30" 503 - /> 504 - <ThemedText style={styles.bottomSheetOptionTextDanger}> 505 - Logout 506 - </ThemedText> 507 - </TouchableOpacity> 508 - </BottomSheetView> 509 - </BottomSheet> 510 - </SafeAreaView> 511 - ); 512 - } 513 - 514 - // Utility function to navigate to a profile 515 - export function navigateToProfile(did: string) { 516 - // If navigating to a profile, always include the DID parameter 517 - // This allows the correct handling when coming from different contexts 518 - router.push({ 519 - pathname: "/(tabs)/ProfileScreen", 520 - params: { did } 521 - }); 1 + import React, { useEffect, useState, useRef, useCallback } from 'react'; 2 + import { 3 + Platform, 4 + SafeAreaView, 5 + ScrollView, 6 + StyleSheet, 7 + useColorScheme, 8 + View, 9 + } from 'react-native'; 10 + import { ThemedText } from '@/components/ThemedText'; 11 + import ContentWrapper from '@/components/global/ContentWrapper'; 12 + import { Colors } from '@/constants/Colors'; 13 + import ProfileInfo from '@/components/Profile/ProfileInfo'; 14 + import { did } from '@/constants/MockData'; 15 + import { UserProps, PostProps } from '@/types/Interfaces'; 16 + import { router, useRouter, useLocalSearchParams } from 'expo-router'; 17 + import { getProfile, getProfileMedia } from '@/api/profileServices'; 18 + import useAtProto from '@/hooks/useAtProto'; 19 + import BottomSheet from '@gorhom/bottom-sheet'; 20 + 21 + // Import new modular components 22 + import ProfileHeader from '@/components/Profile/ProfileHeader'; 23 + import ProfileActionButtons from '@/components/Profile/ProfileActionButtons'; 24 + import ProfileTabs from '@/components/Profile/ProfileTabs'; 25 + import ProfileVideoGrid, { padVideosWithPlaceholders } from '@/components/Profile/ProfileVideoGrid'; 26 + import ProfileOptionsBottomSheet from '@/components/Profile/ProfileOptionsBottomSheet'; 27 + 28 + export default function ProfileScreen() { 29 + const colorScheme = useColorScheme(); 30 + const route = useRouter(); 31 + const params = useLocalSearchParams(); 32 + 33 + const { isLoggedIn, session, agent, logout } = useAtProto(); 34 + 35 + const profileDid = typeof params.did === 'string' && params.did ? 36 + params.did : 37 + (isLoggedIn && session ? session.did : did); 38 + 39 + const isMine = isLoggedIn && session && profileDid === session.did; 40 + 41 + const [userData, setUserData] = useState<UserProps | null>(null); 42 + const [videoPosts, setVideoPosts] = useState<PostProps[]>([]); 43 + const [activeTab, setActiveTab] = useState<'videos' | 'photos'>('videos'); 44 + 45 + const bottomSheetRef = useRef<BottomSheet>(null); 46 + 47 + const openBottomSheet = useCallback(() => { 48 + bottomSheetRef.current?.expand(); 49 + }, []); 50 + 51 + const closeBottomSheet = useCallback(() => { 52 + bottomSheetRef.current?.close(); 53 + }, []); 54 + 55 + const loadVideoPosts = async () => { 56 + try { 57 + const mediaPosts = await getProfileMedia(profileDid, 'video'); 58 + const posts = mediaPosts.map((item: any) => item.post); 59 + setVideoPosts(posts); 60 + } catch (error) { 61 + console.error('Error loading video posts:', error); 62 + } 63 + }; 64 + 65 + useEffect(() => { 66 + if (isLoggedIn || (!isLoggedIn && !isMine)) { 67 + const loadProfileData = async () => { 68 + try { 69 + const profileData = await getProfile(profileDid); 70 + if (profileData) { 71 + setUserData({ 72 + id: profileData.did, 73 + did: profileData.did, 74 + displayName: profileData.displayName || (isMine && session?.handle ? session.handle : ''), 75 + handle: profileData.handle || (isMine && session?.handle ? session.handle : ''), 76 + description: profileData.description || '', 77 + avatar: profileData.avatar || '', 78 + banner: profileData.banner || '', 79 + followersCount: profileData.followersCount || 0, 80 + followsCount: profileData.followsCount || 0, 81 + postsCount: profileData.postsCount || 0, 82 + associated: profileData.associated, 83 + joinedViaStarterPack: profileData.joinedViaStarterPack, 84 + indexedAt: profileData.indexedAt || '', 85 + createdAt: profileData.createdAt || '', 86 + viewer: profileData.viewer, 87 + labels: profileData.labels || [], 88 + pinnedPost: profileData.pinnedPost, 89 + }); 90 + } 91 + } catch (error) { 92 + console.error('Error loading profile:', error); 93 + 94 + if (isLoggedIn && session && isMine) { 95 + setUserData({ 96 + id: session.did, 97 + did: session.did, 98 + displayName: session.handle || 'My Profile', 99 + handle: session.handle || '', 100 + description: '', 101 + avatar: '', 102 + banner: '', 103 + followersCount: 0, 104 + followsCount: 0, 105 + postsCount: 0, 106 + indexedAt: '', 107 + createdAt: '', 108 + labels: [], 109 + }); 110 + } 111 + } 112 + }; 113 + 114 + loadProfileData(); 115 + loadVideoPosts(); 116 + } else if (!isLoggedIn && isMine) { 117 + setUserData({ 118 + id: '', 119 + did: '', 120 + displayName: 'Login ou Registrar', 121 + handle: 'null', 122 + description: '', 123 + avatar: 'https://static.sprk.so/branding/default-profile.png?d', 124 + banner: '', 125 + followersCount: 0, 126 + followsCount: 0, 127 + postsCount: 0, 128 + indexedAt: '', 129 + createdAt: '', 130 + labels: [], 131 + }); 132 + } 133 + }, [isLoggedIn, isMine, session, profileDid]); 134 + 135 + const paddedVideoData = padVideosWithPlaceholders(videoPosts); 136 + 137 + function handleOpenProfileFeed(post: PostProps) { 138 + const index = videoPosts.findIndex((video) => video.uri === post.uri); 139 + route.push({ 140 + pathname: '../ProfileFeed', 141 + params: { 142 + videoData: JSON.stringify(videoPosts), 143 + initialIndex: index.toString(), 144 + animation: Platform.OS === 'android' ? 'fade' : 'none' 145 + }, 146 + }); 147 + } 148 + 149 + function goTo(route: string) { 150 + route = route.toLowerCase(); 151 + if (route === 'register') { 152 + router.push('/(auth)/Register', { relativeToDirectory: true }); 153 + } else { 154 + router.push('/(auth)/Login', { relativeToDirectory: true }); 155 + } 156 + } 157 + 158 + const handleProfileSettings = () => { 159 + closeBottomSheet(); 160 + console.log('Navigate to profile settings'); 161 + }; 162 + 163 + const handleLogout = async () => { 164 + try { 165 + closeBottomSheet(); 166 + await logout(); 167 + console.log('User logged out'); 168 + } catch (error) { 169 + console.error('Error logging out:', error); 170 + } 171 + }; 172 + 173 + const handleTabChange = (tab: 'videos' | 'photos') => { 174 + setActiveTab(tab); 175 + }; 176 + 177 + const handleFollow = () => { 178 + console.log("followed " + userData?.did); 179 + }; 180 + 181 + const styles = StyleSheet.create({ 182 + container: { 183 + flex: 1, 184 + backgroundColor: Colors[colorScheme ?? 'light'].background, 185 + }, 186 + scrollViewContent: { 187 + flexGrow: 1, 188 + paddingBottom: 20, 189 + }, 190 + profileHeader: { 191 + alignItems: 'center', 192 + width: '100%', 193 + }, 194 + profileHeaderNull: { 195 + alignItems: 'center', 196 + width: '100%', 197 + justifyContent: 'center', 198 + height: '70%', 199 + }, 200 + profileContent: { 201 + marginTop: 20, 202 + height: '100%', 203 + }, 204 + }); 205 + 206 + return ( 207 + <SafeAreaView style={styles.container}> 208 + <ContentWrapper> 209 + <ScrollView 210 + contentContainerStyle={styles.scrollViewContent} 211 + showsVerticalScrollIndicator={false} 212 + > 213 + <ProfileHeader 214 + title={isMine ? 'My Profile' : userData?.displayName ?? ''} 215 + showBackButton={!!params.did} 216 + onBackPress={() => { 217 + if (params.did) { 218 + router.back(); 219 + } 220 + }} 221 + onSettingsPress={openBottomSheet} 222 + isSettingsVisible={isLoggedIn && isMine} 223 + /> 224 + 225 + <View style={!isLoggedIn && isMine ? styles.profileHeaderNull : styles.profileHeader}> 226 + {userData && <ProfileInfo userData={userData} />} 227 + 228 + <ProfileActionButtons 229 + isLoggedIn={isLoggedIn} 230 + isMine={isMine} 231 + onRegister={() => goTo('register')} 232 + onLogin={() => goTo('login')} 233 + onFollow={handleFollow} 234 + onLogout={handleLogout} 235 + /> 236 + </View> 237 + 238 + <View style={styles.profileContent}> 239 + {(isLoggedIn || (!isLoggedIn && !isMine)) && ( 240 + <> 241 + <ProfileTabs 242 + activeTab={activeTab} 243 + onTabChange={handleTabChange} 244 + /> 245 + 246 + <ProfileVideoGrid 247 + videos={paddedVideoData} 248 + onVideoPress={handleOpenProfileFeed} 249 + /> 250 + </> 251 + )} 252 + </View> 253 + </ScrollView> 254 + </ContentWrapper> 255 + 256 + <ProfileOptionsBottomSheet 257 + bottomSheetRef={bottomSheetRef} 258 + onProfileSettings={handleProfileSettings} 259 + onLogout={handleLogout} 260 + /> 261 + </SafeAreaView> 262 + ); 263 + } 264 + 265 + // Utility function to navigate to a profile 266 + export function navigateToProfile(did: string) { 267 + // If navigating to a profile, always include the DID parameter 268 + // This allows the correct handling when coming from different contexts 269 + router.push({ 270 + pathname: "/(tabs)/ProfileScreen", 271 + params: { did } 272 + }); 522 273 }
+1 -1
app/(tabs)/_layout.tsx
··· 91 91 display: 'flex', 92 92 flexDirection: 'row', 93 93 borderWidth: 0, 94 + safeAreaInsets: { bottom: 30 }, 94 95 }, 95 96 android: { 96 97 backgroundColor: Colors[colorScheme ?? 'light'].background, ··· 112 113 }, 113 114 }), 114 115 }} 115 - safeAreaInsets={{ bottom: 0 }} 116 116 > 117 117 <Tabs.Screen 118 118 name="index"
+96
components/Profile/ProfileActionButtons.tsx
··· 1 + import React from 'react'; 2 + import { View, StyleSheet } from 'react-native'; 3 + import ActionButton from '@/components/global/ActionButton'; 4 + 5 + interface ProfileActionButtonsProps { 6 + isLoggedIn: boolean; 7 + isMine: boolean; 8 + onRegister: () => void; 9 + onLogin: () => void; 10 + onFollow: () => void; 11 + onLogout: () => void; 12 + } 13 + 14 + const ProfileActionButtons = ({ 15 + isLoggedIn, 16 + isMine, 17 + onRegister, 18 + onLogin, 19 + onFollow, 20 + onLogout 21 + }: ProfileActionButtonsProps) => { 22 + 23 + if (!isLoggedIn && isMine) { 24 + return ( 25 + <View style={styles.profileActionButtonsVertical}> 26 + <ActionButton 27 + type="primary" 28 + title="Registrar" 29 + onPress={onRegister} 30 + width="60%" 31 + /> 32 + <ActionButton 33 + type="outline" 34 + title="Login" 35 + onPress={onLogin} 36 + width="60%" 37 + /> 38 + </View> 39 + ); 40 + } 41 + 42 + if (!isLoggedIn && !isMine) { 43 + return ( 44 + <ActionButton 45 + type="primary" 46 + title="Follow" 47 + onPress={onLogin} 48 + width={250} 49 + /> 50 + ); 51 + } 52 + 53 + if (isLoggedIn && !isMine) { 54 + return ( 55 + <ActionButton 56 + type="primary" 57 + title="Follow" 58 + onPress={onFollow} 59 + width={250} 60 + /> 61 + ); 62 + } 63 + 64 + if (isLoggedIn && isMine) { 65 + return ( 66 + <View style={styles.profileActionButtons}> 67 + <ActionButton 68 + type="outline" 69 + title="Logout" 70 + onPress={onLogout} 71 + width="60%" 72 + /> 73 + </View> 74 + ); 75 + } 76 + 77 + return null; 78 + }; 79 + 80 + const styles = StyleSheet.create({ 81 + profileActionButtons: { 82 + flexDirection: 'row', 83 + justifyContent: 'center', 84 + gap: 10, 85 + width: '100%', 86 + }, 87 + profileActionButtonsVertical: { 88 + display: 'flex', 89 + flexDirection: 'column', 90 + alignItems: 'center', 91 + gap: 10, 92 + width: '100%', 93 + }, 94 + }); 95 + 96 + export default ProfileActionButtons;
+66
components/Profile/ProfileDescription.tsx
··· 1 + import React, { useState, useEffect } from 'react'; 2 + import { TouchableOpacity, StyleSheet, Text } from 'react-native'; 3 + import { ThemedText } from '@/components/ThemedText'; 4 + 5 + interface ProfileDescriptionProps { 6 + description?: string; 7 + } 8 + 9 + const ProfileDescription: React.FC<ProfileDescriptionProps> = ({ description }) => { 10 + const [isExpanded, setIsExpanded] = useState(false); 11 + const [textTooLong, setTextTooLong] = useState(false); 12 + 13 + // Reset expanded state when description changes 14 + useEffect(() => { 15 + setIsExpanded(false); 16 + }, [description]); 17 + 18 + const handleTextLayout = (e: any) => { 19 + // Check if the text has been truncated by counting lines 20 + if (e.nativeEvent.lines && e.nativeEvent.lines.length > 2) { 21 + setTextTooLong(true); 22 + } else { 23 + setTextTooLong(false); 24 + } 25 + }; 26 + 27 + const toggleExpand = () => { 28 + setIsExpanded(!isExpanded); 29 + }; 30 + 31 + const styles = StyleSheet.create({ 32 + bioContainer: { 33 + marginVertical: 4, 34 + width: '80%', 35 + }, 36 + bio: { 37 + fontSize: 14, 38 + color: '#888', 39 + textAlign: 'left', 40 + } 41 + }); 42 + 43 + return ( 44 + <TouchableOpacity 45 + style={styles.bioContainer} 46 + onPress={toggleExpand} 47 + activeOpacity={0.7} 48 + > 49 + <ThemedText 50 + type="default" 51 + style={styles.bio} 52 + numberOfLines={isExpanded ? undefined : 2} 53 + ellipsizeMode="tail" 54 + onTextLayout={handleTextLayout} 55 + > 56 + {description || ''} 57 + </ThemedText> 58 + 59 + {textTooLong && !isExpanded && ( 60 + <Text style={{ color: '#888', fontSize: 12, marginTop: 2 }}>...</Text> 61 + )} 62 + </TouchableOpacity> 63 + ); 64 + }; 65 + 66 + export default ProfileDescription;
+30
components/Profile/ProfileHandler.tsx
··· 1 + import React from 'react'; 2 + import { StyleSheet, useColorScheme } from 'react-native'; 3 + import { ThemedText } from '@/components/ThemedText'; 4 + import { Colors } from '@/constants/Colors'; 5 + 6 + interface ProfileHandlerProps { 7 + handle: string; 8 + } 9 + 10 + const ProfileHandler: React.FC<ProfileHandlerProps> = ({ handle }) => { 11 + const colorScheme = useColorScheme(); 12 + 13 + const styles = StyleSheet.create({ 14 + handler: { 15 + fontSize: 16, 16 + fontWeight: 'bold', 17 + color: Colors[colorScheme ?? 'light'].textGray, 18 + }, 19 + }); 20 + 21 + if (handle === 'null') return null; 22 + 23 + return ( 24 + <ThemedText type="username" style={styles.handler}> 25 + @{handle} 26 + </ThemedText> 27 + ); 28 + }; 29 + 30 + export default ProfileHandler;
+73
components/Profile/ProfileHeader.tsx
··· 1 + import React from 'react'; 2 + import { View, TouchableOpacity, StyleSheet, useColorScheme } from 'react-native'; 3 + import { Ionicons } from '@expo/vector-icons'; 4 + import { ThemedText } from '@/components/ThemedText'; 5 + import { Colors } from '@/constants/Colors'; 6 + 7 + interface ProfileHeaderProps { 8 + title: string; 9 + showBackButton: boolean; 10 + onBackPress: () => void; 11 + onSettingsPress: () => void; 12 + isSettingsVisible: boolean; 13 + } 14 + 15 + const ProfileHeader = ({ 16 + title, 17 + showBackButton, 18 + onBackPress, 19 + onSettingsPress, 20 + isSettingsVisible 21 + }: ProfileHeaderProps) => { 22 + const colorScheme = useColorScheme(); 23 + 24 + return ( 25 + <View style={styles.profileNavbar}> 26 + <TouchableOpacity onPress={onBackPress}> 27 + <Ionicons 28 + name="chevron-back" 29 + size={24} 30 + color={Colors[colorScheme ?? 'light'].text} 31 + style={{ opacity: showBackButton ? 1 : 0 }} 32 + /> 33 + </TouchableOpacity> 34 + 35 + <ThemedText style={styles.profileTopText}> 36 + {title} 37 + </ThemedText> 38 + 39 + <TouchableOpacity onPress={onSettingsPress}> 40 + {isSettingsVisible ? ( 41 + <Ionicons 42 + name="settings-outline" 43 + size={24} 44 + color={Colors[colorScheme ?? 'light'].text} 45 + /> 46 + ) : ( 47 + <Ionicons 48 + name="ellipsis-horizontal" 49 + size={24} 50 + color={Colors[colorScheme ?? 'light'].text} 51 + /> 52 + )} 53 + </TouchableOpacity> 54 + </View> 55 + ); 56 + }; 57 + 58 + const styles = StyleSheet.create({ 59 + profileNavbar: { 60 + flexDirection: 'row', 61 + justifyContent: 'space-between', 62 + alignItems: 'center', 63 + paddingVertical: 10, 64 + paddingHorizontal: 20, 65 + marginBottom: 10, 66 + }, 67 + profileTopText: { 68 + fontSize: 24, 69 + paddingTop: 2, 70 + }, 71 + }); 72 + 73 + export default ProfileHeader;
+60 -104
components/Profile/ProfileInfo.tsx
··· 1 - import React from 'react'; 2 - import { View, StyleSheet, useColorScheme } from 'react-native'; 3 - import { ThemedText } from '@/components/ThemedText'; 4 - import { UserProps } from '@/types/Interfaces'; 5 - import { Colors } from '@/constants/Colors'; 6 - 7 - const formatNumber = (num?: number) => { 8 - if (!num) return '0'; 9 - if (num >= 1_000_000_000) return (num / 1_000_000_000).toFixed(1) + 'B'; 10 - if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M'; 11 - if (num >= 1_000) return (num / 1_000).toFixed(1) + 'K'; 12 - return num.toString(); 13 - }; 14 - 15 - const ProfileInfo: React.FC<{ userData: UserProps }> = ({ userData }) => { 16 - const colorScheme = useColorScheme(); 17 - 18 - const styles = StyleSheet.create({ 19 - container: { 20 - alignItems: 'center', 21 - padding: 10, 22 - width: '100%', 23 - }, 24 - nameContainer: { 25 - alignItems: 'center', 26 - width: '80%', 27 - marginBottom: 4, 28 - }, 29 - name: { 30 - fontSize: 18, 31 - fontWeight: 'bold', 32 - }, 33 - handler: { 34 - fontSize: 14, 35 - color: Colors[colorScheme ?? 'light'].textGray, 36 - borderRadius: 4, 37 - paddingVertical: 2, 38 - paddingHorizontal: 8, 39 - }, 40 - statsContainer: { 41 - marginTop: 5, 42 - flexDirection: 'row', 43 - justifyContent: 'space-between', 44 - width: '70%', 45 - }, 46 - statItem: { 47 - alignItems: 'center', 48 - width: '33%', 49 - gap: 0, 50 - }, 51 - statNumber: { 52 - fontSize: 16, 53 - fontWeight: 'bold', 54 - color: Colors[colorScheme ?? 'light'].text, 55 - }, 56 - statLabel: { 57 - fontSize: 12, 58 - color: Colors[colorScheme ?? 'light'].textGray, 59 - }, 60 - bioContainer: { 61 - marginVertical: 4, 62 - width: '80%', 63 - }, 64 - bio: { 65 - fontSize: 14, 66 - color: '#888', 67 - textAlign: 'center', 68 - }, 69 - }); 70 - 71 - return ( 72 - <View style={styles.container}> 73 - <View style={styles.nameContainer}> 74 - <ThemedText type="defaultBold" style={styles.name}>{userData.displayName}</ThemedText> 75 - {userData.handle === 'null' ? null : 76 - <ThemedText type="username" style={styles.handler}>@{userData.handle}</ThemedText> 77 - } 78 - </View> 79 - {userData.handle === 'null' ? null : 80 - <View style={styles.statsContainer}> 81 - <View style={styles.statItem}> 82 - <ThemedText type="defaultBold" style={styles.statNumber}>{formatNumber(userData.followsCount ?? 0)}</ThemedText> 83 - <ThemedText type="default" style={styles.statLabel}>Following</ThemedText> 84 - </View> 85 - <View style={styles.statItem}> 86 - <ThemedText type="defaultBold" style={styles.statNumber}>{formatNumber(userData.followersCount ?? 0)}</ThemedText> 87 - <ThemedText type="default" style={styles.statLabel}>Followers</ThemedText> 88 - </View> 89 - <View style={styles.statItem}> 90 - <ThemedText type="defaultBold" style={styles.statNumber}>{formatNumber(userData.likes ?? 0)}</ThemedText> 91 - <ThemedText type="default" style={styles.statLabel}>Likes</ThemedText> 92 - </View> 93 - </View> 94 - } 95 - <View style={styles.bioContainer}> 96 - <ThemedText type="default" style={styles.bio}>{userData.description}</ThemedText> 97 - </View> 98 - </View> 99 - ); 100 - }; 101 - 102 - 103 - 104 - export default ProfileInfo; 1 + import React from 'react'; 2 + import { View, StyleSheet } from 'react-native'; 3 + import { UserProps } from '@/types/Interfaces'; 4 + import ProfileNumbers from './profileNumbers'; 5 + import ProfileName from './ProfileName'; 6 + import ProfileHandler from './ProfileHandler'; 7 + import ProfilePicture from './ProfilePicture'; 8 + import ProfileDescription from './ProfileDescription'; 9 + 10 + const ProfileInfo: React.FC<{ userData: UserProps }> = ({ userData }) => { 11 + const styles = StyleSheet.create({ 12 + container: { 13 + flexDirection: 'column', 14 + justifyContent: 'center', 15 + width: '100%', 16 + padding: 10, 17 + }, 18 + topContainer: { 19 + flexDirection: 'row', 20 + width: '100%', 21 + alignItems: 'center', 22 + justifyContent: 'center', 23 + marginBottom: 10, 24 + }, 25 + rightContainer: { 26 + flexDirection: 'column', 27 + justifyContent: 'center', 28 + width: 250, 29 + }, 30 + bottomContainer: { 31 + width: '100%', 32 + paddingHorizontal: 20, 33 + } 34 + }); 35 + 36 + return ( 37 + <View style={styles.container}> 38 + <View style={styles.topContainer}> 39 + {userData && <ProfilePicture userData={userData} />} 40 + <View style={styles.rightContainer}> 41 + <ProfileName displayName={userData.displayName} /> 42 + {userData.handle === 'null' ? null : 43 + <ProfileNumbers 44 + followsCount={userData.followsCount} 45 + followersCount={userData.followersCount} 46 + likes={userData.likes} 47 + /> 48 + } 49 + </View> 50 + 51 + </View> 52 + <View style={styles.bottomContainer}> 53 + <ProfileHandler handle={userData.handle} /> 54 + <ProfileDescription description={userData?.description} /> 55 + </View> 56 + </View> 57 + ); 58 + }; 59 + 60 + export default ProfileInfo;
+25
components/Profile/ProfileName.tsx
··· 1 + import React from 'react'; 2 + import { View, StyleSheet } from 'react-native'; 3 + import { ThemedText } from '@/components/ThemedText'; 4 + 5 + interface ProfileNameProps { 6 + displayName: string; 7 + } 8 + 9 + const ProfileName: React.FC<ProfileNameProps> = ({ displayName }) => { 10 + const styles = StyleSheet.create({ 11 + name: { 12 + fontSize: 20, 13 + fontWeight: 'bold', 14 + marginLeft: 15, 15 + }, 16 + }); 17 + 18 + return ( 19 + <ThemedText type="defaultBold" style={styles.name}> 20 + {displayName} 21 + </ThemedText> 22 + ); 23 + }; 24 + 25 + export default ProfileName;
+91
components/Profile/ProfileOptionsBottomSheet.tsx
··· 1 + import React, { useRef, useCallback, useMemo } from 'react'; 2 + import { TouchableOpacity, StyleSheet, useColorScheme, ViewStyle } from 'react-native'; 3 + import { Ionicons } from '@expo/vector-icons'; 4 + import { ThemedText } from '@/components/ThemedText'; 5 + import { Colors } from '@/constants/Colors'; 6 + import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'; 7 + 8 + interface ProfileOptionsBottomSheetProps { 9 + bottomSheetRef: React.RefObject<BottomSheet>; 10 + onProfileSettings: () => void; 11 + onLogout: () => void; 12 + } 13 + 14 + const ProfileOptionsBottomSheet = ({ 15 + bottomSheetRef, 16 + onProfileSettings, 17 + onLogout, 18 + }: ProfileOptionsBottomSheetProps) => { 19 + const colorScheme = useColorScheme(); 20 + const snapPoints = useMemo(() => ['25%'], []); 21 + 22 + const handleSheetChanges = useCallback((index: number) => { 23 + console.log('handleSheetChanges', index); 24 + }, []); 25 + 26 + return ( 27 + <BottomSheet 28 + ref={bottomSheetRef} 29 + index={-1} 30 + snapPoints={snapPoints} 31 + onChange={handleSheetChanges} 32 + enablePanDownToClose 33 + backgroundStyle={{ backgroundColor: Colors[colorScheme ?? 'light'].background }} 34 + handleIndicatorStyle={{ backgroundColor: Colors[colorScheme ?? 'light'].underlineColor }} 35 + > 36 + <BottomSheetView style={styles.bottomSheetContent}> 37 + <TouchableOpacity 38 + style={styles.bottomSheetOption} 39 + onPress={onProfileSettings} 40 + > 41 + <Ionicons 42 + name="person-circle-outline" 43 + size={24} 44 + color={Colors[colorScheme ?? 'light'].text} 45 + /> 46 + <ThemedText style={styles.bottomSheetOptionText}> 47 + Profile Settings 48 + </ThemedText> 49 + </TouchableOpacity> 50 + 51 + <TouchableOpacity 52 + style={styles.bottomSheetOption} 53 + onPress={onLogout} 54 + > 55 + <Ionicons 56 + name="log-out-outline" 57 + size={24} 58 + color="#FF3B30" 59 + /> 60 + <ThemedText style={styles.bottomSheetOptionTextDanger}> 61 + Logout 62 + </ThemedText> 63 + </TouchableOpacity> 64 + </BottomSheetView> 65 + </BottomSheet> 66 + ); 67 + }; 68 + 69 + const styles = StyleSheet.create({ 70 + bottomSheetContent: { 71 + padding: 20, 72 + }, 73 + bottomSheetOption: { 74 + flexDirection: 'row' as const, 75 + alignItems: 'center' as const, 76 + paddingVertical: 15, 77 + borderBottomWidth: 1, 78 + borderBottomColor: 'rgba(150, 150, 150, 0.2)', 79 + }, 80 + bottomSheetOptionText: { 81 + marginLeft: 15, 82 + fontSize: 16, 83 + }, 84 + bottomSheetOptionTextDanger: { 85 + marginLeft: 15, 86 + fontSize: 16, 87 + color: '#FF3B30', 88 + }, 89 + }); 90 + 91 + export default ProfileOptionsBottomSheet;
+88
components/Profile/ProfileTabs.tsx
··· 1 + import React from 'react'; 2 + import { View, StyleSheet, useColorScheme, TouchableOpacity } from 'react-native'; 3 + import { Ionicons } from '@expo/vector-icons'; 4 + import { ThemedText } from '@/components/ThemedText'; 5 + import { Colors } from '@/constants/Colors'; 6 + 7 + interface ProfileTabsProps { 8 + activeTab: 'videos' | 'photos'; 9 + onTabChange: (tab: 'videos' | 'photos') => void; 10 + } 11 + 12 + const ProfileTabs = ({ activeTab, onTabChange }: ProfileTabsProps) => { 13 + const colorScheme = useColorScheme(); 14 + 15 + return ( 16 + <View style={styles.profileTabs}> 17 + <TouchableOpacity 18 + style={styles.tabButton} 19 + onPress={() => onTabChange('videos')} 20 + > 21 + <Ionicons 22 + name="film" 23 + size={24} 24 + color={ 25 + activeTab === 'videos' 26 + ? Colors[colorScheme ?? 'light'].selectedIcon 27 + : Colors[colorScheme ?? 'light'].notSelectedIcon 28 + } 29 + /> 30 + <ThemedText 31 + style={{ 32 + color: 33 + activeTab === 'videos' 34 + ? Colors[colorScheme ?? 'light'].selectedIcon 35 + : Colors[colorScheme ?? 'light'].notSelectedIcon, 36 + marginLeft: 5, 37 + }} 38 + > 39 + Videos 40 + </ThemedText> 41 + </TouchableOpacity> 42 + <TouchableOpacity 43 + style={styles.tabButton} 44 + onPress={() => onTabChange('photos')} 45 + > 46 + <Ionicons 47 + name="image" 48 + size={24} 49 + color={ 50 + activeTab === 'photos' 51 + ? Colors[colorScheme ?? 'light'].selectedIcon 52 + : Colors[colorScheme ?? 'light'].notSelectedIcon 53 + } 54 + /> 55 + <ThemedText 56 + style={{ 57 + color: 58 + activeTab === 'photos' 59 + ? Colors[colorScheme ?? 'light'].selectedIcon 60 + : Colors[colorScheme ?? 'light'].notSelectedIcon, 61 + marginLeft: 5, 62 + }} 63 + > 64 + Photos 65 + </ThemedText> 66 + </TouchableOpacity> 67 + </View> 68 + ); 69 + }; 70 + 71 + const styles = StyleSheet.create({ 72 + profileTabs: { 73 + flexDirection: 'row', 74 + justifyContent: 'space-around', 75 + marginBottom: 10, 76 + width: '100%', 77 + borderBottomWidth: 1, 78 + borderBottomColor: 'rgba(150, 150, 150, 0.2)', 79 + }, 80 + tabButton: { 81 + flexDirection: 'row', 82 + alignItems: 'center', 83 + justifyContent: 'center', 84 + padding: 10, 85 + }, 86 + }); 87 + 88 + export default ProfileTabs;
+81
components/Profile/ProfileVideoGrid.tsx
··· 1 + import React from 'react'; 2 + import { View, StyleSheet } from 'react-native'; 3 + import VideoDisplay from '@/components/global/VideoDisplay'; 4 + import PlaceholderVideoDisplay from '@/components/Profile/PlaceholderVideoDisplay'; 5 + import { PostProps } from '@/types/Interfaces'; 6 + 7 + interface ProfileVideoGridProps { 8 + videos: (PostProps & { isPlaceholder?: boolean })[]; 9 + onVideoPress: (post: PostProps) => void; 10 + } 11 + 12 + const ProfileVideoGrid = ({ videos, onVideoPress }: ProfileVideoGridProps) => { 13 + return ( 14 + <View style={styles.videoGrid}> 15 + {videos.map((item, index) => { 16 + const key = item.uri ? item.uri : `fallback-${index}`; 17 + 18 + if (item.isPlaceholder) { 19 + return <PlaceholderVideoDisplay key={key} />; 20 + } 21 + 22 + return ( 23 + <VideoDisplay 24 + key={key} 25 + videoSource={item} 26 + onVideoPress={onVideoPress} 27 + /> 28 + ); 29 + })} 30 + </View> 31 + ); 32 + }; 33 + 34 + // Utility function to ensure the grid is filled with placeholders if needed 35 + export function padVideosWithPlaceholders( 36 + videos: (PostProps & { isPlaceholder?: boolean })[] 37 + ): (PostProps & { isPlaceholder?: boolean })[] { 38 + const remainder = videos.length % 3; 39 + const placeholdersNeeded = remainder === 0 ? 0 : 3 - remainder; 40 + 41 + const placeholders: (PostProps & { isPlaceholder?: boolean })[] = Array( 42 + placeholdersNeeded 43 + ) 44 + .fill(null) 45 + .map((_, i) => ({ 46 + uri: `placeholder-${i}`, 47 + cid: '', 48 + author: { 49 + did: '', 50 + handle: '', 51 + displayName: '', 52 + avatar: '', 53 + banner: '', 54 + }, 55 + record: { 56 + $type: 'app.bsky.feed.post', 57 + createdAt: '', 58 + text: '', 59 + langs: [], 60 + }, 61 + replyCount: 0, 62 + repostCount: 0, 63 + likeCount: 0, 64 + quoteCount: 0, 65 + indexedAt: '', 66 + labels: [], 67 + isPlaceholder: true, 68 + })); 69 + 70 + return [...videos, ...placeholders]; 71 + } 72 + 73 + const styles = StyleSheet.create({ 74 + videoGrid: { 75 + flexDirection: 'row', 76 + flexWrap: 'wrap', 77 + justifyContent: 'center', 78 + }, 79 + }); 80 + 81 + export default ProfileVideoGrid;
+80
components/Profile/profileNumbers.tsx
··· 1 + import React from 'react'; 2 + import { View, StyleSheet, useColorScheme } from 'react-native'; 3 + import { ThemedText } from '@/components/ThemedText'; 4 + import { Colors } from '@/constants/Colors'; 5 + 6 + interface ProfileNumbersProps { 7 + followsCount?: number; 8 + followersCount?: number; 9 + likes?: number; 10 + } 11 + 12 + const formatNumber = (num?: number) => { 13 + if (!num) return '0'; 14 + if (num >= 1_000_000_000) return (num / 1_000_000_000).toFixed(1) + 'B'; 15 + if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M'; 16 + if (num >= 1_000) return (num / 1_000).toFixed(1) + 'K'; 17 + return num.toString(); 18 + }; 19 + 20 + const ProfileNumbers: React.FC<ProfileNumbersProps> = ({ 21 + followsCount, 22 + followersCount, 23 + likes, 24 + }) => { 25 + const colorScheme = useColorScheme(); 26 + 27 + const styles = StyleSheet.create({ 28 + statsContainer: { 29 + marginTop: 5, 30 + flexDirection: 'row', 31 + justifyContent: 'space-between', 32 + width: '100%', 33 + }, 34 + statItem: { 35 + alignItems: 'center', 36 + width: '33%', 37 + gap: 0, 38 + }, 39 + statNumber: { 40 + fontSize: 16, 41 + fontWeight: 'bold', 42 + color: Colors[colorScheme ?? 'light'].text, 43 + }, 44 + statLabel: { 45 + fontSize: 12, 46 + color: Colors[colorScheme ?? 'light'].textGray, 47 + }, 48 + }); 49 + 50 + return ( 51 + <View style={styles.statsContainer}> 52 + <View style={styles.statItem}> 53 + <ThemedText type="defaultBold" style={styles.statNumber}> 54 + {formatNumber(followsCount)} 55 + </ThemedText> 56 + <ThemedText type="default" style={styles.statLabel}> 57 + Following 58 + </ThemedText> 59 + </View> 60 + <View style={styles.statItem}> 61 + <ThemedText type="defaultBold" style={styles.statNumber}> 62 + {formatNumber(followersCount)} 63 + </ThemedText> 64 + <ThemedText type="default" style={styles.statLabel}> 65 + Followers 66 + </ThemedText> 67 + </View> 68 + <View style={styles.statItem}> 69 + <ThemedText type="defaultBold" style={styles.statNumber}> 70 + {formatNumber(likes)} 71 + </ThemedText> 72 + <ThemedText type="default" style={styles.statLabel}> 73 + Likes 74 + </ThemedText> 75 + </View> 76 + </View> 77 + ); 78 + }; 79 + 80 + export default ProfileNumbers;