mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat: add reset progress and flagged media analysis
This commit is contained in:
1 parent
a7e29682c1
commit
7e686c147a
15 files changed
+1022
-188
No files matched your search
@@ -0,0 +1,39 @@
|
|||||||
|
import { getOpenAI, getModel } from '../client';
|
||||||
|
|
||||||
|
export async function describeImage(imageBase64OrUrl: string): Promise<string> {
|
||||||
|
const openai = getOpenAI();
|
||||||
|
const model = getModel('describe_image');
|
||||||
|
|
||||||
|
// If it's a data URL or http URL, use it as is.
|
||||||
|
// If it's raw base64, prepend the data URI scheme.
|
||||||
|
let imageUrl = imageBase64OrUrl;
|
||||||
|
if (!imageBase64OrUrl.startsWith('http') && !imageBase64OrUrl.startsWith('data:')) {
|
||||||
|
imageUrl = `data:image/jpeg;base64,${imageBase64OrUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model: model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Describe this image in detail. Focus on any potentially inappropriate content or safety concerns.' },
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
"url": imageUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.choices[0].message.content || 'No description available.';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error describing image:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ export function getModel(purpose?: ModelPurpose): string {
|
|||||||
return remoteConfig?.model_fix || import.meta.env.VITE_AI_MODEL_FIX || fallback;
|
return remoteConfig?.model_fix || import.meta.env.VITE_AI_MODEL_FIX || fallback;
|
||||||
case 'polish':
|
case 'polish':
|
||||||
return remoteConfig?.model_polish || import.meta.env.VITE_AI_MODEL_POLISH || fallback;
|
return remoteConfig?.model_polish || import.meta.env.VITE_AI_MODEL_POLISH || fallback;
|
||||||
|
case 'describe_image':
|
||||||
|
return remoteConfig?.classifier_model || 'gpt-4o';
|
||||||
default:
|
default:
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Model purpose for selecting appropriate model
|
// Model purpose for selecting appropriate model
|
||||||
export type ModelPurpose = 'generate' | 'fix' | 'polish';
|
export type ModelPurpose = 'generate' | 'fix' | 'polish' | 'describe_image';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COUNCIL CONFIGURATION TYPES
|
// COUNCIL CONFIGURATION TYPES
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { supabase } from '@/config';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -13,15 +13,16 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { CheckCircle, Eye, RefreshCw, MessageSquare, Sparkles } from 'lucide-react';
|
||||||
import { Shield, CheckCircle, AlertTriangle, Eye, RefreshCw } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { describeImage } from '@/lib/ai/analyzers/media';
|
||||||
|
|
||||||
interface FlaggedMessage {
|
interface FlaggedMessage {
|
||||||
id: string;
|
id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
match_id: string | null;
|
||||||
moderation_status: 'flagged' | 'safe' | 'unmoderated';
|
moderation_status: 'flagged' | 'safe' | 'unmoderated';
|
||||||
flag_reason: string | null;
|
flag_reason: string | null;
|
||||||
content?: string; // Encrypted usually
|
content?: string; // Encrypted usually
|
||||||
@@ -30,19 +31,24 @@ interface FlaggedMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FlaggedMessagesPage() {
|
export function FlaggedMessagesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [messages, setMessages] = useState<FlaggedMessage[]>([]);
|
const [messages, setMessages] = useState<FlaggedMessage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
||||||
const [decryptedContent, setDecryptedContent] = useState<{ text: string | null; mediaUrl: string | null }>({ text: null, mediaUrl: null });
|
const [decryptedContent, setDecryptedContent] = useState<{ text: string | null; mediaUrl: string | null }>({ text: null, mediaUrl: null });
|
||||||
const [decrypting, setDecrypting] = useState(false);
|
const [decrypting, setDecrypting] = useState(false);
|
||||||
const [processing, setProcessing] = useState<string | null>(null); // messageId being processed
|
const [processing, setProcessing] = useState<string | null>(null); // messageId being processed
|
||||||
|
const [imageDescription, setImageDescription] = useState<string | null>(null);
|
||||||
|
const [describing, setDescribing] = useState(false);
|
||||||
|
const [inlineDescriptions, setInlineDescriptions] = useState<Record<string, string>>({});
|
||||||
|
const [describingInline, setDescribingInline] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchMessages = async () => {
|
const fetchMessages = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select('id, created_at, user_id, moderation_status, flag_reason, content, media_path, media_type')
|
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type')
|
||||||
.eq('moderation_status', 'flagged')
|
.eq('moderation_status', 'flagged')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
@@ -60,61 +66,6 @@ export function FlaggedMessagesPage() {
|
|||||||
fetchMessages();
|
fetchMessages();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleView = async (message: FlaggedMessage) => {
|
|
||||||
setSelectedMessage(message);
|
|
||||||
setDecrypting(true);
|
|
||||||
setDecryptedContent({ text: null, mediaUrl: null });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Call edge function to decrypt
|
|
||||||
// We use admin-decrypt-message for text
|
|
||||||
let text = null;
|
|
||||||
let mediaUrl = null;
|
|
||||||
|
|
||||||
// 1. Decrypt Text/Metadata
|
|
||||||
const { data: decryptData, error: decryptError } = await supabase.functions.invoke('admin-decrypt-message', {
|
|
||||||
body: { messageId: message.id }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (decryptError) throw decryptError;
|
|
||||||
|
|
||||||
if (decryptData) {
|
|
||||||
text = decryptData.content;
|
|
||||||
|
|
||||||
// 2. Handle Media
|
|
||||||
// If it has media, admin-decrypt-message returns media_path but not the file content directly decrypted usually
|
|
||||||
// But admin-decrypt-media function returns the raw bytes
|
|
||||||
if (decryptData.media_path) {
|
|
||||||
const { data: mediaBlob, error: mediaError } = await supabase.functions.invoke('admin-decrypt-media', {
|
|
||||||
body: { messageId: message.id },
|
|
||||||
// Response is a blob/file usually?
|
|
||||||
// supabase.functions.invoke by default parses JSON.
|
|
||||||
// We might need to handle blob response.
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invoke returns data as parsed JSON if header is json, or blob?
|
|
||||||
// The admin-decrypt-media returns binary body.
|
|
||||||
// Supabase js client might try to parse it.
|
|
||||||
// We might need a direct fetch or handle responseType.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: For now, displaying text content. Media decryption in frontend via edge function binary response
|
|
||||||
// is tricky with supabase-js invoke().
|
|
||||||
// We will just show the text for now or implement media later if critical.
|
|
||||||
// Actually, we can just use a direct fetch with the user's session token.
|
|
||||||
|
|
||||||
setDecryptedContent({ text, mediaUrl });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Decryption failed:', error);
|
|
||||||
toast.error('Failed to decrypt content');
|
|
||||||
setDecryptedContent({ text: 'Error decrypting content', mediaUrl: null });
|
|
||||||
} finally {
|
|
||||||
setDecrypting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||||
const fetchDecryptedMedia = async (messageId: string) => {
|
const fetchDecryptedMedia = async (messageId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -140,11 +91,11 @@ export function FlaggedMessagesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Improved handleView with media support
|
const handleView = async (message: FlaggedMessage) => {
|
||||||
const handleViewComplete = async (message: FlaggedMessage) => {
|
|
||||||
setSelectedMessage(message);
|
setSelectedMessage(message);
|
||||||
setDecrypting(true);
|
setDecrypting(true);
|
||||||
setDecryptedContent({ text: null, mediaUrl: null });
|
setDecryptedContent({ text: null, mediaUrl: null });
|
||||||
|
setImageDescription(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Text
|
// Text
|
||||||
@@ -169,6 +120,72 @@ export function FlaggedMessagesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDescribeImage = async () => {
|
||||||
|
if (!decryptedContent.mediaUrl) return;
|
||||||
|
|
||||||
|
setDescribing(true);
|
||||||
|
try {
|
||||||
|
const description = await describeImage(decryptedContent.mediaUrl);
|
||||||
|
setImageDescription(description);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to describe image:', error);
|
||||||
|
toast.error('Failed to describe image');
|
||||||
|
} finally {
|
||||||
|
setDescribing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDescribeInline = async (message: FlaggedMessage) => {
|
||||||
|
if (!message.media_path || message.media_type === 'video') return;
|
||||||
|
|
||||||
|
setDescribingInline(message.id);
|
||||||
|
try {
|
||||||
|
// First fetch the decrypted media as blob
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session) {
|
||||||
|
toast.error('Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${session.access_token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ messageId: message.id })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error('Failed to decrypt media');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert blob to base64
|
||||||
|
const blob = await response.blob();
|
||||||
|
const base64 = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
// Extract base64 part from data URL
|
||||||
|
const base64Data = result.split(',')[1];
|
||||||
|
resolve(base64Data);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then describe it
|
||||||
|
const description = await describeImage(base64);
|
||||||
|
setInlineDescriptions(prev => ({ ...prev, [message.id]: description }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to describe image:', error);
|
||||||
|
toast.error('Failed to describe image');
|
||||||
|
} finally {
|
||||||
|
setDescribingInline(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMarkSafe = async (messageId: string) => {
|
const handleMarkSafe = async (messageId: string) => {
|
||||||
setProcessing(messageId);
|
setProcessing(messageId);
|
||||||
try {
|
try {
|
||||||
@@ -189,6 +206,14 @@ export function FlaggedMessagesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleJumpToChat = () => {
|
||||||
|
if (!selectedMessage || !selectedMessage.match_id) {
|
||||||
|
toast.error("No match context available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/users/${selectedMessage.user_id}/matches/${selectedMessage.match_id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -226,7 +251,8 @@ export function FlaggedMessagesPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
messages.map((message) => (
|
messages.map((message) => (
|
||||||
<TableRow key={message.id}>
|
<React.Fragment key={message.id}>
|
||||||
|
<TableRow>
|
||||||
<TableCell className="font-mono text-sm">
|
<TableCell className="font-mono text-sm">
|
||||||
{format(new Date(message.created_at), 'MMM d, HH:mm')}
|
{format(new Date(message.created_at), 'MMM d, HH:mm')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -246,11 +272,26 @@ export function FlaggedMessagesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleViewComplete(message)}
|
onClick={() => handleView(message)}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4 mr-2" />
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
Review
|
Review
|
||||||
</Button>
|
</Button>
|
||||||
|
{message.media_path && message.media_type !== 'video' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDescribeInline(message)}
|
||||||
|
disabled={describingInline === message.id}
|
||||||
|
>
|
||||||
|
{describingInline === message.id ? (
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Describe
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -263,6 +304,19 @@ export function FlaggedMessagesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
{inlineDescriptions[message.id] && (
|
||||||
|
<TableRow className="bg-blue-50/50 dark:bg-blue-900/10">
|
||||||
|
<TableCell colSpan={4} className="py-2">
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<Sparkles className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-blue-800 dark:text-blue-200">
|
||||||
|
{inlineDescriptions[message.id]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -270,7 +324,7 @@ export function FlaggedMessagesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={!!selectedMessage} onOpenChange={(open) => !open && setSelectedMessage(null)}>
|
<Dialog open={!!selectedMessage} onOpenChange={(open) => !open && setSelectedMessage(null)}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Review Content</DialogTitle>
|
<DialogTitle>Review Content</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -278,36 +332,79 @@ export function FlaggedMessagesPage() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-[200px] bg-muted/30 rounded-md p-4 flex items-center justify-center">
|
<div className="min-h-[200px] bg-muted/30 rounded-md p-4 flex flex-col gap-4">
|
||||||
{decrypting ? (
|
{decrypting ? (
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 text-muted-foreground py-8">
|
||||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
<p className="text-sm">Decrypting secure content...</p>
|
<p className="text-sm">Decrypting secure content...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
{decryptedContent.text && (
|
{decryptedContent.text && (
|
||||||
<div className="bg-background border rounded p-3 text-sm">
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">Message Text</h4>
|
||||||
|
<div className="bg-background border rounded p-3 text-sm whitespace-pre-wrap">
|
||||||
{decryptedContent.text}
|
{decryptedContent.text}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{decryptedContent.mediaUrl && (
|
{decryptedContent.mediaUrl && (
|
||||||
<div className="flex justify-center">
|
<div className="space-y-2">
|
||||||
{selectedMessage?.media_type === 'video' ? (
|
<div className="flex items-center justify-between">
|
||||||
<video src={decryptedContent.mediaUrl} controls className="max-h-[400px] rounded" />
|
<h4 className="text-sm font-medium text-muted-foreground">Media Content</h4>
|
||||||
|
{selectedMessage?.media_type !== 'video' && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDescribeImage}
|
||||||
|
disabled={describing}
|
||||||
|
>
|
||||||
|
{describing ? (
|
||||||
|
<RefreshCw className="h-3 w-3 mr-2 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<img src={decryptedContent.mediaUrl} alt="Flagged content" className="max-h-[400px] rounded object-contain" />
|
<Sparkles className="h-3 w-3 mr-2" />
|
||||||
|
)}
|
||||||
|
Describe Image
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center bg-black/5 rounded-lg overflow-hidden border">
|
||||||
|
{selectedMessage?.media_type === 'video' ? (
|
||||||
|
<video src={decryptedContent.mediaUrl} controls className="max-h-[400px] w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<img src={decryptedContent.mediaUrl} alt="Flagged content" className="max-h-[400px] w-full object-contain" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageDescription && (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||||
|
<h5 className="text-xs font-semibold text-blue-700 dark:text-blue-300 mb-1 flex items-center">
|
||||||
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
|
AI Description
|
||||||
|
</h5>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">{imageDescription}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!decryptedContent.text && !decryptedContent.mediaUrl && (
|
{!decryptedContent.text && !decryptedContent.mediaUrl && (
|
||||||
<p className="text-center text-muted-foreground">No content displayed</p>
|
<p className="text-center text-muted-foreground py-8">No content displayed</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleJumpToChat}>
|
||||||
|
<MessageSquare className="h-4 w-4 mr-2" />
|
||||||
|
Jump to Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" onClick={() => setSelectedMessage(null)}>Cancel</Button>
|
<Button variant="ghost" onClick={() => setSelectedMessage(null)}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700"
|
||||||
@@ -315,9 +412,11 @@ export function FlaggedMessagesPage() {
|
|||||||
>
|
>
|
||||||
Mark Safe
|
Mark Safe
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
+109
-8
@@ -5,29 +5,130 @@ This guide covers local release builds for Android and iOS.
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Java JDK 17** - Required for Android Gradle builds
|
- **Java JDK 17** - Required for Android Gradle builds
|
||||||
- **Android Studio** - For SDK and build tools
|
- **Android Studio** - For SDK and build tools (including Android SDK/NDK)
|
||||||
- **Xcode** - For iOS builds
|
- **Xcode** - For iOS builds (macOS only)
|
||||||
- **EAS CLI** - `npm install -g eas-cli`
|
- **EAS CLI** - `npm install -g eas-cli`
|
||||||
|
- **Expo account** - Run `eas login` to authenticate
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommended: EAS Build Local
|
## EAS Build Local (Recommended)
|
||||||
|
|
||||||
The easiest way to build locally with correct signing credentials:
|
The `--local` flag runs the EAS build process on your machine instead of Expo's cloud servers. This automatically handles signing credentials and produces the same output as cloud builds.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/mobile
|
cd apps/mobile
|
||||||
|
|
||||||
# Android - produces .aab file
|
# Android - produces .aab file (for Play Store)
|
||||||
eas build --platform android --profile production --local
|
eas build --platform android --profile production --local
|
||||||
|
|
||||||
# iOS - produces .ipa file
|
# iOS - produces .ipa file (for App Store)
|
||||||
eas build --platform ios --profile production --local
|
eas build --platform ios --profile production --local
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs the build on your machine but automatically uses the correct keystore/certificates from EAS credentials.
|
Output files are placed in the current directory by default.
|
||||||
|
|
||||||
Output files will be in the current directory.
|
### Building an APK (for Testing)
|
||||||
|
|
||||||
|
The production profile creates an AAB (Android App Bundle), which can't be directly installed on devices. To build an APK for testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use the preview profile (configured for APK output)
|
||||||
|
eas build --platform android --profile preview --local
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add a custom profile to `eas.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"local-apk": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
```bash
|
||||||
|
eas build --platform android --profile local-apk --local
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installing the APK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via ADB (device connected via USB)
|
||||||
|
adb install ./build-*.apk
|
||||||
|
|
||||||
|
# Or drag-and-drop onto Android Emulator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Profiles
|
||||||
|
|
||||||
|
| Profile | Output | Use Case |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| `production` | .aab | Play Store / App Store submission |
|
||||||
|
| `preview` | .apk | Internal testing, device installation |
|
||||||
|
| `development` | .apk | Dev client with hot reload |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Useful for debugging build issues:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `EAS_LOCAL_BUILD_SKIP_CLEANUP=1` | Keep build directory after completion |
|
||||||
|
| `EAS_LOCAL_BUILD_WORKINGDIR=/path` | Custom build directory (default: /tmp) |
|
||||||
|
| `EAS_LOCAL_BUILD_ARTIFACTS_DIR=/path` | Output directory for build artifacts |
|
||||||
|
|
||||||
|
Example with debugging enabled:
|
||||||
|
```bash
|
||||||
|
EAS_LOCAL_BUILD_SKIP_CLEANUP=1 \
|
||||||
|
EAS_LOCAL_BUILD_WORKINGDIR=~/eas-builds \
|
||||||
|
eas build --platform android --profile production --local
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limitations of Local Builds
|
||||||
|
|
||||||
|
- **Single platform only** - Can't use `--platform all`
|
||||||
|
- **Software versions ignored** - `node`, `yarn`, `ndk`, `image` fields in eas.json are ignored
|
||||||
|
- **No caching** - Each build starts fresh
|
||||||
|
- **Secret env vars not available** - Set them in your local environment instead
|
||||||
|
- **macOS/Linux only** - Windows requires WSL (unsupported)
|
||||||
|
|
||||||
|
### Troubleshooting Local Builds
|
||||||
|
|
||||||
|
**Java version mismatch:**
|
||||||
|
```bash
|
||||||
|
# Check Java version (should be 17)
|
||||||
|
java -version
|
||||||
|
|
||||||
|
# On macOS, switch Java version
|
||||||
|
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gradle daemon issues:**
|
||||||
|
```bash
|
||||||
|
cd android && ./gradlew --stop && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
**Clean build after dependency changes:**
|
||||||
|
```bash
|
||||||
|
# Remove native folders and rebuild
|
||||||
|
rm -rf android ios
|
||||||
|
npx expo prebuild --clean
|
||||||
|
eas build --platform android --profile production --local
|
||||||
|
```
|
||||||
|
|
||||||
|
**Out of memory:**
|
||||||
|
```bash
|
||||||
|
# Increase Gradle memory
|
||||||
|
echo "org.gradle.jvmargs=-Xmx4g" >> android/gradle.properties
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, ActivityIndicator, Alert } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
@@ -18,7 +18,7 @@ import { clearKeys } from '../../lib/encryption';
|
|||||||
import { useProfileSettings, useCoupleManagement } from './hooks';
|
import { useProfileSettings, useCoupleManagement } from './hooks';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem } from './components';
|
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, ResetProgress, SettingsSection, MenuItem } from './components';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
@@ -36,7 +36,6 @@ export function ProfileScreen() {
|
|||||||
handleUnpair,
|
handleUnpair,
|
||||||
handleDeleteRelationship,
|
handleDeleteRelationship,
|
||||||
handleResetProgress,
|
handleResetProgress,
|
||||||
isResettingProgress,
|
|
||||||
handleSignOut,
|
handleSignOut,
|
||||||
navigateToPairing
|
navigateToPairing
|
||||||
} = useCoupleManagement();
|
} = useCoupleManagement();
|
||||||
@@ -216,38 +215,9 @@ export function ProfileScreen() {
|
|||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Reset Progress Layout */}
|
{/* Reset Progress */}
|
||||||
{couple && (
|
{couple && (
|
||||||
<SettingsSection title="Reset Progress" delay={500}>
|
<ResetProgress onResetProgress={handleResetProgress} />
|
||||||
<View style={styles.resetContent}>
|
|
||||||
<View style={styles.resetInfo}>
|
|
||||||
<View style={styles.resetIconContainer}>
|
|
||||||
<Ionicons name="refresh" size={20} color={colors.warning} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.resetTextContainer}>
|
|
||||||
<Text style={styles.resetTitle}>Start Fresh</Text>
|
|
||||||
<Text style={styles.resetDescription}>
|
|
||||||
Delete all swipes, matches, and chats while keeping your partner connection.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.resetButton, isResettingProgress && styles.resetButtonDisabled]}
|
|
||||||
onPress={handleResetProgress}
|
|
||||||
disabled={isResettingProgress}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
{isResettingProgress ? (
|
|
||||||
<ActivityIndicator size="small" color={colors.warning} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
|
|
||||||
<Text style={styles.resetButtonText}>Reset</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Debug Section */}
|
{/* Debug Section */}
|
||||||
@@ -484,57 +454,6 @@ const styles = StyleSheet.create({
|
|||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
// Reset Progress Styles
|
|
||||||
resetContent: {
|
|
||||||
gap: spacing.md,
|
|
||||||
},
|
|
||||||
resetInfo: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
},
|
|
||||||
resetIconContainer: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: colors.warningLight,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
resetTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
marginLeft: spacing.md,
|
|
||||||
},
|
|
||||||
resetTitle: {
|
|
||||||
...typography.headline,
|
|
||||||
color: colors.text,
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
resetDescription: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
resetButton: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: colors.warningLight,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
borderRadius: radius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(243, 156, 18, 0.3)',
|
|
||||||
gap: spacing.xs,
|
|
||||||
alignSelf: "flex-end",
|
|
||||||
},
|
|
||||||
resetButtonDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
resetButtonText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: colors.warning,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
// Version Badge
|
// Version Badge
|
||||||
versionContainer: {
|
versionContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -225,6 +225,9 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
padding: spacing.xl,
|
padding: spacing.xl,
|
||||||
backgroundColor: colors.backgroundLight,
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
},
|
},
|
||||||
modalHeader: {
|
modalHeader: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -278,11 +281,16 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: colors.error,
|
color: colors.error,
|
||||||
},
|
},
|
||||||
inputContainer: {
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
confirmInput: {
|
confirmInput: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.error,
|
||||||
|
padding: spacing.md,
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
},
|
},
|
||||||
modalButtons: {
|
modalButtons: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { GlassCard } from '../../../components/ui';
|
||||||
|
import { colors, spacing, radius, typography } from '../../../theme';
|
||||||
|
|
||||||
|
interface ResetProgressProps {
|
||||||
|
onResetProgress: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResetProgress: React.FC<ResetProgressProps> = ({ onResetProgress }) => {
|
||||||
|
const [showResetModal, setShowResetModal] = useState(false);
|
||||||
|
const [resetConfirmText, setResetConfirmText] = useState("");
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (resetConfirmText !== "RESET") return;
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
await onResetProgress();
|
||||||
|
setShowResetModal(false);
|
||||||
|
setResetConfirmText("");
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling relies on parent
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(500).duration(500)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<Text style={styles.sectionTitle}>Reset Progress</Text>
|
||||||
|
<GlassCard>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.info}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name="refresh" size={20} color={colors.warning} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.textContainer}>
|
||||||
|
<Text style={styles.title}>Start Fresh</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
Delete all swipes, matches, and chats while keeping your partner connection.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.resetButton}
|
||||||
|
onPress={() => setShowResetModal(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.resetButtonText}>Reset</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showResetModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => !isResetting && setShowResetModal(false)}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={Platform.OS === 'ios' ? 20 : 0}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View style={styles.modalIconContainer}>
|
||||||
|
<Ionicons name="refresh" size={32} color={colors.warning} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Reset Progress?</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.modalDescription}>
|
||||||
|
This action cannot be undone. This will permanently delete:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.resetList}>
|
||||||
|
<View style={styles.resetListItem}>
|
||||||
|
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.resetListText}>All your swipes</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.resetListItem}>
|
||||||
|
<Ionicons name="heart" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.resetListText}>All matches</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.resetListItem}>
|
||||||
|
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.resetListText}>All chat messages</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.keepList}>
|
||||||
|
<View style={styles.keepListItem}>
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||||
|
<Text style={styles.keepListText}>Your partner connection stays</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.confirmLabel}>
|
||||||
|
Type <Text style={styles.confirmKeyword}>RESET</Text> to confirm:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.confirmInput}
|
||||||
|
value={resetConfirmText}
|
||||||
|
onChangeText={setResetConfirmText}
|
||||||
|
placeholder="Type RESET"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isResetting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cancelButton}
|
||||||
|
onPress={() => {
|
||||||
|
setShowResetModal(false);
|
||||||
|
setResetConfirmText("");
|
||||||
|
}}
|
||||||
|
disabled={isResetting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.confirmResetButton,
|
||||||
|
(resetConfirmText !== "RESET" || isResetting) && styles.confirmResetButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleReset}
|
||||||
|
disabled={resetConfirmText !== "RESET" || isResetting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isResetting ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="refresh" size={16} color={colors.text} />
|
||||||
|
<Text style={styles.confirmResetButtonText}>Reset</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</BlurView>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
...typography.caption1,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.warningLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
resetButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: colors.warningLight,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||||
|
gap: spacing.xs,
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
},
|
||||||
|
resetButtonText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
modalIconContainer: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: colors.warningLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
...typography.title2,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
modalDescription: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
resetList: {
|
||||||
|
backgroundColor: "rgba(243, 156, 18, 0.1)",
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.2)',
|
||||||
|
},
|
||||||
|
resetListItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
resetListText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
keepList: {
|
||||||
|
backgroundColor: "rgba(46, 204, 113, 0.1)",
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(46, 204, 113, 0.2)',
|
||||||
|
},
|
||||||
|
keepListItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
keepListText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
confirmKeyword: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
confirmInput: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.warning,
|
||||||
|
padding: spacing.md,
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
modalButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.glass.backgroundLight,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.borderLight,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
confirmResetButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
confirmResetButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
confirmResetButtonText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ export type { NotificationSettingsProps } from './NotificationSettings';
|
|||||||
export { CoupleStatus } from './CoupleStatus';
|
export { CoupleStatus } from './CoupleStatus';
|
||||||
export { PrivacySettings } from './PrivacySettings';
|
export { PrivacySettings } from './PrivacySettings';
|
||||||
export { DangerZone } from './DangerZone';
|
export { DangerZone } from './DangerZone';
|
||||||
|
export { ResetProgress } from './ResetProgress';
|
||||||
|
|
||||||
export { SettingsSection } from './SettingsSection';
|
export { SettingsSection } from './SettingsSection';
|
||||||
export type { SettingsSectionProps } from './SettingsSection';
|
export type { SettingsSectionProps } from './SettingsSection';
|
||||||
|
|||||||
@@ -66,16 +66,6 @@ export function useCoupleManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleResetProgress = async () => {
|
const handleResetProgress = async () => {
|
||||||
Alert.alert(
|
|
||||||
"Reset Progress",
|
|
||||||
"This will delete all your swipes, matches, and chat messages. You'll stay connected with your partner and can start fresh. This cannot be undone.",
|
|
||||||
[
|
|
||||||
{ text: "Cancel", style: "cancel" },
|
|
||||||
{
|
|
||||||
text: "Reset",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
setIsResettingProgress(true);
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.functions.invoke("reset-couple-progress", {
|
const { error } = await supabase.functions.invoke("reset-couple-progress", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -90,13 +80,8 @@ export function useCoupleManagement() {
|
|||||||
Alert.alert("Success", "Your progress has been reset. You can now start swiping again!");
|
Alert.alert("Success", "Your progress has been reset. You can now start swiping again!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert("Error", "Failed to reset progress. Please try again.");
|
Alert.alert("Error", "Failed to reset progress. Please try again.");
|
||||||
} finally {
|
throw error;
|
||||||
setIsResettingProgress(false);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2",
|
||||||
|
"std/": "https://deno.land/std@0.224.0/"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"test": "deno test --allow-net --allow-env --allow-read"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
import { assertEquals } from "std/testing/asserts.ts";
|
||||||
|
import { createTestUser, createRedemptionCode, cleanup, adminClient, SUPABASE_URL } from "./utils.ts";
|
||||||
|
|
||||||
|
const FUNCTION_URL = `${SUPABASE_URL}/functions/v1/redeem-code`;
|
||||||
|
|
||||||
|
Deno.test("redeem-code: successfully redeems code for user", async () => {
|
||||||
|
const email = `test_redeem_${Date.now()}@example.com`;
|
||||||
|
let userIds: string[] = [];
|
||||||
|
let codeId: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Setup User
|
||||||
|
const user = await createTestUser(email);
|
||||||
|
userIds = [user.id];
|
||||||
|
|
||||||
|
// 2. Setup Code
|
||||||
|
const codeData = await createRedemptionCode();
|
||||||
|
codeId = codeData.id;
|
||||||
|
|
||||||
|
// 3. Call Function
|
||||||
|
const res = await fetch(FUNCTION_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
code: codeData.code
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
assertEquals(data.success, true);
|
||||||
|
|
||||||
|
// 4. Verify Profile is Premium
|
||||||
|
const { data: profile } = await adminClient
|
||||||
|
.from("profiles")
|
||||||
|
.select("is_premium")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
assertEquals(profile.is_premium, true);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
await cleanup(userIds);
|
||||||
|
if (codeId) await adminClient.from("redemption_codes").delete().eq("id", codeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
|
||||||
|
import { assert, assertEquals } from "std/testing/asserts.ts";
|
||||||
|
import { createTestUser, createCouple, cleanup, signInUser, createTestQuestion, adminClient, SUPABASE_URL } from "./utils.ts";
|
||||||
|
|
||||||
|
const FUNCTION_URL = `${SUPABASE_URL}/functions/v1/submit-response`;
|
||||||
|
|
||||||
|
Deno.test("submit-response: creates match when both users answer yes", async () => {
|
||||||
|
const email1 = `test_user_1_${Date.now()}@example.com`;
|
||||||
|
const email2 = `test_user_2_${Date.now()}@example.com`;
|
||||||
|
let coupleId: string | undefined;
|
||||||
|
let questionId: string | undefined;
|
||||||
|
let packId: string | undefined;
|
||||||
|
let users: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Setup Users and Couple
|
||||||
|
const user1 = await createTestUser(email1);
|
||||||
|
const user2 = await createTestUser(email2);
|
||||||
|
users = [user1.id, user2.id];
|
||||||
|
|
||||||
|
const couple = await createCouple(user1.id, user2.id);
|
||||||
|
coupleId = couple.id;
|
||||||
|
|
||||||
|
// 2. Setup Question
|
||||||
|
const qData = await createTestQuestion();
|
||||||
|
questionId = qData.question!.id;
|
||||||
|
packId = qData.packId;
|
||||||
|
|
||||||
|
// 3. Sign In Users to get tokens
|
||||||
|
const session1 = await signInUser(email1);
|
||||||
|
const session2 = await signInUser(email2);
|
||||||
|
|
||||||
|
assert(session1?.access_token, "User 1 should have access token");
|
||||||
|
assert(session2?.access_token, "User 2 should have access token");
|
||||||
|
|
||||||
|
// 4. User 1 submits 'yes'
|
||||||
|
const res1 = await fetch(FUNCTION_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${session1!.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
question_id: questionId,
|
||||||
|
answer: "yes",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data1 = await res1.json();
|
||||||
|
assertEquals(res1.status, 200);
|
||||||
|
assertEquals(data1.match, null, "First response should not create match");
|
||||||
|
|
||||||
|
// 5. User 2 submits 'yes'
|
||||||
|
const res2 = await fetch(FUNCTION_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${session2!.access_token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
question_id: questionId,
|
||||||
|
answer: "yes",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data2 = await res2.json();
|
||||||
|
assertEquals(res2.status, 200);
|
||||||
|
assert(data2.match, "Second response should create match");
|
||||||
|
assertEquals(data2.match.match_type, "yes_yes");
|
||||||
|
|
||||||
|
// 6. Verify Match in DB
|
||||||
|
const { data: match } = await adminClient
|
||||||
|
.from("matches")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", data2.match.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
assert(match, "Match should exist in DB");
|
||||||
|
assertEquals(match.match_type, "yes_yes");
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
await cleanup(users, coupleId);
|
||||||
|
if (questionId) await adminClient.from("questions").delete().eq("id", questionId);
|
||||||
|
if (packId) await adminClient.from("question_packs").delete().eq("id", packId);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
|
||||||
|
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { assert } from "std/testing/asserts.ts";
|
||||||
|
|
||||||
|
export const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || "http://127.0.0.1:54321";
|
||||||
|
export const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
|
|
||||||
|
if (!SUPABASE_SERVICE_ROLE_KEY) {
|
||||||
|
throw new Error("SUPABASE_SERVICE_ROLE_KEY is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createTestUser(email: string) {
|
||||||
|
const { data, error } = await adminClient.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: "password123",
|
||||||
|
email_confirm: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (!data.user) throw new Error("User not created");
|
||||||
|
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signInUser(email: string) {
|
||||||
|
const { data, error } = await adminClient.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password: "password123",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestQuestion(packId?: string) {
|
||||||
|
let finalPackId = packId;
|
||||||
|
if (!finalPackId) {
|
||||||
|
const { data: pack } = await adminClient.from("question_packs").insert({
|
||||||
|
name: "Test Pack " + Math.random(),
|
||||||
|
is_premium: false,
|
||||||
|
sort_order: 999
|
||||||
|
}).select().single();
|
||||||
|
finalPackId = pack!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: question } = await adminClient.from("questions").insert({
|
||||||
|
pack_id: finalPackId,
|
||||||
|
text: "Test Question?",
|
||||||
|
intensity: 1
|
||||||
|
}).select().single();
|
||||||
|
|
||||||
|
return { question, packId: finalPackId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRedemptionCode() {
|
||||||
|
const code = "TEST-" + Math.random().toString(36).substring(7).toUpperCase();
|
||||||
|
const { data, error } = await adminClient.from("redemption_codes").insert({
|
||||||
|
code,
|
||||||
|
max_uses: 1,
|
||||||
|
active: true,
|
||||||
|
expires_at: new Date(Date.now() + 3600000).toISOString() // 1 hour
|
||||||
|
}).select().single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCouple(user1Id: string, user2Id: string) {
|
||||||
|
// 1. Create a couple
|
||||||
|
const { data: couple, error: coupleError } = await adminClient
|
||||||
|
.from("couples")
|
||||||
|
.insert({
|
||||||
|
// invite_code is unique, so generate a random one
|
||||||
|
invite_code: Math.random().toString(36).substring(7),
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (coupleError) throw coupleError;
|
||||||
|
|
||||||
|
// 2. Link users to couple
|
||||||
|
const { error: updateError } = await adminClient
|
||||||
|
.from("profiles")
|
||||||
|
.update({ couple_id: couple.id })
|
||||||
|
.in("id", [user1Id, user2Id]);
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
|
return couple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanup(userIds: string[], coupleId?: string) {
|
||||||
|
// Delete couple (should cascade to matches, responses, etc. if configured, but let's be safe)
|
||||||
|
// Based on schema analysis, explicit cleanup is safer.
|
||||||
|
|
||||||
|
if (coupleId) {
|
||||||
|
await adminClient.from("matches").delete().eq("couple_id", coupleId);
|
||||||
|
await adminClient.from("responses").delete().eq("couple_id", coupleId);
|
||||||
|
await adminClient.from("couples").delete().eq("id", coupleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete users (cascades to profiles)
|
||||||
|
for (const id of userIds) {
|
||||||
|
await adminClient.auth.admin.deleteUser(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.SUPABASE_SERVICE_KEY!;
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
const userId = 'a4b92675-5cea-4e26-a581-2e7fcd9c4789'; // Mark
|
||||||
|
const matchId = 'bd7e742e-137a-43c2-a9b0-9c2e0b7a8d5f'; // Guessing match ID from message or I can find it
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
// 1. Get the match ID from the message
|
||||||
|
const { data: msg } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.select('match_id')
|
||||||
|
.eq('id', 'e9741569-166d-4742-bd85-b185db352ff4')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!msg) {
|
||||||
|
console.log('Message not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchId = msg.match_id;
|
||||||
|
console.log('Match ID:', matchId);
|
||||||
|
|
||||||
|
// 2. Fetch all messages
|
||||||
|
const { data: allMessages } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.select('*')
|
||||||
|
.eq('match_id', matchId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
console.log(`Fetched ${allMessages?.length} messages`);
|
||||||
|
|
||||||
|
// 3. Fetch deletions
|
||||||
|
const { data: deletions } = await supabase
|
||||||
|
.from('message_deletions')
|
||||||
|
.select('message_id')
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
console.log(`Fetched ${deletions?.length} deletions`);
|
||||||
|
|
||||||
|
const deletedIds = new Set(deletions?.map(d => d.message_id));
|
||||||
|
console.log('Deleted IDs:', Array.from(deletedIds));
|
||||||
|
|
||||||
|
// 4. Filter
|
||||||
|
const filteredMessages = allMessages?.filter(m => !deletedIds.has(m.id));
|
||||||
|
|
||||||
|
// 5. Check if the image message is present
|
||||||
|
const imageMsgId = 'e9741569-166d-4742-bd85-b185db352ff4';
|
||||||
|
const found = filteredMessages?.find(m => m.id === imageMsgId);
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
console.log('FAIL: Image message still present in filtered list!');
|
||||||
|
} else {
|
||||||
|
console.log('SUCCESS: Image message was filtered out.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
Reference in new issue
Block a user