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();