Add match archiving, chat enhancements, and daily limits

- Match archives with swipe-to-archive and unread filtering
- Chat settings sheet and full-screen media modals
- Match notification modal for new matches
- Admin AI settings with model/provider configuration
- Daily response limit feature with recommended questions
- New test coverage for stores, hooks, and components
- Claude Code agent and command configurations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.5 committed 2026-01-09 20:18:34 +00:00
1 parent 595d6bd394
commit b0f1700e40
57 files changed
+8359 -1661

No files matched your search

+9
View File
@@ -2,15 +2,24 @@ FROM node:20-alpine
WORKDIR /app
# Copy root package files and workspace configs
COPY package*.json ./
COPY turbo.json ./
# Copy all workspace package.json files
COPY apps/admin/package.json ./apps/admin/
COPY apps/mobile/package.json ./apps/mobile/
COPY apps/supabase/package.json ./apps/supabase/
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
# Install all dependencies at root level (workspaces)
RUN npm install
# Copy all source code
COPY . .
# Build shared package
RUN npx turbo run build --filter=@sauci/shared
WORKDIR /app/apps/admin
+2
View File
@@ -23,4 +23,6 @@ services:
- ./vite.config.ts:/app/apps/admin/vite.config.ts
- ./tsconfig.json:/app/apps/admin/tsconfig.json
- ../../packages/shared/src:/app/packages/shared/src
# Preserve node_modules from container (don't override with host)
- /app/node_modules
restart: unless-stopped
+10
View File
@@ -23,6 +23,16 @@ export interface AiConfig {
classifier_enabled: boolean | null;
classifier_model: string | null;
classifier_prompt: string | null;
heuristics_enabled: boolean | null;
heuristic_min_text_length: number | null;
heuristic_whitelist_max_length: number | null;
heuristic_skip_if_no_alnum: boolean | null;
heuristic_skip_media_without_text: boolean | null;
heuristic_record_reason: boolean | null;
heuristic_use_default_whitelist: boolean | null;
heuristic_use_default_keywords: boolean | null;
heuristic_whitelist: string | null;
heuristic_keyword_triggers: string | null;
updated_at: string | null;
updated_by: string | null;
}
+1
View File
@@ -4,6 +4,7 @@ import { supabase } from '@/config';
export interface AppConfig {
id: string;
answer_gap_threshold: number;
daily_response_limit: number;
updated_at: string | null;
updated_by: string | null;
}
+305 -1
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import type { KeyboardEvent } from 'react';
import { useAiConfig, AiConfig, CouncilGenerator } from '@/hooks/useAiConfig';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
@@ -10,11 +11,35 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Textarea } from '@/components/ui/textarea';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield, X } from 'lucide-react';
import { toast } from 'sonner';
import { format } from 'date-fns';
import { ModelCombobox } from '@/components/ModelCombobox';
type ListField = 'heuristic_whitelist' | 'heuristic_keyword_triggers';
const parseListValue = (value?: string | null): string[] => {
if (!value) return [];
return value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
};
const appendUniqueItem = (items: string[], value: string): string[] => {
const trimmed = value.trim();
if (!trimmed) return items;
const existing = new Set(items.map((item) => item.toLowerCase()));
if (existing.has(trimmed.toLowerCase())) return items;
return [...items, trimmed];
};
const removeItem = (items: string[], value: string): string[] => {
const target = value.toLowerCase();
return items.filter((item) => item.toLowerCase() !== target);
};
export function AiSettingsPage() {
const { hasPermission, isSuperAdmin } = useAuth();
const { config, loading, error, updateConfig, refetch } = useAiConfig();
@@ -24,6 +49,8 @@ export function AiSettingsPage() {
const [showApiKey, setShowApiKey] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [whitelistInput, setWhitelistInput] = useState('');
const [keywordInput, setKeywordInput] = useState('');
// Permission check - only super admins or those with MANAGE_AI_CONFIG can access
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_AI_CONFIG);
@@ -51,6 +78,16 @@ export function AiSettingsPage() {
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
heuristic_record_reason: config.heuristic_record_reason ?? false,
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
heuristic_whitelist: config.heuristic_whitelist || '',
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
});
setHasChanges(false);
}
@@ -61,6 +98,46 @@ export function AiSettingsPage() {
setHasChanges(true);
};
const whitelistItems = parseListValue(formData.heuristic_whitelist ?? '');
const keywordItems = parseListValue(formData.heuristic_keyword_triggers ?? '');
const updateListField = (field: ListField, items: string[]) => {
handleChange(field, items.length > 0 ? items.join('\n') : '');
};
const handleAddListItem = (
field: ListField,
value: string,
clearValue: (next: string) => void
) => {
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
const nextItems = appendUniqueItem(current, value);
if (nextItems !== current) {
updateListField(field, nextItems);
}
if (value.trim()) {
clearValue('');
}
};
const handleRemoveListItem = (field: ListField, value: string) => {
const current = field === 'heuristic_whitelist' ? whitelistItems : keywordItems;
const nextItems = removeItem(current, value);
updateListField(field, nextItems);
};
const handleListKeyDown = (
event: KeyboardEvent<HTMLInputElement>,
field: ListField,
value: string,
clearValue: (next: string) => void
) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddListItem(field, value, clearValue);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
@@ -99,6 +176,16 @@ export function AiSettingsPage() {
classifier_enabled: config.classifier_enabled ?? true,
classifier_model: config.classifier_model || 'openai/gpt-4o',
classifier_prompt: config.classifier_prompt || '',
heuristics_enabled: config.heuristics_enabled ?? false,
heuristic_min_text_length: config.heuristic_min_text_length ?? 12,
heuristic_whitelist_max_length: config.heuristic_whitelist_max_length ?? 30,
heuristic_skip_if_no_alnum: config.heuristic_skip_if_no_alnum ?? true,
heuristic_skip_media_without_text: config.heuristic_skip_media_without_text ?? false,
heuristic_record_reason: config.heuristic_record_reason ?? false,
heuristic_use_default_whitelist: config.heuristic_use_default_whitelist ?? true,
heuristic_use_default_keywords: config.heuristic_use_default_keywords ?? true,
heuristic_whitelist: config.heuristic_whitelist || '',
heuristic_keyword_triggers: config.heuristic_keyword_triggers || '',
});
setHasChanges(false);
}
@@ -305,6 +392,223 @@ export function AiSettingsPage() {
Instructions for the AI classifier.
</p>
</div>
<Separator />
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Enable Heuristic Pre-Filter</Label>
<p className="text-xs text-muted-foreground">
Skip AI calls for low-risk messages using simple rules
</p>
</div>
<Switch
checked={formData.heuristics_enabled ?? false}
onCheckedChange={(checked) => handleChange('heuristics_enabled', checked)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="heuristic-min-length">Minimum Text Length</Label>
<Input
id="heuristic-min-length"
type="number"
min={0}
value={formData.heuristic_min_text_length ?? 12}
onChange={(e) =>
handleChange(
'heuristic_min_text_length',
e.target.value === '' ? null : Number(e.target.value)
)
}
/>
<p className="text-xs text-muted-foreground">
Skip AI for shorter messages without media
</p>
</div>
<div className="space-y-2">
<Label htmlFor="heuristic-whitelist-max">Whitelist Max Length</Label>
<Input
id="heuristic-whitelist-max"
type="number"
min={0}
value={formData.heuristic_whitelist_max_length ?? 30}
onChange={(e) =>
handleChange(
'heuristic_whitelist_max_length',
e.target.value === '' ? null : Number(e.target.value)
)
}
/>
<p className="text-xs text-muted-foreground">
Only apply whitelist to messages below this length
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Skip If No Letters/Numbers</Label>
<p className="text-xs text-muted-foreground">
Treat emoji-only or punctuation-only messages as safe
</p>
</div>
<Switch
checked={formData.heuristic_skip_if_no_alnum ?? true}
onCheckedChange={(checked) => handleChange('heuristic_skip_if_no_alnum', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Skip Media Without Text</Label>
<p className="text-xs text-muted-foreground">
Skip AI when only media is present
</p>
</div>
<Switch
checked={formData.heuristic_skip_media_without_text ?? false}
onCheckedChange={(checked) => handleChange('heuristic_skip_media_without_text', checked)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Use Default Whitelist</Label>
<p className="text-xs text-muted-foreground">
Include built-in common phrases
</p>
</div>
<Switch
checked={formData.heuristic_use_default_whitelist ?? true}
onCheckedChange={(checked) => handleChange('heuristic_use_default_whitelist', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Use Default Keywords</Label>
<p className="text-xs text-muted-foreground">
Include built-in safety keywords
</p>
</div>
<Switch
checked={formData.heuristic_use_default_keywords ?? true}
onCheckedChange={(checked) => handleChange('heuristic_use_default_keywords', checked)}
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Record Heuristic Reason</Label>
<p className="text-xs text-muted-foreground">
Store skip reason in flag_reason for debugging
</p>
</div>
<Switch
checked={formData.heuristic_record_reason ?? false}
onCheckedChange={(checked) => handleChange('heuristic_record_reason', checked)}
/>
</div>
<div className="space-y-2">
<Label>Custom Whitelist</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={whitelistInput}
onChange={(e) => setWhitelistInput(e.target.value)}
onKeyDown={(event) =>
handleListKeyDown(event, 'heuristic_whitelist', whitelistInput, setWhitelistInput)
}
placeholder="Add phrase"
/>
<Button
type="button"
variant="outline"
onClick={() =>
handleAddListItem('heuristic_whitelist', whitelistInput, setWhitelistInput)
}
disabled={!whitelistInput.trim()}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{whitelistItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{whitelistItems.map((item) => (
<Badge key={item} variant="secondary" className="flex items-center gap-1">
<span className="font-mono text-xs">{item}</span>
<button
type="button"
onClick={() => handleRemoveListItem('heuristic_whitelist', item)}
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
aria-label={`Remove ${item}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No phrases added yet.</p>
)}
<p className="text-xs text-muted-foreground">
Messages matching these phrases are marked safe
</p>
</div>
<div className="space-y-2">
<Label>Keyword Triggers</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={(event) =>
handleListKeyDown(event, 'heuristic_keyword_triggers', keywordInput, setKeywordInput)
}
placeholder="Add keyword"
/>
<Button
type="button"
variant="outline"
onClick={() =>
handleAddListItem('heuristic_keyword_triggers', keywordInput, setKeywordInput)
}
disabled={!keywordInput.trim()}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{keywordItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{keywordItems.map((item) => (
<Badge key={item} variant="secondary" className="flex items-center gap-1">
<span className="font-mono text-xs">{item}</span>
<button
type="button"
onClick={() => handleRemoveListItem('heuristic_keyword_triggers', item)}
className="rounded-full p-0.5 text-muted-foreground hover:text-foreground"
aria-label={`Remove ${item}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No keywords added yet.</p>
)}
<p className="text-xs text-muted-foreground">
If any term appears, the message will be sent to AI
</p>
</div>
</div>
</CardContent>
</Card>
+39
View File
@@ -28,6 +28,7 @@ export function AppSettingsPage() {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
daily_response_limit: config.daily_response_limit ?? 0,
});
setHasChanges(false);
}
@@ -59,6 +60,7 @@ export function AppSettingsPage() {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
daily_response_limit: config.daily_response_limit ?? 0,
});
setHasChanges(false);
}
@@ -176,6 +178,43 @@ export function AppSettingsPage() {
</div>
</CardContent>
</Card>
{/* Daily Response Limit */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Daily Response Limit (Free Users)
</CardTitle>
<CardDescription>
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dailyLimit">Daily Response Limit</Label>
<Input
id="dailyLimit"
type="number"
min="0"
max="1000"
value={formData.daily_response_limit ?? 0}
onChange={(e) => handleChange('daily_response_limit', parseInt(e.target.value) || 0)}
className="max-w-[200px]"
/>
<p className="text-xs text-muted-foreground">
Maximum number of questions a non-premium user can answer per UTC day.
Premium users (via personal or partner subscription) bypass this limit.
</p>
<p className="text-xs text-muted-foreground">
Set to <strong>0</strong> to disable daily limits entirely.
</p>
<p className="text-xs text-muted-foreground">
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
+53 -137
View File
@@ -10,8 +10,9 @@ import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
import { Events } from "../../src/lib/analytics";
import type { MatchWithQuestion } from "../../src/types";
import type { MatchWithQuestion, AnswerType } from "../../src/types";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -143,8 +144,7 @@ export default function AppLayout() {
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
const matchModalAnim = useRef(new Animated.Value(0)).current;
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLocked, setIsLocked] = useState(false);
@@ -381,10 +381,42 @@ export default function AppLayout() {
Events.milestoneMatch(currentMatchCount + 1);
}
// Add to store and show notification
// Add to store
addMatch(matchWithQuestion);
setMatchNotification(matchWithQuestion);
Events.matchCreated(matchWithQuestion.match_type || "unknown");
// Fetch individual responses to show what each person said
const { data: responses } = await supabase
.from("responses")
.select("user_id, answer")
.eq("question_id", matchWithQuestion.question_id)
.eq("couple_id", user.couple_id);
let userResponse: AnswerType | undefined;
let partnerResponse: AnswerType | undefined;
if (responses && responses.length >= 2) {
for (const r of responses) {
if (r.user_id === user.id) {
userResponse = r.answer as AnswerType;
} else {
partnerResponse = r.answer as AnswerType;
}
}
}
// Get partner name from auth store
const { partner } = useAuthStore.getState();
// Show notification with response data
setMatchNotification({
id: matchWithQuestion.id,
match_type: matchWithQuestion.match_type,
question: matchWithQuestion.question,
userResponse,
partnerResponse,
partnerName: partner?.name,
});
}
}
)
@@ -509,27 +541,17 @@ export default function AppLayout() {
};
}, [lastMessage]);
// Animate match modal when notification changes
useEffect(() => {
if (matchNotification) {
Animated.spring(matchModalAnim, {
toValue: 1,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
}
}, [matchNotification, matchModalAnim]);
const dismissNotification = useCallback(() => {
Animated.timing(matchModalAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start(() => {
setMatchNotification(null);
}, []);
const handleMatchChat = useCallback(() => {
if (matchNotification) {
const matchId = matchNotification.id;
setMatchNotification(null);
});
}, [matchModalAnim]);
router.push(`/(app)/chat/${matchId}`);
}
}, [matchNotification, router]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
@@ -786,53 +808,13 @@ export default function AppLayout() {
})}
</Animated.View>
{/* Match Notification Overlay - Using Animated.View instead of Modal for Android compatibility */}
{matchNotification && (
<Animated.View
style={[
styles.matchOverlay,
{
opacity: matchModalAnim,
},
]}
>
<Pressable style={styles.matchOverlayBackground} onPress={dismissNotification} />
<Animated.View
style={[
styles.modalContent,
{
transform: [
{
scale: matchModalAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 1],
}),
},
],
},
]}
>
<View style={styles.modalHeader}>
<Ionicons name="heart" size={48} color={colors.primary} />
<Text style={styles.modalTitle}>It's a Match!</Text>
</View>
<Text style={styles.modalSubtitle}>You both agreed on:</Text>
<Text style={styles.modalQuestion}>
"{matchNotification?.question?.text}"
</Text>
<View style={styles.matchTypeBadge}>
<Text style={styles.matchTypeText}>
{matchNotification?.match_type === 'yes_yes' ? 'YES + YES' :
matchNotification?.match_type === 'yes_maybe' ? 'YES + MAYBE' :
'MAYBE + MAYBE'}
</Text>
</View>
<Pressable style={styles.dismissButton} onPress={dismissNotification}>
<Text style={styles.dismissButtonText}>Nice!</Text>
</Pressable>
</Animated.View>
</Animated.View>
)}
{/* Match Notification Modal - Premium styling with response details */}
<MatchNotificationModal
visible={!!matchNotification}
match={matchNotification}
onDismiss={dismissNotification}
onChat={handleMatchChat}
/>
{/* Message Toast Notification */}
{lastMessage && (
@@ -875,72 +857,6 @@ const styles = StyleSheet.create({
alignItems: "center",
backgroundColor: colors.background,
},
matchOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
elevation: 9999,
},
matchOverlayBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
modalContent: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.xl,
alignItems: 'center',
width: '90%',
maxWidth: 340,
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.lg,
},
modalHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
},
modalTitle: {
...typography.title1,
color: colors.text,
marginTop: spacing.md,
},
modalSubtitle: {
...typography.body,
color: colors.textSecondary,
marginBottom: spacing.sm,
},
modalQuestion: {
...typography.headline,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.lg,
},
matchTypeBadge: {
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
marginBottom: spacing.lg,
},
matchTypeText: {
...typography.caption1,
color: colors.primary,
fontWeight: '600',
},
dismissButton: {
backgroundColor: colors.primary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.lg,
minWidth: 120,
},
dismissButtonText: {
...typography.headline,
color: colors.text,
textAlign: 'center',
},
messageToast: {
position: 'absolute',
top: Platform.OS === 'ios' ? 50 : 20,
+249 -101
View File
@@ -18,6 +18,7 @@ import Animated, {
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { SwipeableMatchItem } from "../../src/components/matches";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import { MatchesTutorial } from "../../src/components/tutorials";
@@ -37,7 +38,23 @@ const HEADER_SCROLL_DISTANCE = 100;
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
export default function MatchesScreen() {
const { matches, fetchMatches, markAllAsSeen, isLoading, hasMore, isLoadingMore, totalCount } = useMatchStore();
const {
matches,
fetchMatches,
markAllAsSeen,
isLoading,
hasMore,
isLoadingMore,
totalCount,
// Archive state and methods
archivedMatches,
showArchived,
toggleShowArchived,
archiveMatch,
unarchiveMatch,
isLoadingArchived,
fetchArchivedMatches,
} = useMatchStore();
const { user } = useAuthStore();
const router = useRouter();
const { width } = useWindowDimensions();
@@ -150,84 +167,97 @@ export default function MatchesScreen() {
const isYesYes = item.match_type === "yes_yes";
const handleArchive = async () => {
if (showArchived) {
await unarchiveMatch(item.id);
} else {
await archiveMatch(item.id);
}
};
return (
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
<TouchableOpacity
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.8}
<SwipeableMatchItem
onArchive={handleArchive}
isArchived={showArchived}
>
<View style={styles.matchCardPremium}>
{/* 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 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<TouchableOpacity
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.8}
>
<View style={styles.matchCardPremium}>
{/* 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 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.matchRow}>
{/* Premium icon container */}
<View style={[
styles.iconContainerPremium,
isYesYes && styles.iconContainerYesYes
]}>
<Ionicons
name={isYesYes ? "heart" : "heart-half"}
size={20}
color={isYesYes ? ACCENT : ROSE}
/>
</View>
<View style={styles.matchRow}>
{/* Premium icon container */}
<View style={[
styles.iconContainerPremium,
isYesYes && styles.iconContainerYesYes
]}>
<Ionicons
name={isYesYes ? "heart" : "heart-half"}
size={20}
color={isYesYes ? ACCENT : ROSE}
/>
</View>
<View style={styles.content}>
<Text style={styles.questionTextPremium} numberOfLines={2}>
{userText}
</Text>
{item.question.partner_text && (
<Text style={styles.partnerTextPremium} numberOfLines={1}>
Partner: {partnerText}
<View style={styles.content}>
<Text style={styles.questionTextPremium} numberOfLines={2}>
{userText}
</Text>
)}
<View style={styles.metaRow}>
<View style={[
styles.tagPremium,
isYesYes && styles.tagPremiumHighlight
]}>
<Text style={[
styles.tagTextPremium,
isYesYes && styles.tagTextPremiumHighlight
{item.question.partner_text && (
<Text style={styles.partnerTextPremium} numberOfLines={1}>
Partner: {partnerText}
</Text>
)}
<View style={styles.metaRow}>
<View style={[
styles.tagPremium,
isYesYes && styles.tagPremiumHighlight
]}>
{isYesYes ? "YES + YES" : "YES + MAYBE"}
<Text style={[
styles.tagTextPremium,
isYesYes && styles.tagTextPremiumHighlight
]}>
{isYesYes ? "YES + YES" : "YES + MAYBE"}
</Text>
</View>
<Text style={styles.datePremium}>
{new Date(item.created_at).toLocaleDateString()}
</Text>
</View>
<Text style={styles.datePremium}>
{new Date(item.created_at).toLocaleDateString()}
</Text>
</View>
</View>
<View style={styles.rightSection}>
{item.unreadCount > 0 && (
<View style={styles.unreadBadgePremium}>
<Ionicons name="chatbubble" size={10} color={colors.text} />
<View style={styles.rightSection}>
{item.unreadCount > 0 && (
<View style={styles.unreadBadgePremium}>
<Ionicons name="chatbubble" size={10} color={colors.text} />
</View>
)}
<View style={styles.chevronContainerPremium}>
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
</View>
)}
<View style={styles.chevronContainerPremium}>
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
</View>
</View>
</View>
{/* Premium border */}
<View style={styles.cardPremiumBorder} pointerEvents="none" />
</View>
</TouchableOpacity>
{/* Premium border */}
<View style={styles.cardPremiumBorder} pointerEvents="none" />
</View>
</TouchableOpacity>
</SwipeableMatchItem>
</Animated.View>
);
};
@@ -296,7 +326,7 @@ export default function MatchesScreen() {
</View>
<AnimatedFlatList
data={matches}
data={showArchived ? archivedMatches : matches}
renderItem={renderItem}
keyExtractor={(item: any) => item.id}
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
@@ -328,50 +358,122 @@ export default function MatchesScreen() {
{/* Decorative separator */}
<DecorativeSeparator variant="gold" />
{/* Filter toggle */}
<View style={styles.filterContainer}>
<TouchableOpacity
style={[styles.filterTab, !showArchived && styles.filterTabActive]}
onPress={() => showArchived && toggleShowArchived()}
activeOpacity={0.7}
>
<Ionicons
name="heart"
size={14}
color={!showArchived ? ACCENT : colors.textTertiary}
/>
<Text style={[styles.filterTabText, !showArchived && styles.filterTabTextActive]}>
Active
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterTab, showArchived && styles.filterTabActive]}
onPress={() => {
if (!showArchived) {
toggleShowArchived();
}
}}
activeOpacity={0.7}
>
{isLoadingArchived ? (
<ActivityIndicator size="small" color={ACCENT} />
) : (
<>
<Ionicons
name="archive-outline"
size={14}
color={showArchived ? ACCENT : colors.textTertiary}
/>
<Text style={[styles.filterTabText, showArchived && styles.filterTabTextActive]}>
Archived
</Text>
</>
)}
</TouchableOpacity>
</View>
{/* Count badge */}
<View style={styles.countBadgePremium}>
<Ionicons name="heart" size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>{totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}</Text>
<Ionicons name={showArchived ? "archive" : "heart"} size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>
{showArchived
? `${archivedMatches.length} ARCHIVED`
: `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}`
}
</Text>
</View>
</View>
</Animated.View>
}
ListEmptyComponent={
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
{/* Description */}
<Text style={styles.emptyDescription}>
Answer questions together to discover what you both enjoy. Matches appear when you agree!
</Text>
{/* Feature hints */}
<View style={styles.emptyFeatures}>
<View style={styles.emptyFeatureItem}>
<Ionicons name="heart" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
<GlassButton
onPress={() => router.push("/(app)/swipe")}
style={{ marginTop: spacing.lg }}
showArchived ? (
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
Start Swiping
</GlassButton>
</Animated.View>
{/* Archive icon */}
<View style={styles.emptyIconContainer}>
<Ionicons name="archive-outline" size={48} color={ACCENT} />
</View>
{/* Description */}
<Text style={styles.emptyTitle}>No Archived Matches</Text>
<Text style={styles.emptyDescription}>
Matches you archive will appear here. Swipe left on any match to archive it.
</Text>
<GlassButton
onPress={() => toggleShowArchived()}
style={{ marginTop: spacing.lg }}
>
View Active Matches
</GlassButton>
</Animated.View>
) : (
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
{/* Description */}
<Text style={styles.emptyDescription}>
Answer questions together to discover what you both enjoy. Matches appear when you agree!
</Text>
{/* Feature hints */}
<View style={styles.emptyFeatures}>
<View style={styles.emptyFeatureItem}>
<Ionicons name="heart" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
<GlassButton
onPress={() => router.push("/(app)/swipe")}
style={{ marginTop: spacing.lg }}
>
Start Swiping
</GlassButton>
</Animated.View>
)
}
/>
</View>
@@ -478,6 +580,36 @@ const styles = StyleSheet.create({
color: colors.text,
textAlign: 'center',
},
// Filter toggle
filterContainer: {
flexDirection: 'row',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: radius.lg,
padding: 4,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
},
filterTab: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
gap: spacing.xs,
},
filterTabActive: {
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
filterTabText: {
...typography.caption1,
color: colors.textTertiary,
fontWeight: '500',
},
filterTabTextActive: {
color: ACCENT,
fontWeight: '600',
},
countBadgePremium: {
flexDirection: 'row',
alignItems: 'center',
@@ -628,6 +760,22 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xl,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.lg,
},
emptyTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.sm,
},
emptyDescription: {
...typography.body,
color: colors.textSecondary,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,284 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { Text, View, Platform } from 'react-native';
import { GlassButton } from '@/components/ui/GlassButton';
// Store the mock for expo-haptics so we can check calls
const mockImpactAsync = jest.fn();
// Mock expo-haptics - must happen before import
jest.mock('expo-haptics', () => ({
impactAsync: mockImpactAsync,
ImpactFeedbackStyle: {
Light: 'light',
},
}));
describe('GlassButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('rendering', () => {
it('renders children text correctly', () => {
const { getByText } = render(
<GlassButton>Click Me</GlassButton>
);
expect(getByText('Click Me')).toBeTruthy();
});
it('renders with primary variant by default', () => {
const { getByText } = render(
<GlassButton>Primary Button</GlassButton>
);
const text = getByText('Primary Button');
expect(text).toBeTruthy();
});
it('renders secondary variant', () => {
const { getByText } = render(
<GlassButton variant="secondary">Secondary Button</GlassButton>
);
expect(getByText('Secondary Button')).toBeTruthy();
});
it('renders ghost variant', () => {
const { getByText } = render(
<GlassButton variant="ghost">Ghost Button</GlassButton>
);
expect(getByText('Ghost Button')).toBeTruthy();
});
it('renders danger variant', () => {
const { getByText } = render(
<GlassButton variant="danger">Danger Button</GlassButton>
);
expect(getByText('Danger Button')).toBeTruthy();
});
it('renders with icon when provided', () => {
const TestIcon = () => <Text testID="test-icon">Icon</Text>;
const { getByTestId, getByText } = render(
<GlassButton icon={<TestIcon />}>With Icon</GlassButton>
);
expect(getByTestId('test-icon')).toBeTruthy();
expect(getByText('With Icon')).toBeTruthy();
});
it('renders loading indicator when loading is true', () => {
const { queryByText, UNSAFE_getByType } = render(
<GlassButton loading>Loading Button</GlassButton>
);
// When loading, text should not be visible
expect(queryByText('Loading Button')).toBeNull();
// Activity indicator should be present
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
});
it('renders with different sizes', () => {
const { rerender, getByText } = render(
<GlassButton size="sm">Small</GlassButton>
);
expect(getByText('Small')).toBeTruthy();
rerender(<GlassButton size="md">Medium</GlassButton>);
expect(getByText('Medium')).toBeTruthy();
rerender(<GlassButton size="lg">Large</GlassButton>);
expect(getByText('Large')).toBeTruthy();
});
});
describe('press handling', () => {
it('calls onPress when pressed', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>Press Me</GlassButton>
);
fireEvent.press(getByText('Press Me'));
await waitFor(() => {
expect(onPress).toHaveBeenCalledTimes(1);
});
});
it('does not call onPress when disabled', () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} disabled>
Disabled Button
</GlassButton>
);
fireEvent.press(getByText('Disabled Button'));
expect(onPress).not.toHaveBeenCalled();
});
it('does not call onPress when loading', () => {
const onPress = jest.fn();
const { UNSAFE_getByType } = render(
<GlassButton onPress={onPress} loading>
Loading Button
</GlassButton>
);
const ActivityIndicator = require('react-native').ActivityIndicator;
const indicator = UNSAFE_getByType(ActivityIndicator);
// Try to press the parent pressable
// Since loading hides the text, we can't press by text
// The component should still be non-pressable
expect(onPress).not.toHaveBeenCalled();
});
// Note: Haptic tests use haptic={false} because the component uses dynamic import
// which doesn't work properly in Jest without --experimental-vm-modules
it('handles button with haptic prop', () => {
// Verify the component renders with haptic prop
// Actual haptic functionality cannot be tested due to dynamic import limitations
const { getByText } = render(
<GlassButton haptic>Haptic Button</GlassButton>
);
expect(getByText('Haptic Button')).toBeTruthy();
});
it('calls onPress when haptic is false', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>
No Haptic
</GlassButton>
);
fireEvent.press(getByText('No Haptic'));
await waitFor(() => {
expect(onPress).toHaveBeenCalled();
});
});
});
describe('styling', () => {
it('applies fullWidth style when fullWidth is true', () => {
const { getByText } = render(
<GlassButton fullWidth>Full Width Button</GlassButton>
);
const button = getByText('Full Width Button');
expect(button).toBeTruthy();
// The fullWidth style is applied to parent, which sets width: '100%'
});
it('applies custom style prop', () => {
const customStyle = { marginTop: 20 };
const { getByText } = render(
<GlassButton style={customStyle}>Styled Button</GlassButton>
);
expect(getByText('Styled Button')).toBeTruthy();
});
it('applies custom textStyle prop', () => {
const customTextStyle = { fontWeight: 'bold' as const };
const { getByText } = render(
<GlassButton textStyle={customTextStyle}>Styled Text</GlassButton>
);
expect(getByText('Styled Text')).toBeTruthy();
});
it('applies disabled opacity when disabled', () => {
const { getByText } = render(
<GlassButton disabled>Disabled Button</GlassButton>
);
// The disabled style reduces opacity to 0.5
expect(getByText('Disabled Button')).toBeTruthy();
});
});
describe('press in/out animations', () => {
it('handles press in event', () => {
const { getByText } = render(
<GlassButton>Animated Button</GlassButton>
);
const button = getByText('Animated Button');
// Trigger press in - this should work without errors
fireEvent(button, 'pressIn');
expect(button).toBeTruthy();
});
it('handles press out event', () => {
const { getByText } = render(
<GlassButton>Animated Button</GlassButton>
);
const button = getByText('Animated Button');
// Trigger press in and out
fireEvent(button, 'pressIn');
fireEvent(button, 'pressOut');
expect(button).toBeTruthy();
});
});
describe('default props', () => {
it('has default variant of primary', () => {
const { getByText } = render(<GlassButton>Default</GlassButton>);
expect(getByText('Default')).toBeTruthy();
});
it('has default size of md', () => {
const { getByText } = render(<GlassButton>Default Size</GlassButton>);
expect(getByText('Default Size')).toBeTruthy();
});
it('has haptic enabled by default', () => {
// The component has haptic={true} by default
// We can't test the actual haptic call due to dynamic import limitations
// but we verify the prop default works
const { getByText } = render(<GlassButton>Default Haptic</GlassButton>);
expect(getByText('Default Haptic')).toBeTruthy();
});
it('has disabled false by default', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>Not Disabled</GlassButton>
);
fireEvent.press(getByText('Not Disabled'));
await waitFor(() => {
expect(onPress).toHaveBeenCalled();
});
});
it('has loading false by default', () => {
const { getByText } = render(
<GlassButton>Not Loading</GlassButton>
);
expect(getByText('Not Loading')).toBeTruthy();
});
it('has fullWidth false by default', () => {
const { getByText } = render(<GlassButton>Not Full Width</GlassButton>);
expect(getByText('Not Full Width')).toBeTruthy();
});
});
});
@@ -0,0 +1,299 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { Text, Platform } from 'react-native';
import { GlassCard } from '@/components/ui/GlassCard';
// Mock expo-blur
jest.mock('expo-blur', () => {
const React = require('react');
const { View } = require('react-native');
return {
BlurView: ({ children, intensity, tint, style, ...props }: any) =>
React.createElement(
View,
{ ...props, style, testID: 'blur-view', 'data-intensity': intensity, 'data-tint': tint },
children
),
};
});
// Store original Platform.OS
const originalPlatformOS = Platform.OS;
describe('GlassCard', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to iOS for consistent testing
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
});
afterAll(() => {
Object.defineProperty(Platform, 'OS', {
get: () => originalPlatformOS,
});
});
describe('rendering', () => {
it('renders children correctly', () => {
const { getByText } = render(
<GlassCard>
<Text>Card Content</Text>
</GlassCard>
);
expect(getByText('Card Content')).toBeTruthy();
});
it('renders multiple children', () => {
const { getByText } = render(
<GlassCard>
<Text>First Child</Text>
<Text>Second Child</Text>
</GlassCard>
);
expect(getByText('First Child')).toBeTruthy();
expect(getByText('Second Child')).toBeTruthy();
});
it('renders nested components', () => {
const { getByText, getByTestId } = render(
<GlassCard>
<Text testID="nested-text">Nested Content</Text>
</GlassCard>
);
expect(getByTestId('nested-text')).toBeTruthy();
expect(getByText('Nested Content')).toBeTruthy();
});
});
describe('platform behavior', () => {
it('uses BlurView on iOS', () => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
const { getByTestId } = render(
<GlassCard>
<Text>iOS Card</Text>
</GlassCard>
);
expect(getByTestId('blur-view')).toBeTruthy();
});
// Note: Testing Android platform requires component re-evaluation
// which is complex with module-level Platform checks
it('renders correctly', () => {
const { getByText } = render(
<GlassCard>
<Text>Card Content</Text>
</GlassCard>
);
expect(getByText('Card Content')).toBeTruthy();
});
});
describe('intensity prop', () => {
beforeEach(() => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
});
it('applies light intensity', () => {
const { getByTestId } = render(
<GlassCard intensity="light">
<Text>Light Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
});
it('applies medium intensity by default', () => {
const { getByTestId } = render(
<GlassCard>
<Text>Medium Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
});
it('applies heavy intensity', () => {
const { getByTestId } = render(
<GlassCard intensity="heavy">
<Text>Heavy Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
});
});
describe('variant prop', () => {
it('renders default variant', () => {
const { getByText } = render(
<GlassCard variant="default">
<Text>Default Card</Text>
</GlassCard>
);
expect(getByText('Default Card')).toBeTruthy();
});
it('renders elevated variant', () => {
const { getByText } = render(
<GlassCard variant="elevated">
<Text>Elevated Card</Text>
</GlassCard>
);
expect(getByText('Elevated Card')).toBeTruthy();
});
it('renders subtle variant', () => {
const { getByText } = render(
<GlassCard variant="subtle">
<Text>Subtle Card</Text>
</GlassCard>
);
expect(getByText('Subtle Card')).toBeTruthy();
});
});
describe('noPadding prop', () => {
it('has padding by default', () => {
const { getByText } = render(
<GlassCard>
<Text>Padded Card</Text>
</GlassCard>
);
expect(getByText('Padded Card')).toBeTruthy();
});
it('removes padding when noPadding is true', () => {
const { getByText } = render(
<GlassCard noPadding>
<Text>No Padding Card</Text>
</GlassCard>
);
expect(getByText('No Padding Card')).toBeTruthy();
});
});
describe('style prop', () => {
it('applies custom style', () => {
const customStyle = { marginTop: 20, backgroundColor: 'red' };
const { getByText } = render(
<GlassCard style={customStyle}>
<Text>Styled Card</Text>
</GlassCard>
);
expect(getByText('Styled Card')).toBeTruthy();
});
it('merges custom style with default styles', () => {
const { getByText } = render(
<GlassCard style={{ marginBottom: 10 }}>
<Text>Merged Styles Card</Text>
</GlassCard>
);
expect(getByText('Merged Styles Card')).toBeTruthy();
});
});
describe('default props', () => {
it('has default intensity of medium', () => {
const { getByText } = render(
<GlassCard>
<Text>Default Intensity</Text>
</GlassCard>
);
expect(getByText('Default Intensity')).toBeTruthy();
});
it('has default variant of default', () => {
const { getByText } = render(
<GlassCard>
<Text>Default Variant</Text>
</GlassCard>
);
expect(getByText('Default Variant')).toBeTruthy();
});
it('has noPadding false by default', () => {
const { getByText } = render(
<GlassCard>
<Text>Has Padding</Text>
</GlassCard>
);
expect(getByText('Has Padding')).toBeTruthy();
});
});
describe('gradient highlight', () => {
it('renders highlight gradient', () => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
const { getByText } = render(
<GlassCard>
<Text>Card with Highlight</Text>
</GlassCard>
);
// LinearGradient is mocked to render as View, so we just verify the card renders
expect(getByText('Card with Highlight')).toBeTruthy();
});
});
describe('accessibility', () => {
it('renders children accessible to screen readers', () => {
const { getByText } = render(
<GlassCard>
<Text accessibilityLabel="Accessible content">Content</Text>
</GlassCard>
);
const text = getByText('Content');
expect(text.props.accessibilityLabel).toBe('Accessible content');
});
});
describe('combination of props', () => {
it('handles all props together', () => {
const customStyle = { margin: 10 };
const { getByText } = render(
<GlassCard
intensity="heavy"
variant="elevated"
noPadding
style={customStyle}
>
<Text>Full Props Card</Text>
</GlassCard>
);
expect(getByText('Full Props Card')).toBeTruthy();
});
});
});
@@ -0,0 +1,380 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Text, Platform } from 'react-native';
import { GlassInput } from '@/components/ui/GlassInput';
// Mock expo-blur
jest.mock('expo-blur', () => {
const React = require('react');
const { View } = require('react-native');
return {
BlurView: ({ children, intensity, tint, style, ...props }: any) =>
React.createElement(
View,
{ ...props, style, testID: 'blur-view' },
children
),
};
});
// Store original Platform.OS
const originalPlatformOS = Platform.OS;
describe('GlassInput', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to iOS for consistent testing
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
});
afterAll(() => {
// Restore original
Object.defineProperty(Platform, 'OS', {
get: () => originalPlatformOS,
});
});
describe('rendering', () => {
it('renders text input', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Enter text" />
);
expect(getByPlaceholderText('Enter text')).toBeTruthy();
});
it('renders with label', () => {
const { getByText, getByPlaceholderText } = render(
<GlassInput label="Username" placeholder="Enter username" />
);
expect(getByText('Username')).toBeTruthy();
expect(getByPlaceholderText('Enter username')).toBeTruthy();
});
it('renders without label', () => {
const { queryByText, getByPlaceholderText } = render(
<GlassInput placeholder="No label input" />
);
expect(getByPlaceholderText('No label input')).toBeTruthy();
// No label text should be present
});
it('renders error message when provided', () => {
const { getByText } = render(
<GlassInput placeholder="Input" error="This field is required" />
);
expect(getByText('This field is required')).toBeTruthy();
});
it('does not render error message when not provided', () => {
const { queryByText, getByPlaceholderText } = render(
<GlassInput placeholder="No error input" />
);
expect(getByPlaceholderText('No error input')).toBeTruthy();
expect(queryByText(/error/i)).toBeNull();
});
it('renders with icon', () => {
const TestIcon = () => <Text testID="input-icon">Icon</Text>;
const { getByTestId } = render(
<GlassInput placeholder="Input with icon" icon={<TestIcon />} />
);
expect(getByTestId('input-icon')).toBeTruthy();
});
});
describe('platform behavior', () => {
it('uses BlurView on iOS', () => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
const { getByTestId } = render(
<GlassInput placeholder="iOS Input" />
);
expect(getByTestId('blur-view')).toBeTruthy();
});
// Note: Testing Android platform requires component re-evaluation
// which is complex with module-level Platform checks
it('renders correctly', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Input" />
);
expect(getByPlaceholderText('Input')).toBeTruthy();
});
});
describe('input interactions', () => {
it('handles text input changes', () => {
const onChangeText = jest.fn();
const { getByPlaceholderText } = render(
<GlassInput placeholder="Type here" onChangeText={onChangeText} />
);
const input = getByPlaceholderText('Type here');
fireEvent.changeText(input, 'Hello World');
expect(onChangeText).toHaveBeenCalledWith('Hello World');
});
it('handles focus events', () => {
const onFocus = jest.fn();
const { getByPlaceholderText } = render(
<GlassInput placeholder="Focus me" onFocus={onFocus} />
);
const input = getByPlaceholderText('Focus me');
fireEvent(input, 'focus', {});
expect(onFocus).toHaveBeenCalled();
});
it('handles blur events', () => {
const onBlur = jest.fn();
const { getByPlaceholderText } = render(
<GlassInput placeholder="Blur me" onBlur={onBlur} />
);
const input = getByPlaceholderText('Blur me');
fireEvent(input, 'blur', {});
expect(onBlur).toHaveBeenCalled();
});
it('displays value prop', () => {
const { getByDisplayValue } = render(
<GlassInput placeholder="Input" value="Initial value" />
);
expect(getByDisplayValue('Initial value')).toBeTruthy();
});
it('handles controlled input', () => {
const onChangeText = jest.fn();
const { getByDisplayValue, rerender } = render(
<GlassInput
placeholder="Controlled"
value="Initial"
onChangeText={onChangeText}
/>
);
expect(getByDisplayValue('Initial')).toBeTruthy();
rerender(
<GlassInput
placeholder="Controlled"
value="Updated"
onChangeText={onChangeText}
/>
);
expect(getByDisplayValue('Updated')).toBeTruthy();
});
});
describe('focus state styling', () => {
it('updates styling on focus', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Focus styling" />
);
const input = getByPlaceholderText('Focus styling');
// Trigger focus
fireEvent(input, 'focus', {});
// The component should update without errors
expect(input).toBeTruthy();
});
it('updates styling on blur', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Blur styling" />
);
const input = getByPlaceholderText('Blur styling');
// Trigger focus then blur
fireEvent(input, 'focus', {});
fireEvent(input, 'blur', {});
expect(input).toBeTruthy();
});
});
describe('error state', () => {
it('displays error styling when error prop is provided', () => {
const { getByText, getByPlaceholderText } = render(
<GlassInput
placeholder="Error input"
error="Invalid input"
/>
);
expect(getByPlaceholderText('Error input')).toBeTruthy();
expect(getByText('Invalid input')).toBeTruthy();
});
it('applies error border color', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Error border" error="Has error" />
);
// The component renders with error styling
expect(getByPlaceholderText('Error border')).toBeTruthy();
});
});
describe('input props passthrough', () => {
it('passes secureTextEntry prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Password" secureTextEntry />
);
const input = getByPlaceholderText('Password');
expect(input.props.secureTextEntry).toBe(true);
});
it('passes keyboardType prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Email" keyboardType="email-address" />
);
const input = getByPlaceholderText('Email');
expect(input.props.keyboardType).toBe('email-address');
});
it('passes autoCapitalize prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Name" autoCapitalize="words" />
);
const input = getByPlaceholderText('Name');
expect(input.props.autoCapitalize).toBe('words');
});
it('passes autoCorrect prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Code" autoCorrect={false} />
);
const input = getByPlaceholderText('Code');
expect(input.props.autoCorrect).toBe(false);
});
it('passes maxLength prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Short" maxLength={10} />
);
const input = getByPlaceholderText('Short');
expect(input.props.maxLength).toBe(10);
});
it('passes multiline prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Message" multiline />
);
const input = getByPlaceholderText('Message');
expect(input.props.multiline).toBe(true);
});
it('passes editable prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Read only" editable={false} />
);
const input = getByPlaceholderText('Read only');
expect(input.props.editable).toBe(false);
});
it('passes returnKeyType prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Search" returnKeyType="search" />
);
const input = getByPlaceholderText('Search');
expect(input.props.returnKeyType).toBe('search');
});
});
describe('containerStyle prop', () => {
it('applies custom container style', () => {
const customStyle = { marginTop: 20 };
const { getByPlaceholderText } = render(
<GlassInput
placeholder="Styled container"
containerStyle={customStyle}
/>
);
expect(getByPlaceholderText('Styled container')).toBeTruthy();
});
});
describe('accessibility', () => {
it('supports accessibilityLabel', () => {
const { getByLabelText } = render(
<GlassInput
placeholder="Accessible input"
accessibilityLabel="Username input field"
/>
);
expect(getByLabelText('Username input field')).toBeTruthy();
});
it('supports accessibilityHint', () => {
const { getByPlaceholderText } = render(
<GlassInput
placeholder="Hint input"
accessibilityHint="Enter your username"
/>
);
const input = getByPlaceholderText('Hint input');
expect(input.props.accessibilityHint).toBe('Enter your username');
});
});
describe('combination of props', () => {
it('handles all props together', () => {
const onChangeText = jest.fn();
const onFocus = jest.fn();
const TestIcon = () => <Text>Icon</Text>;
const { getByText, getByPlaceholderText } = render(
<GlassInput
label="Full Props Input"
placeholder="Enter value"
error="Validation error"
icon={<TestIcon />}
value="Initial"
onChangeText={onChangeText}
onFocus={onFocus}
secureTextEntry
keyboardType="default"
containerStyle={{ margin: 10 }}
/>
);
expect(getByText('Full Props Input')).toBeTruthy();
expect(getByPlaceholderText('Enter value')).toBeTruthy();
expect(getByText('Validation error')).toBeTruthy();
expect(getByText('Icon')).toBeTruthy();
});
});
});
@@ -0,0 +1,255 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { LoadingOverlay } from '@/components/ui/LoadingOverlay';
describe('LoadingOverlay', () => {
describe('visibility', () => {
it('returns null when not visible', () => {
const { queryByText, toJSON } = render(
<LoadingOverlay visible={false} statusText="Loading..." />
);
expect(queryByText('Loading...')).toBeNull();
expect(toJSON()).toBeNull();
});
it('renders when visible', () => {
const { getByText } = render(
<LoadingOverlay visible={true} statusText="Loading..." />
);
expect(getByText('Loading...')).toBeTruthy();
});
});
describe('spinner variant (default)', () => {
it('renders spinner by default', () => {
const { UNSAFE_getByType } = render(
<LoadingOverlay visible={true} />
);
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
});
it('renders status text', () => {
const { getByText } = render(
<LoadingOverlay visible={true} statusText="Please wait..." />
);
expect(getByText('Please wait...')).toBeTruthy();
});
it('renders detail text', () => {
const { getByText } = render(
<LoadingOverlay visible={true} detailText="This may take a moment" />
);
expect(getByText('This may take a moment')).toBeTruthy();
});
it('renders both status and detail text', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
statusText="Uploading..."
detailText="50% complete"
/>
);
expect(getByText('Uploading...')).toBeTruthy();
expect(getByText('50% complete')).toBeTruthy();
});
});
describe('shimmer variant', () => {
it('renders shimmer variant', () => {
const { UNSAFE_getByType, getByText } = render(
<LoadingOverlay visible={true} variant="shimmer" statusText="Loading..." />
);
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
expect(getByText('Loading...')).toBeTruthy();
});
it('renders shimmer with detail text', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
variant="shimmer"
statusText="Syncing"
detailText="Almost done"
/>
);
expect(getByText('Syncing')).toBeTruthy();
expect(getByText('Almost done')).toBeTruthy();
});
});
describe('progress variant', () => {
it('renders progress variant', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
variant="progress"
progress={50}
statusText="Uploading..."
/>
);
expect(getByText('Uploading...')).toBeTruthy();
});
it('renders progress with detail text', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
variant="progress"
progress={75}
statusText="Processing"
detailText="3 of 4 complete"
/>
);
expect(getByText('Processing')).toBeTruthy();
expect(getByText('3 of 4 complete')).toBeTruthy();
});
it('clamps progress to 0-100', () => {
// Progress value should be clamped internally
const { toJSON } = render(
<LoadingOverlay visible={true} variant="progress" progress={150} />
);
// Should render without error
expect(toJSON()).toBeTruthy();
});
it('handles negative progress', () => {
const { toJSON } = render(
<LoadingOverlay visible={true} variant="progress" progress={-10} />
);
// Should render without error
expect(toJSON()).toBeTruthy();
});
});
describe('fullScreen mode', () => {
it('renders in fullScreen mode', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
fullScreen={true}
statusText="Full screen loading"
/>
);
expect(getByText('Full screen loading')).toBeTruthy();
});
it('renders fullScreen with different variants', () => {
const { rerender, getByText } = render(
<LoadingOverlay
visible={true}
fullScreen={true}
variant="spinner"
statusText="Spinner"
/>
);
expect(getByText('Spinner')).toBeTruthy();
rerender(
<LoadingOverlay
visible={true}
fullScreen={true}
variant="progress"
statusText="Progress"
/>
);
expect(getByText('Progress')).toBeTruthy();
rerender(
<LoadingOverlay
visible={true}
fullScreen={true}
variant="shimmer"
statusText="Shimmer"
/>
);
expect(getByText('Shimmer')).toBeTruthy();
});
});
describe('custom styling', () => {
it('applies custom style', () => {
const customStyle = { marginTop: 20 };
const { getByText } = render(
<LoadingOverlay
visible={true}
style={customStyle}
statusText="Styled"
/>
);
expect(getByText('Styled')).toBeTruthy();
});
it('applies custom text style', () => {
const customTextStyle = { fontSize: 20 };
const { getByText } = render(
<LoadingOverlay
visible={true}
textStyle={customTextStyle}
statusText="Custom text"
/>
);
expect(getByText('Custom text')).toBeTruthy();
});
it('uses custom spinner color', () => {
const { UNSAFE_getByType } = render(
<LoadingOverlay
visible={true}
spinnerColor="#FF0000"
/>
);
const ActivityIndicator = require('react-native').ActivityIndicator;
const indicator = UNSAFE_getByType(ActivityIndicator);
expect(indicator.props.color).toBe('#FF0000');
});
});
describe('default props', () => {
it('has spinner variant by default', () => {
const { UNSAFE_getByType } = render(
<LoadingOverlay visible={true} />
);
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
});
it('has fullScreen false by default', () => {
const { toJSON } = render(
<LoadingOverlay visible={true} statusText="Test" />
);
// Should render in non-fullscreen mode
expect(toJSON()).toBeTruthy();
});
it('has progress 0 by default', () => {
const { toJSON } = render(
<LoadingOverlay visible={true} variant="progress" />
);
// Should render without error
expect(toJSON()).toBeTruthy();
});
});
});
@@ -0,0 +1,186 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { Text, View } from 'react-native';
import { ShimmerEffect } from '@/components/ui/ShimmerEffect';
describe('ShimmerEffect', () => {
describe('rendering', () => {
it('renders children correctly', () => {
const { getByText } = render(
<ShimmerEffect>
<Text>Child Content</Text>
</ShimmerEffect>
);
expect(getByText('Child Content')).toBeTruthy();
});
it('renders multiple children', () => {
const { getByText } = render(
<ShimmerEffect>
<Text>First Child</Text>
<Text>Second Child</Text>
</ShimmerEffect>
);
expect(getByText('First Child')).toBeTruthy();
expect(getByText('Second Child')).toBeTruthy();
});
it('renders nested components', () => {
const { getByText, getByTestId } = render(
<ShimmerEffect>
<View testID="nested-view">
<Text>Nested Content</Text>
</View>
</ShimmerEffect>
);
expect(getByTestId('nested-view')).toBeTruthy();
expect(getByText('Nested Content')).toBeTruthy();
});
});
describe('enabled prop', () => {
it('shows glow effect when enabled (default)', () => {
const { toJSON } = render(
<ShimmerEffect>
<Text>Content</Text>
</ShimmerEffect>
);
// Should render with glow elements
expect(toJSON()).toBeTruthy();
});
it('hides glow effect when disabled', () => {
const { getByText, toJSON } = render(
<ShimmerEffect enabled={false}>
<Text>Content</Text>
</ShimmerEffect>
);
expect(getByText('Content')).toBeTruthy();
// The glow container should not be rendered when disabled
expect(toJSON()).toBeTruthy();
});
it('toggles enabled state', () => {
const { rerender, getByText } = render(
<ShimmerEffect enabled={true}>
<Text>Content</Text>
</ShimmerEffect>
);
expect(getByText('Content')).toBeTruthy();
rerender(
<ShimmerEffect enabled={false}>
<Text>Content</Text>
</ShimmerEffect>
);
expect(getByText('Content')).toBeTruthy();
});
});
describe('custom styling', () => {
it('applies custom style', () => {
const customStyle = { margin: 10, padding: 20 };
const { getByText } = render(
<ShimmerEffect style={customStyle}>
<Text>Styled Content</Text>
</ShimmerEffect>
);
expect(getByText('Styled Content')).toBeTruthy();
});
it('uses custom shimmer color', () => {
const { getByText } = render(
<ShimmerEffect shimmerColor="#FF0000">
<Text>Red Shimmer</Text>
</ShimmerEffect>
);
expect(getByText('Red Shimmer')).toBeTruthy();
});
});
describe('duration prop', () => {
it('accepts custom duration', () => {
const { getByText } = render(
<ShimmerEffect duration={2000}>
<Text>Fast Shimmer</Text>
</ShimmerEffect>
);
expect(getByText('Fast Shimmer')).toBeTruthy();
});
it('uses default duration of 4000ms', () => {
const { getByText } = render(
<ShimmerEffect>
<Text>Default Duration</Text>
</ShimmerEffect>
);
expect(getByText('Default Duration')).toBeTruthy();
});
});
describe('default props', () => {
it('has enabled true by default', () => {
const { toJSON, getByText } = render(
<ShimmerEffect>
<Text>Content</Text>
</ShimmerEffect>
);
expect(getByText('Content')).toBeTruthy();
expect(toJSON()).toBeTruthy();
});
it('uses default shimmer color', () => {
const { getByText } = render(
<ShimmerEffect>
<Text>Default Color</Text>
</ShimmerEffect>
);
expect(getByText('Default Color')).toBeTruthy();
});
});
describe('composition', () => {
it('works with complex children', () => {
const { getByText, getByTestId } = render(
<ShimmerEffect>
<View testID="outer">
<Text>Header</Text>
<View testID="inner">
<Text>Body</Text>
</View>
<Text>Footer</Text>
</View>
</ShimmerEffect>
);
expect(getByTestId('outer')).toBeTruthy();
expect(getByTestId('inner')).toBeTruthy();
expect(getByText('Header')).toBeTruthy();
expect(getByText('Body')).toBeTruthy();
expect(getByText('Footer')).toBeTruthy();
});
it('can be nested', () => {
const { getByText } = render(
<ShimmerEffect shimmerColor="#FF0000">
<ShimmerEffect shimmerColor="#00FF00" enabled={false}>
<Text>Nested Shimmer</Text>
</ShimmerEffect>
</ShimmerEffect>
);
expect(getByText('Nested Shimmer')).toBeTruthy();
});
});
});
@@ -0,0 +1,197 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useFeatureInterest } from '@/hooks/useFeatureInterest';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
insert: jest.fn(() => query),
delete: jest.fn(() => query),
maybeSingle: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('useFeatureInterest', () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({ user: { id: 'user1' } } as any);
});
it('fetches initial interest status as true when interest exists', async () => {
const query = createThenableQuery({ data: { id: 'interest1' } });
(supabase.from as jest.Mock).mockReturnValue(query);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(true);
expect(result.current.isAuthenticated).toBe(true);
expect(query.eq).toHaveBeenCalledWith('user_id', 'user1');
expect(query.eq).toHaveBeenCalledWith('feature_name', 'dares');
});
it('fetches initial interest status as false when no interest exists', async () => {
const query = createThenableQuery({ data: null });
(supabase.from as jest.Mock).mockReturnValue(query);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
});
it('returns isAuthenticated false when user is null', async () => {
useAuthStore.setState({ user: null } as any);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.isInterested).toBe(false);
expect(supabase.from).not.toHaveBeenCalled();
});
it('adds interest when toggling from not interested', async () => {
// Initial query returns no interest
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery) // Initial fetch
.mockReturnValueOnce(insertQuery); // Insert on toggle
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isInterested).toBe(true);
expect(insertQuery.insert).toHaveBeenCalledWith({
user_id: 'user1',
feature_name: 'dares',
});
});
it('removes interest when toggling from interested', async () => {
// Initial query returns existing interest
const initialQuery = createThenableQuery({ data: { id: 'interest1' } });
const deleteQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery) // Initial fetch
.mockReturnValueOnce(deleteQuery); // Delete on toggle
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(true);
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isInterested).toBe(false);
expect(deleteQuery.delete).toHaveBeenCalled();
expect(deleteQuery.eq).toHaveBeenCalledWith('user_id', 'user1');
expect(deleteQuery.eq).toHaveBeenCalledWith('feature_name', 'dares');
});
it('reverts optimistic update on error', async () => {
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: new Error('Insert failed') });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery)
.mockReturnValueOnce(insertQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
await act(async () => {
await result.current.toggleInterest();
});
// Should revert to original state after error
expect(result.current.isInterested).toBe(false);
});
it('does not toggle when user is not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.toggleInterest();
});
// Should not make any API calls
expect(supabase.from).not.toHaveBeenCalled();
});
it('isToggling is false when not toggling', async () => {
const initialQuery = createThenableQuery({ data: null });
(supabase.from as jest.Mock).mockReturnValueOnce(initialQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isToggling).toBe(false);
});
it('isToggling is false after toggle completes', async () => {
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery)
.mockReturnValueOnce(insertQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isToggling).toBe(false);
});
});
@@ -6,91 +6,355 @@ import { useMediaPicker } from '@/hooks/useMediaPicker';
describe('useMediaPicker', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
it('returns null when user cancels library picker', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
assets: [],
});
const { result } = renderHook(() => useMediaPicker());
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
expect(picked).toBeNull();
afterEach(() => {
jest.useRealTimers();
});
it('maps selected image to MediaResult', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
describe('pickMedia', () => {
it('returns null when user cancels library picker', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
assets: [],
});
const { result } = renderHook(() => useMediaPicker());
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
expect(picked).toBeNull();
});
it('maps selected image to MediaResult', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://image.jpg',
type: 'image',
width: 100,
height: 200,
}],
});
const { result } = renderHook(() => useMediaPicker());
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
expect(picked).toEqual({
uri: 'file://image.jpg',
type: 'image',
mediaType: 'image',
width: 100,
height: 200,
}],
duration: undefined,
});
});
const { result } = renderHook(() => useMediaPicker());
it('maps selected video to MediaResult', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://video.mp4',
type: 'video',
width: 640,
height: 480,
duration: 1234,
}],
});
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
const { result } = renderHook(() => useMediaPicker());
expect(picked).toEqual({
uri: 'file://image.jpg',
mediaType: 'image',
width: 100,
height: 200,
duration: undefined,
});
});
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
it('maps selected video to MediaResult', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
expect(picked).toEqual({
uri: 'file://video.mp4',
type: 'video',
mediaType: 'video',
width: 640,
height: 480,
duration: 1234,
}],
});
});
const { result } = renderHook(() => useMediaPicker());
it('uses custom config options', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
});
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
const { result } = renderHook(() => useMediaPicker({
imageQuality: 0.5,
libraryVideoMaxDuration: 120,
}));
expect(picked).toEqual({
uri: 'file://video.mp4',
mediaType: 'video',
width: 640,
height: 480,
duration: 1234,
await act(async () => {
await result.current.pickMedia();
});
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
mediaTypes: ImagePicker.MediaTypeOptions.All,
quality: 0.5,
videoMaxDuration: 120,
});
});
});
it('alerts and returns false when camera permission denied', async () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
describe('requestCameraPermission', () => {
it('returns true when permission granted', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
const { result } = renderHook(() => useMediaPicker());
const { result } = renderHook(() => useMediaPicker());
let ok: any;
await act(async () => {
ok = await result.current.requestCameraPermission();
let ok: any;
await act(async () => {
ok = await result.current.requestCameraPermission();
});
expect(ok).toBe(true);
});
expect(ok).toBe(false);
expect(alertSpy).toHaveBeenCalled();
it('alerts and returns false when camera permission denied', async () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
const { result } = renderHook(() => useMediaPicker());
let ok: any;
await act(async () => {
ok = await result.current.requestCameraPermission();
});
expect(ok).toBe(false);
expect(alertSpy).toHaveBeenCalledWith(
'Camera Permission',
'Please allow camera access to use this feature.'
);
});
});
describe('takePhoto', () => {
it('takes photo successfully', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://photo.jpg',
type: 'image',
width: 3024,
height: 4032,
}],
});
const { result } = renderHook(() => useMediaPicker());
let photo: any;
await act(async () => {
photo = await result.current.takePhoto();
});
expect(photo).toEqual({
uri: 'file://photo.jpg',
mediaType: 'image',
width: 3024,
height: 4032,
});
});
it('returns null when permission denied', async () => {
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
const { result } = renderHook(() => useMediaPicker());
let photo: any;
await act(async () => {
photo = await result.current.takePhoto();
});
expect(photo).toBeNull();
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
});
it('returns null when cancelled', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
});
const { result } = renderHook(() => useMediaPicker());
let photo: any;
await act(async () => {
photo = await result.current.takePhoto();
});
expect(photo).toBeNull();
});
it('uses custom image quality', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker({ imageQuality: 0.9 }));
await act(async () => {
await result.current.takePhoto();
});
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({ quality: 0.9 });
});
});
describe('recordVideo', () => {
it('records video successfully', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://video.mp4',
type: 'video',
width: 1920,
height: 1080,
duration: 15000,
}],
});
const { result } = renderHook(() => useMediaPicker());
let video: any;
await act(async () => {
video = await result.current.recordVideo();
});
expect(video).toEqual({
uri: 'file://video.mp4',
mediaType: 'video',
width: 1920,
height: 1080,
duration: 15000,
});
});
it('returns null when permission denied', async () => {
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
const { result } = renderHook(() => useMediaPicker());
let video: any;
await act(async () => {
video = await result.current.recordVideo();
});
expect(video).toBeNull();
});
it('returns null when cancelled', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker());
let video: any;
await act(async () => {
video = await result.current.recordVideo();
});
expect(video).toBeNull();
});
it('uses custom video max duration', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker({ cameraVideoMaxDuration: 30 }));
await act(async () => {
await result.current.recordVideo();
});
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith(
expect.objectContaining({
videoMaxDuration: 30,
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
})
);
});
it('handles null duration in video result', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://video.mp4',
type: 'video',
width: 1920,
height: 1080,
duration: null,
}],
});
const { result } = renderHook(() => useMediaPicker());
let video: any;
await act(async () => {
video = await result.current.recordVideo();
});
expect(video.duration).toBeUndefined();
});
});
describe('default config', () => {
it('uses default image quality of 0.7', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker());
await act(async () => {
await result.current.pickMedia();
});
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith(
expect.objectContaining({ quality: 0.7 })
);
});
it('uses default library video max duration of 300', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker());
await act(async () => {
await result.current.pickMedia();
});
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith(
expect.objectContaining({ videoMaxDuration: 300 })
);
});
it('uses default camera video max duration of 60', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker());
await act(async () => {
await result.current.recordVideo();
});
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith(
expect.objectContaining({ videoMaxDuration: 60 })
);
});
});
});
@@ -0,0 +1,231 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system';
import { useMediaSaver } from '@/hooks/useMediaSaver';
// Mock expo-media-library
jest.mock('expo-media-library', () => ({
usePermissions: jest.fn(),
createAssetAsync: jest.fn(),
}));
// Mock expo-file-system with all required methods
jest.mock('expo-file-system', () => ({
readAsStringAsync: jest.fn(async () => ''),
downloadAsync: jest.fn(),
deleteAsync: jest.fn(),
cacheDirectory: 'file:///cache/',
EncodingType: {
Base64: 'base64',
},
}));
// Mock Alert
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
describe('useMediaSaver', () => {
const mockRequestPermission = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Default: permission granted
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
{ granted: true },
mockRequestPermission,
]);
(MediaLibrary.createAssetAsync as jest.Mock).mockResolvedValue({ id: 'asset1' });
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
uri: 'file:///cache/download_123.jpg',
});
(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined);
});
it('initializes with saving as false', () => {
const { result } = renderHook(() => useMediaSaver());
expect(result.current.saving).toBe(false);
expect(typeof result.current.saveMedia).toBe('function');
});
it('saves local image successfully', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///local/image.jpg');
expect(Alert.alert).toHaveBeenCalledWith('Saved', 'Image saved to your gallery.');
expect(result.current.saving).toBe(false);
});
it('saves local video successfully', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/video.mp4', 'video');
});
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///local/video.mp4');
expect(Alert.alert).toHaveBeenCalledWith('Saved', 'Video saved to your gallery.');
});
it('downloads remote image before saving', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('https://example.com/image.jpg', 'image');
});
expect(FileSystem.downloadAsync).toHaveBeenCalledWith(
'https://example.com/image.jpg',
expect.stringMatching(/download_\d+\.jpg$/)
);
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///cache/download_123.jpg');
expect(FileSystem.deleteAsync).toHaveBeenCalledWith('file:///cache/download_123.jpg', { idempotent: true });
});
it('downloads remote video before saving', async () => {
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
uri: 'file:///cache/download_123.mp4',
});
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('https://example.com/video.mp4', 'video');
});
expect(FileSystem.downloadAsync).toHaveBeenCalledWith(
'https://example.com/video.mp4',
expect.stringMatching(/download_\d+\.mp4$/)
);
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///cache/download_123.mp4');
});
it('handles http URLs (upgrades to https internally)', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('http://example.com/image.jpg', 'image');
});
// http URLs are also considered remote
expect(FileSystem.downloadAsync).toHaveBeenCalled();
});
it('requests permission when not granted', async () => {
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
{ granted: false },
mockRequestPermission,
]);
mockRequestPermission.mockResolvedValue({ granted: true });
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(mockRequestPermission).toHaveBeenCalled();
expect(MediaLibrary.createAssetAsync).toHaveBeenCalled();
});
it('shows alert and returns early when permission denied', async () => {
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
{ granted: false },
mockRequestPermission,
]);
mockRequestPermission.mockResolvedValue({ granted: false });
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(Alert.alert).toHaveBeenCalledWith(
'Permission required',
'Please allow access to your photo library to save media.'
);
expect(MediaLibrary.createAssetAsync).not.toHaveBeenCalled();
expect(result.current.saving).toBe(false);
});
it('handles save errors gracefully', async () => {
(MediaLibrary.createAssetAsync as jest.Mock).mockRejectedValue(new Error('Save failed'));
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to save media.');
expect(result.current.saving).toBe(false);
});
it('handles download errors gracefully', async () => {
(FileSystem.downloadAsync as jest.Mock).mockRejectedValue(new Error('Download failed'));
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('https://example.com/image.jpg', 'image');
});
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to save media.');
expect(result.current.saving).toBe(false);
});
it('sets saving to true during operation', async () => {
let savingDuringOperation = false;
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
// This would capture the saving state during the async operation
// but since we can't access result.current here synchronously,
// we'll test this differently
return { id: 'asset1' };
});
const { result } = renderHook(() => useMediaSaver());
const savePromise = act(async () => {
// Check saving is true after calling but before completion
const promise = result.current.saveMedia('file:///local/image.jpg', 'image');
// Can't reliably check intermediate state, so we just verify final state
await promise;
});
await savePromise;
expect(result.current.saving).toBe(false);
});
it('does not delete local files after saving', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(FileSystem.deleteAsync).not.toHaveBeenCalled();
});
it('cleans up downloaded file after saving remote URL', async () => {
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
uri: 'file:///cache/download_123.jpg',
});
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('https://example.com/image.jpg', 'image');
});
expect(FileSystem.deleteAsync).toHaveBeenCalledWith(
'file:///cache/download_123.jpg',
{ idempotent: true }
);
});
});
@@ -11,6 +11,9 @@ function createThenableQuery(result: any) {
in: jest.fn(() => query),
order: jest.fn(() => query),
range: jest.fn(() => query),
update: jest.fn(() => query),
insert: jest.fn(() => query),
delete: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
@@ -19,31 +22,464 @@ function createThenableQuery(result: any) {
describe('matchStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
useMatchStore.setState({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isLoadingMore: false,
page: 0,
hasMore: true,
error: null,
archivedMatches: [],
archivedMatchIds: new Set<string>(),
showArchived: false,
isLoadingArchived: false,
});
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
it('sorts matches with unread messages first', async () => {
const matches = [
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
];
describe('fetchMatches', () => {
it('sorts matches with unread messages first', async () => {
const matches = [
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
];
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 2 });
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(matchesQuery) // matches
.mockReturnValueOnce(responsesQuery) // responses
.mockReturnValueOnce(unreadQuery); // messages
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery) // archived matches
.mockReturnValueOnce(countQuery) // count
.mockReturnValueOnce(matchesQuery) // matches
.mockReturnValueOnce(responsesQuery) // responses
.mockReturnValueOnce(unreadQuery); // messages
await useMatchStore.getState().fetchMatches();
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('a');
expect(state.newMatchesCount).toBe(1);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('a');
expect(state.newMatchesCount).toBe(1);
});
it('sets empty state when user has no couple', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.newMatchesCount).toBe(0);
expect(state.totalCount).toBe(0);
expect(supabase.from).not.toHaveBeenCalled();
});
it('prevents concurrent loading', async () => {
useMatchStore.setState({ isLoading: true });
await useMatchStore.getState().fetchMatches(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('filters out archived matches', async () => {
const matches = [
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: false },
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
];
const archivesQuery = createThenableQuery({ data: [{ match_id: 'a' }] });
const countQuery = createThenableQuery({ count: 2 });
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: [], error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(unreadQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('b');
// Total count should subtract archived
expect(state.totalCount).toBe(1);
});
it('handles empty matches correctly', async () => {
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 0 });
const matchesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.hasMore).toBe(false);
expect(state.isLoading).toBe(false);
});
it('handles fetch error gracefully', async () => {
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 0 });
// Use createThenableQuery with error data to simulate error response
const matchesQuery = createThenableQuery({ data: null, error: { message: 'Network error' } });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.error).toBe('Failed to load matches');
expect(state.isLoading).toBe(false);
});
it('paginates matches correctly', async () => {
useMatchStore.setState({
matches: [{ id: 'existing' }] as any,
page: 1,
hasMore: true,
});
const newMatches = [
{ id: 'c', question_id: 'q3', created_at: '2024-01-01T00:00:00.000Z', is_new: false },
];
const matchesQuery = createThenableQuery({ data: newMatches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(unreadQuery);
await useMatchStore.getState().fetchMatches(false);
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(2);
expect(state.page).toBe(2);
});
});
describe('markAsSeen', () => {
it('marks a match as seen and updates count', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1', is_new: true } as any,
{ id: 'm2', is_new: true } as any,
],
newMatchesCount: 2,
});
const updateQuery = createThenableQuery({});
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
await useMatchStore.getState().markAsSeen('m1');
const state = useMatchStore.getState();
expect(state.matches[0].is_new).toBe(false);
expect(state.matches[1].is_new).toBe(true);
expect(state.newMatchesCount).toBe(1);
expect(updateQuery.update).toHaveBeenCalledWith({ is_new: false });
});
});
describe('markAllAsSeen', () => {
it('marks all matches as seen', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1', is_new: true } as any,
{ id: 'm2', is_new: true } as any,
],
newMatchesCount: 2,
});
const updateQuery = createThenableQuery({});
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
await useMatchStore.getState().markAllAsSeen();
const state = useMatchStore.getState();
expect(state.matches.every(m => !m.is_new)).toBe(true);
expect(state.newMatchesCount).toBe(0);
});
it('does nothing when no new matches', async () => {
useMatchStore.setState({
matches: [{ id: 'm1', is_new: false } as any],
newMatchesCount: 0,
});
await useMatchStore.getState().markAllAsSeen();
expect(supabase.from).not.toHaveBeenCalled();
});
});
describe('addMatch', () => {
it('adds a new match to the beginning', () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
newMatchesCount: 0,
totalCount: 1,
});
useMatchStore.getState().addMatch({ id: 'm2', is_new: true } as any);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('m2');
expect(state.matches).toHaveLength(2);
expect(state.newMatchesCount).toBe(1);
expect(state.totalCount).toBe(2);
});
});
describe('updateMatchUnreadCount', () => {
it('increments unread count', () => {
useMatchStore.setState({
matches: [{ id: 'm1', unreadCount: 1 } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', 2);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(3);
});
it('decrements unread count but not below zero', () => {
useMatchStore.setState({
matches: [{ id: 'm1', unreadCount: 1 } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', -5);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(0);
});
it('handles missing unreadCount', () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', 1);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(1);
});
});
describe('clearMatches', () => {
it('resets all state to initial values', () => {
useMatchStore.setState({
matches: [{ id: 'm1' }] as any,
newMatchesCount: 5,
totalCount: 10,
isLoading: true,
error: 'some error',
page: 3,
hasMore: false,
isLoadingMore: true,
archivedMatches: [{ id: 'a1' }] as any,
archivedMatchIds: new Set(['a1']),
showArchived: true,
isLoadingArchived: true,
});
useMatchStore.getState().clearMatches();
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.newMatchesCount).toBe(0);
expect(state.totalCount).toBeNull();
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
expect(state.page).toBe(0);
expect(state.hasMore).toBe(true);
expect(state.isLoadingMore).toBe(false);
expect(state.archivedMatches).toEqual([]);
expect(state.archivedMatchIds.size).toBe(0);
expect(state.showArchived).toBe(false);
expect(state.isLoadingArchived).toBe(false);
});
});
describe('archiveMatch', () => {
it('archives a match with optimistic update', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1' } as any,
{ id: 'm2' } as any,
],
archivedMatches: [],
archivedMatchIds: new Set(),
totalCount: 2,
});
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
await useMatchStore.getState().archiveMatch('m1');
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('m2');
expect(state.archivedMatches).toHaveLength(1);
expect(state.archivedMatches[0].id).toBe('m1');
expect(state.archivedMatchIds.has('m1')).toBe(true);
expect(state.totalCount).toBe(1);
});
it('does nothing if user not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
await useMatchStore.getState().archiveMatch('m1');
expect(supabase.from).not.toHaveBeenCalled();
});
it('reverts on error', async () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
archivedMatches: [],
archivedMatchIds: new Set(),
totalCount: 1,
});
const insertQuery = createThenableQuery({ error: new Error('DB error') });
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
await useMatchStore.getState().archiveMatch('m1');
const state = useMatchStore.getState();
// Should revert
expect(state.matches).toHaveLength(1);
expect(state.archivedMatchIds.has('m1')).toBe(false);
});
});
describe('unarchiveMatch', () => {
it('unarchives a match with optimistic update', async () => {
useMatchStore.setState({
matches: [],
archivedMatches: [{ id: 'm1' } as any],
archivedMatchIds: new Set(['m1']),
totalCount: 0,
});
const deleteQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock).mockReturnValue(deleteQuery);
await useMatchStore.getState().unarchiveMatch('m1');
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('m1');
expect(state.archivedMatches).toHaveLength(0);
expect(state.archivedMatchIds.has('m1')).toBe(false);
expect(state.totalCount).toBe(1);
});
it('does nothing if user not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
await useMatchStore.getState().unarchiveMatch('m1');
expect(supabase.from).not.toHaveBeenCalled();
});
});
describe('fetchArchivedMatches', () => {
it('fetches archived matches', async () => {
const archivesQuery = createThenableQuery({ data: [{ match_id: 'm1' }] });
const matchesQuery = createThenableQuery({
data: [{ id: 'm1', question_id: 'q1', created_at: '2024-01-01' }],
});
const unreadQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(unreadQuery);
await useMatchStore.getState().fetchArchivedMatches();
const state = useMatchStore.getState();
expect(state.archivedMatches).toHaveLength(1);
expect(state.archivedMatches[0].id).toBe('m1');
expect(state.isLoadingArchived).toBe(false);
});
it('does nothing if no couple', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useMatchStore.getState().fetchArchivedMatches();
expect(supabase.from).not.toHaveBeenCalled();
});
it('handles empty archives', async () => {
const archivesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
await useMatchStore.getState().fetchArchivedMatches();
const state = useMatchStore.getState();
expect(state.archivedMatches).toEqual([]);
expect(state.isLoadingArchived).toBe(false);
});
});
describe('toggleShowArchived', () => {
it('toggles showArchived state', () => {
expect(useMatchStore.getState().showArchived).toBe(false);
useMatchStore.getState().toggleShowArchived();
expect(useMatchStore.getState().showArchived).toBe(true);
useMatchStore.getState().toggleShowArchived();
expect(useMatchStore.getState().showArchived).toBe(false);
});
it('fetches archived matches when toggling to show', async () => {
const archivesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
useMatchStore.getState().toggleShowArchived();
// Should trigger fetch
expect(supabase.from).toHaveBeenCalled();
});
});
describe('isMatchArchived', () => {
it('returns true for archived matches', () => {
useMatchStore.setState({
archivedMatchIds: new Set(['m1', 'm2']),
});
expect(useMatchStore.getState().isMatchArchived('m1')).toBe(true);
expect(useMatchStore.getState().isMatchArchived('m2')).toBe(true);
expect(useMatchStore.getState().isMatchArchived('m3')).toBe(false);
});
});
});
@@ -0,0 +1,499 @@
import { useResponsesStore, groupResponses, ResponseWithQuestion } from '@/store/responsesStore';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { supabase } from '@/lib/supabase';
// Mock authErrorHandler
jest.mock('@/lib/authErrorHandler', () => ({
invokeWithAuthRetry: jest.fn(),
}));
import { invokeWithAuthRetry } from '@/lib/authErrorHandler';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
neq: jest.fn(() => query),
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
range: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('responsesStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useResponsesStore.setState({
responses: [],
isLoading: false,
isLoadingMore: false,
groupBy: 'date',
dateSortOrder: 'newest',
hasMore: true,
page: 0,
totalCount: null,
});
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
useMatchStore.setState({ matches: [], fetchMatches: jest.fn() } as any);
});
describe('fetchResponses', () => {
it('fetches responses with questions and packs on refresh', async () => {
const responses = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-01T00:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
partner_text: null,
intensity: 2,
pack_id: 'p1',
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
},
},
];
const countQuery = createThenableQuery({ count: 1 });
const responsesQuery = createThenableQuery({ data: responses });
const matchesQuery = createThenableQuery({ data: [{ id: 'm1', question_id: 'q1' }] });
const partnerQuery = createThenableQuery({ data: [{ question_id: 'q1' }] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(partnerQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.responses).toHaveLength(1);
expect(state.responses[0].has_match).toBe(true);
expect(state.responses[0].match_id).toBe('m1');
expect(state.responses[0].partner_answered).toBe(true);
expect(state.totalCount).toBe(1);
expect(state.isLoading).toBe(false);
});
it('does nothing if user is not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('does nothing if couple_id is missing', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('prevents concurrent loading', async () => {
useResponsesStore.setState({ isLoading: true });
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('handles empty responses correctly', async () => {
const countQuery = createThenableQuery({ count: 0 });
const responsesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.responses).toEqual([]);
expect(state.hasMore).toBe(false);
expect(state.totalCount).toBe(0);
});
it('paginates responses correctly', async () => {
// Set up initial state with some responses
useResponsesStore.setState({
page: 1,
hasMore: true,
responses: [{ id: 'r0' }] as any,
});
const responses = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-01T00:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
},
},
];
const responsesQuery = createThenableQuery({ data: responses });
const matchesQuery = createThenableQuery({ data: [] });
const partnerQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(partnerQuery);
await useResponsesStore.getState().fetchResponses(false);
const state = useResponsesStore.getState();
// Should append to existing responses
expect(state.responses).toHaveLength(2);
expect(state.page).toBe(2);
});
it('handles fetch errors gracefully', async () => {
const countQuery = createThenableQuery({ count: 1 });
const responsesQuery = createThenableQuery({ data: null, error: new Error('Network error') });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.isLoading).toBe(false);
expect(state.isLoadingMore).toBe(false);
});
});
describe('updateResponse', () => {
it('updates response and refreshes matches on success', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'maybe', has_match: false } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, new_match: { id: 'm1' } },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(result.success).toBe(true);
expect(result.new_match).toEqual({ id: 'm1' });
const state = useResponsesStore.getState();
expect(state.responses[0].answer).toBe('yes');
expect(state.responses[0].has_match).toBe(true);
expect(state.responses[0].match_id).toBe('m1');
expect(fetchMatchesMock).toHaveBeenCalled();
});
it('handles match deletion correctly', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'yes', has_match: true, match_id: 'm1' } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, match_deleted: true },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'no', true);
expect(result.success).toBe(true);
expect(result.match_deleted).toBe(true);
const state = useResponsesStore.getState();
expect(state.responses[0].answer).toBe('no');
expect(state.responses[0].has_match).toBe(false);
expect(state.responses[0].match_id).toBeUndefined();
expect(fetchMatchesMock).toHaveBeenCalled();
});
it('returns requires_confirmation without updating local state', async () => {
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'yes', has_match: true } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: false, requires_confirmation: true, message_count: 5 },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
expect(result.requires_confirmation).toBe(true);
expect(result.message_count).toBe(5);
const state = useResponsesStore.getState();
// Should not update local state
expect(state.responses[0].answer).toBe('yes');
});
it('handles errors from edge function', async () => {
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: null,
error: { message: 'Server error' },
});
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(result.success).toBe(false);
expect(result.error).toBe('Server error');
});
it('refreshes matches when match_type_updated', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'maybe', has_match: true, match_id: 'm1' } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, match_type_updated: true },
error: null,
});
await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(fetchMatchesMock).toHaveBeenCalled();
});
});
describe('setGroupBy', () => {
it('updates groupBy option', () => {
useResponsesStore.getState().setGroupBy('pack');
expect(useResponsesStore.getState().groupBy).toBe('pack');
useResponsesStore.getState().setGroupBy('answer');
expect(useResponsesStore.getState().groupBy).toBe('answer');
useResponsesStore.getState().setGroupBy('date');
expect(useResponsesStore.getState().groupBy).toBe('date');
});
});
describe('setDateSortOrder', () => {
it('updates date sort order', () => {
useResponsesStore.getState().setDateSortOrder('oldest');
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
useResponsesStore.getState().setDateSortOrder('newest');
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
});
});
describe('toggleDateSortOrder', () => {
it('toggles between newest and oldest', () => {
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
useResponsesStore.getState().toggleDateSortOrder();
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
useResponsesStore.getState().toggleDateSortOrder();
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
});
});
describe('clearResponses', () => {
it('resets store to initial state', () => {
useResponsesStore.setState({
responses: [{ id: 'r1' } as any],
isLoading: true,
groupBy: 'pack',
dateSortOrder: 'oldest',
page: 5,
hasMore: false,
isLoadingMore: true,
totalCount: 10,
});
useResponsesStore.getState().clearResponses();
const state = useResponsesStore.getState();
expect(state.responses).toEqual([]);
expect(state.isLoading).toBe(false);
expect(state.groupBy).toBe('date');
expect(state.dateSortOrder).toBe('newest');
expect(state.page).toBe(0);
expect(state.hasMore).toBe(true);
expect(state.isLoadingMore).toBe(false);
expect(state.totalCount).toBeNull();
});
});
});
describe('groupResponses', () => {
const mockResponses: ResponseWithQuestion[] = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-15T10:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
partner_text: null,
intensity: 2,
pack_id: 'p1',
created_at: '',
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
},
has_match: true,
partner_answered: true,
},
{
id: 'r2',
question_id: 'q2',
answer: 'maybe',
created_at: '2024-01-15T14:00:00.000Z',
question: {
id: 'q2',
text: 'Question 2',
partner_text: null,
intensity: 3,
pack_id: 'p2',
created_at: '',
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
},
has_match: false,
partner_answered: false,
},
{
id: 'r3',
question_id: 'q3',
answer: 'no',
created_at: '2024-01-14T10:00:00.000Z',
question: {
id: 'q3',
text: 'Question 3',
partner_text: null,
intensity: 1,
pack_id: 'p1',
created_at: '',
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
},
has_match: false,
partner_answered: true,
},
{
id: 'r4',
question_id: 'q4',
answer: 'yes',
created_at: '2024-01-14T14:00:00.000Z',
question: {
id: 'q4',
text: 'Question 4',
partner_text: null,
intensity: 2,
pack_id: 'p2',
created_at: '',
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
},
has_match: true,
partner_answered: true,
},
];
describe('groupBy pack', () => {
it('groups responses by pack name', () => {
const grouped = groupResponses(mockResponses, 'pack');
expect(grouped).toHaveLength(2);
const packA = grouped.find(g => g.title === 'Pack A');
const packB = grouped.find(g => g.title === 'Pack B');
expect(packA?.data).toHaveLength(2);
expect(packB?.data).toHaveLength(2);
});
});
describe('groupBy answer', () => {
it('groups responses by answer type in order: yes, maybe, no', () => {
const grouped = groupResponses(mockResponses, 'answer');
expect(grouped).toHaveLength(3);
expect(grouped[0].title).toBe('Yes');
expect(grouped[0].data).toHaveLength(2);
expect(grouped[1].title).toBe('Maybe');
expect(grouped[1].data).toHaveLength(1);
expect(grouped[2].title).toBe('No');
expect(grouped[2].data).toHaveLength(1);
});
it('excludes empty answer groups', () => {
const yesOnlyResponses = mockResponses.filter(r => r.answer === 'yes');
const grouped = groupResponses(yesOnlyResponses, 'answer');
expect(grouped).toHaveLength(1);
expect(grouped[0].title).toBe('Yes');
});
});
describe('groupBy date', () => {
it('groups responses by date with newest first', () => {
const grouped = groupResponses(mockResponses, 'date', 'newest');
expect(grouped).toHaveLength(2);
// January 15 should be first (newer)
expect(grouped[0].title).toContain('January 15');
expect(grouped[0].data).toHaveLength(2);
expect(grouped[1].title).toContain('January 14');
expect(grouped[1].data).toHaveLength(2);
});
it('groups responses by date with oldest first', () => {
const grouped = groupResponses(mockResponses, 'date', 'oldest');
expect(grouped).toHaveLength(2);
// January 14 should be first (older)
expect(grouped[0].title).toContain('January 14');
expect(grouped[1].title).toContain('January 15');
});
it('sorts responses within each date group', () => {
const grouped = groupResponses(mockResponses, 'date', 'newest');
// Within January 15, the response at 14:00 should come first (newest)
const jan15Group = grouped[0];
expect(new Date(jan15Group.data[0].created_at).getTime())
.toBeGreaterThan(new Date(jan15Group.data[1].created_at).getTime());
});
});
describe('default grouping', () => {
it('returns all responses in a single group for unknown groupBy', () => {
// @ts-expect-error Testing unknown groupBy value
const grouped = groupResponses(mockResponses, 'unknown');
expect(grouped).toHaveLength(1);
expect(grouped[0].title).toBe('All Responses');
expect(grouped[0].data).toHaveLength(4);
});
});
});
@@ -1,11 +1,12 @@
jest.mock('../../lib/revenuecat', () => {
const service = {
jest.mock('../../lib/revenuecat', () => ({
__esModule: true,
default: {
isAvailable: jest.fn(() => true),
isInitialized: jest.fn(() => true),
initialize: jest.fn(async () => {}),
login: jest.fn(async () => null),
logout: jest.fn(async () => {}),
addCustomerInfoUpdateListener: jest.fn(() => null),
addCustomerInfoUpdateListener: jest.fn(() => () => {}),
getOfferingsDebug: jest.fn(async () => ({
current: { identifier: 'default', availablePackages: [{ identifier: 'monthly', packageType: 'MONTHLY', product: { price: 1, priceString: '$1', currencyCode: 'USD' } }] },
availableOfferings: ['default'],
@@ -15,24 +16,33 @@ jest.mock('../../lib/revenuecat', () => {
restorePurchases: jest.fn(async () => ({ entitlements: { active: {} } })),
getCustomerInfo: jest.fn(async () => ({ entitlements: { active: {} } })),
parseCustomerInfo: jest.fn(() => ({ isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false })),
};
return {
__esModule: true,
default: service,
revenueCatService: service,
SubscriptionState: {},
PurchasesPackage: {},
PurchasesOffering: {},
};
});
},
get revenueCatService() {
return this.default;
},
SubscriptionState: {},
PurchasesPackage: {},
PurchasesOffering: {},
}));
import { useSubscriptionStore } from '@/store/subscriptionStore';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
import revenueCatService from '../../lib/revenuecat';
describe('subscriptionStore', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset mock implementations to defaults
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(true);
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(true);
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: false,
activeSubscription: null,
expirationDate: null,
willRenew: false,
});
useSubscriptionStore.setState({
subscription: { isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false },
offerings: null,
@@ -40,24 +50,236 @@ describe('subscriptionStore', () => {
isPurchasing: false,
error: null,
} as any);
useAuthStore.setState({ user: { id: 'me', is_premium: false } } as any);
useAuthStore.setState({
user: { id: 'me', is_premium: false },
fetchUser: jest.fn(),
} as any);
});
it('fetchOfferings stores offering on success', async () => {
await useSubscriptionStore.getState().fetchOfferings();
describe('initializeRevenueCat', () => {
it('skips initialization if RevenueCat not available', async () => {
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(false);
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeTruthy();
expect(state.isLoadingOfferings).toBe(false);
expect(state.error).toBeNull();
await useSubscriptionStore.getState().initializeRevenueCat('user1');
expect(revenueCatService.initialize).not.toHaveBeenCalled();
});
it('initializes and logs in when available', async () => {
await useSubscriptionStore.getState().initializeRevenueCat('user1');
expect(revenueCatService.initialize).toHaveBeenCalledWith('user1');
expect(revenueCatService.login).toHaveBeenCalledWith('user1');
expect(revenueCatService.addCustomerInfoUpdateListener).toHaveBeenCalled();
});
it('handles initialization errors gracefully', async () => {
(revenueCatService.initialize as jest.Mock).mockRejectedValueOnce(new Error('Init failed'));
// Should not throw
await useSubscriptionStore.getState().initializeRevenueCat('user1');
// Error is logged but not set in state
expect(useSubscriptionStore.getState().error).toBeNull();
});
});
it('clearSubscription resets state', () => {
useSubscriptionStore.setState({ offerings: { identifier: 'x', availablePackages: [] } } as any);
useSubscriptionStore.getState().clearSubscription();
describe('fetchOfferings', () => {
it('stores offering on success', async () => {
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeNull();
expect(state.subscription.isProUser).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeTruthy();
expect(state.isLoadingOfferings).toBe(false);
expect(state.error).toBeNull();
});
it('sets error if RevenueCat not initialized', async () => {
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(false);
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Subscriptions not available');
expect(state.offerings).toBeNull();
expect(state.isLoadingOfferings).toBe(false);
});
it('handles offering fetch error', async () => {
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
error: 'Failed to fetch',
});
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Failed to fetch');
expect(state.offerings).toBeNull();
});
it('handles no current offering', async () => {
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
current: null,
availableOfferings: ['test'],
});
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toContain('No current offering');
});
it('handles empty packages', async () => {
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
current: { identifier: 'default', availablePackages: [] },
});
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toContain('No packages in offering');
});
it('handles exception during fetch', async () => {
(revenueCatService.getOfferingsDebug as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toContain('Error: Network error');
expect(state.isLoadingOfferings).toBe(false);
});
});
describe('purchasePackage', () => {
it('purchases package successfully', async () => {
const mockPackage = { identifier: 'monthly' };
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'monthly',
expirationDate: '2025-01-01',
willRenew: true,
});
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(true);
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
expect(state.isPurchasing).toBe(false);
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
});
it('handles cancelled purchase', async () => {
const mockPackage = { identifier: 'monthly' };
(revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce(
new Error('Purchase cancelled')
);
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Purchase was cancelled');
expect(state.isPurchasing).toBe(false);
});
it('handles other purchase errors', async () => {
const mockPackage = { identifier: 'monthly' };
(revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce(
new Error('Payment failed')
);
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Purchase failed. Please try again.');
});
});
describe('restorePurchases', () => {
it('restores purchases successfully when user has pro', async () => {
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'yearly',
expirationDate: '2025-12-01',
willRenew: true,
});
const result = await useSubscriptionStore.getState().restorePurchases();
expect(result).toBe(true);
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
expect(state.isPurchasing).toBe(false);
});
it('returns false when no purchases to restore', async () => {
const result = await useSubscriptionStore.getState().restorePurchases();
expect(result).toBe(false);
});
it('handles restore error', async () => {
(revenueCatService.restorePurchases as jest.Mock).mockRejectedValueOnce(
new Error('Restore failed')
);
const result = await useSubscriptionStore.getState().restorePurchases();
expect(result).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Failed to restore purchases');
});
});
describe('refreshSubscriptionStatus', () => {
it('refreshes subscription status', async () => {
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'monthly',
expirationDate: '2025-01-01',
willRenew: true,
});
await useSubscriptionStore.getState().refreshSubscriptionStatus();
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
});
it('handles refresh errors gracefully', async () => {
(revenueCatService.getCustomerInfo as jest.Mock).mockRejectedValueOnce(
new Error('Network error')
);
// Should not throw
await useSubscriptionStore.getState().refreshSubscriptionStatus();
});
});
describe('clearSubscription', () => {
it('resets state and logs out', () => {
useSubscriptionStore.setState({
subscription: {
isProUser: true,
activeSubscription: 'monthly',
expirationDate: new Date('2025-01-01'),
willRenew: true,
},
offerings: { identifier: 'x', availablePackages: [] } as any,
error: 'some error',
});
useSubscriptionStore.getState().clearSubscription();
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeNull();
expect(state.subscription.isProUser).toBe(false);
expect(state.subscription.activeSubscription).toBeNull();
expect(state.error).toBeNull();
expect(revenueCatService.logout).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,526 @@
/**
* MatchNotificationModal - Premium celebration modal for new matches
*
* Features:
* - Glass-morphism styling following boutique design aesthetic
* - Animated floating hearts celebration effect
* - Shows what each partner answered (You / Partner)
* - Uses brand gradients and premium styling
*/
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
Pressable,
Animated,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius, typography, shadows } from '../theme';
import { DecorativeSeparator } from './ui';
import type { MatchType, AnswerType } from '../types';
export interface MatchNotificationData {
id: string;
match_type: MatchType;
question: {
id: string;
text: string;
partner_text?: string | null;
};
userResponse?: AnswerType;
partnerResponse?: AnswerType;
partnerName?: string | null;
}
interface Props {
visible: boolean;
match: MatchNotificationData | null;
onDismiss: () => void;
onChat: () => void;
}
// Helper to get response styling
const getResponseStyle = (response: AnswerType, isPartner: boolean = false) => {
switch (response) {
case 'yes':
return {
bg: isPartner ? 'rgba(46, 204, 113, 0.15)' : 'rgba(233, 69, 96, 0.15)',
border: isPartner ? 'rgba(46, 204, 113, 0.3)' : 'rgba(233, 69, 96, 0.3)',
color: isPartner ? colors.success : colors.primary,
text: 'YES',
icon: 'heart' as const,
};
case 'maybe':
return {
bg: 'rgba(243, 156, 18, 0.15)',
border: 'rgba(243, 156, 18, 0.3)',
color: colors.warning,
text: 'MAYBE',
icon: 'help-circle' as const,
};
default:
return {
bg: 'rgba(255, 255, 255, 0.1)',
border: 'rgba(255, 255, 255, 0.2)',
color: colors.textSecondary,
text: response.toUpperCase(),
icon: 'ellipse' as const,
};
}
};
// Compact response badge (for two-part questions)
const ResponseBadge: React.FC<{
response: AnswerType;
isPartner?: boolean;
}> = ({ response, isPartner = false }) => {
const style = getResponseStyle(response, isPartner);
return (
<View
style={[
styles.responseBadgeCompact,
{ backgroundColor: style.bg, borderColor: style.border },
]}
>
<Ionicons name={style.icon} size={14} color={style.color} />
<Text style={[styles.responseBadgeTextCompact, { color: style.color }]}>
{style.text}
</Text>
</View>
);
};
export const MatchNotificationModal: React.FC<Props> = ({
visible,
match,
onDismiss,
onChat,
}) => {
const modalScale = useRef(new Animated.Value(0.8)).current;
const modalOpacity = useRef(new Animated.Value(0)).current;
const overlayOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
// Reset animations
modalScale.setValue(0.8);
modalOpacity.setValue(0);
overlayOpacity.setValue(0);
// Animate in
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
Animated.spring(modalScale, {
toValue: 1,
tension: 80,
friction: 10,
useNativeDriver: true,
}),
Animated.timing(modalOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
}
}, [visible]);
const handleDismiss = () => {
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(modalScale, {
toValue: 0.8,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(modalOpacity, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start(() => onDismiss());
};
const handleChat = () => {
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(modalScale, {
toValue: 0.8,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(modalOpacity, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start(() => onChat());
};
if (!visible || !match) return null;
// Determine responses based on match_type if not explicitly provided
const getUserResponse = (): AnswerType => {
if (match.userResponse) return match.userResponse;
// Infer from match_type (user typically triggers the match)
if (match.match_type === 'yes_yes') return 'yes';
if (match.match_type === 'maybe_maybe') return 'maybe';
// yes_maybe - user could be either
return 'yes';
};
const getPartnerResponse = (): AnswerType => {
if (match.partnerResponse) return match.partnerResponse;
// Infer from match_type
if (match.match_type === 'yes_yes') return 'yes';
if (match.match_type === 'maybe_maybe') return 'maybe';
// yes_maybe - partner could be either
return 'maybe';
};
const userResponse = getUserResponse();
const partnerResponse = getPartnerResponse();
// Get match type styling
const getMatchTypeLabel = () => {
switch (match.match_type) {
case 'yes_yes':
return { text: 'Perfect Match', color: colors.primary };
case 'yes_maybe':
return { text: 'Curious Match', color: colors.warning };
case 'maybe_maybe':
return { text: 'Exploring Together', color: colors.premium.rose };
default:
return { text: 'Match', color: colors.primary };
}
};
const matchTypeInfo = getMatchTypeLabel();
// Use partner's name or fallback to "Partner"
const partnerDisplayName = match.partnerName || 'Partner';
return (
<Animated.View
style={[styles.overlay, { opacity: overlayOpacity }]}
>
{/* Background press to dismiss */}
<Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} />
{/* Modal card with glow */}
<Animated.View
style={[
styles.glowContainer,
{
transform: [{ scale: modalScale }],
opacity: modalOpacity,
},
]}
>
{/* Outer glow effect */}
<View style={styles.glowOuter} />
<View style={styles.modalContainer}>
{/* Glass background */}
<BlurView intensity={40} tint="dark" style={styles.blurContainer}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={styles.gradientOverlay}
/>
</BlurView>
{/* Content */}
<View style={styles.content}>
{/* Title section */}
<Text style={styles.title}>It's a Match!</Text>
{/* Match type badge */}
<View style={[styles.matchTypeBadge, { borderColor: `${matchTypeInfo.color}30` }]}>
<Text style={[styles.matchTypeText, { color: matchTypeInfo.color }]}>
{matchTypeInfo.text}
</Text>
</View>
<DecorativeSeparator variant="muted" width={80} marginVertical={spacing.md} />
{/* Question text and responses */}
{match.question.partner_text ? (
// Two-part question - show what each person saw
<View style={styles.twoPartContainer}>
<View style={styles.responseSection}>
<View style={styles.responseLabelRow}>
<Text style={styles.responsePersonLabel}>You said</Text>
<ResponseBadge response={userResponse} />
</View>
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
</View>
<View style={styles.matchConnector}>
<View style={styles.connectorLine} />
<View style={styles.connectorHeart}>
<Ionicons name="heart" size={16} color={colors.primary} />
</View>
<View style={styles.connectorLine} />
</View>
<View style={styles.responseSection}>
<View style={styles.responseLabelRow}>
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
<ResponseBadge response={partnerResponse} isPartner />
</View>
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
</View>
</View>
) : (
// Single question - show shared text with inline responses
<View style={styles.questionContainer}>
<Text style={styles.questionText}>"{match.question.text}"</Text>
<View style={styles.responsesInline}>
<Text style={styles.responseInlineText}>You said </Text>
<Text style={[styles.responseInlineAnswer, { color: userResponse === 'yes' ? colors.primary : colors.warning }]}>
{userResponse.toUpperCase()}
</Text>
<Text style={styles.responseInlineText}> · {partnerDisplayName} said </Text>
<Text style={[styles.responseInlineAnswer, { color: partnerResponse === 'yes' ? colors.success : colors.warning }]}>
{partnerResponse.toUpperCase()}
</Text>
</View>
</View>
)}
<DecorativeSeparator variant="muted" width={100} marginVertical={spacing.md} />
{/* Action buttons */}
<View style={styles.buttonRow}>
<Pressable onPress={handleChat} style={styles.chatButton}>
<LinearGradient
colors={gradients.primary as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.chatButtonGradient}
>
<Ionicons name="chatbubble" size={16} color={colors.text} />
<Text style={styles.chatButtonText}>Chat</Text>
</LinearGradient>
</Pressable>
<Pressable onPress={handleDismiss} style={styles.dismissButton}>
<Text style={styles.dismissButtonText}>Continue</Text>
</Pressable>
</View>
</View>
{/* Border */}
<View style={styles.border} pointerEvents="none" />
</View>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
},
glowContainer: {
width: '88%',
maxWidth: 360,
alignItems: 'center',
justifyContent: 'center',
},
glowOuter: {
position: 'absolute',
top: -3,
left: -3,
right: -3,
bottom: -3,
borderRadius: radius.xl + 3,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.15)',
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
},
modalContainer: {
width: '100%',
borderRadius: radius.xl,
overflow: 'hidden',
},
blurContainer: {
...StyleSheet.absoluteFillObject,
},
gradientOverlay: {
...StyleSheet.absoluteFillObject,
},
content: {
padding: spacing.xl,
paddingTop: spacing.lg,
alignItems: 'center',
},
title: {
...typography.title2,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.sm,
},
matchTypeBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
marginBottom: spacing.lg,
},
matchTypeText: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 1,
},
questionContainer: {
alignItems: 'center',
},
questionText: {
...typography.body,
color: colors.text,
textAlign: 'center',
fontStyle: 'italic',
lineHeight: 24,
paddingHorizontal: spacing.sm,
marginBottom: spacing.md,
},
responsesInline: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
},
responseInlineText: {
...typography.caption1,
color: colors.textSecondary,
},
responseInlineAnswer: {
...typography.caption1,
fontWeight: '700',
},
responseBadgeCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.md,
borderWidth: 1,
},
responseBadgeTextCompact: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 0.5,
},
// Two-part question styles
twoPartContainer: {
width: '100%',
marginBottom: spacing.md,
},
responseSection: {
alignItems: 'center',
paddingHorizontal: spacing.sm,
},
responseLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.xs,
},
responsePersonLabel: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: '500',
},
questionTextSmall: {
...typography.subhead,
color: colors.text,
textAlign: 'center',
fontStyle: 'italic',
lineHeight: 20,
},
matchConnector: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: spacing.md,
paddingHorizontal: spacing.xl,
},
connectorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(233, 69, 96, 0.2)',
},
connectorHeart: {
marginHorizontal: spacing.sm,
opacity: 0.8,
},
buttonRow: {
flexDirection: 'row',
gap: spacing.md,
width: '100%',
},
chatButton: {
flex: 1,
borderRadius: radius.lg,
overflow: 'hidden',
...shadows.md,
},
chatButtonGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
},
chatButtonText: {
...typography.headline,
color: colors.text,
},
dismissButton: {
flex: 1,
borderRadius: radius.lg,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
paddingVertical: spacing.md,
alignItems: 'center',
justifyContent: 'center',
},
dismissButtonText: {
...typography.headline,
color: colors.textSecondary,
},
border: {
...StyleSheet.absoluteFillObject,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
},
});
@@ -0,0 +1,173 @@
import React from "react";
import { View, Text, StyleSheet, Platform } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, spacing, radius, typography } from "../../theme";
const SWIPE_THRESHOLD = 80;
const ACCENT = colors.premium.gold;
// Haptics helper
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
interface SwipeableMatchItemProps {
children: React.ReactNode;
onArchive: () => void;
isArchived?: boolean;
enabled?: boolean;
}
export function SwipeableMatchItem({
children,
onArchive,
isArchived = false,
enabled = true,
}: SwipeableMatchItemProps) {
const translateX = useSharedValue(0);
const hasTriggeredHaptic = useSharedValue(false);
const panGesture = Gesture.Pan()
.enabled(enabled)
.activeOffsetX([-20, 20])
.onUpdate((event) => {
// Only allow left swipe (negative X) for archive
translateX.value = Math.min(0, event.translationX);
// Trigger haptic when crossing threshold
if (translateX.value < -SWIPE_THRESHOLD && !hasTriggeredHaptic.value) {
hasTriggeredHaptic.value = true;
runOnJS(triggerHaptic)('medium');
} else if (translateX.value >= -SWIPE_THRESHOLD) {
hasTriggeredHaptic.value = false;
}
})
.onEnd(() => {
if (translateX.value < -SWIPE_THRESHOLD) {
// Animate out then trigger archive
translateX.value = withSpring(-200, { damping: 15 }, (finished) => {
if (finished) {
runOnJS(onArchive)();
}
});
} else {
// Spring back to center
translateX.value = withSpring(0, { damping: 15 });
}
hasTriggeredHaptic.value = false;
});
const containerStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const actionStyle = useAnimatedStyle(() => ({
opacity: interpolate(
translateX.value,
[-SWIPE_THRESHOLD, -20, 0],
[1, 0.5, 0],
Extrapolation.CLAMP
),
transform: [
{
scale: interpolate(
translateX.value,
[-SWIPE_THRESHOLD, 0],
[1, 0.8],
Extrapolation.CLAMP
),
},
],
}));
const actionIconStyle = useAnimatedStyle(() => ({
transform: [
{
translateX: interpolate(
translateX.value,
[-SWIPE_THRESHOLD * 1.5, -SWIPE_THRESHOLD, 0],
[0, 10, 30],
Extrapolation.CLAMP
),
},
],
}));
return (
<View style={styles.container}>
{/* Archive action revealed on swipe */}
<Animated.View style={[styles.actionContainer, actionStyle]}>
<Animated.View style={[styles.actionContent, actionIconStyle]}>
<View style={styles.iconContainer}>
<Ionicons
name={isArchived ? "arrow-undo" : "archive"}
size={22}
color={ACCENT}
/>
</View>
<Text style={styles.actionText}>
{isArchived ? "Restore" : "Archive"}
</Text>
</Animated.View>
</Animated.View>
<GestureDetector gesture={panGesture}>
<Animated.View style={containerStyle}>
{children}
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: "relative",
overflow: "hidden",
},
actionContainer: {
position: "absolute",
right: spacing.md,
top: 0,
bottom: 0,
justifyContent: "center",
alignItems: "flex-end",
paddingRight: spacing.sm,
},
actionContent: {
alignItems: "center",
gap: spacing.xs,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: `rgba(212, 175, 55, 0.15)`,
borderWidth: 1,
borderColor: `rgba(212, 175, 55, 0.3)`,
justifyContent: "center",
alignItems: "center",
},
actionText: {
...typography.caption2,
color: ACCENT,
fontWeight: "600",
letterSpacing: 0.5,
},
});
@@ -0,0 +1 @@
export { SwipeableMatchItem } from './SwipeableMatchItem';
+105 -172
View File
@@ -1,21 +1,18 @@
import { useState, useEffect, useCallback } from "react";
import { View, Text, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
import { Image } from "expo-image";
import { Video, ResizeMode } from "expo-av";
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useIsFocused } 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 Animated, { FadeInDown } from "react-native-reanimated";
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet } from "./components";
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet, ChatSettingsSheet, FullScreenImageModal, FullScreenVideoModal } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { useMessageActions } from "./hooks/useMessageActions";
import type { ReportReason } from "./types";
import type { ReportReason, Match } from "./types";
import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store";
import { useAuthStore, useMessageStore, useMatchStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
@@ -25,7 +22,6 @@ 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, ';
@@ -36,8 +32,10 @@ export const ChatScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null);
const { archiveMatch, unarchiveMatch, isMatchArchived } = useMatchStore();
const [match, setMatch] = useState<Match | null>(null);
const [inputText, setInputText] = useState("");
const [showSettingsSheet, setShowSettingsSheet] = useState(false);
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const { saveMedia, saving: mediaSaving } = useMediaSaver();
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
@@ -81,6 +79,62 @@ export const ChatScreen: React.FC = () => {
onReport: (message) => setReportingMessage(message),
});
// Memoized navigation callbacks
const handleBack = useCallback(() => {
router.push('/(app)/matches');
}, [router]);
const handleOpenSettings = useCallback(() => {
setShowSettingsSheet(true);
}, []);
const handleCloseSettings = useCallback(() => {
setShowSettingsSheet(false);
}, []);
// Memoized modal callbacks
const handleCloseImage = useCallback(() => {
setFullScreenImage(null);
}, []);
const handleSaveImage = useCallback(() => {
if (fullScreenImage) {
saveMedia(fullScreenImage, 'image');
}
}, [fullScreenImage, saveMedia]);
const handleImageLoadStart = useCallback(() => {
setFullScreenImageLoading(true);
}, []);
const handleImageLoadEnd = useCallback(() => {
setFullScreenImageLoading(false);
}, []);
const handleCloseVideo = useCallback(() => {
setFullScreenVideo(null);
}, []);
const handleSaveVideo = useCallback(() => {
if (fullScreenVideo) {
saveMedia(fullScreenVideo, 'video');
}
}, [fullScreenVideo, saveMedia]);
const handleCloseReport = useCallback(() => {
setReportingMessage(null);
}, []);
// Memoized archive/unarchive callbacks
const handleArchive = useCallback(async () => {
await archiveMatch(matchId);
router.push('/(app)/matches');
}, [archiveMatch, matchId, router]);
const handleUnarchive = useCallback(async () => {
await unarchiveMatch(matchId);
}, [unarchiveMatch, matchId]);
// Handle long press on message to show options
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
// Don't show options for already deleted messages
@@ -89,7 +143,7 @@ export const ChatScreen: React.FC = () => {
}, [showDeleteOptions]);
// Handle report submission
const handleSubmitReport = async (reason: ReportReason) => {
const handleSubmitReport = useCallback(async (reason: ReportReason) => {
if (!reportingMessage || !user?.id) return;
try {
@@ -127,7 +181,7 @@ export const ChatScreen: React.FC = () => {
Alert.alert('Error', 'Failed to submit report');
setReportingMessage(null);
}
};
}, [reportingMessage, user?.id, deleteForSelf, setMessages]);
// Track active chat to prevent notifications for current chat
useFocusEffect(
@@ -169,15 +223,15 @@ export const ChatScreen: React.FC = () => {
fetchMatch();
}, [matchId]);
const handleTyping = (text: string) => {
const handleTyping = useCallback((text: string) => {
setInputText(text);
if (text.length > 0) {
sendTypingEvent();
}
};
}, [sendTypingEvent]);
const handleSend = async () => {
const handleSend = useCallback(async () => {
if (!inputText.trim()) return;
if (!user) return;
@@ -204,30 +258,30 @@ export const ChatScreen: React.FC = () => {
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
setInputText(content);
}
};
}, [inputText, user, matchId]);
const handlePickMedia = async () => {
const handlePickMedia = useCallback(async () => {
const result = await pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
}
};
}, [pickMedia, uploadMedia]);
const handleTakePhoto = async () => {
const handleTakePhoto = useCallback(async () => {
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
}
};
}, [takePhoto, uploadMedia]);
const handleRecordVideo = async () => {
const handleRecordVideo = useCallback(async () => {
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
}
};
}, [recordVideo, uploadMedia]);
const revealMessage = async (messageId: string) => {
const revealMessage = useCallback(async (messageId: string) => {
const message = messages.find(m => m.id === messageId);
const now = new Date();
// Set expiration 30 days from now for videos only
@@ -250,7 +304,7 @@ export const ChatScreen: React.FC = () => {
media_expires_at: expiresAt
})
.eq("id", messageId);
};
}, [messages, setMessages]);
return (
<GradientBackground>
@@ -280,10 +334,9 @@ export const ChatScreen: React.FC = () => {
<ChatHeader
partner={partner}
user={user}
match={match}
insets={insets}
onBack={() => router.push('/(app)/matches')}
onBack={handleBack}
onSettingsPress={handleOpenSettings}
/>
@@ -327,124 +380,41 @@ export const ChatScreen: React.FC = () => {
</KeyboardAvoidingView>
{/* Full Screen Image Modal */}
<Modal
<FullScreenImageModal
uri={fullScreenImage}
visible={!!fullScreenImage}
transparent
animationType="fade"
onRequestClose={() => setFullScreenImage(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenSaveButton}
onPress={() => fullScreenImage && saveMedia(fullScreenImage, 'image')}
activeOpacity={0.8}
disabled={mediaSaving}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{mediaSaving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<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>
loading={fullScreenImageLoading}
saving={mediaSaving}
onClose={handleCloseImage}
onSave={handleSaveImage}
onLoadStart={handleImageLoadStart}
onLoadEnd={handleImageLoadEnd}
/>
{/* Full Screen Video Modal */}
<Modal
<FullScreenVideoModal
uri={fullScreenVideo}
visible={!!fullScreenVideo}
transparent
animationType="fade"
onRequestClose={() => setFullScreenVideo(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenSaveButton}
onPress={() => fullScreenVideo && saveMedia(fullScreenVideo, 'video')}
activeOpacity={0.8}
disabled={mediaSaving}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{mediaSaving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<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>
saving={mediaSaving}
onClose={handleCloseVideo}
onSave={handleSaveVideo}
/>
{/* Report Message Sheet */}
<ReportMessageSheet
visible={reportingMessage !== null}
onClose={() => setReportingMessage(null)}
onClose={handleCloseReport}
onSubmit={handleSubmitReport}
/>
{/* Chat Settings Sheet */}
<ChatSettingsSheet
visible={showSettingsSheet}
onClose={handleCloseSettings}
isArchived={isMatchArchived(matchId)}
onArchive={handleArchive}
onUnarchive={handleUnarchive}
/>
</GradientBackground>
);
};
@@ -473,43 +443,6 @@ const styles = StyleSheet.create({
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,
},
fullScreenSaveButton: {
position: "absolute",
top: 50,
left: 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',
},
@@ -15,16 +15,16 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface ChatHeaderProps {
partner: Profile | null;
user: { id: string } | null;
match: any; // Kept for compatibility but unused
insets: EdgeInsets;
onBack: () => void;
onSettingsPress?: () => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
const ChatHeaderComponent: React.FC<ChatHeaderProps> = ({
partner,
insets,
onBack,
onSettingsPress,
}) => {
return (
<Animated.View
@@ -50,6 +50,8 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onPress={onBack}
style={styles.backButton}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Go back to matches"
>
<View style={styles.backButtonInner}>
<Ionicons name="chevron-back" size={20} color={ACCENT} />
@@ -86,7 +88,21 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
</View>
</View>
<View style={styles.headerSpacer} />
{onSettingsPress ? (
<TouchableOpacity
onPress={onSettingsPress}
style={styles.settingsButton}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Chat settings"
>
<View style={styles.backButtonInner}>
<Ionicons name="ellipsis-horizontal" size={20} color={ACCENT} />
</View>
</TouchableOpacity>
) : (
<View style={styles.headerSpacer} />
)}
{/* Bottom border */}
<View style={styles.headerBorderBottom} />
@@ -94,6 +110,9 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
);
};
// Wrap with React.memo for performance
export const ChatHeader = React.memo(ChatHeaderComponent);
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
@@ -179,6 +198,12 @@ const styles = StyleSheet.create({
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerBorderBottom: {
position: 'absolute',
bottom: 0,
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useCallback, useMemo } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeIn } from 'react-native-reanimated';
@@ -12,14 +12,14 @@ import { MatchCard } from './MatchCard';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, spacing, typography } from '../../../theme';
import { Database } from '../../../types/supabase';
import { UploadStatus } from '../types';
import { UploadStatus, Match } from '../types';
type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps {
messages: Message[];
userId: string | undefined;
match: any;
match: Match | null;
uploadStatus: UploadStatus | null;
partnerTyping: boolean;
onImagePress: (uri: string) => void;
@@ -43,7 +43,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
);
}
export const ChatMessages: React.FC<ChatMessagesProps> = ({
const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
messages,
userId,
match,
@@ -56,7 +56,14 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
}) => {
const flatListRef = useRef<FlatList>(null);
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
// Memoize keyExtractor to prevent recreation
const keyExtractor = useCallback((item: Message) => item.id, []);
// Memoize user object for MatchCard to prevent re-renders
const userObject = useMemo(() => ({ id: userId || '' }), [userId]);
// Memoize renderMessage to prevent recreation on every render
const renderMessage = useCallback(({ item, index }: { item: Message; index: number }) => {
const isMe = item.user_id === userId;
const messageDate = new Date(item.created_at!);
@@ -70,7 +77,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
);
return (
<>
<View>
<MessageBubble
isMe={isMe}
index={index}
@@ -86,58 +93,68 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
/>
</MessageBubble>
{showDateSeparator && <DateSeparator date={messageDate} />}
</>
</View>
);
};
}, [messages, userId, onMessageLongPress, revealMessage, onImagePress, onVideoFullScreen]);
// Memoize ListHeaderComponent to prevent recreation
const listHeaderComponent = useMemo(() => (
<View>
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
{partnerTyping && <TypingIndicator />}
</View>
), [uploadStatus, partnerTyping]);
// Memoize ListFooterComponent to prevent recreation
const listFooterComponent = useMemo(() => (
<View style={styles.footerContainer}>
<MatchCard match={match} user={userObject} />
</View>
), [match, userObject]);
// Memoize ListEmptyComponent to prevent recreation
const listEmptyComponent = useMemo(() => (
<Animated.View
entering={FadeIn.delay(200).duration(400)}
style={styles.emptyChat}
>
<View style={styles.emptyIconContainer}>
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
</View>
<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>
), []);
return (
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
keyExtractor={keyExtractor}
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 />}
</>
}
ListFooterComponent={
<View style={{ paddingBottom: spacing.md }}>
<MatchCard match={match} user={{ id: userId || '' }} />
</View>
}
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>
}
// Performance optimizations
removeClippedSubviews={true}
maxToRenderPerBatch={15}
windowSize={10}
initialNumToRender={20}
ListHeaderComponent={listHeaderComponent}
ListFooterComponent={listFooterComponent}
ListEmptyComponent={listEmptyComponent}
/>
);
};
// Wrap with React.memo for performance
export const ChatMessages = React.memo(ChatMessagesComponent);
const styles = StyleSheet.create({
messageList: {
flex: 1,
@@ -146,6 +163,9 @@ const styles = StyleSheet.create({
flexGrow: 1,
padding: spacing.md,
},
footerContainer: {
paddingBottom: spacing.md,
},
emptyChat: {
alignItems: 'center',
justifyContent: 'center',
@@ -0,0 +1,247 @@
import { useState } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
ActivityIndicator,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import { colors, spacing, typography, radius } from "../../../theme";
interface ChatSettingsSheetProps {
visible: boolean;
onClose: () => void;
isArchived: boolean;
onArchive: () => Promise<void>;
onUnarchive: () => Promise<void>;
}
const ACCENT = colors.premium.gold;
export function ChatSettingsSheet({
visible,
onClose,
isArchived,
onArchive,
onUnarchive,
}: ChatSettingsSheetProps) {
const [isLoading, setIsLoading] = useState(false);
const handleArchiveToggle = async () => {
if (isLoading) return;
setIsLoading(true);
try {
if (isArchived) {
await onUnarchive();
} else {
await onArchive();
}
onClose();
} finally {
setIsLoading(false);
}
};
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<Animated.View style={styles.overlay} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={styles.sheetContainer}
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent
isArchived={isArchived}
isLoading={isLoading}
onClose={onClose}
onArchiveToggle={handleArchiveToggle}
/>
</BlurView>
) : (
<View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent
isArchived={isArchived}
isLoading={isLoading}
onClose={onClose}
onArchiveToggle={handleArchiveToggle}
/>
</View>
)}
</Animated.View>
</Animated.View>
</Modal>
);
}
function SheetContent({
isArchived,
isLoading,
onClose,
onArchiveToggle,
}: {
isArchived: boolean;
isLoading: boolean;
onClose: () => void;
onArchiveToggle: () => void;
}) {
return (
<>
{/* Handle */}
<View style={styles.handle} />
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Chat Settings</Text>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Archive option */}
<TouchableOpacity
style={styles.optionButton}
onPress={onArchiveToggle}
disabled={isLoading}
activeOpacity={0.7}
>
<View style={styles.optionContent}>
<View style={styles.optionIconContainer}>
<Ionicons
name={isArchived ? "arrow-undo" : "archive"}
size={22}
color={ACCENT}
/>
</View>
<View style={styles.optionTextContainer}>
<Text style={styles.optionTitle}>
{isArchived ? "Unarchive Chat" : "Archive Chat"}
</Text>
<Text style={styles.optionDescription}>
{isArchived
? "Move this chat back to your main list"
: "Hide this chat from your main list"}
</Text>
</View>
{isLoading && (
<ActivityIndicator size="small" color={ACCENT} style={styles.optionLoader} />
)}
</View>
</TouchableOpacity>
{/* Bottom padding for safe area */}
<View style={styles.bottomPadding} />
</>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "flex-end",
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.6)",
},
sheetContainer: {
maxHeight: "80%",
},
sheet: {
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
overflow: "hidden",
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
sheetAndroid: {
backgroundColor: colors.backgroundLight,
},
handle: {
width: 40,
height: 4,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2,
alignSelf: "center",
marginBottom: spacing.md,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginBottom: spacing.lg,
},
headerTitle: {
...typography.title3,
color: colors.text,
},
closeButton: {
position: "absolute",
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
optionButton: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.lg,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
marginBottom: spacing.md,
},
optionContent: {
flexDirection: "row",
alignItems: "center",
},
optionIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: `rgba(212, 175, 55, 0.1)`,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
optionTextContainer: {
flex: 1,
},
optionTitle: {
...typography.callout,
color: colors.text,
fontWeight: "600",
marginBottom: 2,
},
optionDescription: {
...typography.caption1,
color: colors.textSecondary,
},
optionLoader: {
marginLeft: spacing.sm,
},
bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20,
},
});
@@ -24,12 +24,12 @@ const ACCENT = colors.premium.gold;
* Video player component with native-like tap behavior.
* Supports play/pause, fullscreen, caching, and loading states.
*/
export function ChatVideoPlayer({
const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
signedUrl,
storagePath,
urlError,
onFullScreen,
}: ChatVideoPlayerProps) {
}) => {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
@@ -43,11 +43,6 @@ export function ChatVideoPlayer({
const isLocalFile = signedUrl?.startsWith('file://');
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
// Debug logging
useEffect(() => {
console.log(`[ChatVideoPlayer] isLocalFile=${isLocalFile}, storagePath=${storagePath}, signedUrl=${signedUrl?.substring(0, 60)}..., videoSource=${videoSource?.substring(0, 60)}...`);
}, [storagePath, signedUrl, videoSource, isLocalFile]);
// Handle playback status updates
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) {
@@ -71,7 +66,7 @@ export function ChatVideoPlayer({
cacheVideoFile();
}
}
}, [signedUrl, storagePath, cachedUri, isLocalFile]);
}, [signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
@@ -93,7 +88,7 @@ export function ChatVideoPlayer({
cacheVideoFile();
}
}
}, [isPlaying, hasEnded, signedUrl, storagePath, cachedUri]);
}, [isPlaying, hasEnded, signedUrl, cachedUri, cacheVideoFile]);
// Handle full screen
const handleFullScreen = useCallback(() => {
@@ -180,7 +175,10 @@ export function ChatVideoPlayer({
)}
</TouchableOpacity>
);
}
};
// Wrap with React.memo for performance
export const ChatVideoPlayer = React.memo(ChatVideoPlayerComponent);
const styles = StyleSheet.create({
container: {
@@ -245,5 +243,3 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
});
export default ChatVideoPlayer;
@@ -0,0 +1,133 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, gradients } from '../../../theme';
const ACCENT = colors.premium.gold;
interface FullScreenImageModalProps {
uri: string | null;
visible: boolean;
loading: boolean;
saving: boolean;
onClose: () => void;
onSave: () => void;
onLoadStart: () => void;
onLoadEnd: () => void;
}
const FullScreenImageModalComponent: React.FC<FullScreenImageModalProps> = ({
uri,
visible,
loading,
saving,
onClose,
onSave,
onLoadStart,
onLoadEnd,
}) => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
// Use safe area insets for button positioning to avoid notch/dynamic island
const buttonTop = insets.top + 10;
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<TouchableOpacity
style={[styles.saveButton, { top: buttonTop }]}
onPress={onSave}
activeOpacity={0.8}
disabled={saving}
accessibilityRole="button"
accessibilityLabel="Save image to device"
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.buttonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{saving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={[styles.closeButton, { top: buttonTop }]}
onPress={onClose}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Close full screen image"
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.buttonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{loading && (
<ActivityIndicator size="large" color={ACCENT} style={styles.spinner} />
)}
{uri && (
<Image
source={{ uri }}
style={{ width: windowWidth, height: windowHeight * 0.8 }}
contentFit="contain"
cachePolicy="disk"
onLoadStart={onLoadStart}
onLoadEnd={onLoadEnd}
/>
)}
</View>
</Modal>
);
};
export const FullScreenImageModal = React.memo(FullScreenImageModalComponent);
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.95)',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
position: 'absolute',
right: 20,
zIndex: 10,
},
saveButton: {
position: 'absolute',
left: 20,
zIndex: 10,
},
buttonGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
spinner: {
position: 'absolute',
},
});
@@ -0,0 +1,118 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
import { Video, ResizeMode } from 'expo-av';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, gradients } from '../../../theme';
interface FullScreenVideoModalProps {
uri: string | null;
visible: boolean;
saving: boolean;
onClose: () => void;
onSave: () => void;
}
const FullScreenVideoModalComponent: React.FC<FullScreenVideoModalProps> = ({
uri,
visible,
saving,
onClose,
onSave,
}) => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
// Use safe area insets for button positioning to avoid notch/dynamic island
const buttonTop = insets.top + 10;
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<TouchableOpacity
style={[styles.saveButton, { top: buttonTop }]}
onPress={onSave}
activeOpacity={0.8}
disabled={saving}
accessibilityRole="button"
accessibilityLabel="Save video to device"
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.buttonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{saving ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="download-outline" size={24} color={colors.text} />
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={[styles.closeButton, { top: buttonTop }]}
onPress={onClose}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Close full screen video"
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.buttonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{uri && (
<Video
source={{ uri }}
style={{ width: windowWidth, height: windowHeight * 0.8 }}
resizeMode={ResizeMode.CONTAIN}
useNativeControls
shouldPlay={visible}
isLooping={false}
/>
)}
</View>
</Modal>
);
};
export const FullScreenVideoModal = React.memo(FullScreenVideoModalComponent);
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.95)',
justifyContent: 'center',
alignItems: 'center',
},
closeButton: {
position: 'absolute',
right: 20,
zIndex: 10,
},
saveButton: {
position: 'absolute',
left: 20,
zIndex: 10,
},
buttonGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
});
@@ -27,7 +27,7 @@ const ACCENT = colors.premium.gold;
* Chat input bar with expandable media menu.
* Includes text input, send button, and media action buttons.
*/
export function InputBar({
const InputBarComponent: React.FC<InputBarProps> = ({
inputText,
uploading,
onChangeText,
@@ -35,7 +35,7 @@ export function InputBar({
onPickMedia,
onTakePhoto,
onRecordVideo,
}: InputBarProps) {
}) => {
const [menuExpanded, setMenuExpanded] = useState(false);
const menuWidth = useSharedValue(0);
const buttonRotation = useSharedValue(0);
@@ -176,7 +176,10 @@ export function InputBar({
</TouchableOpacity>
</View>
);
}
};
// Wrap with React.memo for performance
export const InputBar = React.memo(InputBarComponent);
const styles = StyleSheet.create({
container: {
@@ -261,5 +264,3 @@ const styles = StyleSheet.create({
height: 20,
},
});
export default InputBar;
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@@ -6,24 +6,38 @@ import Animated, { FadeInDown } from 'react-native-reanimated';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Match } from '../types';
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface MatchCardProps {
match: any;
match: Match | null;
user: { id: string } | null;
}
export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
// Track if animation has already played to prevent re-animation on re-renders
const hasAnimatedRef = useRef(false);
const shouldAnimate = !hasAnimatedRef.current;
// Mark as animated after first render
if (!hasAnimatedRef.current) {
hasAnimatedRef.current = true;
}
if (!match?.question) return null;
const isYesYes = match?.match_type === 'yes_yes';
// Only animate on first render to prevent re-animation when list re-renders
const enteringAnimation = shouldAnimate
? FadeInDown.delay(100).duration(400).springify()
: undefined;
return (
<Animated.View
entering={FadeInDown.delay(100).duration(400).springify()}
entering={enteringAnimation}
style={styles.matchCardContainer}
>
<View style={styles.matchCard}>
@@ -66,19 +80,19 @@ export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
{(() => {
// Sort responses by created_at to determine who answered first
const sortedResponses = [...match.responses].sort(
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
const isTwoPart = !!match.question.partner_text;
const isTwoPart = !!match.question?.partner_text;
return sortedResponses.map((response: any, index: number) => {
return sortedResponses.map((response, index) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
// First responder sees main text, second sees partner_text
const questionText = isTwoPart && index === 1
? match.question.partner_text
: match.question.text;
? match.question?.partner_text
: match.question?.text;
return (
<View key={response.user_id} style={styles.responseCard}>
@@ -130,6 +144,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
);
};
// Wrap with React.memo for performance
export const MatchCard = React.memo(MatchCardComponent);
const styles = StyleSheet.create({
matchCardContainer: {
marginHorizontal: spacing.md,
@@ -10,6 +10,9 @@ import { colors, gradients, spacing, radius } from '../../../theme';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
// Only animate the first few messages to avoid performance issues on large lists
const MAX_ANIMATED_INDEX = 5;
export interface MessageBubbleProps {
isMe: boolean;
index: number;
@@ -18,10 +21,18 @@ export interface MessageBubbleProps {
onLongPress?: () => void;
}
export function MessageBubble({ isMe, index, children, onLongPress }: MessageBubbleProps) {
const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageBubbleProps) => {
// Only animate recent messages (low index in inverted list) to avoid performance issues
const shouldAnimate = index < MAX_ANIMATED_INDEX;
const enteringAnimation = shouldAnimate ? FadeInUp.duration(200) : undefined;
const accessibilityLabel = isMe
? 'Your message. Long press for options'
: "Partner's message. Long press for options";
return (
<Animated.View
entering={FadeInUp.delay(index * 30).duration(200)}
entering={enteringAnimation}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
>
{isMe ? (
@@ -30,6 +41,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
activeOpacity={0.9}
delayLongPress={300}
style={styles.myBubbleContainer}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Long press to delete or report"
>
<LinearGradient
colors={gradients.primary as [string, string]}
@@ -53,6 +67,9 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
activeOpacity={0.9}
delayLongPress={300}
style={styles.theirBubbleContainer}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Long press to report"
>
<View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */}
@@ -75,7 +92,10 @@ export function MessageBubble({ isMe, index, children, onLongPress }: MessageBub
)}
</Animated.View>
);
}
};
// Wrap with React.memo for performance
export const MessageBubble = React.memo(MessageBubbleComponent);
const styles = StyleSheet.create({
messageRow: {
@@ -116,5 +136,3 @@ const styles = StyleSheet.create({
height: 30,
},
});
export default MessageBubble;
@@ -22,18 +22,16 @@ export interface MessageContentProps {
revealMessage: (id: string) => void;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
onLongPress?: () => void;
}
export function MessageContent({
const MessageContentComponent: React.FC<MessageContentProps> = ({
item,
isMe,
currentUserId,
revealMessage,
onImagePress,
onVideoFullScreen,
onLongPress,
}: MessageContentProps) {
}) => {
// Check if message was deleted for everyone
if (item.deleted_at) {
return (
@@ -50,7 +48,7 @@ export function MessageContent({
);
}
const isVideo = (item as any).media_type === 'video';
const isVideo = item.media_type === 'video';
const isViewed = !!item.media_viewed_at;
const isRecipientHidden = !isMe && !isViewed;
@@ -87,7 +85,7 @@ export function MessageContent({
}, [item.media_path, isRecipientHidden]);
// Handle expired videos
const isExpired = !!(item as any).media_expired;
const isExpired = !!item.media_expired;
if (isExpired && isVideo) {
return (
@@ -240,7 +238,10 @@ export function MessageContent({
<MessageMeta item={item} isMe={isMe} />
</>
);
}
};
// Wrap with React.memo for performance
export const MessageContent = React.memo(MessageContentComponent);
const styles = StyleSheet.create({
deletedContainer: {
@@ -347,5 +348,3 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
},
});
export default MessageContent;
@@ -12,7 +12,7 @@ export interface MessageMetaProps {
/**
* Message metadata component showing timestamp and read receipts.
*/
export function MessageMeta({ item, isMe }: MessageMetaProps) {
const MessageMetaComponent: React.FC<MessageMetaProps> = ({ item, isMe }) => {
const status = getReceiptStatus(item.delivered_at, item.read_at);
return (
@@ -27,7 +27,10 @@ export function MessageMeta({ item, isMe }: MessageMetaProps) {
)}
</View>
);
}
};
// Wrap with React.memo for performance
export const MessageMeta = React.memo(MessageMetaComponent);
const styles = StyleSheet.create({
container: {
@@ -43,5 +46,3 @@ const styles = StyleSheet.create({
marginLeft: spacing.xs,
},
});
export default MessageMeta;
@@ -15,7 +15,7 @@ interface TypingDotProps {
delay: number;
}
function TypingDot({ delay }: TypingDotProps) {
const TypingDot = React.memo(function TypingDot({ delay }: TypingDotProps) {
const opacity = useSharedValue(0.4);
useEffect(() => {
@@ -43,13 +43,13 @@ function TypingDot({ delay }: TypingDotProps) {
/>
</Animated.View>
);
}
});
/**
* Typing indicator component showing three animated dots
* when partner is typing.
*/
export function TypingIndicator() {
const TypingIndicatorComponent: React.FC = () => {
return (
<Animated.View
entering={FadeIn.duration(200)}
@@ -71,7 +71,10 @@ export function TypingIndicator() {
</View>
</Animated.View>
);
}
};
// Wrap with React.memo for performance
export const TypingIndicator = React.memo(TypingIndicatorComponent);
const styles = StyleSheet.create({
container: {
@@ -104,5 +107,3 @@ const styles = StyleSheet.create({
borderRadius: 4,
},
});
export default TypingIndicator;
@@ -16,3 +16,6 @@ export { ChatHeader } from './ChatHeader';
export { ChatMessages } from './ChatMessages';
export { MatchCard } from './MatchCard';
export { ReportMessageSheet } from './ReportMessageSheet';
export { ChatSettingsSheet } from './ChatSettingsSheet';
export { FullScreenImageModal } from './FullScreenImageModal';
export { FullScreenVideoModal } from './FullScreenVideoModal';
+1
View File
@@ -25,6 +25,7 @@ export interface Match {
responses?: Array<{
user_id: string;
answer: string;
created_at: string;
profiles?: {
name?: string;
};
+24 -5
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
export interface UseTypingIndicatorConfig {
@@ -10,6 +11,8 @@ export interface UseTypingIndicatorConfig {
typingTimeout?: number;
/** Whether the screen is currently focused */
isFocused?: boolean;
/** Throttle interval in ms for sending typing events. Default: 2000 */
throttleInterval?: number;
}
export interface UseTypingIndicatorReturn {
@@ -22,6 +25,7 @@ export interface UseTypingIndicatorReturn {
}
const DEFAULT_TYPING_TIMEOUT = 3000;
const DEFAULT_THROTTLE_INTERVAL = 2000;
/**
* Hook for managing typing indicator state and broadcast events.
@@ -33,11 +37,19 @@ const DEFAULT_TYPING_TIMEOUT = 3000;
export const useTypingIndicator = (
config: UseTypingIndicatorConfig
): UseTypingIndicatorReturn => {
const { channelName, userId, typingTimeout = DEFAULT_TYPING_TIMEOUT, isFocused = true } = config;
const {
channelName,
userId,
typingTimeout = DEFAULT_TYPING_TIMEOUT,
isFocused = true,
throttleInterval = DEFAULT_THROTTLE_INTERVAL,
} = config;
const [partnerTyping, setPartnerTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isFocusedRef = useRef(isFocused);
const lastTypingEventRef = useRef<number>(0);
const channelRef = useRef<RealtimeChannel | null>(null);
// Update ref when focus changes and clear state if blurred
useEffect(() => {
@@ -65,6 +77,7 @@ export const useTypingIndicator = (
if (!channelName || !userId) return;
const channel = supabase.channel(channelName);
channelRef.current = channel;
channel.on('broadcast', { event: 'typing' }, (payload) => {
// Only process events if the screen is focused
@@ -88,6 +101,7 @@ export const useTypingIndicator = (
channel.subscribe();
return () => {
channelRef.current = null;
supabase.removeChannel(channel);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
@@ -96,17 +110,22 @@ export const useTypingIndicator = (
}, [channelName, userId, typingTimeout]);
/**
* Send a typing event to the partner
* Send a typing event to the partner (throttled to reduce network traffic)
*/
const sendTypingEvent = useCallback(() => {
if (!channelName || !userId) return;
if (!channelRef.current || !userId) return;
supabase.channel(channelName).send({
// Throttle typing events to reduce network traffic
const now = Date.now();
if (now - lastTypingEventRef.current < throttleInterval) return;
lastTypingEventRef.current = now;
channelRef.current.send({
type: 'broadcast',
event: 'typing',
payload: { userId },
});
}, [channelName, userId]);
}, [userId, throttleInterval]);
/**
* Manually clear the typing indicator (e.g., when a message is received)
+232 -9
View File
@@ -14,12 +14,24 @@ interface MatchState {
page: number;
hasMore: boolean;
error: string | null;
// Archive state
archivedMatches: Match[];
archivedMatchIds: Set<string>;
showArchived: boolean;
isLoadingArchived: boolean;
// Methods
fetchMatches: (refresh?: boolean) => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
updateMatchUnreadCount: (matchId: string, delta: number) => void;
clearMatches: () => void;
// Archive methods
archiveMatch: (matchId: string) => Promise<void>;
unarchiveMatch: (matchId: string) => Promise<void>;
fetchArchivedMatches: () => Promise<void>;
toggleShowArchived: () => void;
isMatchArchived: (matchId: string) => boolean;
}
export const useMatchStore = create<MatchState>((set, get) => ({
@@ -31,6 +43,11 @@ export const useMatchStore = create<MatchState>((set, get) => ({
page: 0,
hasMore: true,
error: null,
// Archive state
archivedMatches: [],
archivedMatchIds: new Set<string>(),
showArchived: false,
isLoadingArchived: false,
fetchMatches: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
@@ -57,7 +74,18 @@ export const useMatchStore = create<MatchState>((set, get) => ({
const from = currentPage * BATCH_SIZE;
const to = from + BATCH_SIZE - 1;
// Fetch total count on refresh
// Fetch archived match IDs for this user on refresh
let archivedMatchIds = state.archivedMatchIds;
if (refresh && userId) {
const { data: archives } = await supabase
.from("match_archives")
.select("match_id")
.eq("user_id", userId);
archivedMatchIds = new Set(archives?.map(a => a.match_id) ?? []);
}
// Fetch total count on refresh (excluding archived)
let totalCount = state.totalCount;
if (refresh) {
const { count, error: countError } = await supabase
@@ -66,7 +94,8 @@ export const useMatchStore = create<MatchState>((set, get) => ({
.eq("couple_id", coupleId);
if (!countError) {
totalCount = count ?? 0;
// Subtract archived count from total
totalCount = (count ?? 0) - archivedMatchIds.size;
}
}
@@ -81,9 +110,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
.range(from, to);
if (matchError) throw matchError;
if (!matches || matches.length === 0) {
// Filter out archived matches
const nonArchivedMatches = matches?.filter(m => !archivedMatchIds.has(m.id)) ?? [];
if (nonArchivedMatches.length === 0) {
if (refresh) {
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds });
} else {
set({ isLoadingMore: false, hasMore: false });
}
@@ -91,7 +124,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
}
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
const questionIds = nonArchivedMatches.map(m => m.question_id);
let responses: any[] = [];
if (questionIds.length > 0) {
const { data } = await supabase
@@ -102,7 +135,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
}
// Fetch unread message counts per match
const matchIds = matches.map(m => m.id);
const matchIds = nonArchivedMatches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
if (userId && matchIds.length > 0) {
@@ -123,7 +156,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
});
}
const data = matches.map(match => ({
const data = nonArchivedMatches.map(match => ({
...match,
responses: responses.filter(r => r.question_id === match.question_id),
unreadCount: unreadCounts[match.id] || 0
@@ -165,7 +198,8 @@ export const useMatchStore = create<MatchState>((set, get) => ({
isLoading: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: matches.length === BATCH_SIZE
hasMore: nonArchivedMatches.length === BATCH_SIZE,
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
};
});
} catch (err) {
@@ -222,6 +256,195 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
clearMatches: () => {
set({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, error: null, page: 0, hasMore: true, isLoadingMore: false });
set({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
error: null,
page: 0,
hasMore: true,
isLoadingMore: false,
archivedMatches: [],
archivedMatchIds: new Set<string>(),
showArchived: false,
isLoadingArchived: false,
});
},
// Archive methods
archiveMatch: async (matchId: string) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
// Optimistically update UI
const state = get();
const matchToArchive = state.matches.find(m => m.id === matchId);
if (matchToArchive) {
const newArchivedMatchIds = new Set(state.archivedMatchIds);
newArchivedMatchIds.add(matchId);
set({
matches: state.matches.filter(m => m.id !== matchId),
archivedMatches: [...state.archivedMatches, matchToArchive],
archivedMatchIds: newArchivedMatchIds,
totalCount: state.totalCount !== null ? state.totalCount - 1 : null,
});
}
// Insert archive record
const { error } = await supabase
.from("match_archives")
.insert({ match_id: matchId, user_id: userId });
if (error) {
console.error("Error archiving match:", error);
// Revert on error
if (matchToArchive) {
const revertArchivedIds = new Set(get().archivedMatchIds);
revertArchivedIds.delete(matchId);
const currentTotalCount = get().totalCount;
set({
matches: [matchToArchive, ...get().matches],
archivedMatches: get().archivedMatches.filter(m => m.id !== matchId),
archivedMatchIds: revertArchivedIds,
totalCount: currentTotalCount !== null ? currentTotalCount + 1 : null,
});
}
}
},
unarchiveMatch: async (matchId: string) => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
// Optimistically update UI
const state = get();
const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId);
if (matchToUnarchive) {
const newArchivedMatchIds = new Set(state.archivedMatchIds);
newArchivedMatchIds.delete(matchId);
set({
archivedMatches: state.archivedMatches.filter(m => m.id !== matchId),
matches: [matchToUnarchive, ...state.matches],
archivedMatchIds: newArchivedMatchIds,
totalCount: state.totalCount !== null ? state.totalCount + 1 : null,
});
}
// Delete archive record
const { error } = await supabase
.from("match_archives")
.delete()
.eq("match_id", matchId)
.eq("user_id", userId);
if (error) {
console.error("Error unarchiving match:", error);
// Revert on error
if (matchToUnarchive) {
const revertArchivedIds = new Set(get().archivedMatchIds);
revertArchivedIds.add(matchId);
const currentTotalCount = get().totalCount;
set({
archivedMatches: [matchToUnarchive, ...get().archivedMatches],
matches: get().matches.filter(m => m.id !== matchId),
archivedMatchIds: revertArchivedIds,
totalCount: currentTotalCount !== null ? currentTotalCount - 1 : null,
});
}
}
},
fetchArchivedMatches: async () => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId || !userId) return;
const state = get();
if (state.isLoadingArchived) return;
set({ isLoadingArchived: true });
try {
// Get archived match IDs for this user
const { data: archives, error: archiveError } = await supabase
.from("match_archives")
.select("match_id")
.eq("user_id", userId);
if (archiveError) throw archiveError;
const archivedIds = archives?.map(a => a.match_id) ?? [];
if (archivedIds.length === 0) {
set({ archivedMatches: [], isLoadingArchived: false });
return;
}
// Fetch the actual match data
const { data: matches, error: matchError } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.in("id", archivedIds)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
if (matchError) throw matchError;
// Fetch unread counts for archived matches
const matchIds = matches?.map(m => m.id) ?? [];
let unreadCounts: Record<string, number> = {};
if (matchIds.length > 0) {
const { data: unreadMessages } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
}
const archivedMatches = (matches ?? []).map(match => ({
...match,
unreadCount: unreadCounts[match.id] || 0
}));
set({
archivedMatches,
isLoadingArchived: false,
archivedMatchIds: new Set(archivedIds),
});
} catch (err) {
console.error("Error fetching archived matches:", err);
set({ isLoadingArchived: false });
}
},
toggleShowArchived: () => {
const state = get();
const newShowArchived = !state.showArchived;
set({ showArchived: newShowArchived });
// Fetch archived matches if switching to archived view and not yet loaded
if (newShowArchived && state.archivedMatches.length === 0) {
get().fetchArchivedMatches();
}
},
isMatchArchived: (matchId: string) => {
return get().archivedMatchIds.has(matchId);
},
}));
+22 -14
View File
@@ -1,8 +1,8 @@
// User profile (extends Supabase auth.users)
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export type IntensityLevel = 1 | 2 | 3 | 4 | 5;
export interface Profile {
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export type IntensityLevel = 1 | 2 | 3 | 4 | 5;
export interface Profile {
id: string;
name: string | null;
email: string | null;
@@ -10,10 +10,10 @@ export interface Profile {
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
gender: Gender | null;
show_explicit_content: boolean;
max_intensity: IntensityLevel;
onboarding_completed: boolean;
gender: Gender | null;
show_explicit_content: boolean;
max_intensity: IntensityLevel;
onboarding_completed: boolean;
created_at: string;
updated_at: string;
/** RSA public key for E2EE (JWK format) */
@@ -44,12 +44,12 @@ export interface QuestionPack {
description: string | null;
icon: string | null;
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
min_intensity?: number;
max_intensity?: number;
avg_intensity?: number;
sort_order: number;
is_public: boolean;
is_explicit: boolean;
min_intensity?: number;
max_intensity?: number;
avg_intensity?: number;
sort_order: number;
category_id: string | null;
category?: Category;
questions?: { count: number }[];
@@ -100,6 +100,14 @@ export interface MatchWithQuestion extends Match {
question: Question;
}
// Match archive (per-user)
export interface MatchArchive {
id: string;
match_id: string;
user_id: string;
archived_at: string;
}
// Pack with progress info
export interface PackProgress {
pack: QuestionPack;
+49
View File
@@ -98,18 +98,21 @@ export type Database = {
app_config: {
Row: {
answer_gap_threshold: number | null
daily_response_limit: number | null
id: string
updated_at: string | null
updated_by: string | null
}
Insert: {
answer_gap_threshold?: number | null
daily_response_limit?: number | null
id?: string
updated_at?: string | null
updated_by?: string | null
}
Update: {
answer_gap_threshold?: number | null
daily_response_limit?: number | null
id?: string
updated_at?: string | null
updated_by?: string | null
@@ -373,6 +376,42 @@ export type Database = {
}
Relationships: []
}
match_archives: {
Row: {
id: string
match_id: string
user_id: string
archived_at: string | null
}
Insert: {
id?: string
match_id: string
user_id: string
archived_at?: string | null
}
Update: {
id?: string
match_id?: string
user_id?: string
archived_at?: string | null
}
Relationships: [
{
foreignKeyName: "match_archives_match_id_fkey"
columns: ["match_id"]
isOneToOne: false
referencedRelation: "matches"
referencedColumns: ["id"]
},
{
foreignKeyName: "match_archives_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
matches: {
Row: {
couple_id: string
@@ -925,6 +964,16 @@ export type Database = {
}[]
}
get_auth_user_couple_id: { Args: never; Returns: string }
get_daily_response_limit_status: {
Args: never
Returns: {
is_blocked: boolean
limit_value: number
remaining: number
reset_at: string
responses_today: number
}[]
}
get_feature_interest_counts: {
Args: never
Returns: {
+233 -41
View File
@@ -9,6 +9,174 @@ const corsHeaders = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const DEFAULT_WHITELIST = [
"ok",
"okay",
"yes",
"no",
"maybe",
"thanks",
"thank you",
"lol",
"lmao",
"good night",
"goodnight",
"good morning",
"love you",
];
const DEFAULT_KEYWORD_TRIGGERS = [
"suicide",
"kill myself",
"self harm",
"kill you",
"hurt you",
"rape",
"porn",
"nude",
"explicit",
"stab",
"shoot",
];
function parseBoolean(value, defaultValue = false) {
if (value === null || value === undefined) return defaultValue;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
}
return defaultValue;
}
function parseNumber(value, defaultValue) {
if (value === null || value === undefined) return defaultValue;
if (typeof value === "number" && Number.isFinite(value)) return value;
const parsed = Number(String(value).trim());
return Number.isFinite(parsed) ? parsed : defaultValue;
}
function parseStringArray(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value
.filter((item) => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed
.filter((item) => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
} catch {
// Fall through to delimiter parsing
}
}
return trimmed
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function normalizeList(values) {
return Array.from(
new Set(
values
.map((value) => value.trim().toLowerCase())
.filter(Boolean)
)
);
}
function getHeuristicConfig(config) {
const enabled = parseBoolean(config?.heuristics_enabled, false);
const minTextLength = parseNumber(config?.heuristic_min_text_length, 12);
const whitelistMaxLength = parseNumber(config?.heuristic_whitelist_max_length, 30);
const skipIfNoAlnum = parseBoolean(config?.heuristic_skip_if_no_alnum, true);
const skipMediaWithoutText = parseBoolean(config?.heuristic_skip_media_without_text, false);
const recordReason = parseBoolean(config?.heuristic_record_reason, false);
const useDefaultWhitelist = parseBoolean(config?.heuristic_use_default_whitelist, true);
const useDefaultKeywords = parseBoolean(config?.heuristic_use_default_keywords, true);
const whitelist = parseStringArray(config?.heuristic_whitelist);
const keywordTriggers = parseStringArray(config?.heuristic_keyword_triggers);
const normalizedWhitelist = normalizeList(
useDefaultWhitelist ? DEFAULT_WHITELIST.concat(whitelist) : whitelist
);
const normalizedKeywordTriggers = normalizeList(
useDefaultKeywords ? DEFAULT_KEYWORD_TRIGGERS.concat(keywordTriggers) : keywordTriggers
);
return {
enabled,
minTextLength,
whitelistMaxLength,
skipIfNoAlnum,
skipMediaWithoutText,
recordReason,
whitelist: normalizedWhitelist,
keywordTriggers: normalizedKeywordTriggers,
};
}
function evaluateHeuristics(text, hasMedia, config) {
if (!config.enabled) {
return { shouldCallAi: true, reason: "disabled" };
}
const normalized = (text || "").trim();
const lower = normalized.toLowerCase();
const hasText = normalized.length > 0;
const hasAlnum = /[a-z0-9]/i.test(normalized);
if (config.keywordTriggers.length > 0) {
const keywordHit = config.keywordTriggers.some((keyword) => lower.includes(keyword));
if (keywordHit) {
return { shouldCallAi: true, reason: "keyword_trigger" };
}
}
if (!hasMedia && !hasText) {
return { shouldCallAi: false, reason: "no_content" };
}
if (!hasMedia) {
if (config.skipIfNoAlnum && !hasAlnum) {
return { shouldCallAi: false, reason: "low_signal" };
}
if (normalized.length <= config.whitelistMaxLength) {
const isWhitelisted = config.whitelist.includes(lower);
if (isWhitelisted) {
return { shouldCallAi: false, reason: "whitelist" };
}
}
if (normalized.length < config.minTextLength) {
return { shouldCallAi: false, reason: "short_text" };
}
}
if (hasMedia && config.skipMediaWithoutText && !hasText) {
return { shouldCallAi: false, reason: "media_without_text" };
}
return { shouldCallAi: true, reason: "default" };
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
@@ -42,6 +210,8 @@ Deno.serve(async (req) => {
return new Response(JSON.stringify({ message: "Classifier disabled" }), { status: 200, headers: corsHeaders });
}
const heuristicConfig = getHeuristicConfig(config);
const apiKey = config.openrouter_api_key || Deno.env.get("OPENROUTER_API_KEY");
if (!apiKey) {
console.error("OpenRouter API Key missing");
@@ -64,24 +234,31 @@ Deno.serve(async (req) => {
let decryptedText = message.content;
let mediaBase64: string | null = null;
let mediaMimeType: string | null = null;
const hasMedia = !!message.media_path && message.media_type === "image";
const keysMetadata = message.keys_metadata as KeysMetadata | null;
const encryptionIv = message.encryption_iv as string | null;
let aesKey: CryptoKey | null = null;
if (version === 2) {
const keysMetadata = message.keys_metadata as KeysMetadata;
const encryptionIv = message.encryption_iv;
if (version === 2 && (message.encrypted_content || hasMedia)) {
if (!keysMetadata || !encryptionIv) {
return new Response(JSON.stringify({ error: "Missing encryption metadata" }), { status: 400, headers: corsHeaders });
}
}
const getAesKey = async () => {
if (aesKey) return aesKey;
if (!keysMetadata || !encryptionIv) {
throw new Error("Missing encryption metadata");
}
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
if (!adminPrivateKeyJwkStr) {
throw new Error("ADMIN_PRIVATE_KEY_JWK not set");
}
const adminPrivateKey = JSON.parse(adminPrivateKeyJwkStr);
const rawAesKey = await unwrapWithAdminKey(keysMetadata.admin_wrapped_key, adminPrivateKey);
const aesKey = await crypto.subtle.importKey(
aesKey = await crypto.subtle.importKey(
"raw",
rawAesKey,
{ name: "AES-GCM" },
@@ -89,45 +266,60 @@ Deno.serve(async (req) => {
["decrypt"]
);
// Decrypt Text
if (message.encrypted_content) {
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
base64ToUint8Array(message.encrypted_content)
);
decryptedText = new TextDecoder().decode(plaintext);
}
// Decrypt Media (Image only for now)
if (message.media_path && message.media_type === "image") {
return aesKey;
};
if (version === 2 && message.encrypted_content) {
const key = await getAesKey();
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
key,
base64ToUint8Array(message.encrypted_content)
);
decryptedText = new TextDecoder().decode(plaintext);
}
const heuristicDecision = evaluateHeuristics(decryptedText, hasMedia, heuristicConfig);
if (heuristicConfig.enabled && !heuristicDecision.shouldCallAi) {
console.log(`Heuristic skip for message ${messageId}: ${heuristicDecision.reason}`);
await updateStatus(
supabaseAdmin,
messageId,
"safe",
heuristicConfig.recordReason ? heuristicDecision.reason : null,
"Neutral"
);
return new Response(JSON.stringify({ status: "safe" }), { headers: corsHeaders });
}
if (hasMedia) {
if (version === 2) {
const key = await getAesKey();
const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage
.from("chat-media")
.download(normalizeChatMediaPath(message.media_path));
.from("chat-media")
.download(normalizeChatMediaPath(message.media_path));
if (!downloadError && mediaBlob) {
const encryptedBytes = await mediaBlob.arrayBuffer();
const decryptedBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
aesKey,
encryptedBytes
);
mediaBase64 = arrayBufferToBase64(decryptedBytes);
mediaMimeType = "image/jpeg"; // Default or infer?
const encryptedBytes = await mediaBlob.arrayBuffer();
const decryptedBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv as string) },
key,
encryptedBytes
);
mediaBase64 = arrayBufferToBase64(decryptedBytes);
mediaMimeType = "image/jpeg";
}
} else {
const { data: mediaBlob } = await supabaseAdmin.storage
.from("chat-media")
.download(normalizeChatMediaPath(message.media_path));
if (mediaBlob) {
const buffer = await mediaBlob.arrayBuffer();
mediaBase64 = arrayBufferToBase64(buffer);
mediaMimeType = "image/jpeg";
}
}
} else {
// Version 1 (Plaintext)
if (message.media_path && message.media_type === "image") {
const { data: mediaBlob } = await supabaseAdmin.storage
.from("chat-media")
.download(normalizeChatMediaPath(message.media_path));
if (mediaBlob) {
const buffer = await mediaBlob.arrayBuffer();
mediaBase64 = arrayBufferToBase64(buffer);
mediaMimeType = "image/jpeg";
}
}
}
// 4. Construct LLM Payload
@@ -70,27 +70,88 @@ Deno.serve(async (req) => {
.eq("id", user.id)
.single();
if (profileError || !profile?.couple_id) {
return new Response(
JSON.stringify({ error: "User is not in a couple" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Save or update response
const { data: response, error: responseError } = await supabase
.from("responses")
.upsert(
{
user_id: user.id,
question_id,
couple_id: profile.couple_id,
answer,
},
{ onConflict: "user_id,question_id" }
)
.select()
.single();
if (profileError || !profile?.couple_id) {
return new Response(
JSON.stringify({ error: "User is not in a couple" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Check if this is a new response or an edit (edits bypass daily limit)
const { data: existingResponse } = await supabase
.from("responses")
.select("id")
.eq("user_id", user.id)
.eq("question_id", question_id)
.maybeSingle();
const isNewResponse = !existingResponse;
// Enforce daily response limit for new responses only
if (isNewResponse) {
// Check if user has premium access
const { data: hasPremiumData } = await supabase.rpc("has_premium_access", {
check_user_id: user.id,
});
const hasPremium = hasPremiumData === true;
// If non-premium, check daily limit
if (!hasPremium) {
// Get daily limit from app_config
const { data: configData } = await supabase
.from("app_config")
.select("daily_response_limit")
.limit(1)
.maybeSingle();
const dailyLimit = configData?.daily_response_limit || 0;
// If daily limit is enabled (> 0), check if user has reached it
if (dailyLimit > 0) {
// Calculate UTC day boundaries
const now = new Date();
const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const tomorrowStart = new Date(todayStart);
tomorrowStart.setUTCDate(tomorrowStart.getUTCDate() + 1);
// Count responses created today (UTC)
const { count: dailyCount } = await supabase
.from("responses")
.select("*", { count: "exact", head: true })
.eq("user_id", user.id)
.gte("created_at", todayStart.toISOString())
.lt("created_at", tomorrowStart.toISOString());
// If over limit, reject
if ((dailyCount || 0) >= dailyLimit) {
return new Response(
JSON.stringify({
error: "Daily response limit reached",
daily_limit: dailyLimit,
reset_at: tomorrowStart.toISOString(),
}),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
}
}
// Save or update response
const { data: response, error: responseError } = await supabase
.from("responses")
.upsert(
{
user_id: user.id,
question_id,
couple_id: profile.couple_id,
answer,
},
{ onConflict: "user_id,question_id" }
)
.select()
.single();
if (responseError) {
return new Response(
@@ -0,0 +1,66 @@
-- Add admin_username column to audit_logs table
ALTER TABLE audit_logs
ADD COLUMN admin_username TEXT;
-- Backfill existing audit logs with admin names from profiles
UPDATE audit_logs
SET admin_username = COALESCE(p.name, p.email, 'Unknown')
FROM profiles p
WHERE audit_logs.admin_user_id = p.id
AND audit_logs.admin_username IS NULL;
-- Update the log_admin_action function to populate admin_username from profiles
CREATE OR REPLACE FUNCTION log_admin_action(
p_table_name TEXT,
p_record_id UUID,
p_action audit_action,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
log_id UUID;
admin_role_val public.admin_role;
admin_username_val TEXT;
changed_fields_arr TEXT[];
BEGIN
-- Get admin role from admin_users table
SELECT role
INTO admin_role_val
FROM public.admin_users
WHERE user_id = auth.uid();
IF admin_role_val IS NULL THEN
RAISE EXCEPTION 'User is not an admin';
END IF;
-- Get username from profiles table (name or email as fallback)
SELECT COALESCE(name, email, 'Unknown')
INTO admin_username_val
FROM public.profiles
WHERE id = auth.uid();
IF p_action = 'UPDATE' AND p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN
SELECT ARRAY(
SELECT key FROM jsonb_each(p_new_values)
WHERE p_old_values->key IS DISTINCT FROM p_new_values->key
) INTO changed_fields_arr;
END IF;
INSERT INTO public.audit_logs (
table_name, record_id, action, old_values, new_values,
changed_fields, admin_user_id, admin_role, admin_username
)
VALUES (
p_table_name, p_record_id, p_action, p_old_values, p_new_values,
changed_fields_arr, auth.uid(), admin_role_val, admin_username_val
)
RETURNING id INTO log_id;
RETURN log_id;
END;
$$;
@@ -0,0 +1,46 @@
-- Create match_archives table for per-user match archiving
-- Similar pattern to message_deletions table
CREATE TABLE match_archives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
archived_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(match_id, user_id)
);
-- Index for efficient lookup when filtering matches
CREATE INDEX idx_match_archives_user_match ON match_archives(user_id, match_id);
-- Enable RLS on match_archives
ALTER TABLE match_archives ENABLE ROW LEVEL SECURITY;
-- RLS Policies for match_archives
-- Users can view their own archives (needed for filtering)
CREATE POLICY "Users can view own archives"
ON match_archives FOR SELECT
USING (user_id = auth.uid());
-- Users can insert their own archives (for matches in their couples)
CREATE POLICY "Users can archive matches"
ON match_archives FOR INSERT
WITH CHECK (
user_id = auth.uid() AND
EXISTS (
SELECT 1 FROM matches m
JOIN profiles p ON p.couple_id = m.couple_id
WHERE m.id = match_archives.match_id
AND p.id = auth.uid()
)
);
-- Users can delete their own archives (for unarchiving)
CREATE POLICY "Users can unarchive matches"
ON match_archives FOR DELETE
USING (user_id = auth.uid());
-- Add to realtime for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE match_archives;
-- Documentation
COMMENT ON TABLE match_archives IS 'Per-user match archives. Partner still sees the match. Delete record to unarchive.';
@@ -0,0 +1,77 @@
-- Add daily response limit for non-premium users
-- This feature limits how many questions a non-premium user can answer per UTC day
-- Add daily_response_limit column to app_config
ALTER TABLE app_config
ADD COLUMN daily_response_limit INTEGER DEFAULT 0;
COMMENT ON COLUMN app_config.daily_response_limit IS 'Maximum number of questions a non-premium user can answer per UTC day. Set to 0 to disable daily limits.';
-- ============================================================================
-- RPC FUNCTION: get_daily_response_limit_status
-- Returns the current daily response limit status for the authenticated user
-- ============================================================================
CREATE OR REPLACE FUNCTION get_daily_response_limit_status()
RETURNS TABLE(
responses_today INTEGER,
limit_value INTEGER,
remaining INTEGER,
reset_at TIMESTAMPTZ,
is_blocked BOOLEAN
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_has_premium BOOLEAN;
v_limit INTEGER;
v_count INTEGER;
v_today_start TIMESTAMPTZ;
v_tomorrow_start TIMESTAMPTZ;
BEGIN
v_user_id := auth.uid();
-- Get limit from app_config
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_limit
FROM app_config ac
LIMIT 1;
-- If limit is 0, feature is disabled
IF v_limit = 0 THEN
RETURN QUERY SELECT 0, 0, 0, NULL::TIMESTAMPTZ, false;
RETURN;
END IF;
-- Check if user has premium access (user or partner)
v_has_premium := public.has_premium_access(v_user_id);
-- Premium users bypass the limit
IF v_has_premium THEN
RETURN QUERY SELECT 0, 0, 0, NULL::TIMESTAMPTZ, false;
RETURN;
END IF;
-- Calculate UTC day boundaries
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
v_tomorrow_start := v_today_start + INTERVAL '1 day';
-- Count responses created today (UTC)
SELECT COUNT(*)::INTEGER INTO v_count
FROM responses r
WHERE r.user_id = v_user_id
AND r.created_at >= v_today_start
AND r.created_at < v_tomorrow_start;
RETURN QUERY SELECT
v_count,
v_limit,
GREATEST(0, v_limit - v_count),
v_tomorrow_start,
(v_count >= v_limit);
END;
$$;
COMMENT ON FUNCTION get_daily_response_limit_status() IS 'Returns daily response limit status for the current user. Premium users and disabled limits return is_blocked=false.';
@@ -0,0 +1,205 @@
-- Update get_recommended_questions to enforce daily response limit for non-premium users
-- When daily limit is reached, return empty (old apps will show "All caught up")
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE(
id UUID,
text TEXT,
partner_text TEXT,
is_two_part BOOLEAN,
pack_id UUID,
intensity INTEGER,
partner_answered BOOLEAN,
allowed_couple_genders TEXT[],
target_user_genders TEXT[]
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
v_has_premium BOOLEAN;
v_gap_threshold INTEGER;
v_gap_count INTEGER;
v_user_max_intensity INTEGER;
v_daily_limit INTEGER;
v_daily_count INTEGER;
v_today_start TIMESTAMPTZ;
v_tomorrow_start TIMESTAMPTZ;
BEGIN
v_user_id := auth.uid();
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_couple_id, v_user_gender, v_user_max_intensity
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- Check premium access (used for both answer gap and daily limit)
v_has_premium := public.has_premium_access(v_user_id);
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
-- Get daily limit from app_config
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
FROM app_config ac
LIMIT 1;
-- If daily limit > 0, check if user has reached it
IF v_daily_limit > 0 THEN
-- Calculate UTC day boundaries
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
v_tomorrow_start := v_today_start + INTERVAL '1 day';
-- Count responses created today (UTC)
SELECT COUNT(*)::INTEGER INTO v_daily_count
FROM responses r
WHERE r.user_id = v_user_id
AND r.created_at >= v_today_start
AND r.created_at < v_tomorrow_start;
-- If over daily limit, return empty (old apps see "All caught up!")
IF v_daily_count >= v_daily_limit THEN
RETURN;
END IF;
END IF;
END IF;
-- Check answer gap threshold (only when not browsing a specific pack)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
-- Get threshold from app_config
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
FROM app_config ac
LIMIT 1;
-- If threshold > 0, check the gap
IF v_gap_threshold > 0 THEN
-- Count questions in ENABLED packs where user answered but partner hasn't
WITH enabled_packs AS (
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT COUNT(*)::integer INTO v_gap_count
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.user_id = v_user_id
AND r.couple_id = v_couple_id
AND EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep)
AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
);
-- If over threshold, return empty (old apps see "All caught up!")
IF v_gap_count >= v_gap_threshold THEN
RETURN;
END IF;
END IF;
END IF;
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
IF v_user_gender < v_partner_gender THEN
v_couple_type := v_user_gender || '+' || v_partner_gender;
ELSE
v_couple_type := v_partner_gender || '+' || v_user_gender;
END IF;
END IF;
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = target_pack_id
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
)
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id
AND cp.enabled = TRUE
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
SELECT p.id AS pack_id FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
CASE
WHEN q.partner_text IS NOT NULL AND EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) THEN q.partner_text
ELSE q.text
END as text,
q.partner_text,
q.partner_text IS NOT NULL as is_two_part,
q.pack_id,
q.intensity,
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) AS partner_answered,
q.allowed_couple_genders,
q.target_user_genders
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND (
q.target_user_genders IS NULL
OR v_user_gender = ANY(q.target_user_genders)
OR EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
)
)
-- Filter by user's max_intensity preference
AND (
q.intensity IS NULL
OR q.intensity <= v_user_max_intensity
);
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When daily limit or answer gap threshold is exceeded for non-premium users, returns empty.';