Just stopping now bc PC needs to reboot.

This commit is contained in:
Gabriel Brown 2025-03-11 14:40:34 -05:00
parent cfcf118275
commit 0cdfd1a0eb
9 changed files with 872 additions and 15 deletions

View File

@ -1,6 +1,7 @@
import { Image, StyleSheet, Platform } from 'react-native'; import { Image, StyleSheet, Platform } from 'react-native';
import ParallaxScrollView from '@/components/default/ParallaxScrollView'; import ParallaxScrollView from '@/components/default/ParallaxScrollView';
import { ThemedText, ThemedView } from '@/components/theme'; import { ThemedText, ThemedView } from '@/components/theme';
import StatusList from '@/components/status/StatusList';
const HomeScreen = () => { const HomeScreen = () => {
return ( return (
@ -14,7 +15,9 @@ const HomeScreen = () => {
</ThemedText> </ThemedText>
} }
> >
<ThemedView style={styles.titleContainer}></ThemedView> <ThemedView style={styles.titleContainer}>
<StatusList />
</ThemedView>
</ParallaxScrollView> </ParallaxScrollView>
); );
}; };

View File

@ -105,12 +105,14 @@ const ProfileScreen = () => {
return ( return (
<ScrollView contentContainerStyle={styles.scrollContainer}> <ScrollView contentContainerStyle={styles.scrollContainer}>
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ProfileAvatar <ThemedView style={styles.avatarContainer}>
url={profile.avatar_url} <ProfileAvatar
size={120} url={profile.avatar_url}
onUpload={handleAvatarUpload} size={120}
disabled={updating} onUpload={handleAvatarUpload}
/> disabled={updating}
/>
</ThemedView>
{profile.provider && ( {profile.provider && (
<ThemedText style={styles.providerText}> <ThemedText style={styles.providerText}>
@ -160,6 +162,9 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
alignItems: 'center', alignItems: 'center',
}, },
avatarContainer: {
marginVertical: 20,
},
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',

View File

@ -149,9 +149,13 @@ export default function ProfileAvatar({
{uploading ? ( {uploading ? (
<ActivityIndicator style={styles.uploadingIndicator} size="small" color="#007AFF" /> <ActivityIndicator style={styles.uploadingIndicator} size="small" color="#007AFF" />
) : ( ) : (
<ThemedText style={styles.changePhotoText}> disabled ? (
{disabled ? 'Avatar' : 'Change Photo'} <ThemedView />
</ThemedText> ) : (
<ThemedText style={styles.changePhotoText}>
Change Photo
</ThemedText>
)
)} )}
</TouchableOpacity> </TouchableOpacity>
); );
@ -160,7 +164,6 @@ export default function ProfileAvatar({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
avatarContainer: { avatarContainer: {
alignItems: 'center', alignItems: 'center',
marginVertical: 20,
}, },
avatar: { avatar: {
backgroundColor: '#E1E1E1', backgroundColor: '#E1E1E1',

View 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',
},
});

View 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',
},
});

View File

@ -1,3 +1,4 @@
export type updateUser = { export type updateUser = {
id?: string; id?: string;
updated_at?: Date; updated_at?: Date;
@ -7,6 +8,17 @@ export type updateUser = {
provider?: string; 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 = { export type NotificationMessage = {
sound?: string; sound?: string;
title: string; title: string;

30
package-lock.json generated
View File

@ -15,10 +15,12 @@
"@expo/ngrok": "4.1.0", "@expo/ngrok": "4.1.0",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@react-native-async-storage/async-storage": "^1.23.1", "@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/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@supabase/supabase-js": "^2.48.1", "@supabase/supabase-js": "^2.48.1",
"aes-js": "^3.1.2", "aes-js": "^3.1.2",
"date-fns": "^4.1.0",
"expo": "~52.0.28", "expo": "~52.0.28",
"expo-apple-authentication": "~7.1.3", "expo-apple-authentication": "~7.1.3",
"expo-auth-session": "~6.0.3", "expo-auth-session": "~6.0.3",
@ -45,6 +47,7 @@
"expo-web-browser": "~14.0.2", "expo-web-browser": "~14.0.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.6", "react-native": "0.76.6",
@ -61,6 +64,7 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/lodash": "^4.17.16",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-test-renderer": "^18.3.0", "@types/react-test-renderer": "^18.3.0",
"jest": "^29.2.1", "jest": "^29.2.1",
@ -3813,6 +3817,15 @@
"react-native": "^0.0.0-0 || >=0.60 <1.0" "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": { "node_modules/@react-native/assets-registry": {
"version": "0.76.6", "version": "0.76.6",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.6.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.6.tgz",
@ -5281,6 +5294,13 @@
"@types/node": "*" "@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": { "node_modules/@types/node": {
"version": "22.13.9", "version": "22.13.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
@ -7328,6 +7348,16 @@
"node": ">=12" "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": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",

View File

@ -24,10 +24,12 @@
"@expo/ngrok": "4.1.0", "@expo/ngrok": "4.1.0",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@react-native-async-storage/async-storage": "^1.23.1", "@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/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@supabase/supabase-js": "^2.48.1", "@supabase/supabase-js": "^2.48.1",
"aes-js": "^3.1.2", "aes-js": "^3.1.2",
"date-fns": "^4.1.0",
"expo": "~52.0.28", "expo": "~52.0.28",
"expo-apple-authentication": "~7.1.3", "expo-apple-authentication": "~7.1.3",
"expo-auth-session": "~6.0.3", "expo-auth-session": "~6.0.3",
@ -38,6 +40,8 @@
"expo-font": "~13.0.3", "expo-font": "~13.0.3",
"expo-haptics": "~14.0.1", "expo-haptics": "~14.0.1",
"expo-image": "~2.0.6", "expo-image": "~2.0.6",
"expo-image-manipulator": "~13.0.6",
"expo-image-picker": "~16.0.6",
"expo-insights": "~0.8.2", "expo-insights": "~0.8.2",
"expo-linking": "~7.0.5", "expo-linking": "~7.0.5",
"expo-location": "~18.0.7", "expo-location": "~18.0.7",
@ -52,6 +56,7 @@
"expo-web-browser": "~14.0.2", "expo-web-browser": "~14.0.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.6", "react-native": "0.76.6",
@ -63,13 +68,12 @@
"react-native-svg": "15.8.0", "react-native-svg": "15.8.0",
"react-native-svg-transformer": "^1.5.0", "react-native-svg-transformer": "^1.5.0",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"react-native-webview": "13.12.5", "react-native-webview": "13.12.5"
"expo-image-picker": "~16.0.6",
"expo-image-manipulator": "~13.0.6"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/lodash": "^4.17.16",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-test-renderer": "^18.3.0", "@types/react-test-renderer": "^18.3.0",
"jest": "^29.2.1", "jest": "^29.2.1",

View File

@ -7,7 +7,7 @@ create table profiles (
avatar_url text, avatar_url text,
provider 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) -- Set up Row Level Security (RLS)
-- See https://supabase.com/docs/guides/auth/row-level-security for more details. -- 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 create policy "Anyone can upload an avatar." on storage.objects
for insert with check (bucket_id = 'avatars'); 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;