Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/deno.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +25 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pin checkout action to hash and disable credential persistence.

For security consistency with the pinned denoland/setup-deno action, actions/checkout should also be pinned to a commit hash rather than a tag. Additionally, set persist-credentials: false to prevent the GITHUB_TOKEN from being exposed in subsequent steps where it's not needed.

🔒 Proposed security hardening
       - name: Setup repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
+        with:
+          persist-credentials: false

As per static analysis hints, the unpinned action reference and missing credential persistence settings were flagged by the zizmor tool.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Setup repo
uses: actions/checkout@v4
- name: Setup repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 25-26: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 26-26: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deno.yml around lines 25 - 26, Update the GitHub Actions
checkout step so it is pinned to a specific commit hash instead of the tag and
disable credential persistence; locate the "uses: actions/checkout@v4" line in
the workflow and replace the tag with the repo commit SHA (e.g.,
actions/checkout@<commit-hash>) and add the key "persist-credentials: false"
under that step to prevent GITHUB_TOKEN from being persisted.


- 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
17 changes: 15 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Comment on lines +30 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove READ_PHONE_STATE unless the app really uses telephony state.

Contact syncing needs READ_CONTACTS, not phone-state access. Keeping READ_PHONE_STATE expands the privacy surface and can create avoidable consent/store-review friction.

🔒 Proposed permission trim
       "permissions": [
-        "android.permission.READ_CONTACTS",
-        "android.permission.READ_PHONE_STATE"
+        "android.permission.READ_CONTACTS"
       ],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"permissions": [
"android.permission.READ_CONTACTS",
"android.permission.READ_PHONE_STATE"
],
"permissions": [
"android.permission.READ_CONTACTS"
],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.json` around lines 30 - 33, The permissions list in app.json
unnecessarily includes "android.permission.READ_PHONE_STATE"; remove this
permission from the "permissions" array and keep only
"android.permission.READ_CONTACTS" (or any other permissions your app actually
uses) so the app does not request telephony state; update any related
documentation or rationale comments if present to reflect the trimmed permission
set.

"package": "com.supersimon.whatsapp"
},
"web": {
Expand All @@ -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,
Expand Down
195 changes: 195 additions & 0 deletions app/(tabs)/new-chat.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<TouchableOpacity
style={styles.contactItem}
onPress={() => handleContactPress(item)}
activeOpacity={0.7}
>
<View style={styles.avatarContainer}>
{item.avatar_url ? (
<Image source={{ uri: item.avatar_url }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>{item.device_contact_name[0].toUpperCase()}</Text>
</View>
Comment on lines +58 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard the avatar initial for empty contact names.

This path indexes item.device_contact_name[0] unconditionally. If a synced contact has an empty or missing display name, the screen will crash while rendering the placeholder avatar.

🛡️ Proposed guard
         ) : (
           <View style={[styles.avatar, styles.avatarPlaceholder]}>
-            <Text style={styles.avatarText}>{item.device_contact_name[0].toUpperCase()}</Text>
+            <Text style={styles.avatarText}>
+              {(item.device_contact_name?.trim()?.[0] ?? '#').toUpperCase()}
+            </Text>
           </View>
         )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{item.avatar_url ? (
<Image source={{ uri: item.avatar_url }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>{item.device_contact_name[0].toUpperCase()}</Text>
</View>
{item.avatar_url ? (
<Image source={{ uri: item.avatar_url }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{(item.device_contact_name?.trim()?.[0] ?? '#').toUpperCase()}
</Text>
</View>
)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(tabs)/new-chat.tsx around lines 58 - 63, The placeholder avatar
rendering assumes item.device_contact_name[0] exists and crashes for empty or
missing names; update the conditional in the new-chat.tsx render block to safely
derive an initial (e.g., compute const initial = item.device_contact_name ?
item.device_contact_name.trim()[0]?.toUpperCase() : null) and render that
initial only when present, otherwise render a fallback (such as a default icon
or empty string). Locate the JSX that uses item.device_contact_name[0] (the
avatar placeholder branch) and replace the direct index access with a guarded
value using trim() and optional chaining before calling toUpperCase().

)}
</View>
<View style={styles.contactInfo}>
<Text style={styles.contactName} numberOfLines={1}>
{item.device_contact_name}
</Text>
<Text style={styles.contactPhone} numberOfLines={1}>
{item.phone}
</Text>
</View>
<MaterialCommunityIcons name="chevron-right" size={24} color="#ccc" />
</TouchableOpacity>
);

const renderSectionHeader = ({ section }: { section: any }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
);

const renderEmptyState = () => (
<View style={styles.emptyContainer}>
{permissionDenied ? (
<>
<MaterialCommunityIcons name="phone-off" size={64} color="#ccc" />
<Text style={styles.emptyTitle}>Permission Denied</Text>
<Text style={styles.emptySubtitle}>
Please grant access to contacts in app settings
</Text>
</>
) : contacts.length === 0 && !loading ? (
<>
<MaterialCommunityIcons name="contacts" size={64} color="#ccc" />
<Text style={styles.emptyTitle}>No Contacts</Text>
<Text style={styles.emptySubtitle}>
No matching contacts found
</Text>
</>
) : null}
</View>
);

const renderErrorState = () => (
<View style={styles.errorContainer}>
<MaterialCommunityIcons name="alert-circle" size={64} color="#ff6b6b" />
<Text style={styles.errorTitle}>Error</Text>
<Text style={styles.errorMessage}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={syncContacts}>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
);

const sections = getSectionedContacts();

return (
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<MaterialCommunityIcons name="arrow-left" size={24} color="#000" />
</TouchableOpacity>
<Text style={styles.headerTitle}>New Chat</Text>
<View style={{ width: 24 }} />
</View>

{loading && sections.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#075E54" />
<Text style={styles.loadingText}>Syncing contacts...</Text>
</View>
) : error && sections.length === 0 ? (
renderErrorState()
) : sections.length === 0 ? (
renderEmptyState()
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderItem={renderContactItem}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled={true}
refreshControl={<RefreshControl refreshing={loading} onRefresh={refetch} tintColor="#075E54" />}
contentContainerStyle={styles.listContent}
/>
)}
</View>
);
};

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;
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines 21 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Which expo-contactsversion is compatible with Expo SDK 50, and does Expo recommend~-pinned versions for Expo SDK packages in package.json?

💡 Result:

Expo SDK 50 compatible expo-contacts version: use expo-contacts sdk-50 tag version 12.8.2. The npm registry dist-tags for expo-contacts include “sdk-50”: “12.8.2” [1]. Does Expo recommend ~-pinned versions for Expo SDK packages in package.json? Expo’s guidance is to use expo install (and expo install --fix / --check) to realign dependency versions with the versions expected for your current Expo SDK, rather than manually guessing version ranges [2]. The dependency-reconciliation doc explains tested ranges (including that ~1.2.3 allows v1.2.3 plus a greater patch version) but it does not recommend “always use ~” specifically for Expo SDK packages; instead it emphasizes using the CLI to keep your versions aligned to the SDK expectations [2].

Citations:


Align expo-contacts with Expo SDK 50 (package.json, lines 21–22).

expo-contacts is set to ^14.5.0, but Expo’s SDK 50 compatible expo-contacts version is 12.8.2 (npm sdk-50 dist-tag). Update expo-contacts to the SDK 50 version (or run npx expo install --fix to reconcile automatically).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 21 - 22, The package.json entry for the dependency
"expo-contacts" is incompatible with Expo SDK 50; update the "expo-contacts"
version string from "^14.5.0" to the SDK-50 compatible "12.8.2" (or run npx expo
install --fix to reconcile all Expo package versions automatically) so it
matches the installed "expo" ^50.0.2; change the "expo-contacts" value in
package.json and then reinstall (npm/yarn) to update lockfiles.

"expo-dev-client": "~3.3.6",
"expo-font": "~11.10.2",
"expo-haptics": "~12.8.1",
Expand Down
53 changes: 53 additions & 0 deletions supabase/migrations/001_add_phone_to_profiles.sql
Original file line number Diff line number Diff line change
@@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Normalize phone numbers before enforcing uniqueness.

UNIQUE on raw TEXT will treat the same number as different values when formatting changes (+1..., spaces, dashes, local prefixes). That will make contact matching unreliable and can create duplicate logical identities. Store a canonical value such as E.164 in the unique/indexed field, or add a dedicated normalized column and query against that.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/001_add_phone_to_profiles.sql` at line 5, The migration
currently adds phone TEXT NOT NULL UNIQUE which enforces uniqueness on raw
formatting; change it to add a separate normalized column (e.g.,
phone_normalized) and enforce uniqueness on that column instead: keep the
original phone TEXT for display, add phone_normalized TEXT NOT NULL UNIQUE, and
populate/maintain it via a BEFORE INSERT OR UPDATE trigger that calls a
normalize_phone function (create normalize_phone(text) to produce canonical
E.164-style values or at least digits-only + country normalization); update the
migration to create the normalize_phone function and trigger (e.g.,
normalize_phone_trigger) so all new and updated rows store a canonical value in
phone_normalized used by the UNIQUE constraint.

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);
Comment on lines +20 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Restrict profile reads instead of exposing the whole table.

USING (auth.uid() IS NOT NULL) lets any signed-in user enumerate every profile row, including phone numbers and emails. For contact sync, reads should be limited to the caller's allowed match set via a narrower policy or an RPC that performs the phone matching server-side.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/001_add_phone_to_profiles.sql` around lines 20 - 24, The
current SELECT policy "Users can view profiles" on public.profiles uses USING
(auth.uid() IS NOT NULL) which allows any signed-in user to read all profile
rows; replace it with a tighter policy so SELECT only returns the caller's own
profile (e.g., restrict USING to compare the profile's owner column to
auth.uid(), such as USING (user_id = auth.uid()) on public.profiles) and remove
or narrow any global read grants; for contact-sync functionality, implement a
server-side RPC (e.g., match_contacts) that accepts phone hashes and performs
the matching on the DB side, returning only allowed fields, instead of
broadening the SELECT policy.


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