mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
595d6bd394
commit
b0f1700e40
57 files changed
+8359
-1661
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+1211
-892
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';
|
||||
@@ -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';
|
||||
@@ -25,6 +25,7 @@ export interface Match {
|
||||
responses?: Array<{
|
||||
user_id: string;
|
||||
answer: string;
|
||||
created_at: string;
|
||||
profiles?: {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.';
|
||||
Reference in new issue
Block a user