2025-03-17 09:35:27 -05:00

612 lines
18 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Animated,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
ActivityIndicator,
AppState,
AppStateStatus,
Platform,
} from 'react-native';
import { supabase } from '@/lib/supabase';
import { ThemedView, ThemedText } from '@/components/theme';
import { formatDistanceToNow } from 'date-fns';
import StatusCard from './StatusCard';
import ProfileAvatar from '@/components/auth/Profile_Avatar';
import { useIsFocused } from '@react-navigation/native';
import { RealtimeChannel } from '@supabase/supabase-js';
import { UserStatus } from '@/constants/Types';
import debounce from 'lodash/debounce';
import NetInfo from '@react-native-community/netinfo';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useBottomTabOverflow } from '@/components/ui/TabBarBackground';
const HEADER_HEIGHT = 150;
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
type StatusListProps = {
headerImage?: React.ReactNode;
headerTitle?: React.ReactNode;
};
const StatusList = ({headerImage, headerTitle}: StatusListProps) => {
const scheme = useColorScheme() ?? 'dark';
const bottom = useBottomTabOverflow();
const [statuses, setStatuses] = useState<UserStatus[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserStatus | null>(null);
const [showStatusCard, setShowStatusCard] = useState(false);
const [recentlyUpdatedIds, setRecentlyUpdatedIds] = useState<Set<string>>(new Set());
const [isConnected, setIsConnected] = useState(true);
const [lastFetchTime, setLastFetchTime] = useState<Date | null>(null);
const fadeAnimation = useRef(new Animated.Value(0)).current;
const isFocused = useIsFocused();
const subscriptionRef = useRef<RealtimeChannel | null>(null);
const appStateRef = useRef(AppState.currentState);
const pendingUpdatesRef = useRef<Set<string>>(new Set());
// Parallax animation setup
const scrollY = useRef(new Animated.Value(0)).current;
const headerTranslateY = scrollY.interpolate({
inputRange: [0, HEADER_HEIGHT],
outputRange: [0, -HEADER_HEIGHT/2],
extrapolate: 'clamp',
});
const headerScale = scrollY.interpolate({
inputRange: [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
outputRange: [2, 1, 1],
extrapolate: 'clamp',
});
// Debounced version of the status update handler
const debouncedHandleStatusUpdates = useRef(
debounce(() => {
if (pendingUpdatesRef.current.size > 0) {
const statusesToFetch = Array.from(pendingUpdatesRef.current);
pendingUpdatesRef.current.clear();
// Fetch all pending status updates in a single query
fetchMultipleStatuses(statusesToFetch);
}
}, 500)
).current;
// Fetch multiple statuses at once
const fetchMultipleStatuses = async (statusIds: string[]) => {
if (statusIds.length === 0) return;
try {
const { data, error } = await supabase
.from('statuses')
.select(`
id,
user_id,
status,
created_at,
profiles:profiles(full_name, avatar_url)
`)
.in('id', statusIds);
if (error) throw error;
if (data && data.length > 0) {
// Transform the data
const transformedData = data.map(item => ({
...item,
profiles: Array.isArray(item.profiles) ? item.profiles[0] : item.profiles
}));
// Update statuses
setStatuses(prevStatuses => {
const newStatuses = [...prevStatuses];
const updatedIds = new Set<string>();
// Process each new status
transformedData.forEach(newStatus => {
const existingIndex = newStatuses.findIndex(s => s.user_id === newStatus.user_id);
if (existingIndex !== -1) {
// If the new status is more recent, replace the existing one
if (new Date(newStatus.created_at) > new Date(newStatuses[existingIndex].created_at)) {
newStatuses[existingIndex] = newStatus;
updatedIds.add(newStatus.id);
}
} else {
// If this is a new user, add to the array
newStatuses.push(newStatus);
updatedIds.add(newStatus.id);
}
});
// Sort by most recent
newStatuses.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
// Mark updated statuses for highlighting
if (updatedIds.size > 0) {
setRecentlyUpdatedIds(prev => {
const newSet = new Set(prev);
updatedIds.forEach(id => newSet.add(id));
// Schedule removal of highlights
setTimeout(() => {
setRecentlyUpdatedIds(current => {
const updatedSet = new Set(current);
updatedIds.forEach(id => updatedSet.delete(id));
return updatedSet;
});
}, 3000);
return newSet;
});
// Animate the fade-in
Animated.sequence([
Animated.timing(fadeAnimation, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnimation, {
toValue: 0,
duration: 300,
delay: 2000,
useNativeDriver: true,
}),
]).start();
}
return newStatuses;
});
}
} catch (error) {
console.error('Error fetching multiple statuses:', error);
}
};
// Fetch statuses with time filtering
const fetchStatuses = useCallback(async (forceRefresh = false) => {
try {
if (!isConnected) {
console.log('Skipping fetch - device is offline');
setLoading(false);
setRefreshing(false);
return;
}
// Get current time
const now = new Date();
setLastFetchTime(now);
// Calculate time filter - only get statuses from the last week
// unless it's a force refresh
let query = supabase
.from('statuses')
.select(`
id,
user_id,
status,
created_at,
profiles:profiles(full_name, avatar_url)
`)
.order('created_at', { ascending: false });
if (!forceRefresh) {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
query = query.gte('created_at', oneWeekAgo.toISOString());
}
const { data, error } = await query;
if (error) throw error;
if (data) {
// Transform the data to match our expected type
const transformedData = data.map(item => ({
...item,
profiles: Array.isArray(item.profiles) ? item.profiles[0] : item.profiles
}));
// Get unique users with their latest status
const userMap = new Map();
transformedData.forEach(status => {
if (!userMap.has(status.user_id) ||
new Date(status.created_at) > new Date(userMap.get(status.user_id).created_at)) {
userMap.set(status.user_id, status);
}
});
// Convert map to array and sort by most recent
const latestUserStatuses = Array.from(userMap.values());
latestUserStatuses.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
setStatuses(latestUserStatuses);
}
} catch (error) {
console.error('Error fetching statuses:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [isConnected]);
// Handle individual status update
const handleNewStatus = useCallback((statusId: string) => {
// Add to pending updates
pendingUpdatesRef.current.add(statusId);
// Trigger the debounced handler
debouncedHandleStatusUpdates();
}, [debouncedHandleStatusUpdates]);
// Set up network connectivity listener
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => {
setIsConnected(state.isConnected ?? false);
// If we're coming back online and we have a last fetch time
// that's more than 1 minute old, refresh the data
if (state.isConnected && lastFetchTime) {
const now = new Date();
const timeDiff = now.getTime() - lastFetchTime.getTime();
if (timeDiff > 60000) { // 1 minute
fetchStatuses();
}
}
});
return () => {
unsubscribe();
};
}, [fetchStatuses, lastFetchTime]);
// Set up AppState listener for background/foreground transitions
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (
appStateRef.current.match(/inactive|background/) &&
nextAppState === 'active'
) {
console.log('App has come to the foreground!');
// Refresh data if we've been in the background for a while
if (lastFetchTime) {
const now = new Date();
const timeDiff = now.getTime() - lastFetchTime.getTime();
if (timeDiff > 60000) { // 1 minute
fetchStatuses();
}
}
// Reconnect to realtime if needed
if (!subscriptionRef.current && isFocused) {
setupRealtimeSubscription();
}
}
appStateRef.current = nextAppState;
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription.remove();
};
}, [fetchStatuses, isFocused, lastFetchTime]);
// Set up realtime subscription
const setupRealtimeSubscription = useCallback(() => {
// Get only statuses from the last week to reduce payload
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const subscription = supabase
.channel('status_changes')
.on('postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'statuses',
filter: `created_at>gt.${oneWeekAgo.toISOString()}`
},
(payload) => {
console.log('New status received:', payload);
handleNewStatus(payload.new.id);
}
)
.subscribe((status) => {
console.log('Realtime subscription status:', status);
});
subscriptionRef.current = subscription;
return subscription;
}, [handleNewStatus]);
// Set up real-time subscription when component is focused
useEffect(() => {
if (isFocused && isConnected) {
// Initial fetch
fetchStatuses();
// Set up real-time subscription
const subscription = setupRealtimeSubscription();
// Clean up subscription when component unmounts or loses focus
return () => {
if (subscription) {
supabase.removeChannel(subscription);
subscriptionRef.current = null;
}
};
}
}, [isFocused, isConnected, fetchStatuses, setupRealtimeSubscription]);
// Handle refresh
const onRefresh = useCallback(() => {
setRefreshing(true);
fetchStatuses(true); // Force refresh to get all statuses
}, [fetchStatuses]);
const handleUserSelect = useCallback((user: UserStatus) => {
setSelectedUser(user);
setShowStatusCard(true);
}, []);
const handleStatusUpdate = useCallback(() => {
setShowStatusCard(false);
// No need to manually fetch statuses here since the real-time subscription will handle it
}, []);
const formatDate = useCallback((dateString: string) => {
const date = new Date(dateString);
return {
time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
date: date.toLocaleDateString([], { month: 'short', day: 'numeric' }),
relative: formatDistanceToNow(date, { addSuffix: true })
};
}, []);
// Memoize the list item renderer for better performance
const renderItem = useCallback(({ item }: { item: UserStatus }) => {
const formattedDate = formatDate(item.created_at);
return (
<TouchableOpacity
style={[
styles.statusItem,
recentlyUpdatedIds.has(item.id) && styles.recentlyUpdated,
{backgroundColor: Colors[scheme].card},
]}
onPress={() => handleUserSelect(item)}
activeOpacity={0.7}
>
<ThemedView style={styles.contentContainer}>
<ThemedView style={styles.topRow}>
<ThemedView style={styles.userContainer}>
<ThemedView style={styles.avatarContainer}>
<ProfileAvatar
url={item.profiles.avatar_url}
size={30}
disabled={true}
/>
</ThemedView>
<ThemedText
type='custom'
fontSize={24}
fontWeight='bold'
>
{item.profiles.full_name}
</ThemedText>
</ThemedView>
</ThemedView>
<ThemedView style={styles.bottomRow}>
<ThemedText type='custom' fontSize={18} style={styles.statusText}>
{item.status}
</ThemedText>
</ThemedView>
</ThemedView>
<ThemedView style={styles.timeContainer}>
<ThemedText type='custom' fontSize={20} fontWeight='semibold'>
{formattedDate.time}
</ThemedText>
<ThemedText type='custom' fontSize={20} fontWeight='500'>
{formattedDate.date}
</ThemedText>
</ThemedView>
</TouchableOpacity>
);
}, [formatDate, handleUserSelect, recentlyUpdatedIds]);
// Render the header component
const renderHeader = () => (
<Animated.View
style={[
styles.header,
{
backgroundColor: Colors[scheme].accent,
transform: [
{ translateY: headerTranslateY },
{ scale: headerScale }
]
}
]}
>
{headerImage}
{headerTitle}
</Animated.View>
);
// Empty list component
const ListEmptyComponent = useCallback(() => (
<ThemedView style={styles.emptyContainer}>
<ThemedText style={styles.emptyText}>
{isConnected ? 'No statuses available' : 'You are offline'}
</ThemedText>
</ThemedView>
), [isConnected]);
if (loading) {
return (
<ThemedView style={styles.loadingContainer}>
<ActivityIndicator size="large" />
</ThemedView>
);
}
return (
<ThemedView style={styles.container}>
{!isConnected && (
<ThemedView style={styles.offlineBar}>
<ThemedText style={styles.offlineText}>You are offline</ThemedText>
</ThemedView>
)}
{renderHeader()}
<AnimatedFlatList
data={statuses}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
enabled={isConnected}
/>
}
renderItem={renderItem}
ListEmptyComponent={ListEmptyComponent}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={10}
removeClippedSubviews={Platform.OS !== 'web'}
getItemLayout={(data, index) => (
{length: 120, offset: 120 * index, index}
)}
contentContainerStyle={{
paddingTop: HEADER_HEIGHT, // Add padding to account for the header
paddingBottom: bottom,
paddingHorizontal: 16,
}}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
/>
{selectedUser && (
<StatusCard
visible={showStatusCard}
user={selectedUser}
onClose={() => setShowStatusCard(false)}
onUpdate={handleStatusUpdate}
/>
)}
</ThemedView>
);
};
export default React.memo(StatusList);
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
overflow: 'hidden',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
statusItem: {
flexDirection: 'row',
padding: 16,
borderRadius: 12,
marginHorizontal: 16,
marginVertical: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
height: 104, // Fixed height for getItemLayout optimization
},
recentlyUpdated: {
backgroundColor: 'rgba(100, 200, 255, 0.1)',
borderLeftWidth: 3,
borderLeftColor: '#4C9EFF',
},
userContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
},
avatarContainer: {
marginRight: 4,
backgroundColor: 'transparent',
},
contentContainer: {
flex: 1,
justifyContent: 'space-between',
backgroundColor: 'transparent',
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
},
timeContainer: {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
bottomRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: 'transparent',
},
statusText: {
flex: 1,
marginLeft: 8,
marginRight: 4,
},
emptyContainer: {
padding: 20,
alignItems: 'center',
},
emptyText: {
fontSize: 16,
opacity: 0.6,
},
offlineBar: {
backgroundColor: '#FF3B30',
padding: 8,
alignItems: 'center',
},
offlineText: {
color: 'white',
fontWeight: 'bold',
},
});