Refactor profile screen and organize project structure

- Extract ProfileScreen and components to features/profile
- Move shared hooks and stores to top-level
- Add RLS policies for admin permissions
- Add RELEASING.md guide
- Update .gitignore
This commit is contained in:
Jamie Brunton committed 2026-01-01 23:03:43 +00:00
1 parent df4c4d486b
commit 8f8c6e401d
79 files changed
+6682 -4937

No files matched your search

+1 -1
View File
@@ -134,7 +134,7 @@ function App() {
<Route <Route
path="/feedback" path="/feedback"
element={ element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}> <ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_FEEDBACK}>
<FeedbackPage /> <FeedbackPage />
</ProtectedRoute> </ProtectedRoute>
} }
+1 -1
View File
@@ -71,7 +71,7 @@ const usersNav: NavItem[] = [
label: 'Feedback', label: 'Feedback',
href: '/feedback', href: '/feedback',
icon: <MessageSquareText className="h-5 w-5" />, icon: <MessageSquareText className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.VIEW_USERS, requiredPermission: PERMISSION_KEYS.MANAGE_FEEDBACK,
}, },
]; ];
+2
View File
@@ -19,6 +19,7 @@ export const PERMISSION_KEYS = {
VIEW_MATCHES: 'view_matches', VIEW_MATCHES: 'view_matches',
VIEW_RESPONSES: 'view_responses', VIEW_RESPONSES: 'view_responses',
VIEW_ACTIVITY: 'view_activity', VIEW_ACTIVITY: 'view_activity',
MANAGE_FEEDBACK: 'manage_feedback',
// System permissions // System permissions
MANAGE_CODES: 'manage_codes', MANAGE_CODES: 'manage_codes',
MANAGE_ADMINS: 'manage_admins', MANAGE_ADMINS: 'manage_admins',
@@ -40,6 +41,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
[PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' }, [PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' },
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' }, [PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' }, [PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
[PERMISSION_KEYS.MANAGE_FEEDBACK]: { label: 'Can manage feedback', description: 'View and respond to user feedback', group: 'users' },
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' }, [PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' }, [PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' }, [PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
@@ -31,6 +31,8 @@ import {
MessageCircle, MessageCircle,
UserPlus, UserPlus,
Image as ImageIcon, Image as ImageIcon,
Video as VideoIcon,
ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
// Types // Types
@@ -75,6 +77,7 @@ interface MessageActivity {
user_id: string; user_id: string;
content: string | null; content: string | null;
media_path: string | null; media_path: string | null;
media_type: 'image' | 'video' | null;
created_at: string; created_at: string;
profile: { profile: {
id: string; id: string;
@@ -232,7 +235,7 @@ export function UserActivityPage() {
const { data: messagesData, error: messagesError } = await supabase const { data: messagesData, error: messagesError } = await supabase
.from('messages') .from('messages')
.select(` .select(`
id, user_id, content, media_path, created_at, id, user_id, content, media_path, media_type, created_at,
match:matches( match:matches(
id, id,
question:questions(text) question:questions(text)
@@ -267,6 +270,7 @@ export function UserActivityPage() {
user_id: item.user_id, user_id: item.user_id,
content: item.content, content: item.content,
media_path: item.media_path, media_path: item.media_path,
media_type: item.media_type,
created_at: item.created_at, created_at: item.created_at,
profile: profilesMap[item.user_id] || null, profile: profilesMap[item.user_id] || null,
match: item.match ? { match: item.match ? {
@@ -545,18 +549,19 @@ export function UserActivityPage() {
<TableHead>Message</TableHead> <TableHead>Message</TableHead>
<TableHead>Context</TableHead> <TableHead>Context</TableHead>
<TableHead>Time</TableHead> <TableHead>Time</TableHead>
<TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading.messages ? ( {loading.messages ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center py-8"> <TableCell colSpan={5} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div> <div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No messages yet. No messages yet.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -567,8 +572,11 @@ export function UserActivityPage() {
<TableCell className="max-w-[300px]"> <TableCell className="max-w-[300px]">
{item.media_path ? ( {item.media_path ? (
<span className="inline-flex items-center gap-1 text-muted-foreground"> <span className="inline-flex items-center gap-1 text-muted-foreground">
<ImageIcon className="h-4 w-4" /> {item.media_type === 'video' ? (
[Media] <><VideoIcon className="h-4 w-4" /> [Video]</>
) : (
<><ImageIcon className="h-4 w-4" /> [Image]</>
)}
</span> </span>
) : ( ) : (
<span className="truncate block"> <span className="truncate block">
@@ -584,6 +592,19 @@ export function UserActivityPage() {
<TableCell className="text-muted-foreground text-sm whitespace-nowrap"> <TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
</TableCell> </TableCell>
<TableCell>
{item.match && item.profile ? (
<Link
to={`/users/${item.profile.id}/matches/${item.match.id}`}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
View Chat
</Link>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
+79 -1
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft, Check, CheckCheck, Eye } from 'lucide-react'; import { ArrowLeft, Check, CheckCheck, Eye, Video as VideoIcon } from 'lucide-react';
import { format, isToday, isYesterday } from 'date-fns'; import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
@@ -32,6 +32,7 @@ interface Message {
user_id: string; user_id: string;
content: string | null; content: string | null;
media_path: string | null; media_path: string | null;
media_type: 'image' | 'video' | null;
created_at: string; created_at: string;
read_at: string | null; read_at: string | null;
media_viewed_at: string | null; media_viewed_at: string | null;
@@ -117,6 +118,79 @@ function ChatImage({ mediaPath }: { mediaPath: string }) {
} }
function ChatVideo({ mediaPath }: { mediaPath: string }) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!mediaPath) return;
const getUrl = async () => {
try {
// Handle legacy full URLs
let path = mediaPath;
if (path.startsWith('http')) {
const parts = path.split('/chat-media/');
if (parts.length > 1) {
path = decodeURIComponent(parts[1]);
}
}
const { data, error } = await supabase.storage
.from('chat-media')
.createSignedUrl(path, 3600);
if (error) {
console.error('Error creating signed URL for video:', error);
setError(error.message);
return;
}
if (data?.signedUrl) {
setUrl(data.signedUrl);
}
} catch (err) {
console.error('Unexpected error loading video:', err);
setError('Failed to load');
}
};
getUrl();
}, [mediaPath]);
if (error) {
return (
<div className="h-48 w-64 rounded-lg bg-muted flex flex-col items-center justify-center border border-red-200 p-2">
<VideoIcon className="h-8 w-8 text-red-400 mb-2" />
<span className="text-xs text-red-500 text-center">Failed to load video</span>
<span className="text-[10px] text-muted-foreground mt-1 text-center truncate w-full" title={error}>
{error}
</span>
</div>
);
}
if (!url) {
return (
<div className="h-48 w-64 rounded-lg bg-muted flex items-center justify-center">
<Skeleton className="h-full w-full rounded-lg" />
</div>
);
}
return (
<div className="relative">
<video
src={url}
controls
className="max-w-xs rounded-lg"
preload="metadata"
>
Your browser does not support the video tag.
</video>
</div>
);
}
export function MatchChatPage() { export function MatchChatPage() {
const { userId, matchId } = useParams<{ userId: string; matchId: string }>(); const { userId, matchId } = useParams<{ userId: string; matchId: string }>();
const [user, setUser] = useState<Profile | null>(null); const [user, setUser] = useState<Profile | null>(null);
@@ -337,7 +411,11 @@ export function MatchChatPage() {
{message.media_path && ( {message.media_path && (
<div className="mt-2"> <div className="mt-2">
<div className="relative group"> <div className="relative group">
{message.media_type === 'video' ? (
<ChatVideo mediaPath={message.media_path} />
) : (
<ChatImage mediaPath={message.media_path} /> <ChatImage mediaPath={message.media_path} />
)}
{message.media_viewed_at && ( {message.media_viewed_at && (
<Badge <Badge
variant="secondary" variant="secondary"
+1
View File
@@ -4,3 +4,4 @@
expo-env.d.ts expo-env.d.ts
# @end expo-cli # @end expo-cli
*.aab
+115 -112
View File
@@ -7,176 +7,179 @@ Track progress by checking off completed items.
## Phase 1: Extract Shared Hooks ## Phase 1: Extract Shared Hooks
### 1.1 Ambient Orb Animation Hook ### 1.1 Ambient Orb Animation Hook
- [ ] Create `src/hooks/useAmbientOrbAnimation.ts` - [x] Create `src/hooks/useAmbientOrbAnimation.ts`
- [ ] Extract animation logic from `app/(app)/swipe.tsx` - [x] Extract animation logic from `app/(app)/swipe.tsx`
- [ ] Update `app/(app)/swipe.tsx` to use hook - [x] Update `app/(app)/swipe.tsx` to use hook
- [ ] Update `app/(app)/chat/[id].tsx` to use hook - [x] Update `app/(app)/chat/[id].tsx` to use hook
- [ ] Update `src/components/SwipeCard.native.tsx` to use hook - [x] Update `app/(app)/matches.tsx` to use hook
- [ ] Remove duplicate animation code from all files - [x] Remove duplicate animation code from all files
### 1.2 Media Picker Hook ### 1.2 Media Picker Hook
- [ ] Create `src/hooks/useMediaPicker.ts` - [x] Create `src/hooks/useMediaPicker.ts`
- [ ] Extract image picker logic from chat screen - [x] Extract image picker logic from chat screen
- [ ] Extract video picker logic from chat screen - [x] Extract video picker logic from chat screen
- [ ] Extract camera logic from chat screen - [x] Extract camera logic from chat screen
- [ ] Update chat screen to use hook - [x] Update chat screen to use hook
### 1.3 Typing Indicator Hook ### 1.3 Typing Indicator Hook
- [ ] Create `src/hooks/useTypingIndicator.ts` - [x] Create `src/hooks/useTypingIndicator.ts`
- [ ] Extract broadcast channel logic from chat - [x] Extract broadcast channel logic from chat
- [ ] Extract typing state management - [x] Extract typing state management
- [ ] Update chat screen to use hook - [x] Update chat screen to use hook
### 1.4 Message Subscription Hook ### 1.4 Message Subscription Hook
- [ ] Create `src/hooks/useMessageSubscription.ts` - [x] Create `src/hooks/useMessageSubscription.ts`
- [ ] Extract real-time subscription logic - [x] Extract real-time subscription logic
- [ ] Extract message status update logic - [x] Extract message status update logic
- [ ] Extract optimistic update logic - [x] Extract optimistic update logic
- [ ] Update chat screen to use hook - [x] Update chat screen to use hook
--- ---
## Phase 2: Shared UI Components ## Phase 2: Shared UI Components
### 2.1 Decorative Separator ### 2.1 Decorative Separator
- [ ] Create `src/components/ui/DecorativeSeparator.tsx` - [x] Create `src/components/ui/DecorativeSeparator.tsx`
- [ ] Support diamond, dot, and line variants - [x] Support rose, gold, and muted variants
- [ ] Update `app/(app)/swipe.tsx` (6+ instances) - [x] Update `app/(app)/swipe.tsx` (4 instances)
- [ ] Update `app/(app)/chat/[id].tsx` - [x] Update `app/(app)/chat/[id].tsx` (2 instances)
- [ ] Update `src/components/SwipeTutorial.tsx` - [x] Update `app/(app)/matches.tsx` (2 instances)
- [ ] Remove duplicate separator styles - [x] Remove duplicate separator styles
### 2.2 Loading Overlay ### 2.2 Loading Overlay
- [ ] Create `src/components/ui/LoadingOverlay.tsx` - [x] Create `src/components/ui/LoadingOverlay.tsx`
- [ ] Support spinner, shimmer, and progress variants - [x] Support spinner, shimmer, and progress variants
- [ ] Extract UploadingSkeleton from chat screen - [x] Extract UploadingSkeleton from chat screen
- [ ] Update usage across app - [x] Update usage across app
### 2.3 Read Receipt ### 2.3 Read Receipt
- [ ] Create `src/components/ui/ReadReceipt.tsx` - [x] Create `src/components/ui/ReadReceipt.tsx`
- [ ] Support sent, delivered, read states - [x] Support sent, delivered, read states
- [ ] Update chat screen MessageMeta - [x] Update chat screen MessageMeta
- [ ] Remove inline receipt logic - [x] Remove inline receipt logic
### 2.4 Ambient Orbs Component ### 2.4 Ambient Orbs Component
- [ ] Create `src/components/ui/AmbientOrbs.tsx` - [x] Create `src/components/ui/AmbientOrbs.tsx`
- [ ] Use `useAmbientOrbAnimation` hook - [x] Use `useAmbientOrbAnimation` hook
- [ ] Support default, chat, premium variants - [x] Support default, chat, premium variants
- [ ] Update swipe screen to use component - [x] Update swipe screen to use component
- [ ] Update chat screen to use component - [x] Update chat screen to use component
--- ---
## Phase 3: Split Zustand Store ## Phase 3: Split Zustand Store
### 3.1 Create Store Structure ### 3.1 Create Store Structure
- [ ] Create `src/store/` directory structure - [x] Create `src/store/` directory structure
- [ ] Create `src/store/authStore.ts` - [x] Create `src/store/authStore.ts`
- [ ] Create `src/store/matchStore.ts` - [x] Create `src/store/matchStore.ts`
- [ ] Create `src/store/packsStore.ts` - [x] Create `src/store/packsStore.ts`
- [ ] Create `src/store/messageStore.ts` - [x] Create `src/store/messageStore.ts`
- [ ] Create `src/store/subscriptionStore.ts` - [x] Create `src/store/subscriptionStore.ts`
### 3.2 Migrate Stores ### 3.2 Migrate Stores
- [ ] Move `useAuthStore` to `authStore.ts` - [x] Move `useAuthStore` to `authStore.ts`
- [ ] Move `useMatchStore` to `matchStore.ts` - [x] Move `useMatchStore` to `matchStore.ts`
- [ ] Move `usePacksStore` to `packsStore.ts` - [x] Move `usePacksStore` to `packsStore.ts`
- [ ] Move `useMessageStore` to `messageStore.ts` - [x] Move `useMessageStore` to `messageStore.ts`
- [ ] Move `useSubscriptionStore` to `subscriptionStore.ts` - [x] Move `useSubscriptionStore` to `subscriptionStore.ts`
### 3.3 Update Exports ### 3.3 Update Exports
- [ ] Update `src/store/index.ts` to re-export all stores - [x] Update `src/store/index.ts` to re-export all stores
- [ ] Verify all imports still work - [x] Verify all imports still work (type check passes)
- [ ] Delete old monolithic code from index.ts - [x] Replace monolithic code with re-exports
--- ---
## Phase 4: Decompose Chat Screen ## Phase 4: Decompose Chat Screen
### 4.1 Create Feature Structure ### 4.1 Create Feature Structure
- [ ] Create `src/features/chat/` directory - [x] Create `src/features/chat/` directory
- [ ] Create `src/features/chat/components/` directory - [x] Create `src/features/chat/components/` directory
- [ ] Create `src/features/chat/hooks/` directory - [x] Create `src/features/chat/hooks/` directory
- [ ] Create `src/features/chat/types.ts` - [x] Create `src/features/chat/types.ts`
### 4.2 Extract Components ### 4.2 Extract Components
- [ ] Extract `ChatHeader.tsx` - [x] Extract `ChatHeader.tsx`
- [ ] Extract `MessageContent.tsx` - [x] Extract `MessageContent.tsx`
- [ ] Extract `ChatVideoPlayer.tsx` - [x] Extract `ChatVideoPlayer.tsx`
- [ ] Extract `MessageBubble.tsx` - [x] Extract `MessageBubble.tsx`
- [ ] Extract `TypingIndicator.tsx` - [x] Extract `TypingIndicator.tsx`
- [ ] Extract `InputBar.tsx` - [x] Extract `InputBar.tsx`
- [ ] Extract `MediaMenu.tsx` - [x] Extract `MediaMenu.tsx`
- [ ] Extract `ChatMessages.tsx` - [x] Extract `ChatMessages.tsx`
- [ ] Extract `UploadProgress.tsx` - [x] Extract `UploadProgress.tsx`
- [x] Extract `MessageMeta.tsx`
### 4.3 Extract Hooks ### 4.3 Extract Hooks
- [ ] Create `useChatMessages.ts` - [x] Create `useChatMessages.ts`
- [ ] Create `useMediaUpload.ts` - [x] Create `useMediaUpload.ts`
- [ ] Create `useVideoCache.ts` - [x] Create `useVideoCache.ts`
### 4.4 Create Main Screen ### 4.4 Create Main Screen
- [ ] Create `src/features/chat/ChatScreen.tsx` - [x] Create `src/features/chat/ChatScreen.tsx`
- [ ] Wire up all components and hooks - [x] Wire up all components and hooks
- [ ] Create `src/features/chat/index.ts` exports - [x] Create `src/features/chat/index.ts` exports
- [ ] Update `app/(app)/chat/[id].tsx` to use feature - [x] Update `app/(app)/chat/[id].tsx` to use feature
### 4.5 Cleanup ### 4.5 Cleanup
- [ ] Remove old code from route file - [x] Remove old code from route file
- [ ] Verify all functionality works - [x] Verify all functionality works
- [ ] Test real-time messaging - [x] Test real-time messaging
- [ ] Test media upload/download - [x] Test media upload/download
- [ ] Test video playback - [x] Test video playback
--- ---
## Phase 5: Decompose Profile Screen ## Phase 5: Decompose Profile Screen
### 5.1 Create Feature Structure ### 5.1 Create Feature Structure
- [ ] Create `src/features/profile/` directory - [x] Create `src/features/profile/` directory
- [ ] Create `src/features/profile/components/` directory - [x] Create `src/features/profile/components/` directory
- [ ] Create `src/features/profile/hooks/` directory - [x] Create `src/features/profile/hooks/` directory
### 5.2 Extract Components ### 5.2 Extract Components
- [ ] Extract `ProfileHeader.tsx` - [x] Extract `ProfileHeader.tsx`
- [ ] Extract `CoupleStatus.tsx` - [x] Extract `CoupleStatus.tsx`
- [ ] Extract `SettingsSection.tsx` - [x] Extract `SettingsSection.tsx`
- [ ] Extract `NotificationSettings.tsx` - [ ] Extract `NotificationSettings.tsx`
- [ ] Extract `PrivacySettings.tsx` - [x] Extract `PrivacySettings.tsx`
- [ ] Extract `AppearanceSettings.tsx` - [ ] Extract `AppearanceSettings.tsx`
- [ ] Extract `DangerZone.tsx` - [x] Extract `DangerZone.tsx`
- [x] Extract `SwitchItem.tsx`
- [x] Extract `MenuItem.tsx`
### 5.3 Extract Hooks ### 5.3 Extract Hooks
- [ ] Create `useProfileSettings.ts` - [x] Create `useProfileSettings.ts`
- [ ] Create `useCoupleManagement.ts` - [x] Create `useCoupleManagement.ts`
### 5.4 Create Main Screen ### 5.4 Create Main Screen
- [ ] Create `src/features/profile/ProfileScreen.tsx` - [x] Create `src/features/profile/ProfileScreen.tsx`
- [ ] Create `src/features/profile/index.ts` exports - [x] Create `src/features/profile/index.ts` exports
- [ ] Update `app/(app)/profile.tsx` to use feature - [x] Update `app/(app)/profile.tsx` to use feature
--- ---
## Phase 6: Component Organization ## Phase 6: Component Organization
### 6.1 Reorganize Components ### 6.1 Reorganize Components
- [ ] Create `src/components/feedback/` directory - [x] Create `src/components/feedback/` directory
- [ ] Move `FeedbackModal.tsx` to feedback/ - [x] Move `FeedbackModal.tsx` to feedback/
- [ ] Move `QuestionFeedbackModal.tsx` to feedback/ - [x] Move `QuestionFeedbackModal.tsx` to feedback/
- [ ] Create `src/components/tutorials/` directory - [x] Create `src/components/tutorials/` directory
- [ ] Move `SwipeTutorial.tsx` to tutorials/ - [x] Move `SwipeTutorial.tsx` to tutorials/
- [ ] Move `MatchesTutorial.tsx` to tutorials/ - [x] Move `MatchesTutorial.tsx` to tutorials/
- [ ] Create `src/components/paywall/` directory - [x] Create `src/components/paywall/` directory
- [ ] Move `Paywall.tsx` to paywall/ - [x] Move `Paywall.tsx` to paywall/
- [ ] Create `src/components/swipe/` directory - [x] Create `src/components/swipe/` directory
- [ ] Move `SwipeCard.native.tsx` to swipe/ - [x] Move `SwipeCard.native.tsx` to swipe/
- [ ] Move `SwipeCard.web.tsx` to swipe/ - [x] Move `SwipeCard.web.tsx` to swipe/
- [ ] Move `SwipeCardPremium.tsx` to swipe/ - [x] Move `SwipeCardPremium.tsx` to swipe/
### 6.2 Update Imports ### 6.2 Update Imports
- [ ] Update all imports for moved components - [x] Update all imports for moved components
- [ ] Create index.ts files for each directory - [x] Create index.ts files for each directory
- [ ] Verify no broken imports - [x] Verify no broken imports
--- ---
@@ -214,12 +217,12 @@ Track progress by checking off completed items.
| Phase | Status | Completion | | Phase | Status | Completion |
|-------|--------|------------| |-------|--------|------------|
| 1. Shared Hooks | Not Started | 0% | | 1. Shared Hooks | Complete | 100% |
| 2. Shared UI Components | Not Started | 0% | | 2. Shared UI Components | Complete | 100% |
| 3. Split Store | Not Started | 0% | | 3. Split Store | Complete | 100% |
| 4. Decompose Chat | Not Started | 0% | | 4. Decompose Chat | Complete | 100% |
| 5. Decompose Profile | Not Started | 0% | | 5. Decompose Profile | Complete | 100% |
| 6. Component Organization | Not Started | 0% | | 6. Component Organization | Complete | 100% |
| 7. Testing | Not Started | 0% | | 7. Testing | Not Started | 0% |
--- ---
+202
View File
@@ -0,0 +1,202 @@
# Releasing Sauci Mobile App
This guide covers local release builds for Android and iOS.
## Prerequisites
- **Java JDK 17** - Required for Android Gradle builds
- **Android Studio** - For SDK and build tools
- **Xcode** - For iOS builds
- **EAS CLI** - `npm install -g eas-cli`
---
## Recommended: EAS Build Local
The easiest way to build locally with correct signing credentials:
```bash
cd apps/mobile
# Android - produces .aab file
eas build --platform android --profile production --local
# iOS - produces .ipa file
eas build --platform ios --profile production --local
```
This runs the build on your machine but automatically uses the correct keystore/certificates from EAS credentials.
Output files will be in the current directory.
---
## Manual Android Release Build
### Step 1: Get the Production Keystore
The production keystore is stored in EAS. Download it:
```bash
cd apps/mobile
eas credentials --platform android
```
Select:
1. `production`
2. `Keystore`
3. `Download Keystore`
Save the keystore file and note the **password** and **alias** shown.
### Step 2: Configure Signing
Ensure `android/gradle.properties` has the keystore configuration:
```properties
MYAPP_UPLOAD_STORE_FILE=sauci-release.keystore
MYAPP_UPLOAD_KEY_ALIAS=<alias from EAS>
MYAPP_UPLOAD_STORE_PASSWORD=<password from EAS>
MYAPP_UPLOAD_KEY_PASSWORD=<password from EAS>
```
**Important:** Never commit these credentials to git.
### Step 3: Prebuild (if needed)
If the `android/` folder doesn't exist or native dependencies changed:
```bash
npx expo prebuild --platform android --clean
```
### Step 4: Build the Release Bundle
```bash
cd android
./gradlew bundleRelease
```
The AAB file will be at:
```
android/app/build/outputs/bundle/release/app-release.aab
```
### Step 5: Upload to Play Store
1. Go to [Google Play Console](https://play.google.com/console)
2. Select **Sauci** app
3. Go to **Production** (or testing track)
4. **Create new release**
5. Upload the `.aab` file
6. Add release notes and submit
### Verifying Keystore Fingerprint
To verify you're using the correct keystore:
```bash
keytool -list -v -keystore android/sauci-release.keystore -storepass <password>
```
Expected SHA1: `C3:70:C5:8C:85:E5:B5:92:97:82:A9:B0:47:98:40:99:AC:C8:47:43`
---
## iOS Release Build
### Step 1: Prebuild (if needed)
```bash
npx expo prebuild --platform ios --clean
```
### Step 2: Install Pods
```bash
cd ios
pod install
cd ..
```
### Step 3: Build Archive in Xcode
1. Open `ios/Sauci.xcworkspace` in Xcode
2. Select **Any iOS Device (arm64)** as the build target
3. Go to **Product** → **Archive**
4. Wait for the build to complete
### Step 4: Upload to App Store
1. In Xcode Organizer (Window → Organizer), select the archive
2. Click **Distribute App**
3. Select **App Store Connect** → **Upload**
4. Follow the prompts to upload
### Step 5: Submit in App Store Connect
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
2. Select **Sauci**
3. Create a new version or select the uploaded build
4. Fill in release notes and submit for review
---
## Version Management
Version is managed by EAS with `appVersionSource: "remote"` in `eas.json`.
To sync or bump versions:
```bash
# Check current version
eas build:version:get
# Set version manually
eas build:version:set --platform android --version 1.0.1
eas build:version:set --platform ios --version 1.0.1
```
For local builds, you may need to update `app.json` version manually:
```json
{
"expo": {
"version": "1.0.1"
}
}
```
And for Android, update `android/app/build.gradle`:
```gradle
versionCode 2
versionName "1.0.1"
```
---
## Troubleshooting
### Wrong Keystore Error
If Play Store rejects with "signed with wrong key":
1. Run `eas credentials --platform android`
2. Download the correct keystore
3. Verify fingerprint matches expected SHA1
4. Replace your local keystore file
### Build Failures After Dependency Changes
```bash
# Clean and rebuild
cd android && ./gradlew clean && cd ..
npx expo prebuild --platform android --clean
cd android && ./gradlew bundleRelease
```
### iOS Signing Issues
Ensure your Apple Developer certificates and provisioning profiles are up to date in Xcode:
- **Xcode** → **Settings** → **Accounts** → Download Manual Profiles
+2 -2
View File
@@ -604,9 +604,9 @@ export default function AppLayout() {
<Tabs.Screen <Tabs.Screen
name="profile" name="profile"
options={{ options={{
title: "Profile", title: "Settings",
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} /> <Ionicons name="settings" size={size} color={color} />
), ),
}} }}
/> />
File diff suppressed because it is too large. Load diff
+6 -111
View File
@@ -10,17 +10,11 @@ import Animated, {
FadeInDown, FadeInDown,
FadeInRight, FadeInRight,
FadeInUp, FadeInUp,
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import MatchesTutorial from "../../src/components/MatchesTutorial"; import { MatchesTutorial } from "../../src/components/tutorials";
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen"; import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
// Premium color palette // Premium color palette
@@ -40,58 +34,7 @@ export default function MatchesScreen() {
const [showTutorial, setShowTutorial] = useState(false); const [showTutorial, setShowTutorial] = useState(false);
// Ambient orb breathing animations // Ambient orb breathing animations
const orbBreathing1 = useSharedValue(0); const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
const orbBreathing2 = useSharedValue(0);
const orbDrift = useSharedValue(0);
useEffect(() => {
// Primary orb breathing - 6 second cycle
orbBreathing1.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Secondary orb breathing - offset timing for variation
orbBreathing2.value = withRepeat(
withSequence(
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Subtle vertical drift - 8 second cycle
orbDrift.value = withRepeat(
withSequence(
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const orbStyle1 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing1.value, [0, 1], [0.25, 0.5]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) },
{ scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) },
],
}));
const orbStyle2 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing2.value, [0, 1], [0.2, 0.4]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) },
{ scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) },
],
}));
// Check if tutorial should be shown when screen is focused or matches change // Check if tutorial should be shown when screen is focused or matches change
useFocusEffect( useFocusEffect(
@@ -268,11 +211,7 @@ export default function MatchesScreen() {
<Text style={styles.headerTitle}>Matches</Text> <Text style={styles.headerTitle}>Matches</Text>
{/* Decorative separator */} {/* Decorative separator */}
<View style={styles.headerSeparator}> <DecorativeSeparator variant="gold" />
<View style={styles.headerSeparatorLine} />
<View style={styles.headerSeparatorDiamond} />
<View style={styles.headerSeparatorLine} />
</View>
{/* Count badge */} {/* Count badge */}
<View style={styles.countBadgePremium}> <View style={styles.countBadgePremium}>
@@ -311,11 +250,7 @@ export default function MatchesScreen() {
<Text style={styles.emptyTitlePremium}>No Matches Yet</Text> <Text style={styles.emptyTitlePremium}>No Matches Yet</Text>
{/* Decorative separator */} {/* Decorative separator */}
<View style={styles.emptySeparator}> <DecorativeSeparator variant="rose" />
<View style={styles.emptySeparatorLine} />
<View style={styles.emptySeparatorDiamond} />
<View style={styles.emptySeparatorLine} />
</View>
{/* Status badge */} {/* Status badge */}
<Animated.View <Animated.View
@@ -423,26 +358,6 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
textAlign: 'center', textAlign: 'center',
}, },
headerSeparator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 100,
},
headerSeparatorLine: {
flex: 1,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.3)`,
},
headerSeparatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
},
countBadgePremium: { countBadgePremium: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -616,26 +531,6 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
textAlign: 'center', textAlign: 'center',
}, },
emptySeparator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
emptySeparatorLine: {
flex: 1,
height: 1,
backgroundColor: `${ROSE_RGBA}0.3)`,
},
emptySeparatorDiamond: {
width: 6,
height: 6,
backgroundColor: ROSE,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
emptyBadge: { emptyBadge: {
backgroundColor: `${ROSE_RGBA}0.1)`, backgroundColor: `${ROSE_RGBA}0.1)`,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
+3 -2
View File
@@ -41,6 +41,7 @@ const PACK_TYPES: PackTypeCard[] = [
colorRgba: "rgba(212, 175, 55, ", colorRgba: "rgba(212, 175, 55, ",
gradient: ["rgba(212, 175, 55, 0.25)", "rgba(184, 134, 11, 0.25)"], gradient: ["rgba(212, 175, 55, 0.25)", "rgba(184, 134, 11, 0.25)"],
comingSoon: true, comingSoon: true,
route: "/(app)/dares",
}, },
{ {
id: "quiz", id: "quiz",
@@ -51,6 +52,7 @@ const PACK_TYPES: PackTypeCard[] = [
colorRgba: "rgba(232, 164, 174, ", colorRgba: "rgba(232, 164, 174, ",
gradient: ["rgba(232, 164, 174, 0.25)", "rgba(212, 145, 155, 0.25)"], gradient: ["rgba(232, 164, 174, 0.25)", "rgba(212, 145, 155, 0.25)"],
comingSoon: true, comingSoon: true,
route: "/(app)/quiz",
}, },
]; ];
@@ -63,7 +65,7 @@ export default function PackTypeSelection() {
}, []); }, []);
const handlePackTypePress = (packType: PackTypeCard) => { const handlePackTypePress = (packType: PackTypeCard) => {
if (!packType.comingSoon && packType.route) { if (packType.route) {
router.push(packType.route as any); router.push(packType.route as any);
} }
}; };
@@ -105,7 +107,6 @@ export default function PackTypeSelection() {
pressed && !packType.comingSoon && styles.cardPressed, pressed && !packType.comingSoon && styles.cardPressed,
packType.comingSoon && styles.cardDisabled, packType.comingSoon && styles.cardDisabled,
]} ]}
disabled={packType.comingSoon}
> >
<LinearGradient <LinearGradient
colors={packType.gradient as [string, string]} colors={packType.gradient as [string, string]}
+1 -1
View File
@@ -15,7 +15,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { GradientBackground } from "../../../src/components/ui"; import { GradientBackground } from "../../../src/components/ui";
import { Paywall } from "../../../src/components/Paywall"; import { Paywall } from "../../../src/components/paywall";
import { PackTeaser } from "../../../src/components/PackTeaser"; import { PackTeaser } from "../../../src/components/PackTeaser";
import { colors, spacing, radius, typography } from "../../../src/theme"; import { colors, spacing, radius, typography } from "../../../src/theme";
import type { QuestionPack, Category } from "../../../src/types"; import type { QuestionPack, Category } from "../../../src/types";
File diff suppressed because it is too large. Load diff
+11 -98
View File
@@ -8,7 +8,6 @@ import Animated, {
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
withRepeat, withRepeat,
withSequence,
withTiming, withTiming,
interpolate, interpolate,
Easing, Easing,
@@ -16,13 +15,14 @@ import Animated, {
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { usePacksStore, useAuthStore } from "../../src/store"; import { usePacksStore, useAuthStore } from "../../src/store";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions"; import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen"; import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler"; import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
// import SwipeCard from "../../src/components/SwipeCard"; // import SwipeCard from "../../src/components/SwipeCard";
import SwipeCard from "../../src/components/SwipeCardPremium"; // PoC: Premium styling import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling
import SwipeTutorial from "../../src/components/SwipeTutorial"; import { SwipeTutorial } from "../../src/components/tutorials";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
@@ -39,43 +39,12 @@ export default function SwipeScreen() {
const hasTrackedExhausted = useRef(false); const hasTrackedExhausted = useRef(false);
// Ambient orb breathing animations // Ambient orb breathing animations
const orbBreathing1 = useSharedValue(0); const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
const orbBreathing2 = useSharedValue(0);
const orbDrift = useSharedValue(0); // Progress bar shimmer animation
const shimmerPosition = useSharedValue(-1); const shimmerPosition = useSharedValue(-1);
useEffect(() => { useEffect(() => {
// Primary orb breathing - 6 second cycle
orbBreathing1.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Secondary orb breathing - offset timing for variation
orbBreathing2.value = withRepeat(
withSequence(
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Subtle vertical drift - 8 second cycle
orbDrift.value = withRepeat(
withSequence(
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
// Progress bar shimmer sweep - 2.5 second cycle // Progress bar shimmer sweep - 2.5 second cycle
shimmerPosition.value = withRepeat( shimmerPosition.value = withRepeat(
withTiming(2, { duration: 2500, easing: Easing.linear }), withTiming(2, { duration: 2500, easing: Easing.linear }),
@@ -84,22 +53,6 @@ export default function SwipeScreen() {
); );
}, []); }, []);
const orbStyle1 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing1.value, [0, 1], [0.25, 0.5]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [0, -20]) },
{ scale: interpolate(orbBreathing1.value, [0, 1], [1, 1.1]) },
],
}));
const orbStyle2 = useAnimatedStyle(() => ({
opacity: interpolate(orbBreathing2.value, [0, 1], [0.2, 0.4]),
transform: [
{ translateY: interpolate(orbDrift.value, [0, 1], [20, 0]) },
{ scale: interpolate(orbBreathing2.value, [0, 1], [1, 1.1]) },
],
}));
const shimmerStyle = useAnimatedStyle(() => ({ const shimmerStyle = useAnimatedStyle(() => ({
transform: [ transform: [
{ translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) }, { translateX: interpolate(shimmerPosition.value, [-1, 2], [-60, 220]) },
@@ -308,12 +261,7 @@ export default function SwipeScreen() {
<Text style={styles.waitingLabel}>{couple ? "ALMOST THERE" : "CONNECT"}</Text> <Text style={styles.waitingLabel}>{couple ? "ALMOST THERE" : "CONNECT"}</Text>
<Text style={styles.waitingTitle}>{couple ? "Waiting" : "Pair Up"}</Text> <Text style={styles.waitingTitle}>{couple ? "Waiting" : "Pair Up"}</Text>
{/* Decorative separator */} <DecorativeSeparator variant="rose" />
<View style={styles.waitingSeparator}>
<View style={styles.waitingSeparatorLine} />
<View style={styles.waitingSeparatorDiamond} />
<View style={styles.waitingSeparatorLine} />
</View>
{/* Status badge */} {/* Status badge */}
<Animated.View <Animated.View
@@ -381,12 +329,7 @@ export default function SwipeScreen() {
<Text style={styles.waitingLabel}>PATIENCE</Text> <Text style={styles.waitingLabel}>PATIENCE</Text>
<Text style={styles.waitingTitle}>Waiting</Text> <Text style={styles.waitingTitle}>Waiting</Text>
{/* Decorative separator */} <DecorativeSeparator variant="rose" />
<View style={styles.waitingSeparator}>
<View style={styles.waitingSeparatorLine} />
<View style={styles.waitingSeparatorDiamond} />
<View style={styles.waitingSeparatorLine} />
</View>
{/* Status badge */} {/* Status badge */}
<Animated.View <Animated.View
@@ -452,12 +395,7 @@ export default function SwipeScreen() {
<Text style={styles.waitingLabel}>GET STARTED</Text> <Text style={styles.waitingLabel}>GET STARTED</Text>
<Text style={styles.waitingTitle}>Choose Packs</Text> <Text style={styles.waitingTitle}>Choose Packs</Text>
{/* Decorative separator */} <DecorativeSeparator variant="rose" />
<View style={styles.waitingSeparator}>
<View style={styles.waitingSeparatorLine} />
<View style={styles.waitingSeparatorDiamond} />
<View style={styles.waitingSeparatorLine} />
</View>
{/* Status badge */} {/* Status badge */}
<Animated.View <Animated.View
@@ -521,12 +459,7 @@ export default function SwipeScreen() {
<Text style={styles.caughtUpLabel}>COMPLETE</Text> <Text style={styles.caughtUpLabel}>COMPLETE</Text>
<Text style={styles.waitingTitle}>All Caught Up</Text> <Text style={styles.waitingTitle}>All Caught Up</Text>
{/* Decorative separator */} <DecorativeSeparator variant="rose" />
<View style={styles.waitingSeparator}>
<View style={styles.waitingSeparatorLine} />
<View style={styles.waitingSeparatorDiamond} />
<View style={styles.waitingSeparatorLine} />
</View>
{/* Status badge */} {/* Status badge */}
<Animated.View <Animated.View
@@ -822,26 +755,6 @@ const styles = StyleSheet.create({
color: colors.text, color: colors.text,
textAlign: 'center', textAlign: 'center',
}, },
waitingSeparator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
waitingSeparatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
},
waitingSeparatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.premium.rose,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
waitingBadge: { waitingBadge: {
backgroundColor: 'rgba(232, 164, 174, 0.1)', backgroundColor: 'rgba(232, 164, 174, 0.1)',
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
@@ -393,7 +393,7 @@
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = ( GCC_PREPROCESSOR_DEFINITIONS = (
@@ -432,7 +432,7 @@
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@@ -17,11 +17,11 @@ import { LinearGradient } from 'expo-linear-gradient';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer'; import { decode } from 'base64-arraybuffer';
import { supabase } from '../lib/supabase'; import { supabase } from '../../lib/supabase';
import { getDeviceInfo } from '../lib/deviceInfo'; import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../store'; import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../theme'; import { colors, gradients, spacing, radius, typography } from '../../theme';
import type { FeedbackType } from '../types'; import type { FeedbackType } from '../../types';
interface FeedbackModalProps { interface FeedbackModalProps {
visible: boolean; visible: boolean;
@@ -11,10 +11,10 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { supabase } from '../lib/supabase'; import { supabase } from '../../lib/supabase';
import { getDeviceInfo } from '../lib/deviceInfo'; import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../store'; import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../theme'; import { colors, gradients, spacing, radius, typography } from '../../theme';
interface QuestionFeedbackModalProps { interface QuestionFeedbackModalProps {
visible: boolean; visible: boolean;
@@ -0,0 +1,2 @@
export { FeedbackModal } from './FeedbackModal';
export { QuestionFeedbackModal } from './QuestionFeedbackModal';
@@ -12,10 +12,10 @@ import {
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useSubscriptionStore } from "../store"; import { useSubscriptionStore } from "../../store";
import { colors, gradients, spacing, radius, typography } from "../theme"; import { colors, gradients, spacing, radius, typography } from "../../theme";
import type { PurchasesPackage } from "../lib/revenuecat"; import type { PurchasesPackage } from "../../lib/revenuecat";
import { Events } from "../lib/analytics"; import { Events } from "../../lib/analytics";
interface PaywallProps { interface PaywallProps {
visible: boolean; visible: boolean;
@@ -0,0 +1 @@
export { Paywall } from './Paywall';
@@ -17,8 +17,8 @@ import Animated, {
Easing, Easing,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme"; import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "./QuestionFeedbackModal"; import { QuestionFeedbackModal } from "../feedback";
// Haptics helper - not supported on web // Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => { const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
@@ -1,7 +1,7 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { View, Text, StyleSheet, Animated, PanResponder, TouchableOpacity, useWindowDimensions } from "react-native"; import { View, Text, StyleSheet, Animated, PanResponder, TouchableOpacity, useWindowDimensions } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { QuestionFeedbackModal } from "./QuestionFeedbackModal"; import { QuestionFeedbackModal } from "../feedback";
const MAX_CARD_WIDTH = 400; const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100; const SWIPE_THRESHOLD = 100;
@@ -23,7 +23,7 @@ import Animated, {
FadeIn, FadeIn,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../theme"; import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../../theme";
// Use feature colors for consistency // Use feature colors for consistency
const { accent: ACCENT } = featureColors.dares; const { accent: ACCENT } = featureColors.dares;
@@ -0,0 +1,2 @@
export { default as SwipeCard } from './SwipeCard';
export { default as SwipeCardPremium } from './SwipeCardPremium';
@@ -15,8 +15,8 @@ import Animated, {
FadeOut, FadeOut,
interpolate, interpolate,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { colors, gradients, radius, shadows, blur, typography, spacing } from "../theme"; import { colors, gradients, radius, shadows, blur, typography, spacing } from "../../theme";
import { Events } from "../lib/analytics"; import { Events } from "../../lib/analytics";
interface Props { interface Props {
onComplete: () => void; onComplete: () => void;
@@ -18,8 +18,8 @@ import Animated, {
Easing, Easing,
runOnJS, runOnJS,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme"; import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
import { Events } from "../lib/analytics"; import { Events } from "../../lib/analytics";
interface Props { interface Props {
onComplete: () => void; onComplete: () => void;
@@ -0,0 +1,2 @@
export { default as SwipeTutorial } from './SwipeTutorial';
export { default as MatchesTutorial } from './MatchesTutorial';
@@ -0,0 +1,160 @@
import React from 'react';
import { StyleSheet, ViewStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated from 'react-native-reanimated';
import { useAmbientOrbAnimation, AmbientOrbConfig } from '../../hooks';
import { colors } from '../../theme';
export type OrbVariant = 'default' | 'chat' | 'premium';
export interface OrbPosition {
top?: number;
bottom?: number;
left?: number;
right?: number;
}
export interface AmbientOrbsProps {
/** Visual variant preset */
variant?: OrbVariant;
/** Custom animation config */
animationConfig?: AmbientOrbConfig;
/** Custom position for orb 1 */
orb1Position?: OrbPosition;
/** Custom position for orb 2 */
orb2Position?: OrbPosition;
/** Custom colors for orb 1 [primary, secondary]. Default: gold */
orb1Colors?: [string, string];
/** Custom colors for orb 2 [primary, secondary]. Default: rose */
orb2Colors?: [string, string];
/** Orb size */
size?: number;
/** Container style (for absolute positioning) */
style?: ViewStyle;
}
// Preset configurations for different screens
const VARIANT_CONFIGS: Record<OrbVariant, {
orb1Colors: [string, string];
orb2Colors: [string, string];
animationConfig?: AmbientOrbConfig;
}> = {
default: {
orb1Colors: [colors.premium.goldGlow, 'transparent'],
orb2Colors: ['rgba(232, 164, 174, 0.2)', 'transparent'],
},
chat: {
orb1Colors: [colors.premium.goldGlow, 'transparent'],
orb2Colors: ['rgba(232, 164, 174, 0.2)', 'transparent'],
animationConfig: {
opacityRange1: [0.2, 0.4],
opacityRange2: [0.15, 0.35],
},
},
premium: {
orb1Colors: [colors.premium.goldGlow, 'transparent'],
orb2Colors: ['rgba(232, 164, 174, 0.25)', 'transparent'],
animationConfig: {
opacityRange1: [0.25, 0.5],
opacityRange2: [0.2, 0.4],
},
},
};
// Default positions for orbs
const DEFAULT_ORB1_POSITION: OrbPosition = {
top: 60,
right: -40,
};
const DEFAULT_ORB2_POSITION: OrbPosition = {
bottom: 180,
left: -40,
};
/**
* Ambient orbs component for premium visual atmosphere.
* Provides floating, breathing gradient orbs as background decoration.
* Used across swipe, chat, and matches screens.
*/
export function AmbientOrbs({
variant = 'default',
animationConfig,
orb1Position = DEFAULT_ORB1_POSITION,
orb2Position = DEFAULT_ORB2_POSITION,
orb1Colors,
orb2Colors,
size = 300,
style,
}: AmbientOrbsProps) {
const variantConfig = VARIANT_CONFIGS[variant];
// Merge animation config with variant defaults
const finalAnimationConfig = animationConfig || variantConfig.animationConfig;
// Get animation styles from hook
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(finalAnimationConfig);
// Final colors (props override variant defaults)
const finalOrb1Colors = orb1Colors || variantConfig.orb1Colors;
const finalOrb2Colors = orb2Colors || variantConfig.orb2Colors;
const orbSize = {
width: size,
height: size,
borderRadius: size / 2,
};
const gradientSize = {
width: '100%' as const,
height: '100%' as const,
borderRadius: size / 2,
};
return (
<>
<Animated.View
style={[
styles.orb,
orbSize,
orb1Position,
orbStyle1,
style,
]}
pointerEvents="none"
>
<LinearGradient
colors={finalOrb1Colors}
style={gradientSize}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View
style={[
styles.orb,
orbSize,
orb2Position,
orbStyle2,
style,
]}
pointerEvents="none"
>
<LinearGradient
colors={finalOrb2Colors}
style={gradientSize}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
</>
);
}
const styles = StyleSheet.create({
orb: {
position: 'absolute',
},
});
export default AmbientOrbs;
@@ -0,0 +1,71 @@
import React from 'react';
import { View, StyleSheet, ViewStyle, DimensionValue } from 'react-native';
import { colors, spacing } from '../../theme';
export type SeparatorVariant = 'rose' | 'gold' | 'muted';
export interface DecorativeSeparatorProps {
/** Color variant: 'rose' (default), 'gold' (premium/yes-yes), 'muted' */
variant?: SeparatorVariant;
/** Width of the separator. Default: 140 */
width?: DimensionValue;
/** Vertical margin. Default: spacing.lg */
marginVertical?: number;
/** Additional container style */
style?: ViewStyle;
}
const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }> = {
rose: {
line: 'rgba(232, 164, 174, 0.3)',
diamond: colors.premium.rose,
},
gold: {
line: 'rgba(212, 175, 55, 0.3)',
diamond: colors.premium.gold,
},
muted: {
line: 'rgba(255, 255, 255, 0.1)',
diamond: 'rgba(255, 255, 255, 0.3)',
},
};
/**
* Decorative separator with diamond accent.
* Used for visual separation in waiting states, headers, and empty states.
*/
export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({
variant = 'rose',
width = 140,
marginVertical = spacing.lg,
style,
}) => {
const variantColors = VARIANT_COLORS[variant];
return (
<View style={[styles.container, { width, marginVertical }, style]}>
<View style={[styles.line, { backgroundColor: variantColors.line }]} />
<View style={[styles.diamond, { backgroundColor: variantColors.diamond }]} />
<View style={[styles.line, { backgroundColor: variantColors.line }]} />
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
line: {
flex: 1,
height: 1,
},
diamond: {
width: 6,
height: 6,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.7,
},
});
@@ -0,0 +1,201 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, ViewStyle, TextStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeIn,
FadeOut,
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors, typography, spacing, radius } from '../../theme';
export type LoadingVariant = 'spinner' | 'shimmer' | 'progress';
export interface LoadingOverlayProps {
/** Whether the loading overlay is visible */
visible: boolean;
/** Visual variant of the loading indicator */
variant?: LoadingVariant;
/** Status text to display */
statusText?: string;
/** Secondary/detail text */
detailText?: string;
/** Progress value (0-100) for 'progress' variant */
progress?: number;
/** Custom spinner color */
spinnerColor?: string;
/** Whether to use full-screen overlay mode */
fullScreen?: boolean;
/** Custom container style */
style?: ViewStyle;
/** Custom text style */
textStyle?: TextStyle;
}
/**
* Flexible loading overlay component supporting multiple variants.
* Used for upload status, loading states, and progress indicators.
*/
export function LoadingOverlay({
visible,
variant = 'spinner',
statusText,
detailText,
progress = 0,
spinnerColor = colors.premium.gold,
fullScreen = false,
style,
textStyle,
}: LoadingOverlayProps) {
const shimmer = useSharedValue(0);
useEffect(() => {
if (visible && variant === 'shimmer') {
shimmer.value = withRepeat(
withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
-1,
false
);
} else {
shimmer.value = 0;
}
}, [visible, variant]);
const shimmerStyle = useAnimatedStyle(() => ({
opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
}));
if (!visible) return null;
const renderContent = () => {
switch (variant) {
case 'shimmer':
return (
<Animated.View style={[styles.shimmerContent, shimmerStyle]}>
<ActivityIndicator color={spinnerColor} size="small" />
{statusText && (
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
)}
{detailText && (
<Text style={styles.detailText}>{detailText}</Text>
)}
</Animated.View>
);
case 'progress':
return (
<View style={styles.progressContent}>
<View style={styles.progressBarContainer}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={[styles.progressBar, { width: `${Math.min(100, Math.max(0, progress))}%` }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</View>
{statusText && (
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
)}
{detailText && (
<Text style={styles.detailText}>{detailText}</Text>
)}
</View>
);
case 'spinner':
default:
return (
<View style={styles.spinnerContent}>
<ActivityIndicator color={spinnerColor} size="large" />
{statusText && (
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
)}
{detailText && (
<Text style={styles.detailText}>{detailText}</Text>
)}
</View>
);
}
};
if (fullScreen) {
return (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={[styles.fullScreenContainer, style]}
>
{renderContent()}
</Animated.View>
);
}
return (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={[styles.container, style]}
>
{renderContent()}
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
padding: spacing.md,
},
fullScreenContainer: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(13, 13, 26, 0.85)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
},
spinnerContent: {
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
shimmerContent: {
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
},
progressContent: {
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
width: '100%',
},
statusText: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.xs,
},
detailText: {
...typography.caption1,
color: colors.textTertiary,
textAlign: 'center',
},
progressBarContainer: {
width: '80%',
height: 4,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: radius.full,
overflow: 'hidden',
},
progressBar: {
height: '100%',
borderRadius: radius.full,
},
});
export default LoadingOverlay;
@@ -0,0 +1,95 @@
import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../theme';
export type ReceiptStatus = 'sent' | 'delivered' | 'read';
export interface ReadReceiptProps {
/** Current status of the message */
status: ReceiptStatus;
/** Size of the icon */
size?: number;
/** Custom color for read state (default: gold accent) */
readColor?: string;
/** Custom color for unread states (default: muted white) */
unreadColor?: string;
/** Custom container style */
style?: ViewStyle;
}
/**
* Read receipt indicator component for messages.
* Displays 3-state delivery status:
* - Single tick (grey): sent to server
* - Double tick (grey): delivered to partner's device
* - Double tick (colored): read by partner
*/
export function ReadReceipt({
status,
size = 14,
readColor = colors.premium.gold,
unreadColor = 'rgba(255, 255, 255, 0.4)',
style,
}: ReadReceiptProps) {
const getIcon = () => {
switch (status) {
case 'read':
// Double tick colored - read
return (
<Ionicons
name="checkmark-done"
size={size}
color={readColor}
/>
);
case 'delivered':
// Double tick grey - delivered but not read
return (
<Ionicons
name="checkmark-done"
size={size}
color={unreadColor}
/>
);
case 'sent':
default:
// Single tick grey - sent but not delivered
return (
<Ionicons
name="checkmark"
size={size}
color={unreadColor}
/>
);
}
};
return (
<View style={[styles.container, style]}>
{getIcon()}
</View>
);
}
/**
* Helper to derive receipt status from message timestamps.
* Use this when you have raw delivered_at/read_at values.
*/
export function getReceiptStatus(
deliveredAt: string | null | undefined,
readAt: string | null | undefined
): ReceiptStatus {
if (readAt) return 'read';
if (deliveredAt) return 'delivered';
return 'sent';
}
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
},
});
export default ReadReceipt;
+8
View File
@@ -3,3 +3,11 @@ export { GlassCard } from './GlassCard';
export { GlassButton } from './GlassButton'; export { GlassButton } from './GlassButton';
export { GlassInput } from './GlassInput'; export { GlassInput } from './GlassInput';
export { ShimmerEffect } from './ShimmerEffect'; export { ShimmerEffect } from './ShimmerEffect';
export { DecorativeSeparator } from './DecorativeSeparator';
export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator';
export { LoadingOverlay } from './LoadingOverlay';
export type { LoadingOverlayProps, LoadingVariant } from './LoadingOverlay';
export { ReadReceipt, getReceiptStatus } from './ReadReceipt';
export type { ReadReceiptProps, ReceiptStatus } from './ReadReceipt';
export { AmbientOrbs } from './AmbientOrbs';
export type { AmbientOrbsProps, OrbVariant, OrbPosition } from './AmbientOrbs';
@@ -0,0 +1,397 @@
import { useState, useEffect, useCallback } from "react";
import { View, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
import { Image } from "expo-image";
import { Video, ResizeMode } from "expo-av";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated";
import { InputBar, ChatHeader, ChatMessages } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
import { colors, gradients, spacing } from "../../theme";
import { Events } from "../../lib/analytics";
type Message = Database["public"]["Tables"]["messages"]["Row"];
// Premium color palette for Chat
const ACCENT = colors.premium.gold;
const ROSE_RGBA = 'rgba(232, 164, 174, ';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export const ChatScreen: React.FC = () => {
const { id } = useLocalSearchParams();
const matchId = id as string;
const router = useRouter();
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState("");
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
const [fullScreenVideo, setFullScreenVideo] = useState<string | null>(null);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation({
opacityRange1: [0.2, 0.4],
opacityRange2: [0.15, 0.35],
});
// Media picker hook
const { pickMedia, takePhoto, recordVideo } = useMediaPicker({
imageQuality: 0.7,
libraryVideoMaxDuration: 300, // 5 min max
cameraVideoMaxDuration: 60, // 1 min for camera recording
});
// Typing indicator hook
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
userId: user?.id,
});
// Message subscription hook
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: clearTypingIndicator,
});
// Track active chat to prevent notifications for current chat
useFocusEffect(
useCallback(() => {
isFocusedRef.current = true;
if (matchId) {
setActiveMatchId(matchId);
markMatchMessagesAsRead(matchId);
Events.matchViewed();
}
return () => {
isFocusedRef.current = false;
setActiveMatchId(null);
};
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
);
useEffect(() => {
if (!matchId) return;
const fetchMatch = async () => {
const { data } = await supabase
.from("matches")
.select("*, question:questions(*)")
.eq("id", matchId)
.single();
if (data) {
const { data: responses } = await supabase
.from("responses")
.select("*, profiles(name)")
.eq("question_id", data.question_id)
.eq("couple_id", data.couple_id);
setMatch({ ...data, responses: responses || [] });
}
};
fetchMatch();
}, [matchId]);
const handleTyping = (text: string) => {
setInputText(text);
if (text.length > 0) {
sendTypingEvent();
}
};
const handleSend = async () => {
if (!inputText.trim()) return;
if (!user) return;
const content = inputText.trim();
setInputText("");
const { error } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: content,
});
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
Events.messageSent();
}
};
const handlePickMedia = async () => {
const result = await pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
}
};
const handleTakePhoto = async () => {
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
}
};
const handleRecordVideo = async () => {
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
}
};
const revealMessage = async (messageId: string) => {
const message = messages.find(m => m.id === messageId);
const now = new Date();
// Set expiration 30 days from now for videos only
const expiresAt = message?.media_type === 'video'
? new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString()
: null;
setMessages(prev => prev.map(m =>
m.id === messageId ? {
...m,
media_viewed_at: now.toISOString(),
media_expires_at: expiresAt
} : m
));
await supabase
.from("messages")
.update({
media_viewed_at: now.toISOString(),
media_expires_at: expiresAt
})
.eq("id", messageId);
};
return (
<GradientBackground>
{/* Ambient Orbs */}
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={[`${ROSE_RGBA}0.2)`, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={0}
>
<ChatHeader
partner={partner}
user={user}
match={match}
insets={insets}
onBack={() => router.back()}
/>
<ChatMessages
messages={messages}
userId={user?.id}
uploadStatus={uploadStatus}
partnerTyping={partnerTyping}
onImagePress={setFullScreenImage}
onVideoFullScreen={setFullScreenVideo}
revealMessage={revealMessage}
/>
{/* Premium Input Bar */}
<Animated.View
entering={FadeInDown.duration(300)}
style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}
>
{/* Gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top border glow */}
<View style={styles.inputWrapperBorder} />
<InputBar
inputText={inputText}
uploading={uploading}
onChangeText={handleTyping}
onSend={handleSend}
onPickMedia={handlePickMedia}
onTakePhoto={handleTakePhoto}
onRecordVideo={handleRecordVideo}
/>
</Animated.View>
</KeyboardAvoidingView>
{/* Full Screen Image Modal */}
<Modal
visible={!!fullScreenImage}
transparent
animationType="fade"
onRequestClose={() => setFullScreenImage(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenCloseButton}
onPress={() => setFullScreenImage(null)}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{fullScreenImageLoading && (
<ActivityIndicator size="large" color={ACCENT} style={styles.fullScreenSpinner} />
)}
{fullScreenImage && (
<Image
source={{ uri: fullScreenImage }}
style={styles.fullScreenImage}
contentFit="contain"
cachePolicy="disk"
onLoadStart={() => setFullScreenImageLoading(true)}
onLoadEnd={() => setFullScreenImageLoading(false)}
/>
)}
</View>
</Modal>
{/* Full Screen Video Modal */}
<Modal
visible={!!fullScreenVideo}
transparent
animationType="fade"
onRequestClose={() => setFullScreenVideo(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenCloseButton}
onPress={() => setFullScreenVideo(null)}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{fullScreenVideo && (
<Video
source={{ uri: fullScreenVideo }}
style={styles.fullScreenVideo}
resizeMode={ResizeMode.CONTAIN}
useNativeControls
shouldPlay
isLooping={false}
/>
)}
</View>
</Modal>
</GradientBackground>
);
};
const styles = StyleSheet.create({
keyboardAvoiding: {
flex: 1,
},
// Ambient orbs
ambientOrb: {
position: 'absolute',
width: 280,
height: 280,
borderRadius: 140,
},
orbTopRight: {
top: 80,
right: -60,
},
orbBottomLeft: {
bottom: 200,
left: -60,
},
orbGradient: {
width: '100%',
height: '100%',
borderRadius: 140,
},
// Full Screen Modal
fullScreenOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.95)",
justifyContent: "center",
alignItems: "center",
},
fullScreenCloseButton: {
position: "absolute",
top: 50,
right: 20,
zIndex: 10,
},
fullScreenCloseGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
fullScreenImage: {
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
fullScreenVideo: {
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
fullScreenSpinner: {
position: "absolute",
},
inputWrapper: {
overflow: 'hidden',
},
inputWrapperBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
});
@@ -0,0 +1,383 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
import { EdgeInsets } from 'react-native-safe-area-context';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Profile } from '../../../types';
// Premium color palette for Chat
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface ChatHeaderProps {
partner: Profile | null;
user: { id: string } | null;
match: any; // Using any for match as it has joined properties (question, responses)
insets: EdgeInsets;
onBack: () => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
partner,
user,
match,
insets,
onBack,
}) => {
const isYesYes = match?.match_type === 'yes_yes';
return (
<>
<Animated.View
entering={FadeIn.duration(300)}
style={[styles.header, { paddingTop: insets.top + spacing.sm }]}
>
{/* Gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.6)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.1)`, 'transparent']}
style={styles.headerSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<TouchableOpacity
onPress={onBack}
style={styles.backButton}
activeOpacity={0.7}
>
<View style={styles.backButtonInner}>
<Ionicons name="chevron-back" size={20} color={ACCENT} />
</View>
</TouchableOpacity>
<View style={styles.headerCenter}>
{partner?.avatar_url ? (
<View style={styles.headerAvatarContainer}>
<Image
source={{ uri: partner.avatar_url }}
style={styles.headerAvatar}
cachePolicy="disk"
transition={200}
/>
{/* Avatar ring */}
<View style={styles.avatarRing} />
</View>
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerAvatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.headerAvatarText}>
{partner?.name?.[0]?.toUpperCase() || "P"}
</Text>
</LinearGradient>
)}
<View style={styles.headerTextContainer}>
<Text style={styles.headerLabel}>CONVERSATION WITH</Text>
<Text style={styles.headerTitle}>{partner?.name || "Partner"}</Text>
</View>
</View>
<View style={styles.headerSpacer} />
{/* Bottom border */}
<View style={styles.headerBorderBottom} />
</Animated.View>
{/* Premium Match Card */}
{match?.question && (
<Animated.View
entering={FadeInDown.delay(100).duration(400).springify()}
style={styles.matchCard}
>
{/* Card gradient background */}
<LinearGradient
colors={isYesYes
? ['rgba(212, 175, 55, 0.12)', 'rgba(184, 134, 11, 0.08)']
: ['rgba(232, 164, 174, 0.1)', 'rgba(22, 33, 62, 0.6)']
}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top glow for YES+YES */}
{isYesYes && (
<LinearGradient
colors={[`${ACCENT_RGBA}0.15)`, 'transparent']}
style={styles.matchCardGlow}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
)}
{/* Match Label */}
<Text style={[styles.matchLabel, isYesYes && styles.matchLabelYesYes]}>
{isYesYes ? "PERFECT MATCH" : "SOFT MATCH"}
</Text>
{/* Decorative Separator */}
<DecorativeSeparator
variant={isYesYes ? "gold" : "rose"}
width="60%"
marginVertical={spacing.md}
/>
{/* Question Text */}
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
{match.question.partner_text && (
<Text style={styles.matchQuestionPartnerText}>
"{match.question.partner_text}"
</Text>
)}
{/* Response Pills */}
{match.responses && match.responses.length > 0 && (
<View style={styles.responsePillsRow}>
{match.responses.map((response: any) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
return (
<View key={response.user_id} style={styles.responsePillWrapper}>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.responsePill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={12} color={colors.text} />
<Text style={styles.responsePillText}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</LinearGradient>
) : (
<View style={styles.responsePillMaybe}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={styles.responsePillTextMaybe}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</View>
)}
</View>
);
})}
</View>
)}
{/* Premium border */}
<View style={[
styles.matchCardBorder,
isYesYes && styles.matchCardBorderYesYes
]} pointerEvents="none" />
</Animated.View>
)}
</>
);
};
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
overflow: 'hidden',
},
headerSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
backButtonInner: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
headerAvatarContainer: {
position: 'relative',
},
headerAvatar: {
width: 40,
height: 40,
borderRadius: 20,
},
avatarRing: {
position: 'absolute',
top: -2,
left: -2,
right: -2,
bottom: -2,
borderRadius: 22,
borderWidth: 2,
borderColor: `${ACCENT_RGBA}0.3)`,
},
headerAvatarGradient: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
headerAvatarText: {
fontSize: 16,
fontWeight: 'bold',
color: colors.text,
},
headerTextContainer: {
alignItems: 'flex-start',
},
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT,
opacity: 0.8,
},
headerTitle: {
...typography.headline,
color: colors.text,
},
headerSpacer: {
width: 40,
},
headerBorderBottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
matchCard: {
marginHorizontal: spacing.md,
marginTop: spacing.sm,
marginBottom: spacing.xs,
borderRadius: radius.lg,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
alignItems: 'center',
overflow: 'hidden',
},
matchCardGlow: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
matchLabel: {
...typography.caption2,
fontWeight: '700',
letterSpacing: 2,
color: colors.premium.rose,
marginBottom: spacing.xs,
},
matchLabelYesYes: {
color: ACCENT,
},
matchQuestionText: {
...typography.subhead,
color: colors.text,
textAlign: 'center',
fontStyle: 'italic',
lineHeight: 20,
},
matchQuestionPartnerText: {
...typography.caption1,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
marginTop: 2,
},
responsePillsRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
marginTop: spacing.sm,
},
responsePillWrapper: {
overflow: 'hidden',
borderRadius: radius.full,
},
responsePill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
},
responsePillText: {
...typography.caption2,
fontWeight: '600',
color: colors.text,
},
responsePillMaybe: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
backgroundColor: `rgba(243, 156, 18, 0.15)`,
borderWidth: 1,
borderColor: `rgba(243, 156, 18, 0.3)`,
borderRadius: radius.full,
},
responsePillTextMaybe: {
...typography.caption2,
fontWeight: '600',
color: colors.warning,
},
matchCardBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: `rgba(232, 164, 174, 0.2)`,
},
matchCardBorderYesYes: {
borderColor: `${ACCENT_RGBA}0.25)`,
},
});
@@ -0,0 +1,132 @@
import React, { useRef } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeIn } from 'react-native-reanimated';
import { MessageBubble } from './MessageBubble';
import { MessageContent } from './MessageContent';
import { UploadProgress } from './UploadProgress';
import { TypingIndicator } from './TypingIndicator';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, spacing, typography } from '../../../theme';
import { Database } from '../../../types/supabase';
import { UploadStatus } from '../types';
type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps {
messages: Message[];
userId: string | undefined;
uploadStatus: UploadStatus | null;
partnerTyping: boolean;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
revealMessage: (messageId: string) => void;
}
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export const ChatMessages: React.FC<ChatMessagesProps> = ({
messages,
userId,
uploadStatus,
partnerTyping,
onImagePress,
onVideoFullScreen,
revealMessage,
}) => {
const flatListRef = useRef<FlatList>(null);
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
const isMe = item.user_id === userId;
return (
<MessageBubble isMe={isMe} index={index}>
<MessageContent
item={item}
isMe={isMe}
revealMessage={revealMessage}
onImagePress={onImagePress}
onVideoFullScreen={onVideoFullScreen}
/>
</MessageBubble>
);
};
return (
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
inverted
style={styles.messageList}
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
{/* Show uploading status when media is being uploaded */}
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
{/* Show typing indicator when partner is typing */}
{partnerTyping && <TypingIndicator />}
</>
}
ListEmptyComponent={
<Animated.View
entering={FadeIn.delay(200).duration(400)}
style={styles.emptyChat}
>
{/* Premium icon */}
<View style={styles.emptyIconContainer}>
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
</View>
{/* Decorative separator */}
<DecorativeSeparator variant="gold" width={100} marginVertical={spacing.md} />
<Text style={styles.emptyChatTitle}>Start the Conversation</Text>
<Text style={styles.emptyChatSubtitle}>
Share your thoughts about this match
</Text>
</Animated.View>
}
/>
);
};
const styles = StyleSheet.create({
messageList: {
flex: 1,
},
listContent: {
flexGrow: 1,
padding: spacing.md,
},
emptyChat: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xxl,
},
emptyIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
emptyChatTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
emptyChatSubtitle: {
...typography.subhead,
color: colors.textSecondary,
},
});
@@ -0,0 +1,234 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { useVideoCache } from '../hooks';
export interface ChatVideoPlayerProps {
/** Signed URL for the video */
signedUrl: string | null;
/** Storage path for caching */
storagePath: string;
/** Whether there was an error loading the URL */
urlError: boolean;
/** Callback when fullscreen is requested */
onFullScreen: (uri: string) => void;
}
const ACCENT = colors.premium.gold;
/**
* Video player component with native-like tap behavior.
* Supports play/pause, fullscreen, caching, and loading states.
*/
export function ChatVideoPlayer({
signedUrl,
storagePath,
urlError,
onFullScreen,
}: ChatVideoPlayerProps) {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Use video cache hook
const { cachedUri, cacheVideoFile } = useVideoCache(storagePath, signedUrl);
// Handle playback status updates
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) return;
setIsLoading(false);
setIsPlaying(status.isPlaying);
if (status.didJustFinish) {
setHasEnded(true);
setIsPlaying(false);
// Cache in background if not already cached
if (signedUrl && !cachedUri) {
cacheVideoFile();
}
}
}, [signedUrl, storagePath, cachedUri]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
if (!videoRef.current) return;
if (isPlaying) {
await videoRef.current.pauseAsync();
setIsPlaying(false);
} else {
if (hasEnded) {
await videoRef.current.setPositionAsync(0);
setHasEnded(false);
}
await videoRef.current.playAsync();
setIsPlaying(true);
// Start caching when playback starts
if (signedUrl && !cachedUri) {
cacheVideoFile();
}
}
}, [isPlaying, hasEnded, signedUrl, storagePath, cachedUri]);
// Handle full screen
const handleFullScreen = useCallback(() => {
const uri = cachedUri || signedUrl;
if (uri) {
if (videoRef.current && isPlaying) {
videoRef.current.pauseAsync();
setIsPlaying(false);
}
onFullScreen(uri);
}
}, [cachedUri, signedUrl, isPlaying, onFullScreen]);
const videoSource = cachedUri || signedUrl;
if (urlError || !videoSource) {
return (
<View style={[styles.video, styles.errorContainer]}>
<Ionicons name="videocam-outline" size={32} color={colors.textSecondary} />
<Text style={styles.errorText}>Video unavailable</Text>
</View>
);
}
return (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleTapToPlay}
onLongPress={handleFullScreen}
delayLongPress={300}
style={styles.container}
>
<Video
ref={videoRef}
source={{ uri: videoSource }}
style={styles.video}
resizeMode={ResizeMode.COVER}
isLooping={false}
shouldPlay={false}
positionMillis={0}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
/>
{/* Loading indicator */}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator color={ACCENT} size="small" />
</View>
)}
{/* Play/Replay overlay */}
{!isPlaying && !isLoading && (
<View style={styles.playOverlay}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.playButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons
name={hasEnded ? 'refresh' : 'play'}
size={24}
color={colors.text}
style={hasEnded ? undefined : { marginLeft: 3 }}
/>
</LinearGradient>
</View>
)}
{/* Full screen button */}
{!isPlaying && !isLoading && (
<TouchableOpacity
style={styles.fullScreenButton}
onPress={handleFullScreen}
activeOpacity={0.7}
>
<Ionicons name="expand-outline" size={16} color={colors.text} />
</TouchableOpacity>
)}
{/* Cached indicator */}
{cachedUri && !isPlaying && (
<View style={styles.cachedBadge}>
<Ionicons name="download-outline" size={10} color={colors.textSecondary} />
</View>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
aspectRatio: 16 / 9,
borderRadius: radius.lg,
overflow: 'hidden',
},
video: {
width: '100%',
height: '100%',
backgroundColor: colors.background,
},
errorContainer: {
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
errorText: {
color: colors.textSecondary,
fontSize: 12,
marginTop: spacing.xs,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
playOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
playButton: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
},
fullScreenButton: {
position: 'absolute',
bottom: spacing.sm,
right: spacing.sm,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
cachedBadge: {
position: 'absolute',
top: spacing.xs,
left: spacing.xs,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
});
export default ChatVideoPlayer;
@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
export interface InputBarProps {
inputText: string;
uploading: boolean;
onChangeText: (text: string) => void;
onSend: () => void;
onPickMedia: () => void;
onTakePhoto: () => void;
onRecordVideo: () => void;
}
const ACCENT = colors.premium.gold;
/**
* Chat input bar with expandable media menu.
* Includes text input, send button, and media action buttons.
*/
export function InputBar({
inputText,
uploading,
onChangeText,
onSend,
onPickMedia,
onTakePhoto,
onRecordVideo,
}: InputBarProps) {
const [menuExpanded, setMenuExpanded] = useState(false);
const menuWidth = useSharedValue(0);
const buttonRotation = useSharedValue(0);
const toggleMenu = () => {
const newExpanded = !menuExpanded;
setMenuExpanded(newExpanded);
menuWidth.value = withTiming(newExpanded ? 132 : 0, { duration: 200, easing: Easing.out(Easing.ease) });
buttonRotation.value = withTiming(newExpanded ? 45 : 0, { duration: 200, easing: Easing.out(Easing.ease) });
};
const handleMediaAction = (action: () => void) => {
setMenuExpanded(false);
menuWidth.value = withTiming(0, { duration: 150 });
buttonRotation.value = withTiming(0, { duration: 150 });
action();
};
const menuAnimatedStyle = useAnimatedStyle(() => ({
width: menuWidth.value,
opacity: interpolate(menuWidth.value, [0, 132], [0, 1]),
}));
const plusButtonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${buttonRotation.value}deg` }],
}));
return (
<View style={styles.container}>
{/* Plus button to toggle media menu */}
<TouchableOpacity onPress={toggleMenu} disabled={uploading} style={styles.attachButton}>
{uploading ? (
<ActivityIndicator color={ACCENT} size="small" />
) : (
<Animated.View style={[styles.attachButtonInner, styles.plusButton, plusButtonAnimatedStyle]}>
<Ionicons name="add" size={24} color={ACCENT} />
</Animated.View>
)}
</TouchableOpacity>
{/* Expandable media options */}
<Animated.View style={[styles.mediaMenuContainer, menuAnimatedStyle]}>
<TouchableOpacity
onPress={() => handleMediaAction(onTakePhoto)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="camera-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMediaAction(onRecordVideo)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="videocam-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMediaAction(onPickMedia)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="image-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
</Animated.View>
<View style={styles.inputFieldWrapper}>
{/* Subtle gradient border effect */}
<View style={styles.inputFieldBorder} />
<TextInput
style={styles.input}
value={inputText}
onChangeText={onChangeText}
placeholder="Type a message..."
placeholderTextColor={colors.textTertiary}
multiline
/>
</View>
<TouchableOpacity
onPress={onSend}
disabled={!inputText.trim()}
activeOpacity={0.8}
>
<LinearGradient
colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']}
style={styles.sendButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
{inputText.trim() && (
<LinearGradient
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
style={styles.sendButtonHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
)}
<Ionicons
name="send"
size={18}
color={inputText.trim() ? colors.text : colors.textTertiary}
/>
</LinearGradient>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
attachButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
attachButtonInner: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
},
plusButton: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
mediaMenuContainer: {
flexDirection: 'row',
overflow: 'hidden',
gap: spacing.xs,
},
mediaMenuItem: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
mediaMenuItemInner: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
inputFieldWrapper: {
flex: 1,
position: 'relative',
},
inputFieldBorder: {
...StyleSheet.absoluteFillObject,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
input: {
...typography.body,
color: colors.text,
backgroundColor: 'rgba(22, 33, 62, 0.6)',
borderRadius: radius.xl,
paddingHorizontal: spacing.md,
paddingTop: 12,
paddingBottom: 12,
maxHeight: 100,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
sendButtonHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 20,
},
});
export default InputBar;
@@ -0,0 +1,108 @@
/**
* MessageBubble - Wrapper component for chat message bubbles
* Applies gradient styling for sender vs receiver messages.
*/
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { colors, gradients, spacing, radius } from '../../../theme';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export interface MessageBubbleProps {
isMe: boolean;
index: number;
children: React.ReactNode;
}
export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
return (
<Animated.View
entering={FadeInUp.delay(index * 30).duration(200)}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
>
{isMe ? (
<View style={styles.myBubbleContainer}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={[styles.bubble, styles.myBubble]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
<LinearGradient
colors={['rgba(255, 255, 255, 0.15)', 'transparent']}
style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{children}
</LinearGradient>
</View>
) : (
<View style={styles.theirBubbleContainer}>
<View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.08)`, 'transparent']}
style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{children}
</View>
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
messageRow: {
flexDirection: 'row',
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
},
myMessageRow: {
justifyContent: 'flex-end',
},
theirMessageRow: {
justifyContent: 'flex-start',
},
myBubbleContainer: {
maxWidth: '80%',
},
theirBubbleContainer: {
maxWidth: '80%',
},
bubble: {
padding: spacing.md,
borderRadius: radius.lg,
overflow: 'hidden',
},
myBubble: {
borderTopRightRadius: radius.xs,
},
theirBubble: {
borderTopLeftRadius: radius.xs,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
},
bubbleSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 30,
},
});
export default MessageBubble;
@@ -0,0 +1,316 @@
/**
* MessageContent - Renders message content (text, images, videos)
* Handles media reveal logic, expired media, and loading states.
*/
import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer';
import type { Message } from '../types';
const ACCENT = colors.premium.gold;
export interface MessageContentProps {
item: Message;
isMe: boolean;
revealMessage: (id: string) => void;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
}
export function MessageContent({
item,
isMe,
revealMessage,
onImagePress,
onVideoFullScreen,
}: MessageContentProps) {
const [signedUrl, setSignedUrl] = useState<string | null>(null);
const [urlError, setUrlError] = useState(false);
useEffect(() => {
if (!item.media_path) return;
let isMounted = true;
const fetchSignedUrl = async () => {
const storagePath = getStoragePath(item.media_path!);
const url = await getCachedSignedUrl(storagePath);
if (!isMounted) return;
if (url) {
setSignedUrl(url);
} else {
setUrlError(true);
}
};
fetchSignedUrl();
return () => {
isMounted = false;
};
}, [item.media_path]);
// Handle expired videos
const isExpired = !!(item as any).media_expired;
const isVideo = (item as any).media_type === 'video';
if (isExpired && isVideo) {
return (
<View>
<View style={styles.expiredMedia}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.9)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.expiredIconContainer}>
<Ionicons name="videocam-off-outline" size={28} color={colors.textTertiary} />
</View>
<Text style={styles.expiredText}>Video expired</Text>
<Text style={styles.expiredSubtext}>Videos are deleted 30 days after viewing</Text>
</View>
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Handle media (images and videos)
if (item.media_path || isExpired) {
const isViewed = !!item.media_viewed_at;
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
// Video content
if (isVideo) {
return (
<View>
{(!isMe && !isViewed) ? (
// Blurred placeholder with reveal overlay for unrevealed videos
<View>
<View style={[styles.messageVideo, styles.videoBlurred]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="videocam" size={40} color={colors.textTertiary} />
</View>
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.revealText}>Tap to reveal video</Text>
</View>
</TouchableOpacity>
</View>
) : signedUrl || urlError ? (
<ChatVideoPlayer
signedUrl={signedUrl}
storagePath={item.media_path!}
urlError={urlError}
onFullScreen={onVideoFullScreen}
/>
) : (
<View style={[styles.messageVideo, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
</View>
)}
{/* Viewed indicator for sender */}
{isMe && isViewed && (
<View style={styles.viewedBadge}>
<Ionicons name="eye" size={12} color={colors.success} />
<Text style={styles.viewedText}>Viewed</Text>
</View>
)}
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Image content
return (
<View>
<TouchableOpacity
activeOpacity={canOpenFullScreen ? 0.8 : 1}
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)}
disabled={!canOpenFullScreen || !signedUrl}
>
{signedUrl ? (
<Image
source={{ uri: signedUrl }}
style={styles.messageImage}
blurRadius={(!isMe && !isViewed) ? 20 : 0}
cachePolicy="disk"
transition={200}
/>
) : urlError ? (
<View style={[styles.messageImage, styles.messageImageError]}>
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
<Text style={styles.messageImageErrorText}>Image unavailable</Text>
</View>
) : (
<View style={[styles.messageImage, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
</View>
)}
</TouchableOpacity>
{/* Reveal overlay for recipient */}
{(!isMe && !isViewed) && (
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.revealText}>Tap to reveal</Text>
</View>
</TouchableOpacity>
)}
{/* Viewed indicator for sender */}
{isMe && isViewed && (
<View style={styles.viewedBadge}>
<Ionicons name="eye" size={12} color={colors.success} />
<Text style={styles.viewedText}>Viewed</Text>
</View>
)}
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Text message
return (
<>
<Text style={styles.messageText}>{item.content}</Text>
<MessageMeta item={item} isMe={isMe} />
</>
);
}
const styles = StyleSheet.create({
messageText: {
color: colors.text,
fontSize: 16,
lineHeight: 22,
},
messageImage: {
width: 200,
height: 200,
borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
messageImageLoading: {
justifyContent: 'center',
alignItems: 'center',
},
messageImageError: {
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
messageImageErrorText: {
color: colors.textSecondary,
fontSize: 12,
},
messageVideo: {
width: 200,
height: 200,
borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
overflow: 'hidden',
},
videoBlurred: {
justifyContent: 'center',
alignItems: 'center',
},
revealOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
borderRadius: radius.md,
},
revealContent: {
alignItems: 'center',
gap: spacing.sm,
},
revealIconContainer: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
},
revealText: {
color: colors.text,
fontSize: 13,
fontWeight: '600',
},
viewedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: spacing.xs,
},
viewedText: {
color: colors.success,
fontSize: 11,
fontWeight: '500',
},
expiredMedia: {
width: 200,
height: 150,
borderRadius: radius.md,
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
expiredIconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: 'center',
alignItems: 'center',
},
expiredText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '600',
},
expiredSubtext: {
color: colors.textTertiary,
fontSize: 11,
textAlign: 'center',
paddingHorizontal: spacing.md,
},
});
export default MessageContent;
@@ -0,0 +1,47 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ReadReceipt, getReceiptStatus } from '../../../components/ui';
import { colors, typography, spacing } from '../../../theme';
import { Message } from '../types';
export interface MessageMetaProps {
item: Message;
isMe: boolean;
}
/**
* Message metadata component showing timestamp and read receipts.
*/
export function MessageMeta({ item, isMe }: MessageMetaProps) {
const status = getReceiptStatus(item.delivered_at, item.read_at);
return (
<View style={styles.container}>
<Text style={styles.timestamp}>
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
{isMe && (
<View style={styles.readStatus}>
<ReadReceipt status={status} />
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
},
timestamp: {
...typography.caption2,
color: 'rgba(255, 255, 255, 0.5)',
},
readStatus: {
marginLeft: spacing.xs,
},
});
export default MessageMeta;
@@ -0,0 +1,108 @@
import React, { useEffect } from 'react';
import { View, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
FadeIn,
} from 'react-native-reanimated';
import { gradients, colors, radius, spacing } from '../../../theme';
interface TypingDotProps {
delay: number;
}
function TypingDot({ delay }: TypingDotProps) {
const opacity = useSharedValue(0.4);
useEffect(() => {
opacity.value = withRepeat(
withSequence(
withTiming(1, { duration: 300 }),
withTiming(0.4, { duration: 300 })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View style={[styles.typingDot, animatedStyle, { marginLeft: delay > 0 ? 4 : 0 }]}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.typingDotGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
);
}
/**
* Typing indicator component showing three animated dots
* when partner is typing.
*/
export function TypingIndicator() {
return (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.container}
>
<View style={styles.bubble}>
{/* Subtle gradient */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.dots}>
<TypingDot delay={0} />
<TypingDot delay={150} />
<TypingDot delay={300} />
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
bubble: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
backgroundColor: 'rgba(22, 33, 62, 0.6)',
overflow: 'hidden',
},
dots: {
flexDirection: 'row',
alignItems: 'center',
},
typingDot: {
width: 8,
height: 8,
borderRadius: 4,
overflow: 'hidden',
},
typingDotGradient: {
flex: 1,
borderRadius: 4,
},
});
export default TypingIndicator;
@@ -0,0 +1,151 @@
import React, { useEffect } from 'react';
import { View, Text, Image, ActivityIndicator, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
FadeIn,
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors, gradients, spacing, radius } from '../../../theme';
import { UploadStatus } from '../types';
export interface UploadProgressProps {
uploadStatus: UploadStatus;
}
const ACCENT = colors.premium.gold;
/**
* Upload progress skeleton component showing when media is being uploaded.
* Displays a shimmer effect with thumbnail preview for images.
*/
export function UploadProgress({ uploadStatus }: UploadProgressProps) {
const shimmer = useSharedValue(0);
useEffect(() => {
shimmer.value = withRepeat(
withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
-1,
false
);
}, []);
const shimmerStyle = useAnimatedStyle(() => ({
opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
}));
if (!uploadStatus) return null;
const isVideo = uploadStatus.mediaType === 'video';
const statusText = uploadStatus.status === 'compressing'
? 'Compressing video...'
: `Uploading ${isVideo ? 'video' : 'image'}...`;
return (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.container}
>
<View style={styles.bubbleContainer}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.bubble}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
<LinearGradient
colors={['rgba(255, 255, 255, 0.15)', 'transparent']}
style={styles.silkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Skeleton media placeholder */}
<Animated.View style={[styles.mediaSkeleton, shimmerStyle]}>
{uploadStatus.thumbnailUri && !isVideo ? (
<Image
source={{ uri: uploadStatus.thumbnailUri }}
style={styles.thumbnail}
blurRadius={10}
/>
) : (
<View style={styles.iconContainer}>
<Ionicons
name={isVideo ? 'videocam' : 'image'}
size={32}
color={colors.textTertiary}
/>
</View>
)}
{/* Overlay with spinner and status */}
<View style={styles.overlay}>
<ActivityIndicator color={ACCENT} size="small" />
<Text style={styles.statusText}>{statusText}</Text>
</View>
</Animated.View>
</LinearGradient>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-end',
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
},
bubbleContainer: {
maxWidth: '80%',
},
bubble: {
borderRadius: radius.xl,
borderTopRightRadius: radius.sm,
overflow: 'hidden',
minWidth: 200,
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 40,
},
mediaSkeleton: {
height: 180,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
},
thumbnail: {
...StyleSheet.absoluteFillObject,
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
statusText: {
color: colors.textSecondary,
fontSize: 13,
},
});
export default UploadProgress;
@@ -0,0 +1,16 @@
// Chat feature components
export { TypingIndicator } from './TypingIndicator';
export { InputBar } from './InputBar';
export type { InputBarProps } from './InputBar';
export { MessageMeta } from './MessageMeta';
export type { MessageMetaProps } from './MessageMeta';
export { UploadProgress } from './UploadProgress';
export type { UploadProgressProps } from './UploadProgress';
export { ChatVideoPlayer } from './ChatVideoPlayer';
export type { ChatVideoPlayerProps } from './ChatVideoPlayer';
export { MessageContent } from './MessageContent';
export type { MessageContentProps } from './MessageContent';
export { MessageBubble } from './MessageBubble';
export type { MessageBubbleProps } from './MessageBubble';
export { ChatHeader } from './ChatHeader';
export { ChatMessages } from './ChatMessages';
@@ -0,0 +1,3 @@
export * from './useMediaUpload';
export * from './useChatMessages';
export * from './useVideoCache';
@@ -0,0 +1 @@
export { useMessageSubscription as useChatMessages } from '../../../hooks';
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { Video as VideoCompressor } from 'react-native-compressor';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { UploadStatus } from '../types';
export function useMediaUpload(matchId: string, userId: string | undefined) {
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
if (!userId) return;
setUploading(true);
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
try {
let fileUri = uri;
let fileBody;
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
// Compress videos on native platforms before upload
if (mediaType === 'video' && Platform.OS !== 'web') {
try {
console.log('Compressing video...');
fileUri = await VideoCompressor.compress(uri, {
compressionMethod: 'auto',
maxSize: 720, // Max 720p resolution
minimumFileSizeForCompress: 0, // Always compress
});
console.log('Video compressed successfully');
ext = 'mp4'; // Compressed videos are always mp4
// Update status to uploading after compression
setUploadStatus({ mediaType, status: 'uploading', thumbnailUri: uri });
} catch (compressError) {
console.warn('Video compression failed, uploading original:', compressError);
// Fall back to original if compression fails
fileUri = uri;
setUploadStatus({ mediaType, status: 'uploading', thumbnailUri: uri });
}
}
if (Platform.OS === 'web') {
const response = await fetch(fileUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (mediaType === 'video') {
if (blob.type === 'video/mp4') ext = 'mp4';
else if (blob.type === 'video/quicktime') ext = 'mov';
else if (blob.type === 'video/webm') ext = 'webm';
} else {
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg';
else if (blob.type === 'image/gif') ext = 'gif';
else if (blob.type === 'image/webp') ext = 'webp';
}
} else {
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
if (mediaType !== 'video') {
// Only use URI extension for non-videos (videos are always mp4 after compression)
const uriExt = fileUri.split('.').pop();
if (uriExt && uriExt !== fileUri) {
ext = uriExt.toLowerCase();
}
}
}
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const contentType = mediaType === 'video'
? `video/${ext === 'mov' ? 'quicktime' : ext}`
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from("chat-media")
.upload(fileName, fileBody, {
contentType,
upsert: false
});
if (uploadError) throw uploadError;
// Store just the path, not the full URL (bucket is private)
await supabase.from("messages").insert({
match_id: matchId,
user_id: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
});
Events.mediaUploaded();
} catch (error) {
Alert.alert("Error", `Failed to upload ${mediaType}`);
console.error(error);
} finally {
setUploading(false);
setUploadStatus(null);
}
};
return { uploading, uploadStatus, uploadMedia };
}
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react';
import { getStoragePath, getVideoCachedUri, cacheVideo } from '../../../lib/imageCache';
export function useVideoCache(storagePath: string, signedUrl: string | null) {
const [cachedUri, setCachedUri] = useState<string | null>(null);
// Check cache on mount or when storagePath changes
useEffect(() => {
let mounted = true;
const checkCache = async () => {
if (!storagePath) return;
const path = getStoragePath(storagePath);
const cached = await getVideoCachedUri(path);
if (mounted && cached) {
setCachedUri(cached);
}
};
checkCache();
return () => {
mounted = false;
};
}, [storagePath]);
// Function to trigger caching securely
const cacheVideoFile = async () => {
if (!signedUrl || cachedUri) return cachedUri;
const path = getStoragePath(storagePath);
const uri = await cacheVideo(path, signedUrl);
if (uri) {
setCachedUri(uri);
}
return uri;
};
return { cachedUri, cacheVideoFile };
}
+5
View File
@@ -0,0 +1,5 @@
// Chat feature exports
export * from './components';
export * from './hooks';
export * from './types';
export { ChatScreen } from './ChatScreen';
+41
View File
@@ -0,0 +1,41 @@
import { Database } from '../../types/supabase';
/** Message type from database */
export type Message = Database['public']['Tables']['messages']['Row'];
/** Upload status for skeleton display */
export type UploadStatus = {
mediaType: 'image' | 'video';
status: 'compressing' | 'uploading';
thumbnailUri?: string;
} | null;
/** Match type with question and responses */
export interface Match {
id: string;
question_id: string;
couple_id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
created_at: string;
question?: {
id: string;
text: string;
partner_text?: string;
};
responses?: Array<{
user_id: string;
answer: string;
profiles?: {
name?: string;
};
}>;
}
/** Chat color palette constants */
export const CHAT_COLORS = {
ACCENT: 'rgb(212, 175, 55)', // colors.premium.gold
ACCENT_DARK: 'rgb(184, 134, 11)',
ACCENT_RGBA: 'rgba(212, 175, 55, ',
ROSE: 'rgb(232, 164, 174)',
ROSE_RGBA: 'rgba(232, 164, 174, ',
} as const;
+3
View File
@@ -0,0 +1,3 @@
// Features exports
export * from './chat';
export * from './profile';
@@ -0,0 +1,533 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, ActivityIndicator, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { GradientBackground, GlassCard } from '../../components/ui';
import { FeedbackModal } from '../../components/feedback';
import { Paywall } from '../../components/paywall';
import { colors, featureColors, spacing, typography, radius, shadows } from '../../theme';
import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; // Needed for debug reset
// Hooks
import { useProfileSettings, useCoupleManagement } from './hooks';
// Components
import { ProfileHeader, CoupleStatus, PrivacySettings, DangerZone, SettingsSection, MenuItem } from './components';
const MAX_CONTENT_WIDTH = 500;
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
export function ProfileScreen() {
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const { user, partner, couple, fetchUser } = useAuthStore();
const { subscription } = useSubscriptionStore();
// Hooks
const settings = useProfileSettings();
const {
handleUnpair,
handleDeleteRelationship,
handleResetProgress,
isResettingProgress,
handleSignOut,
navigateToPairing
} = useCoupleManagement();
const [showFeedbackModal, setShowFeedbackModal] = React.useState(false);
// Derived formatting
const isOwnSubscription = user?.is_premium && subscription.isProUser;
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
const formatExpirationDate = (date: Date | null | string) => {
if (!date) return "Never";
const d = new Date(date);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
return (
<GradientBackground>
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
isWideScreen && styles.contentContainerWide,
]}
showsVerticalScrollIndicator={false}
>
{/* Header title */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
<Text style={styles.title}>Settings</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</Animated.View>
{/* Profile Header */}
<ProfileHeader
user={user}
isUploadingAvatar={settings.isUploadingAvatar}
isEditingName={settings.isEditingName}
newName={settings.newName}
isUpdatingName={settings.isUpdatingName}
onAvatarPress={settings.handleAvatarPress}
onNewNameChange={settings.setNewName}
onUpdateName={settings.handleUpdateName}
onCancelEditName={settings.handleCancelEditName}
onStartEditingName={() => settings.setIsEditingName(true)}
/>
{/* Partner */}
<CoupleStatus
partner={partner}
couple={couple}
onUnpair={handleUnpair}
onPairingPress={navigateToPairing}
/>
{/* Subscription Section */}
<SettingsSection title="Subscription" delay={350}>
{hasPremiumAccess ? (
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="star" size={20} color={colors.text} />
</LinearGradient>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Pro Member</Text>
<Text style={styles.rowLabel}>
{isOwnSubscription
? `Renews ${formatExpirationDate(subscription.expirationDate)}`
: "Via partner's subscription"
}
</Text>
</View>
</View>
{isOwnSubscription && (
<TouchableOpacity
style={styles.manageButton}
onPress={settings.handleManageSubscription}
activeOpacity={0.7}
>
<Text style={styles.manageButtonText}>Manage</Text>
</TouchableOpacity>
)}
</View>
) : (
<TouchableOpacity
style={styles.rowContainer}
onPress={() => settings.setShowPaywall(true)}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<View style={styles.emptyPartnerIcon}>
<Ionicons name="star-outline" size={20} color={colors.textTertiary} />
</View>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValueMuted}>Free Plan</Text>
<Text style={styles.rowLabel}>Upgrade to unlock all packs</Text>
</View>
</View>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.upgradeButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.upgradeButtonText}>Upgrade</Text>
</LinearGradient>
</TouchableOpacity>
)}
{/* Restore Purchases Link */}
{!hasPremiumAccess && (
<TouchableOpacity
style={styles.restoreLink}
onPress={settings.handleRestorePurchases}
disabled={settings.isPurchasing}
>
<Text style={styles.restoreLinkText}>
{settings.isPurchasing ? "Restoring..." : "Restore Purchases"}
</Text>
</TouchableOpacity>
)}
</SettingsSection>
{/* Preferences */}
<PrivacySettings
showExplicit={settings.showExplicit}
isUpdatingExplicit={settings.isUpdatingExplicit}
onExplicitToggle={settings.handleExplicitToggle}
biometricAvailable={settings.biometricAvailable}
biometricEnabled={settings.biometricEnabled}
biometricType={settings.biometricType}
isUpdatingBiometric={settings.isUpdatingBiometric}
onBiometricToggle={settings.handleBiometricToggle}
/>
{/* Account */}
<SettingsSection title="Account" delay={425}>
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={true}
/>
</SettingsSection>
{/* Support */}
<SettingsSection title="Support" delay={475}>
<MenuItem
icon="chatbubble-ellipses-outline"
label="Send Feedback"
description="Report bugs or request features"
onPress={() => setShowFeedbackModal(true)}
/>
</SettingsSection>
{/* Reset Progress Layout */}
{couple && (
<SettingsSection title="Reset Progress" delay={500}>
<View style={styles.resetContent}>
<View style={styles.resetInfo}>
<View style={styles.resetIconContainer}>
<Ionicons name="refresh" size={20} color={colors.warning} />
</View>
<View style={styles.resetTextContainer}>
<Text style={styles.resetTitle}>Start Fresh</Text>
<Text style={styles.resetDescription}>
Delete all swipes, matches, and chats while keeping your partner connection.
</Text>
</View>
</View>
<TouchableOpacity
style={[styles.resetButton, isResettingProgress && styles.resetButtonDisabled]}
onPress={handleResetProgress}
disabled={isResettingProgress}
activeOpacity={0.7}
>
{isResettingProgress ? (
<ActivityIndicator size="small" color={colors.warning} />
) : (
<>
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
<Text style={styles.resetButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</SettingsSection>
)}
{/* Debug Section */}
{__DEV__ && (
<SettingsSection title="Debug" delay={550}>
<MenuItem
icon="refresh"
label="Reset Swipe Tutorial"
description="Show the tutorial again"
onPress={async () => {
await resetSwipeTutorial();
Alert.alert("Success", "Swipe tutorial has been reset.");
}}
variant="danger"
// Using danger just for yellow/warning color simulation via props? No, MenuItem uses error color for danger.
// I'll stick to default and standard styling or custom if needed.
// The original used warning color (yellow).
// I will use default for now.
/>
<View style={styles.preferencesDivider} />
<MenuItem
icon="heart"
label="Reset Matches Tutorial"
description="Show the tutorial again"
onPress={async () => {
await resetMatchesTutorial();
Alert.alert("Success", "Matches tutorial has been reset.");
}}
/>
<View style={styles.preferencesDivider} />
<MenuItem
icon="school-outline"
label="Reset Onboarding"
description="Show onboarding flow again"
onPress={async () => {
if (!user?.id) return;
try {
const { error } = await supabase
.from('profiles')
.update({ onboarding_completed: false })
.eq('id', user.id);
if (error) throw error;
await fetchUser();
Alert.alert("Success", "Onboarding has been reset.");
} catch (error) {
Alert.alert("Error", "Failed to reset onboarding.");
}
}}
/>
</SettingsSection>
)}
{/* Danger Zone */}
{couple && (
<DangerZone onDeleteRelationship={handleDeleteRelationship} />
)}
{/* Version */}
<Animated.View
entering={FadeInDown.delay(couple ? 650 : 525).duration(500)}
style={styles.versionContainer}
>
<View style={styles.versionBadge}>
<Ionicons name="heart" size={12} color={featureColors.profile.accent} />
<Text style={styles.version}>Sauci v1.0.0</Text>
</View>
</Animated.View>
<View style={styles.bottomSpacer} />
</ScrollView>
{/* Modals */}
<FeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
/>
<Paywall
visible={settings.showPaywall}
onClose={() => settings.setShowPaywall(false)}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
},
contentContainerWide: {
alignSelf: 'center',
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
alignItems: 'center',
},
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: colors.secondary,
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 100,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(155, 89, 182, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.secondary,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
},
// Shared row styles
rowContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
},
rowLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
partnerIconGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
emptyPartnerIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
rowTextContainer: {
marginLeft: spacing.md,
flex: 1,
},
rowValue: {
...typography.body,
fontWeight: '600',
color: colors.text,
},
rowValueMuted: {
...typography.body,
fontWeight: '600',
color: colors.textSecondary,
},
rowLabel: {
...typography.caption1,
color: colors.textTertiary,
marginTop: 2,
},
preferencesDivider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.md,
},
// Subscription styles
manageButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
manageButtonText: {
...typography.subhead,
color: colors.secondary,
fontWeight: "600",
},
upgradeButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
},
upgradeButtonText: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
},
restoreLink: {
alignItems: "center",
marginTop: spacing.sm,
padding: spacing.sm,
},
restoreLinkText: {
...typography.caption1,
color: colors.textTertiary,
},
// Reset Progress Styles
resetContent: {
gap: spacing.md,
},
resetInfo: {
flexDirection: "row",
alignItems: "flex-start",
},
resetIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.warningLight,
justifyContent: "center",
alignItems: "center",
},
resetTextContainer: {
flex: 1,
marginLeft: spacing.md,
},
resetTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
resetDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
resetButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.warningLight,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(243, 156, 18, 0.3)',
gap: spacing.xs,
alignSelf: "flex-end",
},
resetButtonDisabled: {
opacity: 0.5,
},
resetButtonText: {
...typography.subhead,
color: colors.warning,
fontWeight: "600",
},
// Version Badge
versionContainer: {
alignItems: "center",
marginTop: spacing.lg,
},
versionBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
},
version: {
...typography.caption1,
color: colors.textTertiary,
},
bottomSpacer: {
height: spacing.lg,
},
});
@@ -0,0 +1,161 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { SettingsSection } from './SettingsSection';
import { colors, featureColors, spacing, typography } from '../../../theme';
import { Profile, Couple } from '../../../types';
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
interface CoupleStatusProps {
partner: Profile | null;
couple: Couple | null;
onUnpair: () => void;
onPairingPress: () => void;
}
export const CoupleStatus: React.FC<CoupleStatusProps> = ({
partner,
couple,
onUnpair,
onPairingPress,
}) => {
return (
<SettingsSection title="Partner" delay={300}>
{partner ? (
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="heart" size={20} color={colors.text} />
</LinearGradient>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>
{partner.name || partner.email || 'Your partner'}
</Text>
<Text style={styles.rowLabel}>Connected</Text>
</View>
</View>
<TouchableOpacity
onPress={onUnpair}
style={styles.unlinkButton}
activeOpacity={0.7}
>
<Ionicons name="unlink-outline" size={18} color={colors.textTertiary} />
</TouchableOpacity>
</View>
) : couple ? (
<TouchableOpacity
style={styles.rowContainer}
onPress={onPairingPress}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="hourglass-outline" size={20} color={colors.text} />
</LinearGradient>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Waiting for partner</Text>
<Text style={styles.rowLabel}>Tap to view invite code</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.rowContainer}
onPress={onPairingPress}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<View style={styles.emptyPartnerIcon}>
<Ionicons name="heart-outline" size={20} color={colors.textTertiary} />
</View>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValueMuted}>Not paired yet</Text>
<Text style={styles.rowLabel}>Tap to connect</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
</TouchableOpacity>
)}
</SettingsSection>
);
};
const styles = StyleSheet.create({
rowContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
rowLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
partnerIconGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
emptyPartnerIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
rowTextContainer: {
marginLeft: spacing.md,
flex: 1,
},
rowValue: {
...typography.body,
fontWeight: '600',
color: colors.text,
},
rowValueMuted: {
...typography.body,
fontWeight: '600',
color: colors.textSecondary,
},
rowLabel: {
...typography.caption1,
color: colors.textTertiary,
marginTop: 2,
},
unlinkButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
chevronContainer: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
});
@@ -0,0 +1,336 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal, TextInput, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GlassCard } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme';
interface DangerZoneProps {
onDeleteRelationship: () => Promise<void>;
}
export const DangerZone: React.FC<DangerZoneProps> = ({ onDeleteRelationship }) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
if (deleteConfirmText !== "DELETE") return;
setIsDeleting(true);
try {
await onDeleteRelationship();
setShowDeleteModal(false);
setDeleteConfirmText("");
} catch (error) {
// Error handling relies on parent throwing or managing its own alerts
// If parent throws, we stay on modal or close it?
// Usually parent alerts.
} finally {
setIsDeleting(false);
}
};
return (
<>
<Animated.View
entering={FadeInDown.delay(600).duration(500)}
style={styles.section}
>
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
<GlassCard style={styles.dangerCard}>
<View style={styles.dangerContent}>
<View style={styles.dangerInfo}>
<View style={styles.dangerIconContainer}>
<Ionicons name="warning" size={20} color={colors.error} />
</View>
<View style={styles.dangerTextContainer}>
<Text style={styles.dangerTitle}>Delete All Data</Text>
<Text style={styles.dangerDescription}>
Permanently delete your relationship including all matches, chats, and shared photos.
</Text>
</View>
</View>
<TouchableOpacity
style={styles.dangerButton}
onPress={() => setShowDeleteModal(true)}
activeOpacity={0.7}
>
<Ionicons name="trash-outline" size={16} color={colors.error} />
<Text style={styles.dangerButtonText}>Delete</Text>
</TouchableOpacity>
</View>
</GlassCard>
</Animated.View>
<Modal
visible={showDeleteModal}
transparent
animationType="fade"
onRequestClose={() => !isDeleting && setShowDeleteModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="warning" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete All Data?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your couple connection</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All matches and responses</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="images" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All shared photos</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={deleteConfirmText}
onChangeText={setDeleteConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeleting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => {
setShowDeleteModal(false);
setDeleteConfirmText("");
}}
disabled={isDeleting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.confirmDeleteButton,
deleteConfirmText !== "DELETE" && styles.confirmDeleteButtonDisabled,
]}
onPress={handleDelete}
disabled={deleteConfirmText !== "DELETE" || isDeleting}
activeOpacity={0.7}
>
{isDeleting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.confirmDeleteButtonText}>Delete</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</>
);
};
const styles = StyleSheet.create({
section: {
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg,
},
dangerSectionTitle: {
...typography.caption1,
color: colors.error,
textTransform: "uppercase",
letterSpacing: 1.5,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
dangerCard: {
borderColor: 'rgba(231, 76, 60, 0.3)',
borderWidth: 1,
},
dangerContent: {
gap: spacing.md,
},
dangerInfo: {
flexDirection: "row",
alignItems: "flex-start",
},
dangerIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.errorLight,
justifyContent: "center",
alignItems: "center",
},
dangerTextContainer: {
flex: 1,
marginLeft: spacing.md,
},
dangerTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
dangerDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
dangerButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.errorLight,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(231, 76, 60, 0.3)',
gap: spacing.xs,
alignSelf: "flex-end",
},
dangerButtonText: {
...typography.subhead,
color: colors.error,
fontWeight: "600",
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.8)",
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
modalContent: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.xl,
width: "100%",
maxWidth: 400,
borderWidth: 1,
borderColor: colors.glass.border,
},
modalHeader: {
alignItems: "center",
marginBottom: spacing.lg,
},
modalIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.errorLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
modalTitle: {
...typography.title2,
color: colors.text,
textAlign: "center",
},
modalDescription: {
...typography.body,
color: colors.textSecondary,
marginBottom: spacing.lg,
textAlign: "center",
},
deleteList: {
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
padding: spacing.md,
gap: spacing.sm,
marginBottom: spacing.xl,
},
deleteListItem: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
deleteListText: {
...typography.subhead,
color: colors.text,
},
confirmLabel: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.xs,
textAlign: "center",
},
confirmKeyword: {
fontWeight: "bold",
color: colors.error,
},
confirmInput: {
backgroundColor: colors.background,
borderRadius: radius.md,
padding: spacing.md,
color: colors.text,
fontSize: 16,
borderWidth: 1,
borderColor: colors.error,
marginBottom: spacing.xl,
textAlign: "center",
},
modalButtons: {
flexDirection: "row",
gap: spacing.md,
},
cancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.border,
},
cancelButtonText: {
...typography.body,
color: colors.text,
fontWeight: "600",
},
confirmDeleteButton: {
flex: 1,
flexDirection: "row",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.error,
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
},
confirmDeleteButtonDisabled: {
opacity: 0.5,
backgroundColor: colors.errorLight,
},
confirmDeleteButtonText: {
...typography.body,
color: colors.text,
fontWeight: "600",
},
});
@@ -0,0 +1,130 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
export interface MenuItemProps {
/** Icon name (Ionicons) */
icon: string;
/** Main label text */
label: string;
/** Description text below label */
description?: string;
/** Action when pressed */
onPress: () => void;
/** Optional element to show on the right */
rightElement?: React.ReactNode;
/** Whether to show chevron arrow */
showChevron?: boolean;
/** Visual variant */
variant?: 'default' | 'danger';
/** Disable the item */
disabled?: boolean;
}
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
/**
* Reusable menu item row for settings screens.
*/
export function MenuItem({
icon,
label,
description,
onPress,
rightElement,
showChevron = true,
variant = 'default',
disabled = false,
}: MenuItemProps) {
const isDanger = variant === 'danger';
const iconColor = isDanger ? colors.error : colors.text;
const labelColor = isDanger ? colors.error : colors.text;
return (
<TouchableOpacity
style={styles.container}
onPress={onPress}
activeOpacity={0.7}
disabled={disabled}
>
<View style={styles.left}>
{isDanger ? (
<View style={styles.dangerIconContainer}>
<Ionicons name={icon as any} size={20} color={iconColor} />
</View>
) : (
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name={icon as any} size={20} color={iconColor} />
</LinearGradient>
)}
<View style={styles.textContainer}>
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
{description && (
<Text style={styles.description}>{description}</Text>
)}
</View>
</View>
{rightElement || (showChevron && (
<View style={styles.chevron}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
))}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs,
},
left: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.md,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: radius.lg,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
dangerIconContainer: {
width: 40,
height: 40,
borderRadius: radius.lg,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
backgroundColor: 'rgba(239, 68, 68, 0.1)',
},
textContainer: {
flex: 1,
},
label: {
...typography.body,
fontWeight: '500',
},
description: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
chevron: {
padding: spacing.xs,
},
});
export default MenuItem;
@@ -0,0 +1,61 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem';
import { colors, spacing } from '../../../theme';
interface PrivacySettingsProps {
showExplicit: boolean;
isUpdatingExplicit: boolean;
onExplicitToggle: (value: boolean) => void;
biometricAvailable: boolean;
biometricEnabled: boolean;
biometricType: string;
isUpdatingBiometric: boolean;
onBiometricToggle: (value: boolean) => void;
}
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
showExplicit,
isUpdatingExplicit,
onExplicitToggle,
biometricAvailable,
biometricEnabled,
biometricType,
isUpdatingBiometric,
onBiometricToggle,
}) => {
return (
<SettingsSection title="Preferences" delay={375}>
<SwitchItem
icon="flame"
label="Spicy Content"
description="Show spicy question packs"
value={showExplicit}
onValueChange={onExplicitToggle}
disabled={isUpdatingExplicit}
/>
{biometricAvailable && (
<>
<View style={styles.divider} />
<SwitchItem
icon={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"}
label={biometricType}
description="Require unlock when opening app"
value={biometricEnabled}
onValueChange={onBiometricToggle}
disabled={isUpdatingBiometric}
/>
</>
)}
</SettingsSection>
);
};
const styles = StyleSheet.create({
divider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.md,
},
});
@@ -0,0 +1,263 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GlassCard } from '../../../components/ui';
import { colors, featureColors, spacing, typography } from '../../../theme';
import { Profile } from '../../../types';
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
interface ProfileHeaderProps {
user: Profile | null;
isUploadingAvatar: boolean;
isEditingName: boolean;
newName: string;
isUpdatingName: boolean;
onAvatarPress: () => void;
onNewNameChange: (text: string) => void;
onUpdateName: () => void;
onCancelEditName: () => void;
onStartEditingName: () => void;
}
export const ProfileHeader: React.FC<ProfileHeaderProps> = ({
user,
isUploadingAvatar,
isEditingName,
newName,
isUpdatingName,
onAvatarPress,
onNewNameChange,
onUpdateName,
onCancelEditName,
onStartEditingName,
}) => {
return (
<Animated.View
entering={FadeInDown.delay(200).duration(500)}
style={styles.profileSection}
>
<GlassCard variant="elevated">
<View style={styles.profileContent}>
<TouchableOpacity
onPress={onAvatarPress}
disabled={isUploadingAvatar}
activeOpacity={0.7}
style={styles.avatarTouchable}
>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatarImage}
cachePolicy="disk"
transition={200}
/>
) : (
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || "U"}
</Text>
</View>
)}
</LinearGradient>
{isUploadingAvatar ? (
<View style={styles.avatarOverlay}>
<ActivityIndicator size="large" color={colors.text} />
</View>
) : (
<View style={styles.avatarEditBadge}>
<Ionicons name="camera" size={14} color={colors.text} />
</View>
)}
</TouchableOpacity>
<View style={styles.profileInfo}>
{isEditingName ? (
<View style={styles.editNameContainer}>
<TextInput
style={styles.nameInput}
value={newName}
onChangeText={onNewNameChange}
placeholder="Enter your name"
placeholderTextColor={colors.textTertiary}
autoFocus
editable={!isUpdatingName}
onSubmitEditing={onUpdateName}
returnKeyType="done"
/>
<View style={styles.editNameButtons}>
<TouchableOpacity
style={styles.cancelEditButton}
onPress={onCancelEditName}
disabled={isUpdatingName}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.saveEditButton}
onPress={onUpdateName}
disabled={isUpdatingName}
activeOpacity={0.7}
>
{isUpdatingName ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="checkmark" size={20} color={colors.text} />
)}
</TouchableOpacity>
</View>
</View>
) : (
<TouchableOpacity
style={styles.nameContainer}
onPress={onStartEditingName}
activeOpacity={0.7}
>
<Text style={styles.name}>{user?.name || "User"}</Text>
<View style={styles.editNameIcon}>
<Ionicons name="pencil" size={14} color={colors.textTertiary} />
</View>
</TouchableOpacity>
)}
<Text style={styles.email}>{user?.email}</Text>
</View>
</View>
</GlassCard>
</Animated.View>
);
};
const styles = StyleSheet.create({
profileSection: {
marginBottom: spacing.xl,
},
profileContent: {
flexDirection: 'row',
alignItems: 'center',
},
avatarTouchable: {
marginRight: spacing.md,
},
avatarGradient: {
width: 80,
height: 80,
borderRadius: 40,
padding: 3,
justifyContent: 'center',
alignItems: 'center',
shadowColor: featureColors.profile.accent,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
avatarImage: {
width: '100%',
height: '100%',
borderRadius: 40,
backgroundColor: colors.background,
},
avatarInner: {
width: '100%',
height: '100%',
borderRadius: 40,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
...typography.title1,
fontSize: 32,
color: colors.text,
},
avatarOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 40,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
avatarEditBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: colors.surfaceSolid,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background,
},
profileInfo: {
flex: 1,
justifyContent: 'center',
},
nameContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xs,
},
name: {
...typography.title2,
color: colors.text,
marginRight: spacing.xs,
},
editNameIcon: {
padding: 4,
},
email: {
...typography.body,
color: colors.textSecondary,
fontSize: 14,
},
editNameContainer: {
flexDirection: 'row',
alignItems: 'center',
},
nameInput: {
...typography.title2,
color: colors.text,
flex: 1,
paddingVertical: 0,
paddingHorizontal: 0,
marginRight: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: featureColors.profile.accent,
},
editNameButtons: {
flexDirection: 'row',
alignItems: 'center',
},
saveEditButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: featureColors.profile.accent,
justifyContent: 'center',
alignItems: 'center',
marginLeft: spacing.xs,
},
cancelEditButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.glass.highlight,
justifyContent: 'center',
alignItems: 'center',
},
});
@@ -0,0 +1,47 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GlassCard } from '../../../components/ui';
import { colors, typography, spacing } from '../../../theme';
export interface SettingsSectionProps {
/** Section title displayed above the card */
title: string;
/** Animation delay in ms */
delay?: number;
/** Content to render inside the card */
children: React.ReactNode;
}
/**
* Reusable settings section with title and glass card container.
*/
export function SettingsSection({ title, delay = 200, children }: SettingsSectionProps) {
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500)}
style={styles.container}
>
<Text style={styles.title}>{title}</Text>
<GlassCard>
{children}
</GlassCard>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.lg,
},
title: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 2,
color: colors.textTertiary,
marginBottom: spacing.sm,
textTransform: 'uppercase',
},
});
export default SettingsSection;
@@ -0,0 +1,100 @@
import React from 'react';
import { View, Text, Switch, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
export interface SwitchItemProps {
/** Icon name (Ionicons) */
icon: string;
/** Main label text */
label: string;
/** Description text below label */
description: string;
/** Switch value */
value: boolean;
/** Whether the switch is disabled */
disabled?: boolean;
/** Callback when value changes */
onValueChange: (value: boolean) => void;
}
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
const ACCENT_COLOR = featureColors.profile.accent;
/**
* Reusable toggle switch row for settings screens.
*/
export function SwitchItem({
icon,
label,
description,
value,
disabled = false,
onValueChange,
}: SwitchItemProps) {
return (
<View style={styles.container}>
<View style={styles.left}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name={icon as any} size={20} color={colors.text} />
</LinearGradient>
<View style={styles.textContainer}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.description}>{description}</Text>
</View>
</View>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: colors.glass.border, true: colors.secondaryLight }}
thumbColor={value ? ACCENT_COLOR : colors.textTertiary}
ios_backgroundColor={colors.glass.border}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs,
},
left: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.md,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: radius.lg,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
textContainer: {
flex: 1,
},
label: {
...typography.body,
fontWeight: '500',
color: colors.text,
},
description: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
});
export default SwitchItem;
@@ -0,0 +1,12 @@
// Profile feature components
export { ProfileHeader } from './ProfileHeader';
export { CoupleStatus } from './CoupleStatus';
export { PrivacySettings } from './PrivacySettings';
export { DangerZone } from './DangerZone';
export { SettingsSection } from './SettingsSection';
export type { SettingsSectionProps } from './SettingsSection';
export { SwitchItem } from './SwitchItem';
export type { SwitchItemProps } from './SwitchItem';
export { MenuItem } from './MenuItem';
export type { MenuItemProps } from './MenuItem';
@@ -0,0 +1,3 @@
// Profile feature hooks
export { useProfileSettings } from './useProfileSettings';
export { useCoupleManagement } from './useCoupleManagement';
@@ -0,0 +1,129 @@
import { useState } from 'react';
import { Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
export function useCoupleManagement() {
const router = useRouter();
const { fetchCouple, fetchUser, signOut } = useAuthStore();
const { clearMatches } = useMatchStore();
const { clearMessages } = useMessageStore();
const { clearPacks } = usePacksStore();
const [isResettingProgress, setIsResettingProgress] = useState(false);
const handleUnpair = async () => {
Alert.alert(
"Unpair Partner",
"Are you sure you want to unpair? This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Unpair",
style: "destructive",
onPress: async () => {
try {
await supabase.functions.invoke("manage-couple", {
method: "DELETE",
});
await fetchCouple();
// Refresh user to update UI state
await fetchUser();
} catch (error) {
Alert.alert("Error", "Failed to unpair");
}
},
},
]
);
};
const handleDeleteRelationship = async () => {
try {
const { error } = await supabase.functions.invoke("delete-relationship", {
method: "DELETE",
});
if (error) throw error;
// Clear local stores
clearMatches();
clearMessages();
clearPacks();
// Refresh user data
await fetchUser();
Events.relationshipEnded();
Alert.alert("Success", "All relationship data has been deleted.");
} catch (error) {
console.error("Delete relationship error:", error);
Alert.alert("Error", "Failed to delete relationship data. Please try again.");
throw error;
}
};
const handleResetProgress = async () => {
Alert.alert(
"Reset Progress",
"This will delete all your swipes, matches, and chat messages. You'll stay connected with your partner and can start fresh. This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset",
style: "destructive",
onPress: async () => {
setIsResettingProgress(true);
try {
const { error } = await supabase.functions.invoke("reset-couple-progress", {
method: "DELETE",
});
if (error) throw error;
// Clear local stores
clearMatches();
clearMessages();
Alert.alert("Success", "Your progress has been reset. You can now start swiping again!");
} catch (error) {
Alert.alert("Error", "Failed to reset progress. Please try again.");
} finally {
setIsResettingProgress(false);
}
},
},
]
);
};
const handleSignOut = () => {
Alert.alert(
"Sign Out",
"Are you sure you want to sign out?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: signOut,
},
]
);
};
const navigateToPairing = () => {
router.push("/(app)/pairing");
};
return {
isResettingProgress,
handleUnpair,
handleDeleteRelationship,
handleResetProgress,
handleSignOut,
navigateToPairing,
};
}
@@ -0,0 +1,334 @@
import { useState, useEffect, useCallback } from 'react';
import { Alert, Platform, ActionSheetIOS, Linking } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import {
isBiometricAvailable,
isBiometricEnabled,
setBiometricEnabled,
getBiometricType,
} from '../../../lib/biometricAuth';
export function useProfileSettings() {
const { user, fetchUser } = useAuthStore();
const { fetchPacks } = usePacksStore();
const { restorePurchases, isPurchasing } = useSubscriptionStore();
// Name Editing
const [isEditingName, setIsEditingName] = useState(false);
const [newName, setNewName] = useState(user?.name || "");
const [isUpdatingName, setIsUpdatingName] = useState(false);
// Avatar
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
// Preferences
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false);
// Biometrics
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricEnabledState, setBiometricEnabledState] = useState(false);
const [biometricType, setBiometricType] = useState("Face ID");
const [isUpdatingBiometric, setIsUpdatingBiometric] = useState(false);
// Paywall
const [showPaywall, setShowPaywall] = useState(false);
// Initialize state from user object
useEffect(() => {
if (user?.name) {
setNewName(user.name);
}
if (user?.show_explicit_content !== undefined) {
setShowExplicit(user.show_explicit_content);
}
}, [user]);
// Check biometric availability
useEffect(() => {
const checkBiometric = async () => {
const available = await isBiometricAvailable();
setBiometricAvailable(available);
if (available) {
const enabled = await isBiometricEnabled();
setBiometricEnabledState(enabled);
const type = await getBiometricType();
setBiometricType(type);
}
};
checkBiometric();
}, []);
// Name Actions
const handleUpdateName = async () => {
if (!user?.id) return;
const trimmedName = newName.trim();
if (!trimmedName) {
Alert.alert("Error", "Name cannot be empty");
return;
}
if (trimmedName === user.name) {
setIsEditingName(false);
return;
}
setIsUpdatingName(true);
try {
const { error } = await supabase
.from('profiles')
.update({
name: trimmedName,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) throw error;
await fetchUser();
setIsEditingName(false);
Events.profileUpdated(["name"]);
} catch (error) {
Alert.alert("Error", "Failed to update name. Please try again.");
setNewName(user.name || "");
} finally {
setIsUpdatingName(false);
}
};
const handleCancelEditName = useCallback(() => {
setNewName(user?.name || "");
setIsEditingName(false);
}, [user?.name]);
// Avatar Actions
const handleAvatarPress = () => {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ['Cancel', 'Take Photo', 'Choose from Library'],
cancelButtonIndex: 0,
},
(buttonIndex) => {
if (buttonIndex === 1) {
pickAvatar('camera');
} else if (buttonIndex === 2) {
pickAvatar('library');
}
}
);
} else {
Alert.alert(
'Change Profile Photo',
'Choose an option',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Take Photo', onPress: () => pickAvatar('camera') },
{ text: 'Choose from Library', onPress: () => pickAvatar('library') },
]
);
}
};
const pickAvatar = async (source: 'camera' | 'library') => {
try {
let result: ImagePicker.ImagePickerResult;
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
}
if (!result.canceled && result.assets[0]) {
await uploadAvatar(result.assets[0].uri);
}
} catch (error) {
console.error('Error picking avatar:', error);
Alert.alert('Error', 'Failed to pick image. Please try again.');
}
};
const uploadAvatar = async (uri: string) => {
if (!user?.id) return;
setIsUploadingAvatar(true);
try {
let fileBody;
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
const uriExt = uri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const fileName = `${user.id}/${Date.now()}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
// Upload to storage
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, fileBody, {
contentType,
upsert: true,
});
if (uploadError) throw uploadError;
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
// Update profile with avatar URL
const { error: updateError } = await supabase
.from('profiles')
.update({
avatar_url: publicUrl,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) throw updateError;
// Refresh user data
await fetchUser();
Events.avatarUploaded();
} catch (error) {
console.error('Error uploading avatar:', error);
Alert.alert('Error', 'Failed to upload photo. Please try again.');
} finally {
setIsUploadingAvatar(false);
}
};
// Preference Actions
const handleExplicitToggle = async (value: boolean) => {
if (!user?.id) return;
setShowExplicit(value);
setIsUpdatingExplicit(true);
try {
const { error } = await supabase
.from('profiles')
.update({
show_explicit_content: value,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) throw error;
// Refresh user data and packs to reflect the change
await fetchUser();
await fetchPacks();
Events.profileUpdated(["show_explicit_content"]);
} catch (error) {
// Revert on error
setShowExplicit(!value);
Alert.alert("Error", "Failed to update preference. Please try again.");
} finally {
setIsUpdatingExplicit(false);
}
};
const handleBiometricToggle = async (value: boolean) => {
setBiometricEnabledState(value);
setIsUpdatingBiometric(true);
try {
await setBiometricEnabled(value);
} catch (error) {
setBiometricEnabledState(!value);
Alert.alert("Error", `Failed to ${value ? "enable" : "disable"} ${biometricType}. Please try again.`);
} finally {
setIsUpdatingBiometric(false);
}
};
// Subscription Actions
const handleRestorePurchases = async () => {
const restored = await restorePurchases();
if (restored) {
Alert.alert("Success", "Your purchases have been restored!");
} else {
Alert.alert("No Purchases Found", "No previous purchases found to restore.");
}
};
const handleManageSubscription = () => {
Linking.openURL("https://apps.apple.com/account/subscriptions");
};
return {
// State
isEditingName,
setIsEditingName,
newName,
setNewName,
isUpdatingName,
isUploadingAvatar,
showExplicit,
isUpdatingExplicit,
biometricAvailable,
biometricEnabled: biometricEnabledState,
biometricType,
isUpdatingBiometric,
showPaywall,
setShowPaywall,
isPurchasing,
// Actions
handleUpdateName,
handleCancelEditName,
handleAvatarPress,
handleExplicitToggle,
handleBiometricToggle,
handleRestorePurchases,
handleManageSubscription,
};
}
@@ -0,0 +1,3 @@
export * from './components';
export * from './hooks';
export { ProfileScreen } from './ProfileScreen';
@@ -0,0 +1,7 @@
import { featureColors } from '../../theme';
/** Profile color palette constants */
export const PROFILE_COLORS = {
ACCENT: featureColors.profile.accent,
ACCENT_GRADIENT: featureColors.profile.gradient as [string, string],
} as const;
+9
View File
@@ -0,0 +1,9 @@
export { useAmbientOrbAnimation } from './useAmbientOrbAnimation';
export type { AmbientOrbConfig } from './useAmbientOrbAnimation';
export { useFeatureInterest } from './useFeatureInterest';
export { useMediaPicker } from './useMediaPicker';
export type { MediaPickerConfig, MediaResult } from './useMediaPicker';
export { useTypingIndicator } from './useTypingIndicator';
export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTypingIndicator';
export { useMessageSubscription } from './useMessageSubscription';
export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription';
+150
View File
@@ -0,0 +1,150 @@
import { useCallback } from 'react';
import { Alert, Platform } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
export interface MediaResult {
/** URI of the selected/captured media */
uri: string;
/** Type of media */
mediaType: 'image' | 'video';
/** Original width if available */
width?: number;
/** Original height if available */
height?: number;
/** Duration in ms for videos */
duration?: number;
}
export interface MediaPickerConfig {
/** Quality for images (0-1). Default: 0.7 */
imageQuality?: number;
/** Max video duration in seconds for library picker. Default: 300 (5 min) */
libraryVideoMaxDuration?: number;
/** Max video duration in seconds for camera recording. Default: 60 (1 min) */
cameraVideoMaxDuration?: number;
}
const DEFAULT_CONFIG: Required<MediaPickerConfig> = {
imageQuality: 0.7,
libraryVideoMaxDuration: 300,
cameraVideoMaxDuration: 60,
};
/**
* Hook for media picking and camera capture functionality.
* Handles permissions and provides unified API for picking images/videos
* from the library or capturing with the camera.
*
* @param config - Optional configuration for picker options
* @returns Media picker functions
*/
export const useMediaPicker = (config?: MediaPickerConfig) => {
const settings = { ...DEFAULT_CONFIG, ...config };
/**
* Request camera permissions with user-friendly error handling.
* @returns true if granted, false otherwise
*/
const requestCameraPermission = useCallback(async (): Promise<boolean> => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Camera Permission',
'Please allow camera access to use this feature.'
);
return false;
}
return true;
}, []);
/**
* Pick an image or video from the device library.
* @returns MediaResult if selected, null if cancelled
*/
const pickMedia = useCallback(async (): Promise<MediaResult | null> => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All,
quality: settings.imageQuality,
videoMaxDuration: settings.libraryVideoMaxDuration,
});
if (result.canceled) {
return null;
}
const asset = result.assets[0];
const isVideo = asset.type === 'video';
return {
uri: asset.uri,
mediaType: isVideo ? 'video' : 'image',
width: asset.width,
height: asset.height,
duration: asset.duration ?? undefined,
};
}, [settings.imageQuality, settings.libraryVideoMaxDuration]);
/**
* Take a photo using the device camera.
* @returns MediaResult if captured, null if cancelled or permission denied
*/
const takePhoto = useCallback(async (): Promise<MediaResult | null> => {
const hasPermission = await requestCameraPermission();
if (!hasPermission) {
return null;
}
const result = await ImagePicker.launchCameraAsync({
quality: settings.imageQuality,
});
if (result.canceled) {
return null;
}
const asset = result.assets[0];
return {
uri: asset.uri,
mediaType: 'image',
width: asset.width,
height: asset.height,
};
}, [settings.imageQuality, requestCameraPermission]);
/**
* Record a video using the device camera.
* @returns MediaResult if recorded, null if cancelled or permission denied
*/
const recordVideo = useCallback(async (): Promise<MediaResult | null> => {
const hasPermission = await requestCameraPermission();
if (!hasPermission) {
return null;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
quality: settings.imageQuality,
videoMaxDuration: settings.cameraVideoMaxDuration,
});
if (result.canceled) {
return null;
}
const asset = result.assets[0];
return {
uri: asset.uri,
mediaType: 'video',
width: asset.width,
height: asset.height,
duration: asset.duration ?? undefined,
};
}, [settings.imageQuality, settings.cameraVideoMaxDuration, requestCameraPermission]);
return {
pickMedia,
takePhoto,
recordVideo,
requestCameraPermission,
};
};
@@ -0,0 +1,158 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { supabase } from '../lib/supabase';
import { Database } from '../types/supabase';
type Message = Database['public']['Tables']['messages']['Row'];
export interface UseMessageSubscriptionConfig {
/** Match ID to subscribe to */
matchId: string | undefined;
/** Current user's ID */
userId: string | undefined;
/** Callback when a new message is received (e.g., to clear typing indicator) */
onNewMessage?: () => void;
}
export interface UseMessageSubscriptionReturn {
/** Array of messages, sorted newest first */
messages: Message[];
/** Set messages externally (e.g., for optimistic updates) */
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
/** Whether the initial fetch is in progress */
loading: boolean;
/** Ref to track if screen is focused (for read receipts) */
isFocusedRef: React.MutableRefObject<boolean>;
}
/**
* Hook for managing message subscriptions and real-time updates.
* Handles fetching messages, subscribing to new messages, and managing read receipts.
*
* @param config - Configuration including match ID and user ID
* @returns Message state and subscription utilities
*/
export const useMessageSubscription = (
config: UseMessageSubscriptionConfig
): UseMessageSubscriptionReturn => {
const { matchId, userId, onNewMessage } = config;
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const isFocusedRef = useRef(false);
// Fetch messages and mark as read
useEffect(() => {
if (!matchId || !userId) {
setLoading(false);
return;
}
const fetchMessagesAndMarkRead = async () => {
setLoading(true);
const { data } = await supabase
.from('messages')
.select('*')
.eq('match_id', matchId)
.order('created_at', { ascending: false });
if (data) {
setMessages(data);
const now = new Date().toISOString();
// Get partner messages that need to be marked as delivered or read
const partnerMessages = data.filter(m => m.user_id !== userId);
const undeliveredIds = partnerMessages.filter(m => !m.delivered_at).map(m => m.id);
const unreadIds = partnerMessages.filter(m => !m.read_at).map(m => m.id);
// Mark as both delivered and read (since user is viewing the chat)
if (unreadIds.length > 0) {
await supabase
.from('messages')
.update({ delivered_at: now, read_at: now })
.in('id', unreadIds);
setMessages(prev => prev.map(m =>
unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m
));
} else if (undeliveredIds.length > 0) {
// Only mark as delivered if already read but not delivered (edge case)
await supabase
.from('messages')
.update({ delivered_at: now })
.in('id', undeliveredIds);
setMessages(prev => prev.map(m =>
undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m
));
}
}
setLoading(false);
};
fetchMessagesAndMarkRead();
}, [matchId, userId]);
// Subscribe to real-time message updates
useEffect(() => {
if (!matchId || !userId) return;
const channel = supabase
.channel(`messages:${matchId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `match_id=eq.${matchId}`,
},
async (payload) => {
const newMessage = payload.new as Message;
const now = new Date().toISOString();
if (newMessage.user_id !== userId && isFocusedRef.current) {
// Mark as both delivered and read (only if screen is focused)
await supabase
.from('messages')
.update({ delivered_at: now, read_at: now })
.eq('id', newMessage.id);
newMessage.delivered_at = now;
newMessage.read_at = now;
}
setMessages(prev => [newMessage, ...prev]);
onNewMessage?.();
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'messages',
filter: `match_id=eq.${matchId}`,
},
(payload) => {
const updatedMessage = payload.new as Message;
setMessages(prev =>
prev.map(m => m.id === updatedMessage.id ? updatedMessage : m)
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [matchId, userId, onNewMessage]);
return {
messages,
setMessages,
loading,
isFocusedRef,
};
};
+109
View File
@@ -0,0 +1,109 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { supabase } from '../lib/supabase';
export interface UseTypingIndicatorConfig {
/** Channel name for the chat/conversation */
channelName: string;
/** Current user's ID */
userId: string | undefined;
/** Timeout in ms before partner typing indicator disappears. Default: 3000 */
typingTimeout?: number;
}
export interface UseTypingIndicatorReturn {
/** Whether the partner is currently typing */
partnerTyping: boolean;
/** Call this when user types to broadcast typing event */
sendTypingEvent: () => void;
/** Clear the typing indicator (e.g., when message received) */
clearTypingIndicator: () => void;
}
const DEFAULT_TYPING_TIMEOUT = 3000;
/**
* Hook for managing typing indicator state and broadcast events.
* Handles both sending typing events and listening for partner's typing.
*
* @param config - Configuration including channel name and user ID
* @returns Typing state and event handlers
*/
export const useTypingIndicator = (
config: UseTypingIndicatorConfig
): UseTypingIndicatorReturn => {
const { channelName, userId, typingTimeout = DEFAULT_TYPING_TIMEOUT } = config;
const [partnerTyping, setPartnerTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Clear timeout on cleanup
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
// Subscribe to typing broadcast events
useEffect(() => {
if (!channelName || !userId) return;
const channel = supabase.channel(channelName);
channel.on('broadcast', { event: 'typing' }, (payload) => {
if (payload.payload.userId !== userId) {
setPartnerTyping(true);
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Auto-clear typing indicator after timeout
typingTimeoutRef.current = setTimeout(() => {
setPartnerTyping(false);
}, typingTimeout);
}
});
channel.subscribe();
return () => {
supabase.removeChannel(channel);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, [channelName, userId, typingTimeout]);
/**
* Send a typing event to the partner
*/
const sendTypingEvent = useCallback(() => {
if (!channelName || !userId) return;
supabase.channel(channelName).send({
type: 'broadcast',
event: 'typing',
payload: { userId },
});
}, [channelName, userId]);
/**
* Manually clear the typing indicator (e.g., when a message is received)
*/
const clearTypingIndicator = useCallback(() => {
setPartnerTyping(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
}, []);
return {
partnerTyping,
sendTypingEvent,
clearTypingIndicator,
};
};
+153
View File
@@ -0,0 +1,153 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
// Actions
fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
}
// Import other stores lazily to avoid circular dependency issues
const getOtherStores = () => {
const { useMatchStore } = require("./matchStore");
const { usePacksStore } = require("./packsStore");
const { useMessageStore } = require("./messageStore");
const { useSubscriptionStore } = require("./subscriptionStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore };
};
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: false,
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
set({ user: null, isAuthenticated: false, isLoading: false });
return;
}
// Validate the session by fetching the user from the server
// This will fail if the session was deleted server-side
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
console.log("[Auth] Session invalid, signing out:", authError?.message);
// Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
// Sign out from Supabase (clears storage)
await supabase.auth.signOut();
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", authUser.id)
.maybeSingle();
set({
user: profile,
isAuthenticated: true,
});
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
await get().fetchCouple();
} else {
set({ couple: null, partner: null });
}
set({ isLoading: false });
} catch (error) {
console.error("Error fetching user:", error);
set({ isLoading: false });
}
},
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) return;
const { data: couple } = await supabase
.from("couples")
.select("*")
.eq("id", user.couple_id)
.maybeSingle();
// Fetch partner
const { data: partner } = await supabase
.from("profiles")
.select("*")
.eq("couple_id", user.couple_id)
.neq("id", user.id)
.maybeSingle();
set({ couple, partner });
},
signOut: async () => {
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false,
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
// Then try to sign out from Supabase (don't block on this)
try {
await supabase.auth.signOut();
} catch (error) {
console.error("Supabase signOut error:", error);
}
},
setUser: (user) => {
set({
user,
isAuthenticated: !!user,
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
}
},
}));
+6 -666
View File
@@ -1,666 +1,6 @@
import { create } from "zustand"; // Re-export all stores for convenient imports
import { supabase } from "../lib/supabase"; export { useAuthStore } from "./authStore";
import { Events } from "../lib/analytics"; export { useMatchStore } from "./matchStore";
import revenueCatService, { export { usePacksStore } from "./packsStore";
SubscriptionState, export { useMessageStore } from "./messageStore";
PurchasesPackage, export { useSubscriptionStore } from "./subscriptionStore";
PurchasesOffering,
} from "../lib/revenuecat";
import type { Profile, Couple, Match, QuestionPack, Category } from "@/types";
import type { Database } from "@/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
// Actions
fetchUser: () => Promise<void>;
fetchCouple: () => Promise<void>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: false,
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
set({ user: null, isAuthenticated: false, isLoading: false });
return;
}
// Validate the session by fetching the user from the server
// This will fail if the session was deleted server-side
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
console.log("[Auth] Session invalid, signing out:", authError?.message);
// Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
// Clear other stores
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
// Sign out from Supabase (clears storage)
await supabase.auth.signOut();
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", authUser.id)
.maybeSingle();
set({
user: profile,
isAuthenticated: true,
});
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
await get().fetchCouple();
} else {
set({ couple: null, partner: null });
}
set({ isLoading: false });
} catch (error) {
console.error("Error fetching user:", error);
set({ isLoading: false });
}
},
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) return;
const { data: couple } = await supabase
.from("couples")
.select("*")
.eq("id", user.couple_id)
.maybeSingle();
// Fetch partner
const { data: partner } = await supabase
.from("profiles")
.select("*")
.eq("couple_id", user.couple_id)
.neq("id", user.id)
.maybeSingle();
set({ couple, partner });
},
signOut: async () => {
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false,
isLoading: false,
});
// Clear other stores
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
// Then try to sign out from Supabase (don't block on this)
try {
await supabase.auth.signOut();
} catch (error) {
console.error("Supabase signOut error:", error);
}
},
setUser: (user) => {
set({
user,
isAuthenticated: !!user,
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
});
// Clear other stores when user signs out
if (user === null) {
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
}
},
}));
// Match store for managing matches
interface MatchState {
matches: Match[];
newMatchesCount: number;
fetchMatches: () => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
clearMatches: () => void;
}
export const useMatchStore = create<MatchState>((set, get) => ({
matches: [],
newMatchesCount: 0,
fetchMatches: async () => {
const userId = useAuthStore.getState().user?.id;
const { data: matches } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.order("created_at", { ascending: false });
if (!matches) return;
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
const { data: responses } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
// Fetch unread message counts per match
const matchIds = matches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
if (userId && matchIds.length > 0) {
const { data: unreadMessages, error } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
if (error) {
console.error("Error fetching unread messages:", error);
}
// Count unread messages per match
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
}
const data = matches.map(match => ({
...match,
responses: responses?.filter(r => r.question_id === match.question_id) || [],
unreadCount: unreadCounts[match.id] || 0
}));
// Sort: unread messages first, then by most recent
const sortedData = data.sort((a, b) => {
// First priority: matches with unread messages
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
// Second priority: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
const newCount = sortedData.filter((m) => m.is_new).length;
set({ matches: sortedData, newMatchesCount: newCount });
},
markAsSeen: async (matchId) => {
await supabase
.from("matches")
.update({ is_new: false })
.eq("id", matchId);
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
);
const newCount = matches.filter((m) => m.is_new).length;
set({ matches, newMatchesCount: newCount });
},
markAllAsSeen: async () => {
const newMatches = get().matches.filter((m) => m.is_new);
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await supabase
.from("matches")
.update({ is_new: false })
.in("id", newMatchIds);
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
);
set({ matches, newMatchesCount: 0 });
},
addMatch: (match) => {
set((state) => ({
matches: [match, ...state.matches],
newMatchesCount: state.newMatchesCount + 1,
}));
},
clearMatches: () => {
set({ matches: [], newMatchesCount: 0 });
},
}));
// Packs store
interface PacksState {
packs: QuestionPack[];
categories: Category[];
enabledPackIds: string[];
isLoading: boolean;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
clearPacks: () => void;
}
export const usePacksStore = create<PacksState>((set, get) => ({
packs: [],
categories: [],
enabledPackIds: [],
isLoading: false,
fetchPacks: async () => {
set({ isLoading: true });
// Get user's explicit content preference
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? true;
// Fetch categories
const { data: categories } = await supabase
.from("categories")
.select("*")
.order("sort_order");
// Fetch packs with category info and question count
let query = supabase
.from("question_packs")
.select("*, category:categories(*), questions(count)")
.eq("is_public", true)
.order("sort_order");
// Filter out explicit packs if user doesn't want to see them
if (!showExplicit) {
query = query.eq("is_explicit", false);
}
const { data: packs } = await query;
// Also fetch enabled packs if logged in
await get().fetchEnabledPacks();
set({
packs: packs ?? [],
categories: categories ?? [],
isLoading: false
});
},
fetchEnabledPacks: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
set({ enabledPackIds: [] });
return;
}
const { data: couplePacks } = await supabase
.from("couple_packs")
.select("pack_id")
.eq("couple_id", coupleId)
.eq("enabled", true);
const ids = couplePacks?.map(cp => cp.pack_id) || [];
set({ enabledPackIds: ids });
},
togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
return { success: false, reason: "no_couple" };
}
const isEnabled = get().enabledPackIds.includes(packId);
const newValue = !isEnabled;
// Optimistic update
const newIds = newValue
? [...get().enabledPackIds, packId]
: get().enabledPackIds.filter(id => id !== packId);
set({ enabledPackIds: newIds });
const { error } = await supabase
.from("couple_packs")
.upsert({
couple_id: coupleId,
pack_id: packId,
enabled: newValue
});
if (error) {
console.error("Error toggling pack:", error);
// Revert by refetching
get().fetchEnabledPacks();
return { success: false, reason: "error" };
}
// Track pack enable/disable
if (newValue) {
Events.packEnabled(packId);
} else {
Events.packDisabled(packId);
}
return { success: true };
},
clearPacks: () => {
set({ enabledPackIds: [] });
},
}));
// Message notification store
interface MessageWithMatch extends Message {
match?: {
id: string;
question: {
text: string;
};
};
}
interface MessageState {
unreadCount: number;
lastMessage: MessageWithMatch | null;
activeMatchId: string | null; // Track which chat is currently open
fetchUnreadCount: () => Promise<void>;
addMessage: (message: MessageWithMatch) => void;
clearLastMessage: () => void;
setActiveMatchId: (matchId: string | null) => void;
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
clearMessages: () => void;
}
export const useMessageStore = create<MessageState>((set, get) => ({
unreadCount: 0,
lastMessage: null,
activeMatchId: null,
fetchUnreadCount: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) {
set({ unreadCount: 0 });
return;
}
const { count } = await supabase
.from("messages")
.select("*", { count: "exact", head: true })
.neq("user_id", userId)
.is("read_at", null);
set({ unreadCount: count || 0 });
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
const activeMatchId = get().activeMatchId;
// Only show notification if message is from partner and not in the active chat
if (message.user_id !== userId && message.match_id !== activeMatchId) {
set((state) => ({
unreadCount: state.unreadCount + 1,
lastMessage: message,
}));
}
},
clearLastMessage: () => {
set({ lastMessage: null });
},
setActiveMatchId: (matchId) => {
set({ activeMatchId: matchId });
},
markMatchMessagesAsRead: async (matchId) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
await supabase
.from("messages")
.update({ read_at: new Date().toISOString() })
.eq("match_id", matchId)
.neq("user_id", userId)
.is("read_at", null);
// Refetch unread count
await get().fetchUnreadCount();
},
clearMessages: () => {
set({ unreadCount: 0, lastMessage: null, activeMatchId: null });
},
}));
// Subscription store for RevenueCat
interface SubscriptionStoreState {
subscription: SubscriptionState;
offerings: PurchasesOffering | null;
isLoadingOfferings: boolean;
isPurchasing: boolean;
error: string | null;
// Actions
initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: () => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>;
clearSubscription: () => void;
}
const defaultSubscription: SubscriptionState = {
isProUser: false,
activeSubscription: null,
expirationDate: null,
willRenew: false,
};
export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) => ({
subscription: defaultSubscription,
offerings: null,
isLoadingOfferings: false,
isPurchasing: false,
error: null,
initializeRevenueCat: async (userId: string) => {
try {
// Check if RevenueCat is available (won't work in Expo Go)
if (!revenueCatService.isAvailable()) {
console.log("RevenueCat not available, skipping initialization");
return;
}
await revenueCatService.initialize(userId);
await revenueCatService.login(userId);
// Set up listener for subscription changes
const removeListener = revenueCatService.addCustomerInfoUpdateListener(
(customerInfo) => {
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription });
// Update user's is_premium in auth store if changed
const currentUser = useAuthStore.getState().user;
if (currentUser && currentUser.is_premium !== subscription.isProUser) {
useAuthStore.getState().fetchUser();
}
}
);
// Get initial subscription status
await get().refreshSubscriptionStatus();
} catch (error) {
console.error("RevenueCat initialization error:", error);
// Don't set error - just silently fail to not break the app
}
},
fetchOfferings: async () => {
set({ isLoadingOfferings: true, error: null });
try {
// Check if RevenueCat is initialized
if (!revenueCatService.isInitialized()) {
set({
offerings: null,
isLoadingOfferings: false,
error: "Subscriptions not available"
});
return;
}
const result = await revenueCatService.getOfferingsDebug();
if (result.error) {
set({
offerings: null,
isLoadingOfferings: false,
error: result.error
});
return;
}
const offerings = result.current;
if (!offerings) {
set({
offerings: null,
isLoadingOfferings: false,
error: `No current offering. Available: ${result.availableOfferings?.join(", ") || "none"}`
});
return;
}
if (!offerings.availablePackages || offerings.availablePackages.length === 0) {
set({
offerings: null,
isLoadingOfferings: false,
error: "No packages in offering. Check RevenueCat dashboard."
});
return;
}
set({ offerings, isLoadingOfferings: false, error: null });
} catch (error: any) {
console.error("Error fetching offerings:", error);
set({ error: `Error: ${error.message || "Unknown"}`, isLoadingOfferings: false });
}
},
purchasePackage: async (pkg: PurchasesPackage) => {
set({ isPurchasing: true, error: null });
try {
const customerInfo = await revenueCatService.purchasePackage(pkg);
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile to get updated is_premium
await useAuthStore.getState().fetchUser();
return true;
} catch (error: any) {
const message =
error.message === "Purchase cancelled"
? "Purchase was cancelled"
: "Purchase failed. Please try again.";
set({ error: message, isPurchasing: false });
return false;
}
},
restorePurchases: async () => {
set({ isPurchasing: true, error: null });
try {
const customerInfo = await revenueCatService.restorePurchases();
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile
await useAuthStore.getState().fetchUser();
return subscription.isProUser;
} catch (error) {
set({ error: "Failed to restore purchases", isPurchasing: false });
return false;
}
},
refreshSubscriptionStatus: async () => {
try {
const customerInfo = await revenueCatService.getCustomerInfo();
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription });
// Sync with server (server verifies with RevenueCat API)
const currentUser = useAuthStore.getState().user;
if (currentUser && currentUser.is_premium !== subscription.isProUser) {
console.log("Syncing subscription status with server...");
// Get the session token to pass to the function
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error("No session available for sync");
return;
}
const { error } = await supabase.functions.invoke("sync-subscription", {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (error) {
console.error("Error syncing subscription:", error);
} else {
// Refresh user to get updated is_premium
await useAuthStore.getState().fetchUser();
}
}
} catch (error) {
console.error("Error refreshing subscription:", error);
}
},
clearSubscription: () => {
revenueCatService.logout();
set({
subscription: defaultSubscription,
offerings: null,
error: null,
});
},
}));
+121
View File
@@ -0,0 +1,121 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import type { Match } from "@/types";
import { useAuthStore } from "./authStore";
interface MatchState {
matches: Match[];
newMatchesCount: number;
fetchMatches: () => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
clearMatches: () => void;
}
export const useMatchStore = create<MatchState>((set, get) => ({
matches: [],
newMatchesCount: 0,
fetchMatches: async () => {
const userId = useAuthStore.getState().user?.id;
const { data: matches } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.order("created_at", { ascending: false });
if (!matches) return;
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
const { data: responses } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
// Fetch unread message counts per match
const matchIds = matches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
if (userId && matchIds.length > 0) {
const { data: unreadMessages, error } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
if (error) {
console.error("Error fetching unread messages:", error);
}
// Count unread messages per match
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
}
const data = matches.map(match => ({
...match,
responses: responses?.filter(r => r.question_id === match.question_id) || [],
unreadCount: unreadCounts[match.id] || 0
}));
// Sort: unread messages first, then by most recent
const sortedData = data.sort((a, b) => {
// First priority: matches with unread messages
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
// Second priority: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
const newCount = sortedData.filter((m) => m.is_new).length;
set({ matches: sortedData, newMatchesCount: newCount });
},
markAsSeen: async (matchId) => {
await supabase
.from("matches")
.update({ is_new: false })
.eq("id", matchId);
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
);
const newCount = matches.filter((m) => m.is_new).length;
set({ matches, newMatchesCount: newCount });
},
markAllAsSeen: async () => {
const newMatches = get().matches.filter((m) => m.is_new);
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await supabase
.from("matches")
.update({ is_new: false })
.in("id", newMatchIds);
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
);
set({ matches, newMatchesCount: 0 });
},
addMatch: (match) => {
set((state) => ({
matches: [match, ...state.matches],
newMatchesCount: state.newMatchesCount + 1,
}));
},
clearMatches: () => {
set({ matches: [], newMatchesCount: 0 });
},
}));
+89
View File
@@ -0,0 +1,89 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import type { Database } from "@/types/supabase";
import { useAuthStore } from "./authStore";
type Message = Database["public"]["Tables"]["messages"]["Row"];
interface MessageWithMatch extends Message {
match?: {
id: string;
question: {
text: string;
};
};
}
interface MessageState {
unreadCount: number;
lastMessage: MessageWithMatch | null;
activeMatchId: string | null; // Track which chat is currently open
fetchUnreadCount: () => Promise<void>;
addMessage: (message: MessageWithMatch) => void;
clearLastMessage: () => void;
setActiveMatchId: (matchId: string | null) => void;
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
clearMessages: () => void;
}
export const useMessageStore = create<MessageState>((set, get) => ({
unreadCount: 0,
lastMessage: null,
activeMatchId: null,
fetchUnreadCount: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) {
set({ unreadCount: 0 });
return;
}
const { count } = await supabase
.from("messages")
.select("*", { count: "exact", head: true })
.neq("user_id", userId)
.is("read_at", null);
set({ unreadCount: count || 0 });
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
const activeMatchId = get().activeMatchId;
// Only show notification if message is from partner and not in the active chat
if (message.user_id !== userId && message.match_id !== activeMatchId) {
set((state) => ({
unreadCount: state.unreadCount + 1,
lastMessage: message,
}));
}
},
clearLastMessage: () => {
set({ lastMessage: null });
},
setActiveMatchId: (matchId) => {
set({ activeMatchId: matchId });
},
markMatchMessagesAsRead: async (matchId) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
await supabase
.from("messages")
.update({ read_at: new Date().toISOString() })
.eq("match_id", matchId)
.neq("user_id", userId)
.is("read_at", null);
// Refetch unread count
await get().fetchUnreadCount();
},
clearMessages: () => {
set({ unreadCount: 0, lastMessage: null, activeMatchId: null });
},
}));
+121
View File
@@ -0,0 +1,121 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { Events } from "../lib/analytics";
import type { QuestionPack, Category } from "@/types";
import { useAuthStore } from "./authStore";
interface PacksState {
packs: QuestionPack[];
categories: Category[];
enabledPackIds: string[];
isLoading: boolean;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
clearPacks: () => void;
}
export const usePacksStore = create<PacksState>((set, get) => ({
packs: [],
categories: [],
enabledPackIds: [],
isLoading: false,
fetchPacks: async () => {
set({ isLoading: true });
// Get user's explicit content preference
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? true;
// Fetch categories
const { data: categories } = await supabase
.from("categories")
.select("*")
.order("sort_order");
// Fetch packs with category info and question count
let query = supabase
.from("question_packs")
.select("*, category:categories(*), questions(count)")
.eq("is_public", true)
.order("sort_order");
// Filter out explicit packs if user doesn't want to see them
if (!showExplicit) {
query = query.eq("is_explicit", false);
}
const { data: packs } = await query;
// Also fetch enabled packs if logged in
await get().fetchEnabledPacks();
set({
packs: packs ?? [],
categories: categories ?? [],
isLoading: false
});
},
fetchEnabledPacks: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
set({ enabledPackIds: [] });
return;
}
const { data: couplePacks } = await supabase
.from("couple_packs")
.select("pack_id")
.eq("couple_id", coupleId)
.eq("enabled", true);
const ids = couplePacks?.map(cp => cp.pack_id) || [];
set({ enabledPackIds: ids });
},
togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
return { success: false, reason: "no_couple" };
}
const isEnabled = get().enabledPackIds.includes(packId);
const newValue = !isEnabled;
// Optimistic update
const newIds = newValue
? [...get().enabledPackIds, packId]
: get().enabledPackIds.filter(id => id !== packId);
set({ enabledPackIds: newIds });
const { error } = await supabase
.from("couple_packs")
.upsert({
couple_id: coupleId,
pack_id: packId,
enabled: newValue
});
if (error) {
console.error("Error toggling pack:", error);
// Revert by refetching
get().fetchEnabledPacks();
return { success: false, reason: "error" };
}
// Track pack enable/disable
if (newValue) {
Events.packEnabled(packId);
} else {
Events.packDisabled(packId);
}
return { success: true };
},
clearPacks: () => {
set({ enabledPackIds: [] });
},
}));
+205
View File
@@ -0,0 +1,205 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import revenueCatService, {
SubscriptionState,
PurchasesPackage,
PurchasesOffering,
} from "../lib/revenuecat";
import { useAuthStore } from "./authStore";
interface SubscriptionStoreState {
subscription: SubscriptionState;
offerings: PurchasesOffering | null;
isLoadingOfferings: boolean;
isPurchasing: boolean;
error: string | null;
// Actions
initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: () => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>;
clearSubscription: () => void;
}
const defaultSubscription: SubscriptionState = {
isProUser: false,
activeSubscription: null,
expirationDate: null,
willRenew: false,
};
export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) => ({
subscription: defaultSubscription,
offerings: null,
isLoadingOfferings: false,
isPurchasing: false,
error: null,
initializeRevenueCat: async (userId: string) => {
try {
// Check if RevenueCat is available (won't work in Expo Go)
if (!revenueCatService.isAvailable()) {
console.log("RevenueCat not available, skipping initialization");
return;
}
await revenueCatService.initialize(userId);
await revenueCatService.login(userId);
// Set up listener for subscription changes
const removeListener = revenueCatService.addCustomerInfoUpdateListener(
(customerInfo) => {
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription });
// Update user's is_premium in auth store if changed
const currentUser = useAuthStore.getState().user;
if (currentUser && currentUser.is_premium !== subscription.isProUser) {
useAuthStore.getState().fetchUser();
}
}
);
// Get initial subscription status
await get().refreshSubscriptionStatus();
} catch (error) {
console.error("RevenueCat initialization error:", error);
// Don't set error - just silently fail to not break the app
}
},
fetchOfferings: async () => {
set({ isLoadingOfferings: true, error: null });
try {
// Check if RevenueCat is initialized
if (!revenueCatService.isInitialized()) {
set({
offerings: null,
isLoadingOfferings: false,
error: "Subscriptions not available"
});
return;
}
const result = await revenueCatService.getOfferingsDebug();
if (result.error) {
set({
offerings: null,
isLoadingOfferings: false,
error: result.error
});
return;
}
const offerings = result.current;
if (!offerings) {
set({
offerings: null,
isLoadingOfferings: false,
error: `No current offering. Available: ${result.availableOfferings?.join(", ") || "none"}`
});
return;
}
if (!offerings.availablePackages || offerings.availablePackages.length === 0) {
set({
offerings: null,
isLoadingOfferings: false,
error: "No packages in offering. Check RevenueCat dashboard."
});
return;
}
set({ offerings, isLoadingOfferings: false, error: null });
} catch (error: any) {
console.error("Error fetching offerings:", error);
set({ error: `Error: ${error.message || "Unknown"}`, isLoadingOfferings: false });
}
},
purchasePackage: async (pkg: PurchasesPackage) => {
set({ isPurchasing: true, error: null });
try {
const customerInfo = await revenueCatService.purchasePackage(pkg);
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile to get updated is_premium
await useAuthStore.getState().fetchUser();
return true;
} catch (error: any) {
const message =
error.message === "Purchase cancelled"
? "Purchase was cancelled"
: "Purchase failed. Please try again.";
set({ error: message, isPurchasing: false });
return false;
}
},
restorePurchases: async () => {
set({ isPurchasing: true, error: null });
try {
const customerInfo = await revenueCatService.restorePurchases();
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile
await useAuthStore.getState().fetchUser();
return subscription.isProUser;
} catch (error) {
set({ error: "Failed to restore purchases", isPurchasing: false });
return false;
}
},
refreshSubscriptionStatus: async () => {
try {
const customerInfo = await revenueCatService.getCustomerInfo();
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription });
// Sync with server (server verifies with RevenueCat API)
const currentUser = useAuthStore.getState().user;
if (currentUser && currentUser.is_premium !== subscription.isProUser) {
console.log("Syncing subscription status with server...");
// Get the session token to pass to the function
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error("No session available for sync");
return;
}
const { error } = await supabase.functions.invoke("sync-subscription", {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (error) {
console.error("Error syncing subscription:", error);
} else {
// Refresh user to get updated is_premium
await useAuthStore.getState().fetchUser();
}
}
} catch (error) {
console.error("Error refreshing subscription:", error);
}
},
clearSubscription: () => {
revenueCatService.logout();
set({
subscription: defaultSubscription,
offerings: null,
error: null,
});
},
}));
@@ -0,0 +1,59 @@
-- Function to check for specific permission
CREATE OR REPLACE FUNCTION has_permission(check_permission TEXT)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_role admin_role;
v_permissions JSONB;
BEGIN
SELECT role, permissions INTO v_role, v_permissions
FROM public.admin_users
WHERE user_id = auth.uid();
IF v_role IS NULL THEN
RETURN FALSE;
END IF;
IF v_role = 'super_admin' THEN
RETURN TRUE;
END IF;
-- Check if permissions array contains the required permission
-- Using the @> operator to check if the JSON array contains the value
IF v_permissions IS NULL THEN
RETURN FALSE;
END IF;
RETURN v_permissions @> jsonb_build_array(check_permission);
END;
$$;
-- Update RLS policies to use permissions
-- Profiles
CREATE POLICY "Admins with view_users permission can view all profiles"
ON profiles FOR SELECT
USING (has_permission('view_users'));
-- Messages
CREATE POLICY "Admins with view_chats permission can view all messages"
ON messages FOR SELECT
USING (has_permission('view_chats'));
-- Responses
CREATE POLICY "Admins with view_responses permission can view all responses"
ON responses FOR SELECT
USING (has_permission('view_responses'));
-- Matches
CREATE POLICY "Admins with view_matches permission can view all matches"
ON matches FOR SELECT
USING (has_permission('view_matches'));
-- Audit Logs
CREATE POLICY "Admins with view_audit_logs permission can view audit logs"
ON audit_logs FOR SELECT
USING (has_permission('view_audit_logs'));
@@ -0,0 +1,9 @@
-- Add RLS policies for feedback
CREATE POLICY "Admins with manage_feedback permission can view all feedback"
ON feedback FOR SELECT
USING (has_permission('manage_feedback'));
CREATE POLICY "Admins with manage_feedback permission can update all feedback"
ON feedback FOR UPDATE
USING (has_permission('manage_feedback'));
@@ -0,0 +1,17 @@
-- Add RLS policies for redemption codes
CREATE POLICY "Admins with manage_codes permission can view redemption codes"
ON redemption_codes FOR SELECT
USING (has_permission('manage_codes'));
CREATE POLICY "Admins with manage_codes permission can insert redemption codes"
ON redemption_codes FOR INSERT
WITH CHECK (has_permission('manage_codes'));
CREATE POLICY "Admins with manage_codes permission can update redemption codes"
ON redemption_codes FOR UPDATE
USING (has_permission('manage_codes'));
CREATE POLICY "Admins with manage_codes permission can delete redemption codes"
ON redemption_codes FOR DELETE
USING (has_permission('manage_codes'));