544 lines
16 KiB
TypeScript
544 lines
16 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';
|
|
|
|
const StatusList = () => {
|
|
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());
|
|
|
|
// 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,
|
|
]}
|
|
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]);
|
|
|
|
// 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>
|
|
)}
|
|
|
|
<FlatList
|
|
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}
|
|
)}
|
|
/>
|
|
|
|
{selectedUser && (
|
|
<StatusCard
|
|
visible={showStatusCard}
|
|
user={selectedUser}
|
|
onClose={() => setShowStatusCard(false)}
|
|
onUpdate={handleStatusUpdate}
|
|
/>
|
|
)}
|
|
</ThemedView>
|
|
);
|
|
};
|
|
|
|
export default React.memo(StatusList);
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
statusItem: {
|
|
flexDirection: 'row',
|
|
backgroundColor: 'rgba(200, 200, 200, 0.1)',
|
|
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',
|
|
},
|
|
});
|