From 0cdfd1a0eb8a4dce5eaf18c3d853cd00a3e39d59 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Tue, 11 Mar 2025 14:40:34 -0500 Subject: [PATCH] Just stopping now bc PC needs to reboot. --- app/(tabs)/index.tsx | 5 +- app/(tabs)/settings/profile.tsx | 17 +- components/auth/Profile_Avatar.tsx | 11 +- components/status/StatusCard.tsx | 213 +++++++++++ components/status/StatusList.tsx | 543 +++++++++++++++++++++++++++++ constants/Types.ts | 12 + package-lock.json | 30 ++ package.json | 10 +- scripts/supabase_schema.sql | 46 ++- 9 files changed, 872 insertions(+), 15 deletions(-) create mode 100644 components/status/StatusCard.tsx create mode 100644 components/status/StatusList.tsx diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index cd221da..6b6cbf2 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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 = () => { } > - + + + ); }; diff --git a/app/(tabs)/settings/profile.tsx b/app/(tabs)/settings/profile.tsx index b5aa178..8c25eb7 100644 --- a/app/(tabs)/settings/profile.tsx +++ b/app/(tabs)/settings/profile.tsx @@ -105,12 +105,14 @@ const ProfileScreen = () => { return ( - + + + {profile.provider && ( @@ -160,6 +162,9 @@ const styles = StyleSheet.create({ padding: 16, alignItems: 'center', }, + avatarContainer: { + marginVertical: 20, + }, loadingContainer: { flex: 1, justifyContent: 'center', diff --git a/components/auth/Profile_Avatar.tsx b/components/auth/Profile_Avatar.tsx index 82b455d..abcb7e5 100644 --- a/components/auth/Profile_Avatar.tsx +++ b/components/auth/Profile_Avatar.tsx @@ -149,9 +149,13 @@ export default function ProfileAvatar({ {uploading ? ( ) : ( - - {disabled ? 'Avatar' : 'Change Photo'} - + disabled ? ( + + ) : ( + + Change Photo + + ) )} ); @@ -160,7 +164,6 @@ export default function ProfileAvatar({ const styles = StyleSheet.create({ avatarContainer: { alignItems: 'center', - marginVertical: 20, }, avatar: { backgroundColor: '#E1E1E1', diff --git a/components/status/StatusCard.tsx b/components/status/StatusCard.tsx new file mode 100644 index 0000000..738752a --- /dev/null +++ b/components/status/StatusCard.tsx @@ -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 ( + + + + + + + + + + + + + {user.profiles.full_name} + + + Current: {user.status} + + + + + New Status + + + {newStatus.length}/80 + + + + + + + Cancel + + + + + ); +} + +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', + }, +}); diff --git a/components/status/StatusList.tsx b/components/status/StatusList.tsx new file mode 100644 index 0000000..71d5ce6 --- /dev/null +++ b/components/status/StatusList.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [showStatusCard, setShowStatusCard] = useState(false); + const [recentlyUpdatedIds, setRecentlyUpdatedIds] = useState>(new Set()); + const [isConnected, setIsConnected] = useState(true); + const [lastFetchTime, setLastFetchTime] = useState(null); + + const fadeAnimation = useRef(new Animated.Value(0)).current; + const isFocused = useIsFocused(); + const subscriptionRef = useRef(null); + const appStateRef = useRef(AppState.currentState); + const pendingUpdatesRef = useRef>(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(); + + // 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 ( + handleUserSelect(item)} + activeOpacity={0.7} + > + + + + + + + + {item.profiles.full_name} + + + + + + {item.status} + + + + + + {formattedDate.time} + + + {formattedDate.date} + + + + ); + }, [formatDate, handleUserSelect, recentlyUpdatedIds]); + + // Empty list component + const ListEmptyComponent = useCallback(() => ( + + + {isConnected ? 'No statuses available' : 'You are offline'} + + + ), [isConnected]); + + if (loading) { + return ( + + + + ); + } + + return ( + + {!isConnected && ( + + You are offline + + )} + + item.id} + refreshControl={ + + } + renderItem={renderItem} + ListEmptyComponent={ListEmptyComponent} + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={10} + removeClippedSubviews={Platform.OS !== 'web'} + getItemLayout={(data, index) => ( + {length: 120, offset: 120 * index, index} + )} + /> + + {selectedUser && ( + setShowStatusCard(false)} + onUpdate={handleStatusUpdate} + /> + )} + + ); +}; + +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', + }, +}); diff --git a/constants/Types.ts b/constants/Types.ts index c07878a..b66cb85 100644 --- a/constants/Types.ts +++ b/constants/Types.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index 8c8e5f5..67255b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5e71a06..cbad66c 100644 --- a/package.json +++ b/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", diff --git a/scripts/supabase_schema.sql b/scripts/supabase_schema.sql index 74ada70..253d361 100644 --- a/scripts/supabase_schema.sql +++ b/scripts/supabase_schema.sql @@ -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;