Just stopping now bc PC needs to reboot.
This commit is contained in:
parent
cfcf118275
commit
0cdfd1a0eb
@ -1,6 +1,7 @@
|
||||
import { Image, StyleSheet, Platform } from 'react-native';
|
||||
import ParallaxScrollView from '@/components/default/ParallaxScrollView';
|
||||
import { ThemedText, ThemedView } from '@/components/theme';
|
||||
import StatusList from '@/components/status/StatusList';
|
||||
|
||||
const HomeScreen = () => {
|
||||
return (
|
||||
@ -14,7 +15,9 @@ const HomeScreen = () => {
|
||||
</ThemedText>
|
||||
}
|
||||
>
|
||||
<ThemedView style={styles.titleContainer}></ThemedView>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<StatusList />
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
@ -105,12 +105,14 @@ const ProfileScreen = () => {
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||
<ThemedView style={styles.container}>
|
||||
<ProfileAvatar
|
||||
url={profile.avatar_url}
|
||||
size={120}
|
||||
onUpload={handleAvatarUpload}
|
||||
disabled={updating}
|
||||
/>
|
||||
<ThemedView style={styles.avatarContainer}>
|
||||
<ProfileAvatar
|
||||
url={profile.avatar_url}
|
||||
size={120}
|
||||
onUpload={handleAvatarUpload}
|
||||
disabled={updating}
|
||||
/>
|
||||
</ThemedView>
|
||||
|
||||
{profile.provider && (
|
||||
<ThemedText style={styles.providerText}>
|
||||
@ -160,6 +162,9 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarContainer: {
|
||||
marginVertical: 20,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
|
@ -149,9 +149,13 @@ export default function ProfileAvatar({
|
||||
{uploading ? (
|
||||
<ActivityIndicator style={styles.uploadingIndicator} size="small" color="#007AFF" />
|
||||
) : (
|
||||
<ThemedText style={styles.changePhotoText}>
|
||||
{disabled ? 'Avatar' : 'Change Photo'}
|
||||
</ThemedText>
|
||||
disabled ? (
|
||||
<ThemedView />
|
||||
) : (
|
||||
<ThemedText style={styles.changePhotoText}>
|
||||
Change Photo
|
||||
</ThemedText>
|
||||
)
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@ -160,7 +164,6 @@ export default function ProfileAvatar({
|
||||
const styles = StyleSheet.create({
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
marginVertical: 20,
|
||||
},
|
||||
avatar: {
|
||||
backgroundColor: '#E1E1E1',
|
||||
|
213
components/status/StatusCard.tsx
Normal file
213
components/status/StatusCard.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
// components/status/StatusCard.tsx
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
View,
|
||||
ActivityIndicator,
|
||||
Alert
|
||||
} from 'react-native';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ThemedView, ThemedText, ThemedTextInput, ThemedTextButton } from '@/components/theme';
|
||||
import ProfileAvatar from '@/components/auth/Profile_Avatar';
|
||||
|
||||
interface StatusCardProps {
|
||||
visible: boolean;
|
||||
user: {
|
||||
user_id: string;
|
||||
status: string;
|
||||
profiles: {
|
||||
full_name: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
};
|
||||
onClose: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function StatusCard({ visible, user, onClose, onUpdate }: StatusCardProps) {
|
||||
const [newStatus, setNewStatus] = useState('');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const handleUpdateStatus = async () => {
|
||||
if (!newStatus.trim() || newStatus.trim().length < 3) {
|
||||
Alert.alert('Invalid Status', 'Status must be at least 3 characters long.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const { data: { user: currentUser } } = await supabase.auth.getUser();
|
||||
|
||||
if (!currentUser) throw new Error('Not authenticated');
|
||||
|
||||
// Insert new status
|
||||
const { error } = await supabase
|
||||
.from('statuses')
|
||||
.insert({
|
||||
user_id: user.user_id,
|
||||
status: newStatus.trim()
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setNewStatus('');
|
||||
onUpdate();
|
||||
|
||||
} catch (error) {
|
||||
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to update status');
|
||||
console.error('Status update error:', error);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.modalOverlay} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardAvoidingView}
|
||||
>
|
||||
<ThemedView style={styles.modalContent}>
|
||||
<View style={styles.handle} />
|
||||
|
||||
<View style={styles.userInfoContainer}>
|
||||
<ProfileAvatar
|
||||
url={user.profiles.avatar_url}
|
||||
size={60}
|
||||
disabled={true}
|
||||
/>
|
||||
<ThemedText style={styles.userName}>
|
||||
{user.profiles.full_name}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.currentStatus}>
|
||||
Current: {user.status}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<ThemedView style={styles.inputContainer}>
|
||||
<ThemedText style={styles.inputLabel}>New Status</ThemedText>
|
||||
<ThemedTextInput
|
||||
value={newStatus}
|
||||
onChangeText={setNewStatus}
|
||||
placeholder="What's happening?"
|
||||
maxLength={80}
|
||||
multiline
|
||||
style={styles.input}
|
||||
editable={!updating}
|
||||
/>
|
||||
<ThemedText style={styles.charCount}>
|
||||
{newStatus.length}/80
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedTextButton
|
||||
text={updating ? 'Updating...' : 'Update Status'}
|
||||
onPress={handleUpdateStatus}
|
||||
disabled={updating || newStatus.trim().length < 3}
|
||||
fontSize={18}
|
||||
fontWeight='semibold'
|
||||
width='100%'
|
||||
style={styles.updateButton}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={onClose}
|
||||
disabled={updating}
|
||||
>
|
||||
<ThemedText style={styles.cancelText}>Cancel</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: 20,
|
||||
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#ccc',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
userInfoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
userName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 10,
|
||||
},
|
||||
currentStatus: {
|
||||
fontSize: 16,
|
||||
marginTop: 5,
|
||||
opacity: 0.7,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
fontSize: 16,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 8,
|
||||
minHeight: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
charCount: {
|
||||
fontSize: 12,
|
||||
alignSelf: 'flex-end',
|
||||
marginTop: 4,
|
||||
opacity: 0.6,
|
||||
},
|
||||
updateButton: {
|
||||
borderRadius: 8,
|
||||
marginBottom: 15,
|
||||
},
|
||||
cancelButton: {
|
||||
alignItems: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: 16,
|
||||
color: '#FF3B30',
|
||||
},
|
||||
});
|
543
components/status/StatusList.tsx
Normal file
543
components/status/StatusList.tsx
Normal file
@ -0,0 +1,543 @@
|
||||
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',
|
||||
},
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
|
||||
export type updateUser = {
|
||||
id?: string;
|
||||
updated_at?: Date;
|
||||
@ -7,6 +8,17 @@ export type updateUser = {
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
export type UserStatus = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
profiles: {
|
||||
full_name: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type NotificationMessage = {
|
||||
sound?: string;
|
||||
title: string;
|
||||
|
30
package-lock.json
generated
30
package-lock.json
generated
@ -15,10 +15,12 @@
|
||||
"@expo/ngrok": "4.1.0",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@supabase/supabase-js": "^2.48.1",
|
||||
"aes-js": "^3.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~52.0.28",
|
||||
"expo-apple-authentication": "~7.1.3",
|
||||
"expo-auth-session": "~6.0.3",
|
||||
@ -45,6 +47,7 @@
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
@ -61,6 +64,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"jest": "^29.2.1",
|
||||
@ -3813,6 +3817,15 @@
|
||||
"react-native": "^0.0.0-0 || >=0.60 <1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/netinfo": {
|
||||
"version": "11.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
|
||||
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.59"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.76.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.6.tgz",
|
||||
@ -5281,6 +5294,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
|
||||
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
|
||||
@ -7328,6 +7348,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
|
10
package.json
10
package.json
@ -24,10 +24,12 @@
|
||||
"@expo/ngrok": "4.1.0",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@supabase/supabase-js": "^2.48.1",
|
||||
"aes-js": "^3.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~52.0.28",
|
||||
"expo-apple-authentication": "~7.1.3",
|
||||
"expo-auth-session": "~6.0.3",
|
||||
@ -38,6 +40,8 @@
|
||||
"expo-font": "~13.0.3",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-image": "~2.0.6",
|
||||
"expo-image-manipulator": "~13.0.6",
|
||||
"expo-image-picker": "~16.0.6",
|
||||
"expo-insights": "~0.8.2",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-location": "~18.0.7",
|
||||
@ -52,6 +56,7 @@
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
@ -63,13 +68,12 @@
|
||||
"react-native-svg": "15.8.0",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.12.5",
|
||||
"expo-image-picker": "~16.0.6",
|
||||
"expo-image-manipulator": "~13.0.6"
|
||||
"react-native-webview": "13.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"jest": "^29.2.1",
|
||||
|
@ -7,7 +7,7 @@ create table profiles (
|
||||
avatar_url text,
|
||||
provider text,
|
||||
|
||||
constraint full_name_length check (char_length(full_name) >= 3 and char_length(full_name) <= 50),
|
||||
constraint full_name_length check (char_length(full_name) >= 3 and char_length(full_name) <= 50)
|
||||
);
|
||||
-- Set up Row Level Security (RLS)
|
||||
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
|
||||
@ -57,3 +57,47 @@ create policy "Avatar images are publicly accessible." on storage.objects
|
||||
|
||||
create policy "Anyone can upload an avatar." on storage.objects
|
||||
for insert with check (bucket_id = 'avatars');
|
||||
|
||||
|
||||
-- Create a table for public statuses
|
||||
CREATE TABLE statuses (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
status text NOT NULL,
|
||||
CONSTRAINT status_length CHECK (char_length(status) >= 3 AND char_length(status) <= 80),
|
||||
CONSTRAINT statuses_user_id_fkey FOREIGN KEY (user_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Set up Row Level Security (RLS)
|
||||
ALTER TABLE statuses
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
CREATE POLICY "Public statuses are viewable by everyone." ON statuses
|
||||
FOR SELECT USING (true);
|
||||
|
||||
CREATE POLICY "Users can insert their own statuses." ON statuses
|
||||
FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- Function to add first status
|
||||
CREATE FUNCTION public.handle_first_status()
|
||||
RETURNS TRIGGER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.statuses (user_id, status)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
'Just joined!'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create a separate trigger for the status
|
||||
CREATE TRIGGER on_auth_user_created_add_status
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE PROCEDURE public.handle_first_status();
|
||||
|
||||
alter publication supabase_realtime add table statuses;
|
||||
|
Loading…
x
Reference in New Issue
Block a user