diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml new file mode 100644 index 0000000..782af35 --- /dev/null +++ b/.github/workflows/deno.yml @@ -0,0 +1,42 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow will install Deno then run `deno lint` and `deno test`. +# For more information see: https://github.com/denoland/setup-deno + +name: Deno + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Setup Deno + # uses: denoland/setup-deno@v1 + uses: denoland/setup-deno@61fe2df320078202e33d7d5ad347e7dcfa0e8f31 # v1.1.2 + with: + deno-version: v1.x + + # Uncomment this step to verify the use of 'deno fmt' on each commit. + # - name: Verify formatting + # run: deno fmt --check + + - name: Run linter + run: deno lint + + - name: Run tests + run: deno test -A diff --git a/app.json b/app.json index cd64aa1..af4310a 100644 --- a/app.json +++ b/app.json @@ -17,13 +17,20 @@ ], "ios": { "supportsTablet": true, - "bundleIdentifier": "com.supersimon.whatsapp" + "bundleIdentifier": "com.supersimon.whatsapp", + "infoPlist": { + "NSContactsUsageDescription": "We need access to your contacts to sync them in the app" + } }, "android": { "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive-icon.png", "backgroundColor": "#ffffff" }, + "permissions": [ + "android.permission.READ_CONTACTS", + "android.permission.READ_PHONE_STATE" + ], "package": "com.supersimon.whatsapp" }, "web": { @@ -34,7 +41,13 @@ "plugins": [ "expo-router", "expo-secure-store", - "expo-font" + "expo-font", + [ + "expo-contacts", + { + "contactsPermission": "We need access to your contacts to sync them in the app" + } + ] ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/new-chat.tsx b/app/(tabs)/new-chat.tsx new file mode 100644 index 0000000..039b4ab --- /dev/null +++ b/app/(tabs)/new-chat.tsx @@ -0,0 +1,195 @@ +import React, { useEffect } from 'react'; +import { + View, + Text, + TouchableOpacity, + ActivityIndicator, + StyleSheet, + RefreshControl, + SectionList, + Image, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { useSyncContacts, type SyncedContact } from '@/hooks/useSyncContacts'; + +const NewChatScreen = () => { + const insets = useSafeAreaInsets(); + const { contacts, loading, error, permissionDenied, syncContacts, refetch } = useSyncContacts(); + + useEffect(() => { + syncContacts(); + }, [syncContacts]); + + const getSectionedContacts = () => { + if (contacts.length === 0) return []; + const sections: { title: string; data: SyncedContact[] }[] = []; + const contactsByLetter: { [key: string]: SyncedContact[] } = {}; + contacts.forEach((contact) => { + const firstLetter = (contact.device_contact_name || 'A')[0].toUpperCase(); + if (!contactsByLetter[firstLetter]) { + contactsByLetter[firstLetter] = []; + } + contactsByLetter[firstLetter].push(contact); + }); + Object.keys(contactsByLetter) + .sort() + .forEach((letter) => { + sections.push({ title: letter, data: contactsByLetter[letter] }); + }); + return sections; + }; + + const handleContactPress = (contact: SyncedContact) => { + router.push({ + pathname: '/chat/[id]', + params: { id: contact.user_id }, + }); + }; + + const renderContactItem = ({ item }: { item: SyncedContact }) => ( + handleContactPress(item)} + activeOpacity={0.7} + > + + {item.avatar_url ? ( + + ) : ( + + {item.device_contact_name[0].toUpperCase()} + + )} + + + + {item.device_contact_name} + + + {item.phone} + + + + + ); + + const renderSectionHeader = ({ section }: { section: any }) => ( + + {section.title} + + ); + + const renderEmptyState = () => ( + + {permissionDenied ? ( + <> + + Permission Denied + + Please grant access to contacts in app settings + + + ) : contacts.length === 0 && !loading ? ( + <> + + No Contacts + + No matching contacts found + + + ) : null} + + ); + + const renderErrorState = () => ( + + + Error + {error} + + Try Again + + + ); + + const sections = getSectionedContacts(); + + return ( + + + router.back()}> + + + New Chat + + + + {loading && sections.length === 0 ? ( + + + Syncing contacts... + + ) : error && sections.length === 0 ? ( + renderErrorState() + ) : sections.length === 0 ? ( + renderEmptyState() + ) : ( + item.id} + renderItem={renderContactItem} + renderSectionHeader={renderSectionHeader} + stickySectionHeadersEnabled={true} + refreshControl={} + contentContainerStyle={styles.listContent} + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fff' }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + headerTitle: { fontSize: 18, fontWeight: '600', color: '#000' }, + loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + loadingText: { marginTop: 12, fontSize: 16, color: '#666', fontWeight: '500' }, + listContent: { paddingBottom: 20 }, + sectionHeader: { paddingHorizontal: 16, paddingVertical: 8, backgroundColor: '#f5f5f5' }, + sectionTitle: { fontSize: 14, fontWeight: '600', color: '#666' }, + contactItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + avatarContainer: { marginRight: 12 }, + avatar: { width: 50, height: 50, borderRadius: 25, backgroundColor: '#e0e0e0' }, + avatarPlaceholder: { justifyContent: 'center', alignItems: 'center', backgroundColor: '#075E54' }, + avatarText: { fontSize: 20, fontWeight: '600', color: '#fff' }, + contactInfo: { flex: 1 }, + contactName: { fontSize: 16, fontWeight: '500', color: '#000', marginBottom: 4 }, + contactPhone: { fontSize: 14, color: '#999' }, + emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32 }, + emptyTitle: { fontSize: 18, fontWeight: '600', color: '#000', marginTop: 16 }, + emptySubtitle: { fontSize: 14, color: '#999', marginTop: 8, textAlign: 'center' }, + errorContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32 }, + errorTitle: { fontSize: 18, fontWeight: '600', color: '#ff6b6b', marginTop: 16 }, + errorMessage: { fontSize: 14, color: '#666', marginTop: 8, textAlign: 'center' }, + retryButton: { marginTop: 20, paddingHorizontal: 24, paddingVertical: 12, backgroundColor: '#075E54', borderRadius: 8 }, + retryButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' }, +}); + +export default NewChatScreen; diff --git a/package.json b/package.json index 3597d50..c64f884 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ "@clerk/clerk-expo": "^0.20.1", "@expo/vector-icons": "^14.0.0", "@react-navigation/native": "^6.0.2", + "@react-native-async-storage/async-storage": "^1.21.0", "date-fns": "^3.3.1", "expo": "^50.0.2", + "expo-contacts": "^14.5.0", "expo-dev-client": "~3.3.6", "expo-font": "~11.10.2", "expo-haptics": "~12.8.1", diff --git a/supabase/migrations/001_add_phone_to_profiles.sql b/supabase/migrations/001_add_phone_to_profiles.sql new file mode 100644 index 0000000..bf46826 --- /dev/null +++ b/supabase/migrations/001_add_phone_to_profiles.sql @@ -0,0 +1,53 @@ +-- Create profiles table with phone field for contact syncing +CREATE TABLE IF NOT EXISTS public.profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + phone TEXT NOT NULL UNIQUE, + full_name TEXT NOT NULL, + avatar_url TEXT, + email TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) +); + +-- Create indexes for faster queries +CREATE INDEX IF NOT EXISTS idx_profiles_phone ON public.profiles(phone); +CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON public.profiles(user_id); + +-- Enable Row Level Security +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can view all profiles +DROP POLICY IF EXISTS "Users can view profiles" ON public.profiles; +CREATE POLICY "Users can view profiles" + ON public.profiles FOR SELECT + USING (auth.uid() IS NOT NULL); + +-- Policy: Users can update their own profile +DROP POLICY IF EXISTS "Users can update their own profile" ON public.profiles; +CREATE POLICY "Users can update their own profile" + ON public.profiles FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- Policy: Users can insert their own profile +DROP POLICY IF EXISTS "Users can insert their own profile" ON public.profiles; +CREATE POLICY "Users can insert their own profile" + ON public.profiles FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- Function to automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to call the update function +DROP TRIGGER IF EXISTS update_profiles_updated_at ON public.profiles; +CREATE TRIGGER update_profiles_updated_at + BEFORE UPDATE ON public.profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column();